Forráskód Böngészése

feat: show song progress and scrolling now-playing title

Replace the volume ring with active MediaSession progress. Add a scrolling title/artist footer with a static Nothing playing state; scrolling runs only while audio is playing and holds position when paused. Add a MediaBridge plus notification-listener component for read-only media metadata and a Settings Media card for the user-granted notification access. Keep the centre play/pause control and media keys. Update tests, docs, screenshots and bump to 0.8.0.
Krasimir Kazakov 1 hete
szülő
commit
c91987d8e8

+ 6 - 6
AGENTS.md

@@ -34,14 +34,14 @@ ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-
 ## 3. Architecture
 
 - `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController` (theme/accent/battery), `AppClock`, reusable widgets.
-- `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`.
+- `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
 - `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `PinService`, secure-store adapter.
 - `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags and pinned apps.
 - `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, equalizer.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
-- `lib/features/settings/` — General, Car data and Security pages.
+- `lib/features/settings/` — General, Media, Car data and Security pages.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
-- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327); `SystemBridge` also dispatches media keys.
+- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`; `SystemBridge` also dispatches media keys.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
 
 Rules:
@@ -77,7 +77,7 @@ notifyListeners();
 - NEVER change the application ID, HOME intent filter, landscape lock, or minSdk without human confirmation.
 - NEVER re-lock on resume, screen-off, or route changes; no "Lock now" action. PIN is requested only on cold start.
 - NEVER store, log, or display a plaintext PIN.
-- INTERNET, location and Bluetooth permissions are approved only for the OBD/Open-Meteo weather feature; no scanning or background location.
+- INTERNET, location and Bluetooth permissions are approved only for the OBD/Open-Meteo weather feature; notification access only for MediaSession title/progress; no notification content, scanning or background location.
 - NEVER commit `android/local.properties`, keystores, build outputs, APKs, or secrets.
 - REQUIRE human confirmation before changing signing configuration, package visibility permissions, or the repository remote.
 
@@ -94,8 +94,8 @@ notifyListeners();
 - `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. Do not remove that override without providing another sync path; otherwise Android controls the ring stream when no media is playing.
 - `FLAG_KEEP_SCREEN_ON` in `MainActivity` is intentional. It applies while CarDash is the foreground window; do not move it to a permission-based wake lock.
-- The Music equalizer follows `AudioManager.isMusicActive`, not the playing package. Exact attribution needs notification access and is out of scope unless requested.
+- The Music equalizer follows `AudioManager.isMusicActive`. Now-playing title/progress use the notification-listener component and active MediaSession; never read or store notification content.
 - 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.
-- The circled volume ring is play/pause, not mute; the Mute pill stays. Playback uses Android media keys (no paused-state API), then opens the assigned Music app if nothing responds.
+- The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Playback uses Android media keys; title/progress need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.

+ 42 - 24
README.md

@@ -3,7 +3,7 @@
 A calm, configurable Android car dashboard launcher, built with Flutter.
 
 CarDash replaces the tablet home screen with one landscape page: a large clock,
-battery and Wi-Fi status, the main media-volume control, and up to 16 buttons —
+battery and Wi-Fi status, a now-playing media card, and up to 16 buttons —
 **Navigation**, **Music**, **Radio**, **Settings**, **All apps**, plus any apps
 you pin from All Apps. The first three can be shown, hidden and assigned to
 installed apps. External apps open full-screen using the normal Android launcher
@@ -27,6 +27,8 @@ Also: [Home with top-right Settings](docs/screenshots/home-16.png) ·
 [app picker](docs/screenshots/app-picker.png) ·
 [Settings · accent & buttons](docs/screenshots/settings-buttons.png) ·
 [Settings · Car data](docs/screenshots/settings-car-data.png) ·
+[Settings · Media access](docs/screenshots/settings-media.png) ·
+[Now playing](docs/screenshots/home-now-playing.png) ·
 [Blue accent](docs/screenshots/settings-blue-accent.png) ·
 [user-app icon on Home](docs/screenshots/home-user-icon.png) ·
 [pinned-app menu](docs/screenshots/home-app-menu.png) ·
@@ -48,10 +50,13 @@ Also: [Home with top-right Settings](docs/screenshots/home-16.png) ·
   Settings toggle fetches the current temperature from Open-Meteo using the
   device location. The row reads `Outside, 7°C` for OBD or `Sofia, 7°C` for
   weather, and turns red below 3°C. No network/selection means no row.
-- **Media volume and playback** — the circled ring is now a play/pause button;
-  slider, step and mute controls remain. If audio is paused it resumes, and if
-  nothing is playing it opens the assigned Music app. While CarDash is in front,
-  the hardware volume keys are routed to the same media stream.
+- **Now-playing media card** — the circular ring shows song progress around a
+  play/pause button. The song title and artist scroll along the bottom; paused
+  audio stops the scroll and holds its position. When nothing is playing it
+  reads **Nothing playing**. The volume slider, −/+, and Mute pill remain.
+- **Now-playing access** — Settings → Media grants Android notification access,
+  used only to read the active media session’s title, duration and position.
+  CarDash never reads or stores notification content.
 - **Configurable home buttons** — switches in Settings show or hide Navigation,
   Music and Radio. **All apps** is always the last button; **Settings** now sits
   next to Wi-Fi in the top-right status area and is no longer a grid tile.
@@ -160,13 +165,15 @@ first run shows a one-time tip pointing to this setting.
 | Settings → Car data | Pick a paired ELM327 or None |
 | Settings → Car data → Location temperature | Enable GPS weather fallback |
 | Temperature row | OBD ambient, or location weather when OBD has none |
-| Settings | Theme, accent, battery, home buttons, default-home role, PIN |
+| Settings | Theme, accent, media access, battery, home buttons, default-home role, PIN |
 | All apps | Search every launchable app; tap to open, hold to pin |
 | Music tile while media is active | Shows the selected app icon + equalizer |
+| Circular ring | Song progress; tap the centre to play/pause |
+| Song title at the card bottom | Scrolls only while playing; holds when paused |
 | Screen | Stays awake while CarDash is in front |
