Просмотр исходного кода

fix: control car volume through the FYT/Syu MCU

On FYT/Syu head units the media stream is not what the amplifier plays:
writes were accepted and ignored, and the card immediately trusted the
old read-back, so the slider snapped back to 100%. Bind the car's
com.syu.ms toolkit service, drive the sound module's master volume
(C_VOL, 0-36) and follow the level the MCU reports. Keep an unconfirmed
write for 1.6 s and mark it with `?` instead of reverting it. Add a
FLAG_SHOW_UI fallback for vendor panels and expose the control source,
minimum and vendor state in the long-press diagnostics.

Release 1.2.2+22, including the Jellyfin favorites, playing-album Home
shortcut and streaming-quality setting. 149 tests pass; analyze and the
debug/release builds succeed. The MCU volume path still needs the
real-car test.
Krasimir Kazakov 5 дней назад
Родитель
Сommit
85e15bb893

+ 5 - 3
AGENTS.md

@@ -95,7 +95,9 @@ notifyListeners();
 - The long-press hint uses `onboarding.firstLaunchAt` and `HomeHint`: it is visible only for the first ten minutes after a fresh installation. Upgraded installs are migrated to an expired window and never see it again.
 - The long-press hint uses `onboarding.firstLaunchAt` and `HomeHint`: it is visible only for the first ten minutes after a fresh installation. Upgraded installs are migrated to an expired window and never see it again.
 - Screen-space is deliberately finite: test the 16-button grid at 1280×800, 1280×720 and 1024×600. The shortcut grid is bottom-anchored so a short row sits on the bottom edge; keep that behavior when changing layouts. The grid includes All apps; Settings sits in `StatusCluster` and does not consume a slot. Avoid unbounded `ListView`s inside `Column`s.
 - Screen-space is deliberately finite: test the 16-button grid at 1280×800, 1280×720 and 1024×600. The shortcut grid is bottom-anchored so a short row sits on the bottom edge; keep that behavior when changing layouts. The grid includes All apps; Settings sits in `StatusCluster` and does not consume a slot. Avoid unbounded `ListView`s inside `Column`s.
 - `tool/emulator.sh` starts a headless AVD with the **host GPU**; SwiftShader segfaulted on this machine's API 35 image. Use `CARDASH_GPU=swiftshader_indirect` only as a fallback. Use `adb exec-out screencap -p` for screenshots; there may be multiple `adb` binaries on PATH, so use the absolute one.
 - `tool/emulator.sh` starts a headless AVD with the **host GPU**; SwiftShader segfaulted on this machine's API 35 image. Use `CARDASH_GPU=swiftshader_indirect` only as a fallback. Use `adb exec-out screencap -p` for screenshots; there may be multiple `adb` binaries on PATH, so use the absolute one.
-- While CarDash is foreground, `MainActivity` consumes hardware volume keys and adjusts `STREAM_MUSIC` so they match the dashboard, unless `AudioManager.isVolumeFixed` is true; on fixed-volume car units the keys pass through to the vehicle amplifier and the on-screen slider is disabled with an explanation. `SystemBridge.setVolume` falls back to relative `adjustStreamVolume` steps when a unit ignores absolute `setStreamVolume`. Long-pressing **Media volume** toggles `SystemController.volumeDiagnostics`, which shows the raw `index/max`, fixed flag, all stream levels and the last write result; the native side logs the same data under `CarDashVolume`. Do not remove this path without providing another sync path; otherwise Android controls the ring stream when no media is playing.
+- While CarDash is foreground, `MainActivity` consumes hardware volume keys and adjusts `STREAM_MUSIC` so they match the dashboard, unless `AudioManager.isVolumeFixed` is true; on fixed-volume car units the keys pass through to the vehicle amplifier and the on-screen slider is disabled with an explanation. `SystemBridge.setVolume` falls back to relative `adjustStreamVolume` steps when a unit ignores absolute `setStreamVolume`, then to a `FLAG_SHOW_UI` write that may reach the vendor's own volume panel. Long-pressing **Media volume** toggles `SystemController.volumeDiagnostics`, which shows the raw `index/max`/`min`, fixed flag, control source, vendor state, all stream levels and the last write result; the native side logs the same data under `CarDashVolume`. Do not remove this path without providing another sync path; otherwise Android controls the ring stream when no media is playing.
+- FYT/Syu head units (Joying, Junsun, Teyes, Atoto and similar Chinese firmware) own the amplifier in the MCU, so the Android media stream is not what the driver hears. `SyuVolumeBridge` binds `com.syu.ms` (`app.ToolkitService`, action `com.syu.ms.toolkit`), fetches module 4 (`getRemoteModule`, transaction 1) and sends `cmd(0, [level])` for the absolute master volume (command 0 = `FinalSound.C_VOL`, range 0–36); it also subscribes to update 2 (level) and 3 (mute), so knob changes and slider changes stay in sync. `SystemBridge.readVolume` reports `source = syu` while that bridge is connected. `MainActivity`'s hardware-key path is unchanged: the car's own knob and steering-wheel volume are handled by the MCU, not by injecting extra steps. No permission, root or manifest change is needed; if the package, service or module is missing the bridge stays disconnected and the `AudioManager` path is used. Never make the vendor bind a hard dependency, and never let a failed binder call crash the app.
+- A write that the car does not confirm must not snap the slider back to the old value: `SystemController` keeps the requested level for a 1.6 s settle window, then marks it unconfirmed (the card shows `25%?`) until the read changes. Do not restore the old immediate read-back revert; that is the "it always goes back" bug on units whose read does not follow writes.
 - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock.
 - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock.
 - The Music and Jellyfin equalizers are exclusive. The Jellyfin tile animates only while `JellyfinPlaybackController.playing`; the Music tile animates only when the system reports media playing, Jellyfin is not playing, and the active system session is not CarDash itself (`system.media.packageName != appPackageName`). The package guard prevents the Music tile flashing while a Jellyfin pause propagates through the 900 ms system poll. Jellyfin title/progress come from the native playback controller even without notification access; other apps use the notification-listener component and active MediaSession. Never read or store notification content.
 - The Music and Jellyfin equalizers are exclusive. The Jellyfin tile animates only while `JellyfinPlaybackController.playing`; the Music tile animates only when the system reports media playing, Jellyfin is not playing, and the active system session is not CarDash itself (`system.media.packageName != appPackageName`). The package guard prevents the Music tile flashing while a Jellyfin pause propagates through the 900 ms system poll. Jellyfin title/progress come from the native playback controller even without notification access; other apps use the notification-listener component and active MediaSession. Never read or store notification content.
 - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock.
 - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock.
@@ -104,8 +106,8 @@ notifyListeners();
 - Jellyfin is optional: Settings → Jellyfin owns pairing, the Home switch lives in General → Home buttons, and the Home tile opens the dashboard/player. If a Jellyfin album is active, the tile opens that album's page with the dashboard underneath; Back then lands on Albums. Quick Connect is the only sign-in path: the user enters the code in Jellyfin, and CarDash keeps the returned access token in secure storage and reuses it on the next start. A rejected token or server error disables the integration, clears the token and shows **Pair again**; an unreachable server keeps the token and retries with 5–60 s backoff, so never clear the token for a network error. `JellyfinController.configured` gates the Home switch/tile (saved URL + token, survives outages); `connected` is for network actions. The Home slot default is off, counts toward the 16-button cap, is cleared on disconnect, and has no app assignment or long-press. Do not add a username/password form.
 - Jellyfin is optional: Settings → Jellyfin owns pairing, the Home switch lives in General → Home buttons, and the Home tile opens the dashboard/player. If a Jellyfin album is active, the tile opens that album's page with the dashboard underneath; Back then lands on Albums. Quick Connect is the only sign-in path: the user enters the code in Jellyfin, and CarDash keeps the returned access token in secure storage and reuses it on the next start. A rejected token or server error disables the integration, clears the token and shows **Pair again**; an unreachable server keeps the token and retries with 5–60 s backoff, so never clear the token for a network error. `JellyfinController.configured` gates the Home switch/tile (saved URL + token, survives outages); `connected` is for network actions. The Home slot default is off, counts toward the 16-button cap, is cleared on disconnect, and has no app assignment or long-press. Do not add a username/password form.
 - Jellyfin 12.2 API contract: `/Items?userId=...` (there is no `/Users/{userId}/Items` path); `/Genres` requires `parentId` of a `music` view from `/UserViews`; `genres` is pipe-delimited and OR-ed; `/Items/Latest` returns a bare array; auth is the `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=..., Token=...` header (`X-Emby-Token` and `api_key` were rejected on 12.2). Album `UserData` is not updated by track playback, so "recently played" is derived from played Audio tracks sorted by `DatePlayed`, deduped by `AlbumId`, and capped at 6. Stream URLs use `/Audio/{id}/universal`; the native data source adds the auth header and the token never goes in a URL or log. Favorites are `GET /Items?userId=...&filters=IsFavorite&includeItemTypes=MusicAlbum&recursive=true&enableUserData=true`; toggle with `POST`/`DELETE /UserFavoriteItems/{itemId}?userId=...` (not `/Users/{userId}/FavoriteItems`). The album's `UserData.IsFavorite` is parsed and re-checked on the album page; the Favorites tab refreshes on selection and after an album page closes.
 - Jellyfin 12.2 API contract: `/Items?userId=...` (there is no `/Users/{userId}/Items` path); `/Genres` requires `parentId` of a `music` view from `/UserViews`; `genres` is pipe-delimited and OR-ed; `/Items/Latest` returns a bare array; auth is the `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=..., Token=...` header (`X-Emby-Token` and `api_key` were rejected on 12.2). Album `UserData` is not updated by track playback, so "recently played" is derived from played Audio tracks sorted by `DatePlayed`, deduped by `AlbumId`, and capped at 6. Stream URLs use `/Audio/{id}/universal`; the native data source adds the auth header and the token never goes in a URL or log. Favorites are `GET /Items?userId=...&filters=IsFavorite&includeItemTypes=MusicAlbum&recursive=true&enableUserData=true`; toggle with `POST`/`DELETE /UserFavoriteItems/{itemId}?userId=...` (not `/Users/{userId}/FavoriteItems`). The album's `UserData.IsFavorite` is parsed and re-checked on the album page; the Favorites tab refreshes on selection and after an album page closes.
 - Jellyfin playback is Media3 1.11.1 in `JellyfinPlaybackService` (foreground `mediaPlayback`). `JellyfinPlaybackController` sends the queue, polls `getState` every ~700 ms, and keeps the Home card alive; the service owns the `MediaSession`, audio focus and `JellyfinPlaybackReporter`. Focus loss pauses and keeps position, a transient loss resumes on gain, a permanent loss abandons focus and does not resume. The silent `jellyfin_playback` notification needs `POST_NOTIFICATIONS` on Android 13+; the Settings card's **Allow** row requests it, and playback still works when denied. The service keeps playing while CarDash is backgrounded.
 - Jellyfin playback is Media3 1.11.1 in `JellyfinPlaybackService` (foreground `mediaPlayback`). `JellyfinPlaybackController` sends the queue, polls `getState` every ~700 ms, and keeps the Home card alive; the service owns the `MediaSession`, audio focus and `JellyfinPlaybackReporter`. Focus loss pauses and keeps position, a transient loss resumes on gain, a permanent loss abandons focus and does not resume. The silent `jellyfin_playback` notification needs `POST_NOTIFICATIONS` on Android 13+; the Settings card's **Allow** row requests it, and playback still works when denied. The service keeps playing while CarDash is backgrounded.
-- Jellyfin streaming quality is persisted as `jellyfin.audioQuality` (`JellyfinAudioQuality`). Direct (default) omits `MaxStreamingBitrate` and advertises the source codecs; 320/256/192/128 kbps set `Container=mp3`, `AudioCodec=mp3`, `TranscodingContainer=mp3` and `MaxStreamingBitrate`, asking Jellyfin to transcode. `JellyfinLibraryService` reads `audioQualityProvider` when a queue is built, so a change applies to the next album. The dropdown is staged for the next build; do not claim it is in 1.2.1.
-- Dashboard layout: the controller asks for 6 albums per row; the screen shows 6 when each of six cards is at least 160 px wide, otherwise 5. Album names/artists share the album art's left edge, the album page cover is top-aligned with the track list/controls, and selecting a Random album re-rolls the Random row after playback starts. The **Favorites** tab sits between Albums and Artists (Artists stays disabled), uses the same 5/6 columns in a scrollable multi-row grid, and shows `No favorites` centered when empty. The album-page heart toggles the server favorite; the Favorites tab reloads after the album page closes. These favorites/Home-shortcut changes are staged for the next build, not in 1.2.1. The Home equalizer is exclusive: Jellyfin playing → Jellyfin tile only; another app playing while Jellyfin is paused → Music tile only. The Home `VolumeCard` prefers the Jellyfin state while it is playing, or while it has a queue and no other app is playing; otherwise it uses the system session/media keys.
+- Jellyfin streaming quality is persisted as `jellyfin.audioQuality` (`JellyfinAudioQuality`). Direct (default) omits `MaxStreamingBitrate` and advertises the source codecs; 320/256/192/128 kbps set `Container=mp3`, `AudioCodec=mp3`, `TranscodingContainer=mp3` and `MaxStreamingBitrate`, asking Jellyfin to transcode. `JellyfinLibraryService` reads `audioQualityProvider` when a queue is built, so a change applies to the next album. The dropdown ships in 1.2.2.
+- Dashboard layout: the controller asks for 6 albums per row; the screen shows 6 when each of six cards is at least 160 px wide, otherwise 5. Album names/artists share the album art's left edge, the album page cover is top-aligned with the track list/controls, and selecting a Random album re-rolls the Random row after playback starts. The **Favorites** tab sits between Albums and Artists (Artists stays disabled), uses the same 5/6 columns in a scrollable multi-row grid, and shows `No favorites` centered when empty. The album-page heart toggles the server favorite; the Favorites tab reloads after the album page closes. These favorites/Home-shortcut changes ship in 1.2.2. The Home equalizer is exclusive: Jellyfin playing → Jellyfin tile only; another app playing while Jellyfin is paused → Music tile only. The Home `VolumeCard` prefers the Jellyfin state while it is playing, or while it has a queue and no other app is playing; otherwise it uses the system session/media keys.
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.

+ 20 - 13
README.md