-| Volume ring / mute | Toggle media mute |
+| Mute pill | Toggle media mute |
 | Slider or − / + | Set media volume |
-| Hardware volume keys | Adjust the dashboard's media volume; the ring follows |
+| Hardware volume keys | Adjust the dashboard's media volume; the slider follows |
 
 The app hides the Android status and navigation bars while CarDash is in the
 foreground. Swipe from an edge to reveal them temporarily; they hide again when
@@ -184,6 +191,12 @@ the app resumes.
 - Settings is always available from the gear next to Wi-Fi.
 - Home role status and shortcut to the system prompt.
 
+**Media**
+- Notification access for the now-playing title, duration and progress.
+- Read-only: CarDash does not read notification content.
+- Without access the ring stays empty and the card shows a generic Playing /
+  Nothing playing state.
+
 **Car data**
 - OBD-II adapter dropdown: None plus paired Bluetooth Classic devices.
 - Connection status and a Find/Allow action for Bluetooth permission.
@@ -217,8 +230,8 @@ docs/          implementation plan, design notes
 ```
 
 - Flutter owns all UI. Kotlin exposes package enumeration/launching, the HOME
-  role, battery/Wi-Fi, media volume, Bluetooth OBD and one-shot location over
-  method channels.
+  role, battery/Wi-Fi, media volume, active media metadata, Bluetooth OBD and
+  one-shot location over method channels.
 - The OBD layer is read-only, Bluetooth Classic SPP, bonded devices only. A
   worker thread serializes ELM327 requests; Flutter polls the latest snapshot.
 - `SettingsRepository` is the only persistence path. Controllers are
@@ -236,17 +249,19 @@ flutter test
 ./tool/check.sh
 ```
 
-The 33 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 41 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, slot visibility,
 pinned apps, custom labels/icons, the 16-button cap, the dense shortcut grid,
-the equalizer animation, OBD economy, the OBD card, media-key playback and the
-play/pause ring. Native package
+the equalizer animation, OBD economy, the OBD card, media-key playback, media
+metadata/progress, the marquee and the play/pause ring. Native package
 enumeration, the HOME role, volume, app launching and PIN flows were verified on
 API 35 and API 29 emulators. Location permission, reverse geocoding and the
-Open-Meteo fallback were verified live on the API 35 emulator. The OBD hardware
-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. Release
-0.7.0 is installed on the emulator and the physical Android 17 HyperOS tablet.
+Open-Meteo fallback were verified live on the API 35 emulator. Now-playing
+title, artist, progress and pause/resume were verified against a live Chrome
+MediaSession. The OBD hardware 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. Release
+0.8.0 is installed on the emulator and the physical Android 17 HyperOS tablet.
 
 ## Troubleshooting the emulator
 
@@ -276,10 +291,12 @@ Android 15 AVD is left running headlessly after development; use
   the 29 stylized choices.
 - **Accent-tinted dark theme** — dark canvas, surfaces and borders blend toward
   the selected accent, so Blue no longer shows a green base.
-- **Media equalizer** reacts to system media activity, not a per-app media
-  session. Android does not expose the playing package to a normal launcher
-  without notification access; adding that permission is deliberately out of
-  scope for now.
+- **Media equalizer** reacts to system media activity. The now-playing title and
+  progress use the active Android MediaSession, which requires the special
+  notification-access grant in Settings → Media.
+- **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
+  time; the ring and title then fall back to an empty/generic state.
 - **Screen-on** applies while CarDash is the foreground window. Android can
   still sleep while another app is in front.
 - **Onboarding hint** is anchored to process start and disappears after two
@@ -292,8 +309,10 @@ Android 15 AVD is left running headlessly after development; use
   theme. Default restores the pinned app's own icon.
 - **Play/pause uses Android media keys.** It resumes a paused session when one
   responds; if nothing is playing, it opens the app assigned to the Music slot
-  (or asks you to choose one). Android does not expose a public paused-state API
-  to a normal launcher, so the ring shows Play whenever audio is inactive.
+  (or asks you to choose one). The ring shows Play whenever audio is inactive.
+- **Song progress/title require notification access.** Without it, Android does
+  not expose MediaSession metadata to a normal launcher; the card still controls
+  playback and shows Nothing playing or Playing.
 - **OBD-II is a subset**: Bluetooth Classic SPP only, paired devices only,
   read-only. Speed, throttle, MAF economy and ambient temperature are implemented;
   RPM, coolant, voltage, MIL/DTCs, fuel level and BLE/Wi-Fi adapters are not.
@@ -324,7 +343,6 @@ numerals and quiet line icons. It is not a copy of that UI.
 
 - Optional compact now-playing control beside the volume card.
 - Real-car ELM327 soak test (speed/throttle/MAF/ambient).
-- Exact per-app media detection via notification access, if desired.
 - Release signing configuration.
 - Real-device soak test on an Android 10 car tablet.
 

+ 10 - 0
android/app/src/main/AndroidManifest.xml

@@ -52,6 +52,16 @@
             </intent-filter>
         </activity>
 
+        <service
+            android:name=".CardashNotificationListener"
+            android:label="CarDash now playing"
+            android:exported="false"
+            android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
+            <intent-filter>
+                <action android:name="android.service.notification.NotificationListenerService" />
+            </intent-filter>
+        </service>
+
         <meta-data
             android:name="flutterEmbedding"
             android:value="2" />

+ 12 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/CardashNotificationListener.kt

@@ -0,0 +1,12 @@
+package com.wasp.cardash_launcher
+
+import android.service.notification.NotificationListenerService
+
+/**
+ * Enables the media-session component used for now-playing metadata.
+ *
+ * CarDash never reads notification content. The service only makes this package
+ * eligible to query MediaSessionManager for the active media title, position and
+ * playback state.
+ */
+class CardashNotificationListener : NotificationListenerService()

+ 6 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/MainActivity.kt

@@ -11,6 +11,7 @@ class MainActivity : FlutterActivity() {
     private var launcherBridge: LauncherBridge? = null
     private var systemBridge: SystemBridge? = null
     private var telemetryBridge: TelemetryBridge? = null
+    private var mediaBridge: MediaBridge? = null
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -32,6 +33,9 @@ class MainActivity : FlutterActivity() {
         telemetryBridge = TelemetryBridge(this).also {
             it.attach(flutterEngine.dartExecutor.binaryMessenger)
         }
+        mediaBridge = MediaBridge(this).also {
+            it.attach(flutterEngine.dartExecutor.binaryMessenger)
+        }
     }
 
     override fun onRequestPermissionsResult(
@@ -81,9 +85,11 @@ class MainActivity : FlutterActivity() {
         launcherBridge?.dispose()
         systemBridge?.dispose()
         telemetryBridge?.dispose()
+        mediaBridge?.dispose()
         launcherBridge = null
         systemBridge = null
         telemetryBridge = null
+        mediaBridge = null
         super.onDestroy()
     }
 }

+ 140 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/MediaBridge.kt

@@ -0,0 +1,140 @@
+package com.wasp.cardash_launcher
+
+import android.app.Activity
+import android.app.NotificationManager
+import android.content.ComponentName
+import android.content.Intent
+import android.media.MediaMetadata
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.os.Build
+import android.provider.Settings
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+
+/** Read-only now-playing metadata and song progress. */
+class MediaBridge(private val activity: Activity) : MethodChannel.MethodCallHandler {
+
+    companion object {
+        const val CHANNEL = "com.wasp.cardash_launcher/media"
+    }
+
+    private var channel: MethodChannel? = null
+
+    private val component: ComponentName
+        get() = ComponentName(activity, CardashNotificationListener::class.java)
+
+    fun attach(messenger: BinaryMessenger) {
+        channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
+    }
+
+    fun dispose() {
+        channel?.setMethodCallHandler(null)
+        channel = null
+    }
+
+    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+        when (call.method) {
+            "getMediaState" -> result.success(readState())
+            "openNotificationAccessSettings" -> result.success(openAccessSettings())
+            else -> result.notImplemented()
+        }
+    }
+
+    private fun hasAccess(): Boolean {
+        return try {
+            val manager = activity.getSystemService(NotificationManager::class.java)
+            manager?.isNotificationListenerAccessGranted(component) == true
+        } catch (_: Throwable) {
+            false
+        }
+    }
+
+    private fun readState(): Map<String, Any?> {
+        val access = hasAccess()
+        if (!access) return emptyState(access)
+        return try {
+            val manager = activity.getSystemService(MediaSessionManager::class.java)
+                ?: return emptyState(access)
+            val sessions = manager.getActiveSessions(component)
+            val usable = sessions.filter { controller ->
+                val title = controller.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
+                    ?: controller.metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
+                !title.isNullOrBlank() || controller.playbackState != null
+            }
+            val controller = usable.firstOrNull {
+                it.playbackState?.state == PlaybackState.STATE_PLAYING
+            } ?: usable.firstOrNull() ?: return emptyState(access)
+
+            val metadata = controller.metadata
+            val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)?.trim()
+                ?.takeIf { it.isNotEmpty() }
+                ?: metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)?.trim()
+                    ?.takeIf { it.isNotEmpty() }
+            val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)?.trim()
+                ?.takeIf { it.isNotEmpty() }
+                ?: metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)?.trim()
+                    ?.takeIf { it.isNotEmpty() }
+            val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)
+                ?.coerceAtLeast(0L) ?: 0L
+            val playback = controller.playbackState
+            val position = playback?.position?.coerceAtLeast(0L) ?: 0L
+            val playing = playback?.state == PlaybackState.STATE_PLAYING
+
+            mapOf(
+                "hasAccess" to true,
+                "hasSession" to true,
+                "playing" to playing,
+                "title" to title,
+                "artist" to artist,
+                "packageName" to controller.packageName,
+                "durationMs" to duration,
+                "positionMs" to if (duration > 0) position.coerceAtMost(duration) else position,
+            )
+        } catch (_: Throwable) {
+            emptyState(access)
+        }
+    }
+
+    private fun emptyState(access: Boolean): Map<String, Any?> {
+        return mapOf(
+            "hasAccess" to access,
+            "hasSession" to false,
+            "playing" to false,
+            "title" to null,
+            "artist" to null,
+            "packageName" to null,
+            "durationMs" to 0L,
+            "positionMs" to 0L,
+        )
+    }
+
+    private fun openAccessSettings(): Boolean {
+        return try {
+            val listener = component
+            val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+                Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).apply {
+                    putExtra(
+                        Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
+                        listener.flattenToString(),
+                    )
+                }
+            } else {
+                Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
+            }
+            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            activity.startActivity(intent)
+            true
+        } catch (_: Throwable) {
+            try {
+                val fallback = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
+                    .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                activity.startActivity(fallback)
+                true
+            } catch (_: Throwable) {
+                false
+            }
+        }
+    }
+}

+ 24 - 7
docs/IMPLEMENTATION_PLAN.md

@@ -10,7 +10,8 @@ compatibility:
 
 - One home page. No top bar, no bottom dock, no pages.
 - Clock/date, battery/charging and Wi-Fi on the page.
-- A prominent media-volume card: play/pause ring, slider, −/+, mute.
+- A prominent now-playing card: song-progress ring around play/pause, scrolling
+  title/artist, volume slider, −/+, mute.
 - Up to **16 home buttons**: Navigation, Music, Radio, All apps, plus apps
   pinned from All Apps. Settings lives next to Wi-Fi in the top-right status
   area, outside the grid.
@@ -27,6 +28,8 @@ compatibility:
   four-digit PIN.
 - The stylized Music tile shows an animated equalizer while media audio is
   active.
+- Settings → Media grants notification access for the active MediaSession title,
+  duration and position. CarDash does not read notification content.
 - The long-press hint fades away two minutes after app start.
 - The Home-role banner is gone; a one-time first-run tip points to Settings.
 - Long-pressing a pinned app opens Rename, Change icon and Remove. Rename/icon