@@ -19,7 +19,8 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 ## Download
 ## Download
 
 
 - [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
 - [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
-- [cardash-android-1.2.1.apk](releases/cardash-android-1.2.1.apk) — universal Android release
+- [cardash-android-1.2.2.apk](releases/cardash-android-1.2.2.apk) — universal Android release
+- [cardash-android-1.2.1.apk](releases/cardash-android-1.2.1.apk) — previous release
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 
 
 Gogs limits release attachments to 32 MB, so the 59 MB universal APK is linked
 Gogs limits release attachments to 32 MB, so the 59 MB universal APK is linked
@@ -433,10 +434,12 @@ real 12.2 server (Quick Connect, grouped genres, 6/5-card dashboard, album page,
 background playback, notification, media keys, exclusive equalizers, audio-focus
 background playback, notification, media keys, exclusive equalizers, audio-focus
 handover, Random re-shuffle) and against a local 12.2 container. The OBD hardware
 handover, Random re-shuffle) and against a local 12.2 container. The OBD hardware
 path compiles and is covered by the parser/economy design, but no ELM327 adapter
 path compiles and is covered by the parser/economy design, but no ELM327 adapter
-is available on this machine; socket behavior still needs a car test. The 1.2.1
-universal APK is the current published release; the streaming-quality setting,
-Favorites and the playing-album Home shortcut are staged for the next build. The
-emulator has verified the in-app update path.
+is available on this machine; socket behavior still needs a car test. The 1.2.2
+universal APK is the current published release. It adds the FYT/Syu MCU volume
+bridge and the no-snap-back volume handling, and includes the streaming-quality
+setting, Favorites and the playing-album Home shortcut. The emulator has
+verified the in-app update path; the MCU volume path still needs the real-car
+test.
 
 
 ## Troubleshooting the emulator
 ## Troubleshooting the emulator
 
 
@@ -472,15 +475,19 @@ Android 15 AVD is left running headlessly after development; use
 - **Notification access is used only for media metadata.** CarDash does not read
 - **Notification access is used only for media metadata.** CarDash does not read
   notification text or store track history. Revoke it in Android settings at any
   notification text or store track history. Revoke it in Android settings at any
   time; the ring and title then fall back to an empty/generic state.
   time; the ring and title then fall back to an empty/generic state.
-- **Volume control** writes the media stream. If Android reports a fixed-volume
-  policy (common on car head units), the vehicle amplifier owns the level;
-  CarDash disables the slider and explains why. Otherwise, if a unit ignores
-  absolute volume writes, CarDash falls back to the same relative steps as the
-  hardware volume keys.
+- **Volume control** prefers the car's own MCU module on FYT/Syu head units
+  (Joying, Junsun, Teyes, Atoto and similar Chinese firmware). When the
+  `com.syu.ms` sound service is present, CarDash sends it the absolute master
+  level (0–36) and follows the level the MCU reports, including changes made
+  with the car's knob. On other units it writes the media stream. A fixed-volume
+  policy disables the slider with an explanation; ignored absolute writes fall
+  back to relative steps and then to a write that asks Android to show its
+  volume panel. If a unit still reports the old level after a write, the card
+  keeps the requested level and marks it with `?` instead of snapping back.
 - **Volume diagnostics** — long-press the **Media volume** label to show the raw
 - **Volume diagnostics** — long-press the **Media volume** label to show the raw
-  `index/max`, fixed-volume flag, every stream level, and the target/actual of
-  the last write. The same values are logged under the `CarDashVolume` logcat
-  tag.
+  `index/max`, minimum, fixed-volume flag, control source (`system` or `syu`),
+  the vendor bridge state, every stream level, and the target/actual of the last
+  write. The same values are logged under the `CarDashVolume` logcat tag.
 - **Screen-on** is controlled by Settings → Prevent CarDash from sleeping
 - **Screen-on** is controlled by Settings → Prevent CarDash from sleeping
   (default on). It applies only while CarDash is the foreground window; with the
   (default on). It applies only while CarDash is the foreground window; with the
   switch off, the normal Android display timeout applies.
   switch off, the normal Android display timeout applies.

+ 60 - 4
android/app/src/main/kotlin/com/wasp/cardash_launcher/SystemBridge.kt

@@ -24,12 +24,15 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
 
 
     private var channel: MethodChannel? = null
     private var channel: MethodChannel? = null
     private var lastVolumeLog: String? = null
     private var lastVolumeLog: String? = null
+    private val syu = SyuVolumeBridge(activity.applicationContext)
 
 
     fun attach(messenger: BinaryMessenger) {
     fun attach(messenger: BinaryMessenger) {
+        syu.start()
         channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
         channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
     }
     }
 
 
     fun dispose() {
     fun dispose() {
+        syu.dispose()
         channel?.setMethodCallHandler(null)
         channel?.setMethodCallHandler(null)
         channel = null
         channel = null
     }
     }
@@ -101,11 +104,33 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
 
 
     private fun readVolume(): Map<String, Any?> {
     private fun readVolume(): Map<String, Any?> {
         val audio = audioManager()
         val audio = audioManager()
+        // Until the MCU reports its current level we keep showing Android's
+        // stream value; an absolute write based on the wrong scale could jump
+        // the amplifier to full volume.
+        val syuActive = syu.connected && syu.level != null
+        val max = if (syuActive) {
+            SyuVolumeBridge.MAX_LEVEL
+        } else {
+            audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+        }
+        val min = if (syuActive) 0 else minStreamVolume(audio, AudioManager.STREAM_MUSIC)
+        val index = if (syuActive) {
+            syu.level ?: 0
+        } else {
+            streamVolume(audio, AudioManager.STREAM_MUSIC)
+        }
         val snapshot =
         val snapshot =
             mapOf(
             mapOf(
-                "index" to streamVolume(audio, AudioManager.STREAM_MUSIC),
-                "max" to audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
-                "fixed" to audio.isVolumeFixed,
+                "index" to index,
+                "max" to max,
+                "min" to min,
+                "fixed" to (if (syuActive) false else audio.isVolumeFixed),
+                "source" to when {
+                    syuActive -> "syu"
+                    syu.connected -> "syu-wait"
+                    else -> "system"
+                },
+                "vendor" to syu.status,
                 "streams" to
                 "streams" to
                     mapOf(
                     mapOf(
                         "music" to streamVolume(audio, AudioManager.STREAM_MUSIC),
                         "music" to streamVolume(audio, AudioManager.STREAM_MUSIC),
@@ -134,6 +159,14 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
         }
         }
     }
     }
 
 
+    private fun minStreamVolume(audio: AudioManager, stream: Int): Int {
+        return try {
+            audio.getStreamMinVolume(stream)
+        } catch (_: Throwable) {
+            0
+        }
+    }
+
     private fun isMusicPlaying(): Boolean {
     private fun isMusicPlaying(): Boolean {
         return try {
         return try {
             audioManager().isMusicActive
             audioManager().isMusicActive
@@ -174,6 +207,15 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
     }
     }
 
 
     private fun setVolume(index: Int): Boolean {
     private fun setVolume(index: Int): Boolean {
+        // On FYT/Syu units the amplifier ignores Android's stream volume, so
+        // hand the change to the MCU app first.
+        if (syu.connected && syu.level != null) {
+            val target = index.coerceIn(0, SyuVolumeBridge.MAX_LEVEL)
+            if (syu.setLevel(target)) {
+                Log.i(VOLUME_TAG, "set syu target=$target")
+                return true
+            }
+        }
         return try {
         return try {
             val audio = audioManager()
             val audio = audioManager()
             // A fixed-volume policy means the car amplifier owns the level;
             // A fixed-volume policy means the car amplifier owns the level;
@@ -187,10 +229,24 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
             val target = index.coerceIn(0, max)
             val target = index.coerceIn(0, max)
             val before = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
             val before = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
             audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
             audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
-            val after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
+            var after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
             Log.i(VOLUME_TAG, "set target=$target before=$before after=$after max=$max")
             Log.i(VOLUME_TAG, "set target=$target before=$before after=$after max=$max")
             if (after == target) return true
             if (after == target) return true
 
 
+            if (after == before) {
+                // Some head units only route a volume change through their own
+                // panel; asking Android to show it uses a different code path
+                // than a silent write.
+                audio.setStreamVolume(
+                    AudioManager.STREAM_MUSIC,
+                    target,
+                    AudioManager.FLAG_SHOW_UI,
+                )
+                after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
+                Log.i(VOLUME_TAG, "set with ui target=$target after=$after")
+                if (after == target) return true
+            }
+
             // Some head units ignore absolute volume writes but accept the
             // Some head units ignore absolute volume writes but accept the
             // same relative steps as the hardware volume keys.
             // same relative steps as the hardware volume keys.
             val result = if (after == before) adjustTo(audio, target, after) else false
             val result = if (after == before) adjustTo(audio, target, after) else false

+ 266 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/SyuVolumeBridge.kt

@@ -0,0 +1,266 @@
+package com.wasp.cardash_launcher
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Binder
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Parcel
+import android.util.Log
+
+/**
+ * Master volume for FYT/Syu head units (Joying, Junsun, Teyes, Atoto and other
+ * Chinese firmware built on the Syu MCU).
+ *
+ * On these units the media stream is not what the amplifier uses: Android's
+ * `AudioManager` writes are accepted by the framework but the actual gain is
+ * owned by the MCU, so apps cannot change the audible volume that way. The
+ * stock `com.syu.ms` system app exposes the sound module over its binder
+ * `com.syu.ms.toolkit` service and applies command 1 as the absolute master
+ * volume (0..[MAX_LEVEL]). It is exported without a permission on these
+ * builds, which is how other third-party launchers control the volume too.
+ *
+ * Everything here is best-effort: when the package or the module is missing
+ * the bridge stays disconnected and [SystemBridge] falls back to the regular
+ * `AudioManager` path. No root, no extra permission, no manifest change.
+ */
+class SyuVolumeBridge(private val context: Context) {
+
+    companion object {
+        private const val TAG = "CarDashVolume"
+        private const val PACKAGE = "com.syu.ms"
+        private const val SERVICE = "app.ToolkitService"
+        private const val ACTION = "com.syu.ms.toolkit"
+        private const val MODULE_SOUND = 4
+        private const val CMD_VOLUME = 0
+        private const val UPDATE_VOLUME = 2
+        private const val UPDATE_MUTE = 3
+        private const val TOOLKIT_DESCRIPTOR = "com.syu.ipc.IRemoteToolkit"
+        private const val MODULE_DESCRIPTOR = "com.syu.ipc.IRemoteModule"
+        private const val CALLBACK_DESCRIPTOR = "com.syu.ipc.IModuleCallback"
+        private const val RETRY_MS = 5_000L
+        private const val CONNECT_TIMEOUT_MS = 4_000L
+
+        /** FYT's default master volume range; the MCU clamps lower on some cars. */
+        const val MAX_LEVEL = 36
+    }
+
+    @Volatile
+    var level: Int? = null
+        private set
+
+    @Volatile
+    var muted: Boolean = false
+        private set
+
+    @Volatile
+    var connected: Boolean = false
+        private set
+
+    /** Short state label surfaced in the on-screen diagnostics. */
+    @Volatile
+    var status: String = "off"
+        private set
+
+    private val handler = Handler(Looper.getMainLooper())
+    private var module: IBinder? = null
+    private var bound = false
+    private var disposed = false
+
+    private val callback = object : Binder() {
+        init {
+            attachInterface(null, CALLBACK_DESCRIPTOR)
+        }
+
+        override fun onTransact(
+            code: Int,
+            data: Parcel,
+            reply: Parcel?,
+            flags: Int,
+        ): Boolean {
+            if (code == 1) {
+                try {
+                    data.enforceInterface(CALLBACK_DESCRIPTOR)
+                    val update = data.readInt()
+                    val ints = data.createIntArray()
+                    data.createFloatArray()
+                    data.createStringArray()
+                    when (update) {
+                        UPDATE_VOLUME -> {
+                            val next = ints?.firstOrNull()
+                            if (next != null) {
+                                level = next.coerceIn(0, MAX_LEVEL)
+                                muted = false
+                            }
+                        }
+                        UPDATE_MUTE -> muted = ints?.firstOrNull() == 1
+                    }
+                } catch (_: Throwable) {
+                    // A malformed vendor callback must never take down the app.
+                }
+                reply?.writeNoException()
+                return true
+            }
+            return super.onTransact(code, data, reply, flags)
+        }
+    }
+
+    private val connection = object : ServiceConnection {
+        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+            handler.removeCallbacks(timeout)
+            if (service == null) {
+                status = "no-service"
+                return
+            }
+            val sound = try {
+                remoteModule(service)
+            } catch (error: Throwable) {
+                Log.i(TAG, "syu module lookup failed: $error")
+                null
+            }
+            if (sound == null) {
+                status = "no-module"
+                return
+            }
+            module = sound
+            connected = true
+            status = "ready"
+            try {
+                register(sound, UPDATE_VOLUME)
+                register(sound, UPDATE_MUTE)
+            } catch (error: Throwable) {
+                Log.i(TAG, "syu volume subscribe failed: $error")
+            }
+            Log.i(TAG, "syu sound module ready, level=$level")
+        }
+
+        override fun onServiceDisconnected(name: ComponentName?) {
+            // The binding is kept; Android reconnects when the system app is back.
+            module = null
+            connected = false
+            status = "disconnected"
+            Log.i(TAG, "syu sound module disconnected")
+        }
+
+        override fun onBindingDied(name: ComponentName?) {
+            module = null
+            connected = false
+            status = "died"
+            if (bound) {
+                try {
+                    context.unbindService(this)
+                } catch (_: Throwable) {
+                    // Already detached.
+                }
+            }
+            bound = false
+            handler.postDelayed({ if (!disposed) start() }, RETRY_MS)
+        }
+
+        override fun onNullBinding(name: ComponentName?) {
+            module = null
+            connected = false
+            status = "null-binding"
+        }
+    }
+
+    private val timeout = Runnable {
+        if (!connected) status = "timeout"
+    }
+
+    /** Starts the binding once; safe to call from every attach. */
+    fun start() {
+        if (disposed || bound) return
+        status = "binding"
+        val intent = Intent(ACTION).setComponent(ComponentName(PACKAGE, SERVICE))
+        bound = try {
+            context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
+        } catch (error: Throwable) {
+            Log.i(TAG, "syu bind failed: $error")
+            false
+        }
+        if (!bound) {
+            status = "unavailable"
+            return
+        }
+        handler.postDelayed(timeout, CONNECT_TIMEOUT_MS)
+    }
+
+    /** Absolute master volume, 0..[MAX_LEVEL]. False when the bridge is down. */
+    fun setLevel(value: Int): Boolean {
+        val binder = module ?: return false
+        val target = value.coerceIn(0, MAX_LEVEL)
+        val data = Parcel.obtain()
+        val reply = Parcel.obtain()
+        return try {
+            data.writeInterfaceToken(MODULE_DESCRIPTOR)
+            data.writeInt(CMD_VOLUME)
+            data.writeIntArray(intArrayOf(target))
+            data.writeFloatArray(null)
+            data.writeStringArray(null)
+            binder.transact(1, data, reply, 0)
+            reply.readException()
+            // Do not optimistically record the value: the update callback is
+            // the only proof that the MCU actually applied this level.
+            true
+        } catch (error: Throwable) {
+            Log.i(TAG, "syu set $target failed: $error")
+            false
+        } finally {
+            data.recycle()
+            reply.recycle()
+        }
+    }
+
+    fun dispose() {
+        disposed = true
+        handler.removeCallbacksAndMessages(null)
+        if (bound) {
+            try {
+                context.unbindService(connection)
+            } catch (_: Throwable) {
+                // Already detached.
+            }
+        }
+        bound = false
+        module = null
+        connected = false
+        status = "off"
+    }
+
+    /** Calls `IRemoteToolkit.getRemoteModule(int)` (transaction 1). */
+    private fun remoteModule(toolkit: IBinder): IBinder? {
+        val data = Parcel.obtain()
+        val reply = Parcel.obtain()
+        return try {
+            data.writeInterfaceToken(TOOLKIT_DESCRIPTOR)
+            data.writeInt(MODULE_SOUND)
+            if (!toolkit.transact(1, data, reply, 0)) return null
+            reply.readException()
+            reply.readStrongBinder()
+        } finally {
+            reply.recycle()
+            data.recycle()
+        }
+    }
+
+    /** Calls `IRemoteModule.register(IModuleCallback, int, int)` (transaction 3). */
+    private fun register(binder: IBinder, update: Int) {
+        val data = Parcel.obtain()
+        val reply = Parcel.obtain()
+        try {
+            data.writeInterfaceToken(MODULE_DESCRIPTOR)
+            data.writeStrongBinder(callback)
+            data.writeInt(update)
+            data.writeInt(1) // ask for the current value immediately
+            binder.transact(3, data, reply, 0)
+            reply.readException()
+        } finally {
+            data.recycle()
+            reply.recycle()
+        }
+    }
+}

+ 18 - 4
docs/IMPLEMENTATION_PLAN.md

@@ -67,7 +67,7 @@ compatibility:
 | Home cap | 16 buttons total, including All apps; Settings is outside the grid | Keeps the single page legible at tablet resolutions; enforced in `LauncherController`. |
 | Home cap | 16 buttons total, including All apps; Settings is outside the grid | Keeps the single page legible at tablet resolutions; enforced in `LauncherController`. |
 | Settings placement | Gear next to Wi-Fi in `StatusCluster` | Keeps Settings reachable without spending a grid slot. |
 | Settings placement | Gear next to Wi-Fi in `StatusCluster` | Keeps Settings reachable without spending a grid slot. |
 | Playback control | Circled volume ring is play/pause; Mute pill remains | One prominent control for play/resume/pause without adding another card. |
 | Playback control | Circled volume ring is play/pause; Mute pill remains | One prominent control for play/resume/pause without adding another card. |
-| Volume control | Absolute `setStreamVolume`, relative `adjustStreamVolume` fallback, fixed-policy detection | Some car units ignore absolute writes; fixed-volume amplifiers cannot be controlled by apps. |
+| Volume control | FYT/Syu MCU sound module, then absolute `setStreamVolume`, UI-panel write, relative `adjustStreamVolume`, fixed-policy detection | Car amplifiers ignore the Android stream on many Chinese units; the MCU module is the only app-reachable master volume there. |
 | Icons | Only pinned apps show their own icon by default; fixed slots stay stylized | Keeps the dashboard consistent while making added apps recognizable. |
 | Icons | Only pinned apps show their own icon by default; fixed slots stay stylized | Keeps the dashboard consistent while making added apps recognizable. |
 | Accent | Six curated choices, persisted in Settings | Changes the whole theme without a color picker or arbitrary contrast failures. |
 | Accent | Six curated choices, persisted in Settings | Changes the whole theme without a color picker or arbitrary contrast failures. |
 | Battery visibility | One switch hides level and charging bolt | One control for both indicators, as requested. |
 | Battery visibility | One switch hides level and charging bolt | One control for both indicators, as requested. |
@@ -331,7 +331,7 @@ Android (Kotlin)
 
 
 ## 8. Change log
 ## 8. Change log
 
 
-- 2026-10-31 (unreleased): adds a Jellyfin **Favorites** tab between Albums and
+- 2026-10-31: adds a Jellyfin **Favorites** tab between Albums and
   Artists, backed by the Jellyfin favorites API; a scrollable 5/6-column grid and
   Artists, backed by the Jellyfin favorites API; a scrollable 5/6-column grid and
   a centered `No favorites` empty state. The album page gains a heart that sets
   a centered `No favorites` empty state. The album page gains a heart that sets
   or unsets the server favorite, and the Favorites tab refreshes when you return.
   or unsets the server favorite, and the Favorites tab refreshes when you return.
@@ -339,8 +339,7 @@ Android (Kotlin)
   underneath, so Back returns to Albums instead of Home. Also adds a **Streaming
   underneath, so Back returns to Albums instead of Home. Also adds a **Streaming
   quality** dropdown: Direct (default) or 320/256/192/128 kbps MP3 transcoding.
   quality** dropdown: Direct (default) or 320/256/192/128 kbps MP3 transcoding.
   The quality is persisted in `SettingsRepository`, read when the next album
   The quality is persisted in `SettingsRepository`, read when the next album
-  queue is built, and shown in the Jellyfin settings card. Staged for the next
-  version; no APK or version bump has been made. 149 tests pass.
+  queue is built, and shown in the Jellyfin settings card. Included in 1.2.2.
 - 2026-10-31: CarDash 1.2.1+21 fixes the brief Music-tile equalizer flash after
 - 2026-10-31: CarDash 1.2.1+21 fixes the brief Music-tile equalizer flash after
   pausing Jellyfin from the Home card. The Music equalizer now ignores
   pausing Jellyfin from the Home card. The Music equalizer now ignores
   CarDash's own stale system MediaSession while a Jellyfin pause propagates, and
   CarDash's own stale system MediaSession while a Jellyfin pause propagates, and
@@ -443,3 +442,18 @@ Android (Kotlin)
   selected or OBD speed off) now requests location permission and retries the
   selected or OBD speed off) now requests location permission and retries the
   GPS fallback; a denied request leaves the switch on and shows an explanation.
   GPS fallback; a denied request leaves the switch on and shows an explanation.
   The normal resume path still never prompts. 89 tests pass.
   The normal resume path still never prompts. 89 tests pass.
+- 2026-11-01: Volume against a real FYT/Syu head unit. CarDash now binds the
+  car's `com.syu.ms` toolkit service and drives the sound module's absolute
+  master volume (command 0, module 4, 0–36), so the on-screen slider changes the
+  amplifier instead of the unused Android media stream. The car's own knob and
+  steering-wheel controls stay independent; the Android key path is unchanged.
+  On other units the existing absolute/relative/UI-panel fallbacks remain.
+  Reads that never follow a write no longer snap the slider back: the requested
+  level is kept and shown with a `?`, and the diagnostics panel gained the
+  minimum, control source and vendor state. No new permission, no root and no
+  manifest change.
+- 2026-11-01: CarDash 1.2.2+22 releases the car-volume work together with the
+  Jellyfin favorites/active-album shortcut and streaming-quality setting. The
+  volume card now prefers the FYT/Syu MCU master level, keeps an unconfirmed
+  write instead of snapping back, and exposes the control source in diagnostics.
+  149 tests pass; release `releases/cardash-android-1.2.2.apk`.

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// Keep this in sync with the `version` in pubspec.yaml.
 /// Keep this in sync with the `version` in pubspec.yaml.
-const String appVersion = '1.2.1';
+const String appVersion = '1.2.2';

+ 41 - 0
lib/data/models/system_status.dart

@@ -24,13 +24,20 @@ class VolumeInfo {
   const VolumeInfo({
   const VolumeInfo({
     this.index = 0,
     this.index = 0,
     this.max = 15,
     this.max = 15,
+    this.min = 0,
     this.fixed = false,
     this.fixed = false,
     this.streams = const {},
     this.streams = const {},
+    this.source = 'system',
+    this.vendor = '',
   });
   });
 
 
   final int index;
   final int index;
   final int max;
   final int max;
 
 
+  /// Lowest index Android accepts for the stream. When it equals [max] the
+  /// unit exposes no usable range at all.
+  final int min;
+
   /// True when Android delegates volume to a fixed hardware amplifier (common
   /// True when Android delegates volume to a fixed hardware amplifier (common
   /// on car head units). Apps cannot change that volume.
   /// on car head units). Apps cannot change that volume.
   final bool fixed;
   final bool fixed;
@@ -38,6 +45,13 @@ class VolumeInfo {
   /// Raw indices for every stream, used by the on-screen diagnostics panel.
   /// Raw indices for every stream, used by the on-screen diagnostics panel.
   final Map<String, int> streams;
   final Map<String, int> streams;
 
 
+  /// Which backend produced [index]: `system` for `AudioManager`, `syu` when
+  /// the car's MCU volume module is in control.
+  final String source;
+
+  /// Native vendor state (binding/ready/unavailable), diagnostics only.
+  final String vendor;
+
   factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
   factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
     final rawStreams = map['streams'];
     final rawStreams = map['streams'];
     final streams = <String, int>{};
     final streams = <String, int>{};
@@ -51,8 +65,35 @@ class VolumeInfo {
     return VolumeInfo(
     return VolumeInfo(
       index: (map['index'] as num?)?.toInt() ?? 0,
       index: (map['index'] as num?)?.toInt() ?? 0,
       max: (map['max'] as num?)?.toInt() ?? 15,
       max: (map['max'] as num?)?.toInt() ?? 15,
+      min: (map['min'] as num?)?.toInt() ?? 0,
       fixed: map['fixed'] == true,
       fixed: map['fixed'] == true,
       streams: streams,
       streams: streams,
+      source: map['source'] is String ? map['source'] as String : 'system',
+      vendor: map['vendor'] is String ? map['vendor'] as String : '',
+    );
+  }
+
+  /// True when there is no range to move through, either because the firmware
+  /// says so or because the stream reports a single step.
+  bool get isControllable => !fixed && max > 0 && min < max;
+
+  VolumeInfo copyWith({
+    int? index,
+    int? max,
+    int? min,
+    bool? fixed,
+    Map<String, int>? streams,
+    String? source,
+    String? vendor,
+  }) {
+    return VolumeInfo(
+      index: index ?? this.index,
+      max: max ?? this.max,
+      min: min ?? this.min,
+      fixed: fixed ?? this.fixed,
+      streams: streams ?? this.streams,
+      source: source ?? this.source,
+      vendor: vendor ?? this.vendor,
     );
     );
   }
   }
 
 

+ 1 - 0
lib/features/home/home_screen.dart

@@ -290,6 +290,7 @@ class _HomeScreenState extends State<HomeScreen> {
                                   diagnostics: system.volumeDiagnostics,
                                   diagnostics: system.volumeDiagnostics,
                                   onToggleDiagnostics:
                                   onToggleDiagnostics:
                                       system.toggleVolumeDiagnostics,
                                       system.toggleVolumeDiagnostics,
+                                  unconfirmed: system.volumeUnconfirmed,
                                 ),
                                 ),
                               ],
                               ],
                             ),
                             ),

+ 146 - 18
lib/features/home/system_controller.dart

@@ -34,13 +34,28 @@ class SystemController extends ChangeNotifier {
   bool _disposed = false;
   bool _disposed = false;
   int _lastNonZeroVolume = 0;
   int _lastNonZeroVolume = 0;
 
 
+  /// How long a fresh write may take before a poll decides the read-back is
+  /// not tracking it. Car units often answer a write a few hundred ms later.
+  static const Duration _settleWindow = Duration(milliseconds: 1600);
+
+  int? _pendingIndex;
+  int? _pendingBaseline;
+  DateTime? _pendingSince;
+  int _pendingMisses = 0;
+  bool _readBackStale = false;
+  int? _staleIndex;
+
   SystemStatus get status => _status;
   SystemStatus get status => _status;
   VolumeInfo get volume => _volume;
   VolumeInfo get volume => _volume;
   int get volumePercent => _volume.percent;
   int get volumePercent => _volume.percent;
   bool get isMuted => _volume.index <= 0;
   bool get isMuted => _volume.index <= 0;
-  bool get isVolumeFixed => _volume.fixed;
+  bool get isVolumeFixed => !_volume.isControllable;
   bool get isMusicPlaying => _musicPlaying;
   bool get isMusicPlaying => _musicPlaying;
 
 
+  /// True when the car reports the same level no matter what was written.
+  /// The card still shows the requested level, marked with a question mark.
+  bool get volumeUnconfirmed => _readBackStale;
+
   String? get volumeDiagnostics {
   String? get volumeDiagnostics {
     if (!_showVolumeDiagnostics) return null;
     if (!_showVolumeDiagnostics) return null;
     final write = _lastVolumeTarget == null
     final write = _lastVolumeTarget == null
@@ -49,7 +64,15 @@ class SystemController extends ChangeNotifier {
     final streams = _volume.streams.entries
     final streams = _volume.streams.entries
         .map((entry) => '${entry.key} ${entry.value}')
         .map((entry) => '${entry.key} ${entry.value}')
         .join('  ');
         .join('  ');
-    return 'raw ${_volume.index}/${_volume.max}  fixed ${_volume.fixed}\n'
+    final state = [
+      'raw ${_volume.index}/${_volume.max}',
+      'min ${_volume.min}',
+      'fixed ${_volume.fixed}',
+      'source ${_volume.source}',
+      if (_volume.vendor.isNotEmpty) 'vendor ${_volume.vendor}',
+      if (_readBackStale) 'unconfirmed',
+    ].join('  ');
+    return '$state\n'
         'write $write\n'
         'write $write\n'
         '${streams.isEmpty ? 'streams unavailable' : streams}';
         '${streams.isEmpty ? 'streams unavailable' : streams}';
   }
   }
@@ -94,20 +117,114 @@ class SystemController extends ChangeNotifier {
     _volumeBusy = true;
     _volumeBusy = true;
     try {
     try {
       final next = await _platform.getVolume();
       final next = await _platform.getVolume();
+      if (!_active) return;
       if (_lastVolumeTarget != null) _lastVolumeRead = next;
       if (_lastVolumeTarget != null) _lastVolumeRead = next;
-      if (next.index != _volume.index ||
-          next.max != _volume.max ||
-          next.fixed != _volume.fixed ||
-          !mapEquals(next.streams, _volume.streams)) {
-        _volume = next;
-        if (next.index > 0) _lastNonZeroVolume = next.index;
-        _notify();
+
+      final pending = _pendingIndex;
+      if (pending != null) {
+        if (next.index == pending) {
+          _confirmVolume(next);
+          return;
+        }
+        final baseline = _pendingBaseline;
+        final since = _pendingSince;
+        final elapsed = since == null
+            ? _settleWindow
+            : DateTime.now().difference(since);
+        if (baseline != null && next.index == baseline) {
+          if (elapsed >= _settleWindow) {
+            _pendingMisses++;
+            if (_pendingMisses >= 2) {
+              _readBackStale = true;
+              _staleIndex = baseline;
+            }
+          }
+          _mergeMetadata(next);
+          return;
+        }
+        // The car may still be applying an earlier step of the same gesture.
+        if (elapsed < _settleWindow) {
+          _mergeMetadata(next);
+          return;
+        }
+        // Something else moved the volume (hardware keys, another app).
+        _adoptVolume(next);
+        return;
+      }
+
+      if (_readBackStale) {
+        if (next.index == _staleIndex) {
+          _mergeMetadata(next);
+          return;
+        }
+        _readBackStale = false;
+        _staleIndex = null;
       }
       }
+      _adoptVolume(next);
     } finally {
     } finally {
       _volumeBusy = false;
       _volumeBusy = false;
     }
     }
   }
   }
 
 
+  bool _sameVolume(VolumeInfo a, VolumeInfo b) {
+    return a.index == b.index &&
+        a.max == b.max &&
+        a.min == b.min &&
+        a.fixed == b.fixed &&
+        a.source == b.source &&
+        a.vendor == b.vendor &&
+        mapEquals(a.streams, b.streams);
+  }
+
+  void _clearPending() {
+    _pendingIndex = null;
+    _pendingBaseline = null;
+    _pendingSince = null;
+    _pendingMisses = 0;
+  }
+
+  void _confirmVolume(VolumeInfo next) {
+    _clearPending();
+    _readBackStale = false;
+    _staleIndex = null;
+    _applyVolume(next);
+  }
+
+  void _adoptVolume(VolumeInfo next) {
+    _clearPending();
+    _readBackStale = false;
+    _staleIndex = null;
+    _applyVolume(next);
+  }
+
+  void _applyVolume(VolumeInfo next) {
+    if (next.index > 0) _lastNonZeroVolume = next.index;
+    if (!_sameVolume(next, _volume)) {
+      _volume = next;
+      _notify();
+    }
+  }
+
+  /// Keeps the level the user chose while refreshing the metadata around it.
+  void _mergeMetadata(VolumeInfo next) {
+    final max = next.max <= 0 ? _volume.max : next.max;
+    final min = next.min.clamp(0, max);
+    final index = _volume.index.clamp(min, max);
+    final merged = VolumeInfo(
+      index: index,
+      max: max,
+      min: min,
+      fixed: next.fixed,
+      streams: next.streams,
+      source: next.source,
+      vendor: next.vendor,
+    );
+    if (!_sameVolume(merged, _volume)) {
+      _volume = merged;
+      _notify();
+    }
+  }
+
   Future<void> _pollMusic() async {
   Future<void> _pollMusic() async {
     if (_musicBusy || !_active) return;
     if (_musicBusy || !_active) return;
     _musicBusy = true;
     _musicBusy = true;
@@ -144,16 +261,25 @@ class SystemController extends ChangeNotifier {
 
 
   Future<void> setVolume(int index) async {
   Future<void> setVolume(int index) async {
     final max = _volume.max <= 0 ? 15 : _volume.max;
     final max = _volume.max <= 0 ? 15 : _volume.max;
-    final clamped = index.clamp(0, max);
-    if (_volume.fixed) {
+    final min = _volume.min.clamp(0, max);
+    final clamped = index.clamp(min, max);
+    if (isVolumeFixed) {
       _lastVolumeTarget = clamped;
       _lastVolumeTarget = clamped;
       _lastVolumeRead = _volume;
       _lastVolumeRead = _volume;
       return;
       return;
     }
     }
+    final before = _volume.index;
+    if (clamped == before && _pendingIndex == null) return;
     final writeId = ++_volumeWriteId;
     final writeId = ++_volumeWriteId;
     _lastVolumeTarget = clamped;
     _lastVolumeTarget = clamped;
+    if (_pendingIndex == null) {
+      _pendingBaseline = _lastVolumeRead?.index ?? before;
+    }
+    _pendingIndex = clamped;
+    _pendingSince = DateTime.now();
+    _pendingMisses = 0;
     _volumeSetting = true;
     _volumeSetting = true;
-    _volume = VolumeInfo(index: clamped, max: max, streams: _volume.streams);
+    _volume = _volume.copyWith(index: clamped);
     if (clamped > 0) _lastNonZeroVolume = clamped;
     if (clamped > 0) _lastNonZeroVolume = clamped;
     _notify();
     _notify();
     try {
     try {
@@ -162,12 +288,14 @@ class SystemController extends ChangeNotifier {
       final actual = await _platform.getVolume();
       final actual = await _platform.getVolume();
       if (writeId != _volumeWriteId) return;
       if (writeId != _volumeWriteId) return;
       _lastVolumeRead = actual;
       _lastVolumeRead = actual;
-      _volume = actual;
-      if (actual.index > 0) _lastNonZeroVolume = actual.index;
-      if (!actual.fixed && actual.index != clamped) {
-        _showVolumeDiagnostics = true;
+      if (actual.index == clamped) {
+        _confirmVolume(actual);
+      } else {
+        // The car has not reported the new level yet. Keep what the user set;
+        // the next polls either confirm it or mark it unconfirmed instead of
+        // snapping the slider back to the old value.
+        _mergeMetadata(actual);
       }
       }
-      _notify();
     } finally {
     } finally {
       if (writeId == _volumeWriteId) _volumeSetting = false;
       if (writeId == _volumeWriteId) _volumeSetting = false;
     }
     }
@@ -215,7 +343,7 @@ class SystemController extends ChangeNotifier {
   }
   }
 
 
   Future<void> toggleMute() async {
   Future<void> toggleMute() async {
-    if (_volume.fixed) return;
+    if (isVolumeFixed) return;
     if (_volume.index > 0) {
     if (_volume.index > 0) {
       _lastNonZeroVolume = _volume.index;
       _lastNonZeroVolume = _volume.index;
       await setVolume(0);
       await setVolume(0);

+ 6 - 2
lib/features/home/widgets/volume_card.dart

@@ -29,6 +29,7 @@ class VolumeCard extends StatefulWidget {
     this.canNext = false,
     this.canNext = false,
     this.diagnostics,
     this.diagnostics,
     this.onToggleDiagnostics,
     this.onToggleDiagnostics,
+    this.unconfirmed = false,
   });
   });
 
 
   final VolumeInfo volume;
   final VolumeInfo volume;
@@ -46,6 +47,9 @@ class VolumeCard extends StatefulWidget {
   final String? diagnostics;
   final String? diagnostics;
   final VoidCallback? onToggleDiagnostics;
   final VoidCallback? onToggleDiagnostics;
 
 
+  /// The car kept reporting a different level than the one written.
+  final bool unconfirmed;
+
   @override
   @override
   State<VolumeCard> createState() => _VolumeCardState();
   State<VolumeCard> createState() => _VolumeCardState();
 }
 }
@@ -56,7 +60,7 @@ class _VolumeCardState extends State<VolumeCard> {
   int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
   int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
   int get _value => widget.volume.index.clamp(0, _max);
   int get _value => widget.volume.index.clamp(0, _max);
   bool get _muted => _value == 0;
   bool get _muted => _value == 0;
-  bool get _fixed => widget.volume.fixed;
+  bool get _fixed => !widget.volume.isControllable;
   int get _percent => ((_value / _max) * 100).round();
   int get _percent => ((_value / _max) * 100).round();
 
 
   String get _nowPlayingLabel {
   String get _nowPlayingLabel {
@@ -144,7 +148,7 @@ class _VolumeCardState extends State<VolumeCard> {
                           ),
                           ),
                           const SizedBox(width: 6),
                           const SizedBox(width: 6),
                           Text(
                           Text(
-                            '$_percent%',
+                            '$_percent%${widget.unconfirmed ? '?' : ''}',
                             style: theme.textTheme.headlineMedium?.copyWith(
                             style: theme.textTheme.headlineMedium?.copyWith(
                               fontSize: compact ? 24 : 28,
                               fontSize: compact ? 24 : 28,
                               fontFeatures: const [
                               fontFeatures: const [

+ 1 - 0
lib/features/jellyfin/jellyfin_album_screen.dart

@@ -216,6 +216,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
             onNext: active ? () => unawaited(playback.next()) : null,
             onNext: active ? () => unawaited(playback.next()) : null,
             canPrevious: active && playback.canGoPrevious,
             canPrevious: active && playback.canGoPrevious,
             canNext: active && playback.canGoNext,
             canNext: active && playback.canGoNext,
+            unconfirmed: system.volumeUnconfirmed,
           ),
           ),
         ],
         ],
       );
       );

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
 publish_to: "none"
-version: 1.2.1+21
+version: 1.2.2+22
 
 
 environment:
 environment:
   sdk: ^3.13.7
   sdk: ^3.13.7

+ 49 - 0
releases/1.2.2.md

@@ -0,0 +1,49 @@
+CarDash 1.2.2 fixes the car volume on FYT/Syu head units and stops the volume card from jumping back to 100%.
+
+- **Package:** `com.wasp.cardash_launcher`
+- **Version:** 1.2.2 (versionCode 22)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64 (universal APK)
+- **Signing:** project debug key matching all prior releases
+- **SHA-256:** `fed6d1818cfd28b19c0e568652ad14f44ce4bf15291a635f73fd97efe1ef5a35`
+
+**The bug**
+On FYT/Syu-based head units (Joying, Junsun, Teyes, Atoto and similar Chinese
+firmware) the amplifier belongs to the MCU. Android's media-stream volume is not
+what the driver hears, so `setStreamVolume` was accepted and ignored. The card
+then trusted the old read-back and snapped straight back to 100%, which made the
+car's real steps (10, 20, …) impossible to reach from the dashboard.
+
+**The fix**
+- CarDash binds the car's own `com.syu.ms` toolkit/sound module and sets the same
+  master volume the car's OSD uses (0–36, MCU command `C_VOL`). It subscribes to
+  the MCU level, so the dashboard and the car's knob now follow each other.
+- If the car reports an old level after a write, the card keeps the requested
+  value and marks it with `?` instead of snapping back.
+- Units without the Syu service keep the Android path, now with an extra
+  `FLAG_SHOW_UI` write that can reach vendor volume panels.
+- Volume diagnostics show the raw level/max, minimum, fixed flag, control source
+  (`syu`, `syu-wait`, `system`) and vendor state. Long-press **Media volume**.
+- No new permission, root or manifest change. The car's own knob and
+  steering-wheel handling is untouched.
+
+**Also in 1.2.2**
+- Jellyfin **Favorites** tab, album-page heart and the playing-album Home
+  shortcut.
+- Jellyfin **Streaming quality** setting: Direct or 320/256/192/128 kbps MP3.
+
+**Verified**
+- 149 tests pass; `flutter analyze` clean; debug and release APKs build.
+- **The MCU volume path still needs the real-car test.** After installing,
+  long-press **Media volume**: `source syu` + `vendor ready` means the bridge is
+  active. `source system` + `vendor unavailable` means this is not a Syu unit.
+
+**[Download cardash-android-1.2.2.apk](https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-1.2.2.apk)**
+
+Devices with the in-app updater can use Settings → Updates to download this APK
+and open Android's package installer. Android requires install confirmation and a
+one-time unknown-apps allowance; no silent install is attempted. A yellow dot
+appears on the Settings gear when this release is available.
+
+This build is debug-signed for sideloading/testing, not Play Store distribution.
+Gogs release attachments are limited to 32 MB, so the 59 MB universal APK is
+linked directly from the repository.

+ 16 - 0
releases/README.md

@@ -1,5 +1,21 @@
 # CarDash releases
 # CarDash releases
 
 
+## 1.2.2 — car volume on FYT/Syu units
+
+- **File:** `cardash-android-1.2.2.apk`
+- **Version:** 1.2.2 (versionCode 22)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `fed6d1818cfd28b19c0e568652ad14f44ce4bf15291a635f73fd97efe1ef5a35`
+- **Signing:** project debug key matching all releases.
+- **Changes:** the volume card now drives the FYT/Syu MCU master volume through
+  `com.syu.ms` (sound module, `C_VOL` 0–36), follows the level the car reports,
+  and keeps an unconfirmed write instead of snapping back to 100%. Non-Syu units
+  keep the Android path with an extra UI-panel write. Diagnostics show the
+  control source and vendor state. Includes the Jellyfin Favorites tab, the
+  playing-album Home shortcut and the streaming-quality setting.
+- **Verified:** 149 tests, `flutter analyze`, formatting, debug and release
+  builds. The MCU volume path still needs the real-car test.
+
 ## 1.2.1 — Jellyfin pause equalizer fix
 ## 1.2.1 — Jellyfin pause equalizer fix
 
 
 - **File:** `cardash-android-1.2.1.apk`
 - **File:** `cardash-android-1.2.1.apk`

BIN
releases/cardash-android-1.2.2.apk


+ 1 - 0
releases/cardash-android-1.2.2.apk.sha256

@@ -0,0 +1 @@
+fed6d1818cfd28b19c0e568652ad14f44ce4bf15291a635f73fd97efe1ef5a35  releases/cardash-android-1.2.2.apk

+ 25 - 3
test/system_controller_test.dart

@@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart';
 
 
 class _MediaPlatform implements LauncherPlatform {
 class _MediaPlatform implements LauncherPlatform {
   bool playing = false;
   bool playing = false;
+  bool applyWrites = false;
   MediaPlayback media = const MediaPlayback();
   MediaPlayback media = const MediaPlayback();
   VolumeInfo volume = const VolumeInfo(index: 8, max: 15);
   VolumeInfo volume = const VolumeInfo(index: 8, max: 15);
   final List<String> keys = [];
   final List<String> keys = [];
@@ -37,6 +38,9 @@ class _MediaPlatform implements LauncherPlatform {
   @override
   @override
   Future<void> setVolume(int index) async {
   Future<void> setVolume(int index) async {
     volumeWrites.add(index);
     volumeWrites.add(index);
+    if (applyWrites) {
+      volume = volume.copyWith(index: index);
+    }
   }
   }
 
 
   @override
   @override
@@ -137,7 +141,7 @@ void main() {
     controller.stop();
     controller.stop();
   });
   });
 
 
-  test('a rejected volume write reverts and exposes diagnostics', () async {
+  test('a rejected volume write keeps the requested level', () async {
     final platform = _MediaPlatform()
     final platform = _MediaPlatform()
       ..volume = const VolumeInfo(
       ..volume = const VolumeInfo(
         index: 100,
         index: 100,
@@ -151,8 +155,10 @@ void main() {
     await controller.setVolume(25);
     await controller.setVolume(25);
 
 
     expect(platform.volumeWrites, contains(25));
     expect(platform.volumeWrites, contains(25));
-    expect(controller.volume.index, 100);
-    expect(controller.volumeDiagnostics, isNotNull);
+    expect(controller.volume.index, 25);
+    expect(controller.volumeUnconfirmed, isFalse);
+
+    controller.toggleVolumeDiagnostics();
     expect(controller.volumeDiagnostics, contains('25 -> 100'));
     expect(controller.volumeDiagnostics, contains('25 -> 100'));
     expect(controller.volumeDiagnostics, contains('music 100'));
     expect(controller.volumeDiagnostics, contains('music 100'));
 
 
@@ -160,4 +166,20 @@ void main() {
     expect(controller.volumeDiagnostics, isNull);
     expect(controller.volumeDiagnostics, isNull);
     controller.stop();
     controller.stop();
   });
   });
+
+  test('a confirmed volume write adopts the car level', () async {
+    final platform = _MediaPlatform()
+      ..applyWrites = true
+      ..volume = const VolumeInfo(index: 8, max: 15);
+    final controller = SystemController(platform);
+    await controller.start();
+    addTearDown(controller.dispose);
+
+    await controller.setVolume(3);
+
+    expect(platform.volumeWrites, contains(3));
+    expect(controller.volume.index, 3);
+    expect(controller.volumeUnconfirmed, isFalse);
+    controller.stop();
+  });
 }
 }

+ 23 - 0
test/volume_card_test.dart

@@ -82,6 +82,29 @@ void main() {
     expect(find.text('Mute'), findsNothing);
     expect(find.text('Mute'), findsNothing);
   });
   });
 
 
+  testWidgets('an unconfirmed level is marked with a question mark', (
+    tester,
+  ) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.dark(),
+        home: const Scaffold(
+          body: VolumeCard(
+            volume: VolumeInfo(index: 5, max: 15),
+            onSetVolume: _ignoreVolume,
+            onToggleMute: _ignore,
+            isMusicPlaying: false,
+            onPlayPause: _ignore,
+            media: MediaPlayback(),
+            unconfirmed: true,
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text('33%?'), findsOneWidget);
+  });
+
   testWidgets('the ring switches between play and pause', (tester) async {
   testWidgets('the ring switches between play and pause', (tester) async {
     var playing = false;
     var playing = false;
     var taps = 0;
     var taps = 0;