@@ -57,6 +60,8 @@ compatibility:
 | 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. |
 | Media feedback | Stylized Music tile + animated bars while media is active | Compact visual feedback without adding a media control surface. |
+| Now-playing metadata | MediaSessionManager via a notification-listener component | The only public path to title, duration and position; read-only and scoped to media sessions. |
+| Song progress | Ring fraction from position/duration; scrolling title | Replaces the volume ring as requested; paused state holds position and scroll offset. |
 | Hint lifetime | Two minutes from process start, then fade out | Avoids permanent onboarding chrome; survives widget rebuilds via `AppClock`. |
 | Home role | Settings row only; one-time first-run tip | Removes permanent dashboard chrome while keeping discovery. |
 | Pinned editing | Long-press menu: Rename / Change icon / Remove | Keeps the home page clean; removal remains immediate. |
@@ -84,14 +89,15 @@ Flutter
   HomeScreen
     ClockBlock / StatusCluster / VolumeCard / ShortcutGrid
     LauncherController  -> apps, slots, pinned apps, 16-button cap, default-home state
-    SystemController    -> battery/Wi-Fi/volume/media polling + media keys
+    SystemController    -> battery/Wi-Fi/volume/media state + media keys
   AllAppsScreen / AppPickerSheet
   SettingsScreen -> General / Car data / Security
   PinSetupScreen / LockScreen -> LockController -> PinService
 Android (Kotlin)
   MainActivity -> keep-screen-on, volume keys, permission forwarding
                -> LauncherBridge (packages, icons, system flag, role)
-               -> SystemBridge (battery, Wi-Fi, volume, media activity)
+               -> SystemBridge (battery, Wi-Fi, volume, media activity, keys)
+               -> MediaBridge (MediaSession title/progress) + NotificationListener
                -> TelemetryBridge (Bluetooth devices, OBD, location)
                -> ObdService (Classic SPP ELM327 worker)
 ```
@@ -124,6 +130,7 @@ Android (Kotlin)
 | M11 | Restore stylized fixed icons; pinned apps show own icons by default; 29-icon selector | Done |
 | M12 | Accent-tinted dark theme, OBD-II adapter/telemetry, location weather fallback, Car data Settings | Done |
 | M13 | Play/pause volume ring, Settings gear in status area, 16-button cap without Settings | Done |
+| M14 | Song-progress ring, scrolling now-playing title, notification-access Settings card | Done |
 
 ## 5. Verification checklist
 
@@ -164,9 +171,13 @@ Android (Kotlin)
 - [x] OBD economy/card tests pass; native ELM327 path compiles.
 - [x] Location permission, reverse geocoding and Open-Meteo fallback verified live (Ukiah, 17°C).
 - [x] Temperature row renders below the date and follows OBD/weather priority.
-- [x] Circled volume ring shows Play/Pause; 33 tests include media keys and the ring.
+- [x] Circled ring shows song progress; 41 tests include media keys, metadata,
+  progress, the marquee and the ring.
+- [x] Scrolling title/artist verified with a live Chrome MediaSession; pause holds
+  both the ring and scroll position, resume continues.
+- [x] Settings → Media grants notification access and shows Granted.
 - [x] Settings gear is next to Wi-Fi; the grid ends with All apps.
-- [x] Release 0.7.0 installed on the emulator and physical Android 17 tablet.
+- [x] Release 0.8.0 installed on the emulator and physical Android 17 tablet.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
 ## 6. Known risks and mitigations
@@ -188,8 +199,9 @@ Android (Kotlin)
    limit explicit.
 7. **Screen-on battery use**: the flag applies only while the dashboard is
    foreground; it is not a background wake lock.
-8. **Media attribution**: the equalizer follows `AudioManager.isMusicActive`,
-   not a specific package. Exact attribution would require notification access.
+8. **Media metadata**: the equalizer follows `AudioManager.isMusicActive`;
+   title/progress query the active MediaSession through the notification-listener
+   component. Notification access is user-granted and scoped to media metadata.
 9. **Pinned customizations**: Rename affects only the Home label. Default
    restores the app icon; a chosen stylized icon is a bundled asset and cannot
    become unavailable.
@@ -245,3 +257,8 @@ Android (Kotlin)
   opens the assigned Music app when nothing responds). Settings moved next to
   Wi-Fi in the status area and no longer consumes a grid slot. Verified 33 tests
   and 0.7.0 on the emulator and physical tablet.
+- 2026-10-29: Replaced the volume ring with song progress and added the scrolling
+  now-playing title/artist. Added a MediaSession bridge and a Settings → Media
+  notification-access grant; no notification content is read. Verified metadata,
+  progress, scrolling/pause/resume with a live Chrome media session, 41 tests and
+  0.8.0 on emulator/tablet.

BIN
docs/screenshots/home-16-1024.png


BIN
docs/screenshots/home-16.png


BIN
docs/screenshots/home-now-playing.png


BIN
docs/screenshots/settings-media.png


+ 83 - 0
lib/data/models/media_playback.dart

@@ -0,0 +1,83 @@
+import 'package:flutter/foundation.dart';
+
+/// Read-only snapshot of the active Android media session.
+@immutable
+class MediaPlayback {
+  const MediaPlayback({
+    this.hasAccess = false,
+    this.hasSession = false,
+    this.playing = false,
+    this.title,
+    this.artist,
+    this.packageName,
+    this.durationMs = 0,
+    this.positionMs = 0,
+  });
+
+  final bool hasAccess;
+  final bool hasSession;
+  final bool playing;
+  final String? title;
+  final String? artist;
+  final String? packageName;
+  final int durationMs;
+  final int positionMs;
+
+  bool get hasTitle => (title?.trim().isNotEmpty ?? false);
+
+  String? get displayTitle {
+    final name = title?.trim();
+    if (name == null || name.isEmpty) return null;
+    final by = artist?.trim();
+    return by == null || by.isEmpty ? name : '$name — $by';
+  }
+
+  double get progress {
+    if (durationMs <= 0) return 0;
+    return (positionMs / durationMs).clamp(0.0, 1.0);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return other is MediaPlayback &&
+        other.hasAccess == hasAccess &&
+        other.hasSession == hasSession &&
+        other.playing == playing &&
+        other.title == title &&
+        other.artist == artist &&
+        other.packageName == packageName &&
+        other.durationMs == durationMs &&
+        other.positionMs == positionMs;
+  }
+
+  @override
+  int get hashCode => Object.hash(
+    hasAccess,
+    hasSession,
+    playing,
+    title,
+    artist,
+    packageName,
+    durationMs,
+    positionMs,
+  );
+
+  factory MediaPlayback.fromMap(Map<dynamic, dynamic> map) {
+    int integer(String key) => (map[key] as num?)?.toInt() ?? 0;
+    String? string(String key) {
+      final value = (map[key] as String?)?.trim();
+      return value == null || value.isEmpty ? null : value;
+    }
+
+    return MediaPlayback(
+      hasAccess: map['hasAccess'] == true,
+      hasSession: map['hasSession'] == true,
+      playing: map['playing'] == true,
+      title: string('title'),
+      artist: string('artist'),
+      packageName: string('packageName'),
+      durationMs: integer('durationMs'),
+      positionMs: integer('positionMs'),
+    );
+  }
+}

+ 32 - 0
lib/data/services/native_bridge.dart

@@ -1,6 +1,7 @@
 import 'package:flutter/services.dart';
 
 import '../models/app_entry.dart';
+import '../models/media_playback.dart';
 import '../models/system_status.dart';
 
 class LauncherPlatformException implements Exception {
@@ -36,6 +37,10 @@ abstract class LauncherPlatform {
 
   /// Sends a standard media key to the current media session.
   Future<bool> sendMediaKey(String action);
+
+  Future<MediaPlayback> getMediaState();
+
+  Future<bool> openNotificationAccessSettings();
 }
 
 class NativeBridge implements LauncherPlatform {
@@ -43,6 +48,7 @@ class NativeBridge implements LauncherPlatform {
   static const _systemChannel = MethodChannel(
     'com.wasp.cardash_launcher/system',
   );
+  static const _mediaChannel = MethodChannel('com.wasp.cardash_launcher/media');
 
   @override
   Future<List<AppEntry>> listApps() async {
@@ -145,6 +151,32 @@ class NativeBridge implements LauncherPlatform {
     }
   }
 
+  @override
+  Future<MediaPlayback> getMediaState() async {
+    try {
+      final raw = await _mediaChannel.invokeMethod<dynamic>('getMediaState');
+      return raw is Map ? MediaPlayback.fromMap(raw) : const MediaPlayback();
+    } on PlatformException {
+      return const MediaPlayback();
+    } on MissingPluginException {
+      return const MediaPlayback();
+    }
+  }
+
+  @override
+  Future<bool> openNotificationAccessSettings() async {
+    try {
+      return await _mediaChannel.invokeMethod<bool>(
+            'openNotificationAccessSettings',
+          ) ??
+          false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
   @override
   Future<bool> sendMediaKey(String action) async {
     try {

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

@@ -196,6 +196,7 @@ class HomeScreen extends StatelessWidget {
                                   isMusicPlaying: system.isMusicPlaying,
                                   onPlayPause: () =>
                                       unawaited(_toggleMusic(context)),
+                                  media: system.media,
                                 ),
                               ],
                             ),

+ 28 - 10
lib/features/home/system_controller.dart

@@ -2,6 +2,7 @@ import 'dart:async';
 
 import 'package:flutter/foundation.dart';
 
+import '../../data/models/media_playback.dart';
 import '../../data/models/system_status.dart';
 import '../../data/services/native_bridge.dart';
 
@@ -24,6 +25,7 @@ class SystemController extends ChangeNotifier {
   bool _statusBusy = false;
   bool _musicBusy = false;
   bool _musicPlaying = false;
+  MediaPlayback _media = const MediaPlayback();
   bool _disposed = false;
   int _lastNonZeroVolume = 0;
 
@@ -33,6 +35,10 @@ class SystemController extends ChangeNotifier {
   bool get isMuted => _volume.index <= 0;
   bool get isMusicPlaying => _musicPlaying;
 
+  MediaPlayback get media => _media;
+
+  bool get hasMediaAccess => _media.hasAccess;
+
   Future<void> start() async {
     if (_active) return;
     _active = true;
@@ -78,11 +84,15 @@ class SystemController extends ChangeNotifier {
     if (_musicBusy || !_active) return;
     _musicBusy = true;
     try {
-      final playing = await _platform.isMusicPlaying();
-      if (playing != _musicPlaying) {
-        _musicPlaying = playing;
-        _notify();
-      }
+      final media = await _platform.getMediaState();
+      final fallback = await _platform.isMusicPlaying();
+      final playing = media.hasAccess && media.hasSession
+          ? media.playing
+          : fallback;
+      final changed = media != _media || playing != _musicPlaying;
+      _media = media;
+      _musicPlaying = playing;
+      if (changed) _notify();
     } finally {
       _musicBusy = false;
     }
@@ -115,6 +125,10 @@ class SystemController extends ChangeNotifier {
 
   Future<void> adjustVolume(int delta) => setVolume(_volume.index + delta);
 
+  Future<bool> openMediaAccessSettings() {
+    return _platform.openNotificationAccessSettings();
+  }
+
   /// Sends a pause key immediately. The next poll reconciles the real state.
   Future<void> pauseMusic() async {
     try {
@@ -135,11 +149,15 @@ class SystemController extends ChangeNotifier {
       final sent = await _platform.sendMediaKey('play');
       if (!sent) return false;
       await Future<void>.delayed(const Duration(milliseconds: 450));
-      final playing = await _platform.isMusicPlaying();
-      if (playing != _musicPlaying) {
-        _musicPlaying = playing;
-        _notify();
-      }
+      final media = await _platform.getMediaState();
+      final fallback = await _platform.isMusicPlaying();
+      final playing = media.hasAccess && media.hasSession
+          ? media.playing
+          : fallback;
+      final changed = media != _media || playing != _musicPlaying;
+      _media = media;
+      _musicPlaying = playing;
+      if (changed) _notify();
       return playing;
     } catch (_) {
       return false;

+ 163 - 0
lib/features/home/widgets/marquee_text.dart

@@ -0,0 +1,163 @@
+import 'package:flutter/material.dart';
+
+/// One-line text that scrolls only while [animate] is true.
+///
+/// Long titles loop with a small gap; short titles (including "Nothing
+/// playing") stay static. Pausing stops the animation at its current offset,
+/// and resuming continues from there.
+class MarqueeText extends StatefulWidget {
+  const MarqueeText({
+    super.key,
+    required this.text,
+    this.style,
+    this.animate = false,
+    this.gap = 48,
+  });
+
+  final String text;
+  final TextStyle? style;
+  final bool animate;
+  final double gap;
+
+  @override
+  State<MarqueeText> createState() => _MarqueeTextState();
+}
+
+class _MarqueeTextState extends State<MarqueeText>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+  bool _needsScroll = false;
+  double _textWidth = 0;
+  bool _syncScheduled = false;
+
+  bool get _shouldAnimate => widget.animate && _needsScroll;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      vsync: this,
+      duration: const Duration(seconds: 10),
+    )..addStatusListener(_onStatus);
+  }
+
+  void _onStatus(AnimationStatus status) {
+    if (status == AnimationStatus.completed && _shouldAnimate) {
+      _controller.value = 0;
+      _controller.forward();
+    }
+  }
+
+  @override
+  void didUpdateWidget(covariant MarqueeText oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (oldWidget.text != widget.text || oldWidget.style != widget.style) {
+      _controller.value = 0;
+      _needsScroll = false;
+    }
+    _scheduleSync();
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  void _scheduleSync() {
+    if (_syncScheduled) return;
+    _syncScheduled = true;
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      _syncScheduled = false;
+      if (mounted) _sync();
+    });
+  }
+
+  void _sync() {
+    if (_shouldAnimate) {
+      if (!_controller.isAnimating) {
+        if (_controller.value >= 1) _controller.value = 0;
+        _controller.forward();
+      }
+    } else {
+      _controller.stop();
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return LayoutBuilder(
+      builder: (context, constraints) {
+        final style = widget.style ?? DefaultTextStyle.of(context).style;
+        final painter = TextPainter(
+          text: TextSpan(text: widget.text, style: style),
+          maxLines: 1,
+          textDirection: Directionality.of(context),
+          textScaler: MediaQuery.textScalerOf(context),
+        )..layout();
+        final width = painter.width;
+        final needsScroll =
+            constraints.hasBoundedWidth && width > constraints.maxWidth;
+        if (width != _textWidth || needsScroll != _needsScroll) {
+          _textWidth = width;
+          _needsScroll = needsScroll;
+          _scheduleSync();
+        }
+
+        if (!needsScroll) {
+          return Text(
+            widget.text,
+            maxLines: 1,
+            overflow: TextOverflow.ellipsis,
+            style: style,
+          );
+        }
+
+        final duration = Duration(
+          milliseconds: ((width / 28) * 1000).clamp(6000, 18000).round(),
+        );
+        if (_controller.duration != duration) {
+          _controller.duration = duration;
+        }
+
+        return ClipRect(
+          child: AnimatedBuilder(
+            animation: _controller,
+            builder: (context, _) {
+              final offset = _controller.value * (width + widget.gap);
+              return OverflowBox(
+                alignment: Alignment.centerLeft,
+                maxWidth: double.infinity,
+                maxHeight: constraints.maxHeight,
+                child: Transform.translate(
+                  offset: Offset(-offset, 0),
+                  child: SizedBox(
+                    width: width * 2 + widget.gap,
+                    child: Row(
+                      mainAxisSize: MainAxisSize.min,
+                      children: [
+                        Text(
+                          widget.text,
+                          maxLines: 1,
+                          softWrap: false,
+                          style: style,
+                        ),
+                        SizedBox(width: widget.gap),
+                        Text(
+                          widget.text,
+                          maxLines: 1,
+                          softWrap: false,
+                          style: style,
+                        ),
+                      ],
+                    ),
+                  ),
+                ),
+              );
+            },
+          ),
+        );
+      },
+    );
+  }
+}

+ 42 - 5
lib/features/home/widgets/volume_card.dart

@@ -6,7 +6,9 @@ import '../../../core/theme/app_colors.dart';
 import '../../../core/widgets/app_icon.dart';
 import '../../../core/widgets/pressable.dart';
 import '../../../core/widgets/surface_card.dart';
+import '../../../data/models/media_playback.dart';
 import '../../../data/models/system_status.dart';
+import 'marquee_text.dart';
 
 /// The primary media control: a play/pause ring, a slider and step/mute
 /// actions. The ring remains a volume gauge; its centre is the playback button.
@@ -18,6 +20,7 @@ class VolumeCard extends StatefulWidget {
     required this.onToggleMute,
     required this.isMusicPlaying,
     required this.onPlayPause,
+    required this.media,
     this.compact = false,
   });
 
@@ -26,6 +29,7 @@ class VolumeCard extends StatefulWidget {
   final VoidCallback onToggleMute;
   final bool isMusicPlaying;
   final VoidCallback onPlayPause;
+  final MediaPlayback media;
   final bool compact;
 
   @override
@@ -40,6 +44,11 @@ class _VolumeCardState extends State<VolumeCard> {
   bool get _muted => _value == 0;
   int get _percent => ((_value / _max) * 100).round();
 
+  String get _nowPlayingLabel {
+    return widget.media.displayTitle ??
+        (widget.isMusicPlaying ? 'Playing' : 'Nothing playing');
+  }
+
   @override
   Widget build(BuildContext context) {
     final colors = context.colors;
@@ -60,7 +69,7 @@ class _VolumeCardState extends State<VolumeCard> {
             semanticLabel: widget.isMusicPlaying ? 'Pause music' : 'Play music',
             pressedScale: 0.94,
             child: _VolumeRing(
-              fraction: displayValue / _max,
+              progress: widget.media.progress,
               isPlaying: widget.isMusicPlaying,
               size: compact ? 78 : 92,
             ),
@@ -173,6 +182,34 @@ class _VolumeCardState extends State<VolumeCard> {
                     ),
                   ],
                 ),
+                SizedBox(height: compact ? 8 : 10),
+                SizedBox(
+                  height: compact ? 18 : 20,
+                  child: Row(
+                    children: [
+                      Icon(
+                        Icons.music_note_rounded,
+                        size: compact ? 14 : 15,
+                        color: colors.inkMuted,
+                      ),
+                      const SizedBox(width: 6),
+                      Expanded(
+                        child: MarqueeText(
+                          text: _nowPlayingLabel,
+                          animate:
+                              widget.isMusicPlaying &&
+                              widget.media.displayTitle != null,
+                          style: theme.textTheme.bodySmall?.copyWith(
+                            color: widget.media.displayTitle == null
+                                ? colors.inkMuted
+                                : colors.ink,
+                            fontSize: compact ? 11 : 12,
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                ),
               ],
             ),
           ),
@@ -218,12 +255,12 @@ class _StepButton extends StatelessWidget {
 
 class _VolumeRing extends StatelessWidget {
   const _VolumeRing({
-    required this.fraction,
+    required this.progress,
     required this.isPlaying,
     required this.size,
   });
 
-  final double fraction;
+  final double progress;
   final bool isPlaying;
   final double size;
 
@@ -231,8 +268,8 @@ class _VolumeRing extends StatelessWidget {
   Widget build(BuildContext context) {
     final colors = context.colors;
     return TweenAnimationBuilder<double>(
-      tween: Tween(end: fraction.clamp(0.0, 1.0)),
-      duration: const Duration(milliseconds: 220),
+      tween: Tween(end: progress.clamp(0.0, 1.0)),
+      duration: Duration(milliseconds: isPlaying ? 850 : 250),
       curve: Curves.easeOutCubic,
       builder: (context, value, _) {
         return SizedBox(

+ 95 - 2
lib/features/settings/settings_screen.dart

@@ -11,6 +11,7 @@ import '../../core/widgets/surface_card.dart';
 import '../../data/models/slot_type.dart';
 import '../apps/app_picker_sheet.dart';
 import '../home/launcher_controller.dart';
+import '../home/system_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
 import 'car_data_section.dart';
@@ -24,6 +25,7 @@ class SettingsScreen extends StatelessWidget {
     final theme = Theme.of(context);
     final themeController = context.watch<ThemeController>();
     final launcher = context.watch<LauncherController>();
+    final system = context.watch<SystemController>();
     final lock = context.watch<LockController>();
 
     return Scaffold(
@@ -71,7 +73,7 @@ class SettingsScreen extends StatelessWidget {
                             ),
                             const SizedBox(height: 2),
                             Text(
-                              'Theme, car data and access',
+                              'Theme, media, car data and access',
                               style: theme.textTheme.bodySmall?.copyWith(
                                 color: colors.inkMuted,
                               ),
@@ -215,6 +217,13 @@ class SettingsScreen extends StatelessWidget {
                       ),
                     ),
                     const SizedBox(height: 30),
+                    _SectionTitle(title: 'Media', colors: colors, theme: theme),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 20, 26, 20),
+                      child: _NowPlayingAccessRow(system: system),
+                    ),
+                    const SizedBox(height: 30),
                     _SectionTitle(
                       title: 'Car data',
                       colors: colors,
@@ -318,7 +327,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.7.0 · Android 10+',
+                        'CarDash 0.8.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                         ),
@@ -621,6 +630,90 @@ class _BatteryToggleRow extends StatelessWidget {
   }
 }
 
+class _NowPlayingAccessRow extends StatelessWidget {
+  const _NowPlayingAccessRow({required this.system});
+
+  final SystemController system;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final granted = system.hasMediaAccess;
+
+    return Row(
+      children: [
+        Container(
+          width: 42,
+          height: 42,
+          decoration: BoxDecoration(
+            color: colors.accentSoft,
+            borderRadius: BorderRadius.circular(14),
+          ),
+          alignment: Alignment.center,
+          child: AppIcon(
+            'assets/icons/music.svg',
+            size: 20,
+            color: colors.accent,
+          ),
+        ),
+        const SizedBox(width: 14),
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('Now playing', style: theme.textTheme.titleMedium),
+              const SizedBox(height: 2),
+              Text(
+                granted
+                    ? 'Notification access is on. Song title and progress are read-only.'
+                    : 'Allow notification access to show the song title and progress. No notification content is read.',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(width: 12),
+        if (granted)
+          Row(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Icon(Icons.check_circle_rounded, size: 17, color: colors.accent),
+              const SizedBox(width: 6),
+              Text(
+                'Granted',
+                style: theme.textTheme.labelMedium?.copyWith(
+                  color: colors.accent,
+                ),
+              ),
+            ],
+          )
+        else
+          TextButton(
+            onPressed: () async {
+              final opened = await system.openMediaAccessSettings();
+              if (!opened && context.mounted) {
+                final messenger = ScaffoldMessenger.of(context);
+                messenger.hideCurrentSnackBar();
+                messenger.showSnackBar(
+                  const SnackBar(
+                    content: Text(
+                      'Could not open notification access settings.',
+                    ),
+                  ),
+                );
+              }
+            },
+            style: TextButton.styleFrom(foregroundColor: colors.accent),
+            child: const Text('Allow'),
+          ),
+      ],
+    );
+  }
+}
+
 class _ActionRow extends StatelessWidget {
   const _ActionRow({
     required this.iconAsset,

+ 1 - 1
pubspec.yaml

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

+ 7 - 0
test/launcher_controller_test.dart

@@ -1,4 +1,5 @@
 import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/media_playback.dart';
 import 'package:cardash_launcher/data/models/slot_type.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
@@ -41,6 +42,12 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
   @override
   Future<bool> sendMediaKey(String action) async => true;
+
+  @override
+  Future<MediaPlayback> getMediaState() async => const MediaPlayback();
+
+  @override
+  Future<bool> openNotificationAccessSettings() async => true;
 }
 
 List<AppEntry> _apps(int count) {

+ 39 - 0
test/marquee_text_test.dart

@@ -0,0 +1,39 @@
+import 'package:cardash_launcher/features/home/widgets/marquee_text.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('long text duplicates for a seamless loop', (tester) async {
+    const title = 'A very long song title that cannot possibly fit';
+    await tester.pumpWidget(
+      const MaterialApp(
+        home: Scaffold(
+          body: SizedBox(
+            width: 140,
+            child: MarqueeText(text: title, animate: true),
+          ),
+        ),
+      ),
+    );
+    await tester.pump(const Duration(milliseconds: 100));
+
+    expect(tester.takeException(), isNull);
+    expect(find.text(title), findsNWidgets(2));
+  });
+
+  testWidgets('paused marquee does not animate', (tester) async {
+    const title = 'A very long song title that cannot possibly fit';
+    await tester.pumpWidget(
+      const MaterialApp(
+        home: Scaffold(
+          body: SizedBox(
+            width: 140,
+            child: MarqueeText(text: title, animate: false),
+          ),
+        ),
+      ),
+    );
+    await tester.pump();
+    expect(tester.binding.transientCallbackCount, 0);
+  });
+}

+ 34 - 0
test/media_playback_test.dart

@@ -0,0 +1,34 @@
+import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('parses media session metadata and progress', () {
+    final media = MediaPlayback.fromMap({
+      'hasAccess': true,
+      'hasSession': true,
+      'playing': true,
+      'title': 'Night Drive',
+      'artist': 'The Dashboard',
+      'durationMs': 240000,
+      'positionMs': 60000,
+    });
+
+    expect(media.hasTitle, isTrue);
+    expect(media.displayTitle, 'Night Drive — The Dashboard');
+    expect(media.progress, 0.25);
+    expect(media.playing, isTrue);
+    expect(media.hasAccess, isTrue);
+  });
+
+  test('empty media state has no title or progress', () {
+    const media = MediaPlayback();
+    expect(media.hasTitle, isFalse);
+    expect(media.displayTitle, isNull);
+    expect(media.progress, 0);
+  });
+
+  test('progress is clamped for a malformed duration position', () {
+    const media = MediaPlayback(durationMs: 1000, positionMs: 4000);
+    expect(media.progress, 1.0);
+  });
+}

+ 30 - 0
test/system_controller_test.dart

@@ -1,4 +1,5 @@
 import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/media_playback.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/features/home/system_controller.dart';
@@ -6,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
 
 class _MediaPlatform implements LauncherPlatform {
   bool playing = false;
+  MediaPlayback media = const MediaPlayback();
   final List<String> keys = [];
 
   @override
@@ -42,6 +44,12 @@ class _MediaPlatform implements LauncherPlatform {
     if (action == 'pause') playing = false;
     return true;
   }
+
+  @override
+  Future<MediaPlayback> getMediaState() async => media;
+
+  @override
+  Future<bool> openNotificationAccessSettings() async => true;
 }
 
 void main() {
@@ -58,6 +66,28 @@ void main() {
     controller.stop();
   });
 
+  test('media state supplies the title, progress and playing flag', () async {
+    final platform = _MediaPlatform()
+      ..media = const MediaPlayback(
+        hasAccess: true,
+        hasSession: true,
+        playing: true,
+        title: 'Night Drive',
+        artist: 'The Dashboard',
+        durationMs: 240000,
+        positionMs: 60000,
+      );
+    final controller = SystemController(platform);
+    await controller.start();
+    addTearDown(controller.dispose);
+
+    expect(controller.hasMediaAccess, isTrue);
+    expect(controller.media.displayTitle, 'Night Drive — The Dashboard');
+    expect(controller.media.progress, 0.25);
+    expect(controller.isMusicPlaying, isTrue);
+    controller.stop();
+  });
+
   test('play resumes and reports whether audio started', () async {
     final platform = _MediaPlatform();
     final controller = SystemController(platform);

+ 56 - 0
test/volume_card_test.dart

@@ -1,10 +1,62 @@
 import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/media_playback.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/features/home/widgets/volume_card.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() {
+  testWidgets('the card shows the now-playing title', (tester) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.light(),
+        home: const Scaffold(
+          body: VolumeCard(
+            volume: VolumeInfo(index: 8, max: 15),
+            onSetVolume: _ignoreVolume,
+            onToggleMute: _ignore,
+            isMusicPlaying: true,
+            onPlayPause: _ignore,
+            media: MediaPlayback(
+              hasAccess: true,
+              hasSession: true,
+              playing: true,
+              title: 'Night Drive',
+              artist: 'The Dashboard',
+              durationMs: 240000,
+              positionMs: 60000,
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text('Night Drive — The Dashboard'), findsOneWidget);
+    expect(find.text('Nothing playing'), findsNothing);
+  });
+
+  testWidgets('the card shows Nothing playing without a session', (
+    tester,
+  ) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.dark(),
+        home: const Scaffold(
+          body: VolumeCard(
+            volume: VolumeInfo(index: 8, max: 15),
+            onSetVolume: _ignoreVolume,
+            onToggleMute: _ignore,
+            isMusicPlaying: false,
+            onPlayPause: _ignore,
+            media: MediaPlayback(),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text('Nothing playing'), findsOneWidget);
+  });
+
   testWidgets('the ring switches between play and pause', (tester) async {
     var playing = false;
     var taps = 0;
@@ -20,6 +72,7 @@ void main() {
                 onSetVolume: (_) {},
                 onToggleMute: () {},
                 isMusicPlaying: playing,
+                media: const MediaPlayback(),
                 onPlayPause: () {
                   taps++;
                   setState(() => playing = !playing);
@@ -41,3 +94,6 @@ void main() {
     expect(find.byIcon(Icons.play_arrow_rounded), findsNothing);
   });
 }
+
+void _ignore() {}
+void _ignoreVolume(int _) {}