Procházet zdrojové kódy

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 před 1 týdnem
rodič
revize
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
 ## 3. Architecture
 
 
 - `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController` (theme/accent/battery), `AppClock`, reusable widgets.
 - `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/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/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/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, equalizer.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
 - `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`.
 - `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.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
 
 
 Rules:
 Rules:
@@ -77,7 +77,7 @@ notifyListeners();
 - NEVER change the application ID, HOME intent filter, landscape lock, or minSdk without human confirmation.
 - 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 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.
 - 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.
 - 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.
 - 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.
 - `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.
 - 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.
 - `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.
 - 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.
-- 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.
 - 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.
 A calm, configurable Android car dashboard launcher, built with Flutter.
 
 
 CarDash replaces the tablet home screen with one landscape page: a large clock,
 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
 **Navigation**, **Music**, **Radio**, **Settings**, **All apps**, plus any apps
 you pin from All Apps. The first three can be shown, hidden and assigned to
 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
 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) ·
 [app picker](docs/screenshots/app-picker.png) ·
 [Settings · accent & buttons](docs/screenshots/settings-buttons.png) ·
 [Settings · accent & buttons](docs/screenshots/settings-buttons.png) ·
 [Settings · Car data](docs/screenshots/settings-car-data.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) ·
 [Blue accent](docs/screenshots/settings-blue-accent.png) ·
 [user-app icon on Home](docs/screenshots/home-user-icon.png) ·
 [user-app icon on Home](docs/screenshots/home-user-icon.png) ·
 [pinned-app menu](docs/screenshots/home-app-menu.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
   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
   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.
   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,
 - **Configurable home buttons** — switches in Settings show or hide Navigation,
   Music and Radio. **All apps** is always the last button; **Settings** now sits
   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.
   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 | Pick a paired ELM327 or None |
 | Settings → Car data → Location temperature | Enable GPS weather fallback |
 | Settings → Car data → Location temperature | Enable GPS weather fallback |
 | Temperature row | OBD ambient, or location weather when OBD has none |
 | 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 |
 | All apps | Search every launchable app; tap to open, hold to pin |
 | Music tile while media is active | Shows the selected app icon + equalizer |
 | 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 |
 | Screen | Stays awake while CarDash is in front |
-| Volume ring / mute | Toggle media mute |
+| Mute pill | Toggle media mute |
 | Slider or − / + | Set media volume |
 | 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
 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
 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.
 - Settings is always available from the gear next to Wi-Fi.
 - Home role status and shortcut to the system prompt.
 - 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**
 **Car data**
 - OBD-II adapter dropdown: None plus paired Bluetooth Classic devices.
 - OBD-II adapter dropdown: None plus paired Bluetooth Classic devices.
 - Connection status and a Find/Allow action for Bluetooth permission.
 - 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
 - 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
 - The OBD layer is read-only, Bluetooth Classic SPP, bonded devices only. A
   worker thread serializes ELM327 requests; Flutter polls the latest snapshot.
   worker thread serializes ELM327 requests; Flutter polls the latest snapshot.
 - `SettingsRepository` is the only persistence path. Controllers are
 - `SettingsRepository` is the only persistence path. Controllers are
@@ -236,17 +249,19 @@ flutter test
 ./tool/check.sh
 ./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,
 dark accent tinting, battery visibility, first-run state, slot visibility,
 pinned apps, custom labels/icons, the 16-button cap, the dense shortcut grid,
 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
 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
 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
 ## Troubleshooting the emulator
 
 
@@ -276,10 +291,12 @@ Android 15 AVD is left running headlessly after development; use
   the 29 stylized choices.
   the 29 stylized choices.
 - **Accent-tinted dark theme** — dark canvas, surfaces and borders blend toward
 - **Accent-tinted dark theme** — dark canvas, surfaces and borders blend toward
   the selected accent, so Blue no longer shows a green base.
   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
 - **Screen-on** applies while CarDash is the foreground window. Android can
   still sleep while another app is in front.
   still sleep while another app is in front.
 - **Onboarding hint** is anchored to process start and disappears after two
 - **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.
   theme. Default restores the pinned app's own icon.
 - **Play/pause uses Android media keys.** It resumes a paused session when one
 - **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
   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,
 - **OBD-II is a subset**: Bluetooth Classic SPP only, paired devices only,
   read-only. Speed, throttle, MAF economy and ambient temperature are implemented;
   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.
   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.
 - Optional compact now-playing control beside the volume card.
 - Real-car ELM327 soak test (speed/throttle/MAF/ambient).
 - Real-car ELM327 soak test (speed/throttle/MAF/ambient).
-- Exact per-app media detection via notification access, if desired.
 - Release signing configuration.
 - Release signing configuration.
 - Real-device soak test on an Android 10 car tablet.
 - Real-device soak test on an Android 10 car tablet.
 
 

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

@@ -52,6 +52,16 @@
             </intent-filter>
             </intent-filter>
         </activity>
         </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
         <meta-data
             android:name="flutterEmbedding"
             android:name="flutterEmbedding"
             android:value="2" />
             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 launcherBridge: LauncherBridge? = null
     private var systemBridge: SystemBridge? = null
     private var systemBridge: SystemBridge? = null
     private var telemetryBridge: TelemetryBridge? = null
     private var telemetryBridge: TelemetryBridge? = null
+    private var mediaBridge: MediaBridge? = null
 
 
     override fun onCreate(savedInstanceState: Bundle?) {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         super.onCreate(savedInstanceState)
@@ -32,6 +33,9 @@ class MainActivity : FlutterActivity() {
         telemetryBridge = TelemetryBridge(this).also {
         telemetryBridge = TelemetryBridge(this).also {
             it.attach(flutterEngine.dartExecutor.binaryMessenger)
             it.attach(flutterEngine.dartExecutor.binaryMessenger)
         }
         }
+        mediaBridge = MediaBridge(this).also {
+            it.attach(flutterEngine.dartExecutor.binaryMessenger)
+        }
     }
     }
 
 
     override fun onRequestPermissionsResult(
     override fun onRequestPermissionsResult(
@@ -81,9 +85,11 @@ class MainActivity : FlutterActivity() {
         launcherBridge?.dispose()
         launcherBridge?.dispose()
         systemBridge?.dispose()
         systemBridge?.dispose()
         telemetryBridge?.dispose()
         telemetryBridge?.dispose()
+        mediaBridge?.dispose()
         launcherBridge = null
         launcherBridge = null
         systemBridge = null
         systemBridge = null
         telemetryBridge = null
         telemetryBridge = null
+        mediaBridge = null
         super.onDestroy()
         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.
 - One home page. No top bar, no bottom dock, no pages.
 - Clock/date, battery/charging and Wi-Fi on the page.
 - 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
 - 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
   pinned from All Apps. Settings lives next to Wi-Fi in the top-right status
   area, outside the grid.
   area, outside the grid.
@@ -27,6 +28,8 @@ compatibility:
   four-digit PIN.
   four-digit PIN.
 - The stylized Music tile shows an animated equalizer while media audio is
 - The stylized Music tile shows an animated equalizer while media audio is
   active.
   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 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.
 - 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
 - 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. |
 | 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. |
 | Media feedback | Stylized Music tile + animated bars while media is active | Compact visual feedback without adding a media control surface. |
 | 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`. |
 | 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. |
 | 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. |
 | Pinned editing | Long-press menu: Rename / Change icon / Remove | Keeps the home page clean; removal remains immediate. |
@@ -84,14 +89,15 @@ Flutter
   HomeScreen
   HomeScreen
     ClockBlock / StatusCluster / VolumeCard / ShortcutGrid
     ClockBlock / StatusCluster / VolumeCard / ShortcutGrid
     LauncherController  -> apps, slots, pinned apps, 16-button cap, default-home state
     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
   AllAppsScreen / AppPickerSheet
   SettingsScreen -> General / Car data / Security
   SettingsScreen -> General / Car data / Security
   PinSetupScreen / LockScreen -> LockController -> PinService
   PinSetupScreen / LockScreen -> LockController -> PinService
 Android (Kotlin)
 Android (Kotlin)
   MainActivity -> keep-screen-on, volume keys, permission forwarding
   MainActivity -> keep-screen-on, volume keys, permission forwarding
                -> LauncherBridge (packages, icons, system flag, role)
                -> 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)
                -> TelemetryBridge (Bluetooth devices, OBD, location)
                -> ObdService (Classic SPP ELM327 worker)
                -> 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 |
 | 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 |
 | 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 |
 | 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
 ## 5. Verification checklist
 
 
@@ -164,9 +171,13 @@ Android (Kotlin)
 - [x] OBD economy/card tests pass; native ELM327 path compiles.
 - [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] 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] 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] 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.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
 
 ## 6. Known risks and mitigations
 ## 6. Known risks and mitigations
@@ -188,8 +199,9 @@ Android (Kotlin)
    limit explicit.
    limit explicit.
 7. **Screen-on battery use**: the flag applies only while the dashboard is
 7. **Screen-on battery use**: the flag applies only while the dashboard is
    foreground; it is not a background wake lock.
    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
 9. **Pinned customizations**: Rename affects only the Home label. Default
    restores the app icon; a chosen stylized icon is a bundled asset and cannot
    restores the app icon; a chosen stylized icon is a bundled asset and cannot
    become unavailable.
    become unavailable.
@@ -245,3 +257,8 @@ Android (Kotlin)
   opens the assigned Music app when nothing responds). Settings moved next to
   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
   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.
   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ární
docs/screenshots/home-16-1024.png


binární
docs/screenshots/home-16.png


binární
docs/screenshots/home-now-playing.png


binární
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 'package:flutter/services.dart';
 
 
 import '../models/app_entry.dart';
 import '../models/app_entry.dart';
+import '../models/media_playback.dart';
 import '../models/system_status.dart';
 import '../models/system_status.dart';
 
 
 class LauncherPlatformException implements Exception {
 class LauncherPlatformException implements Exception {
@@ -36,6 +37,10 @@ abstract class LauncherPlatform {
 
 
   /// Sends a standard media key to the current media session.
   /// Sends a standard media key to the current media session.
   Future<bool> sendMediaKey(String action);
   Future<bool> sendMediaKey(String action);
+
+  Future<MediaPlayback> getMediaState();
+
+  Future<bool> openNotificationAccessSettings();
 }
 }
 
 
 class NativeBridge implements LauncherPlatform {
 class NativeBridge implements LauncherPlatform {
@@ -43,6 +48,7 @@ class NativeBridge implements LauncherPlatform {
   static const _systemChannel = MethodChannel(
   static const _systemChannel = MethodChannel(
     'com.wasp.cardash_launcher/system',
     'com.wasp.cardash_launcher/system',
   );
   );
+  static const _mediaChannel = MethodChannel('com.wasp.cardash_launcher/media');
 
 
   @override
   @override
   Future<List<AppEntry>> listApps() async {
   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
   @override
   Future<bool> sendMediaKey(String action) async {
   Future<bool> sendMediaKey(String action) async {
     try {
     try {

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

@@ -196,6 +196,7 @@ class HomeScreen extends StatelessWidget {
                                   isMusicPlaying: system.isMusicPlaying,
                                   isMusicPlaying: system.isMusicPlaying,
                                   onPlayPause: () =>
                                   onPlayPause: () =>
                                       unawaited(_toggleMusic(context)),
                                       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 'package:flutter/foundation.dart';
 
 
+import '../../data/models/media_playback.dart';
 import '../../data/models/system_status.dart';
 import '../../data/models/system_status.dart';
 import '../../data/services/native_bridge.dart';
 import '../../data/services/native_bridge.dart';
 
 
@@ -24,6 +25,7 @@ class SystemController extends ChangeNotifier {
   bool _statusBusy = false;
   bool _statusBusy = false;
   bool _musicBusy = false;
   bool _musicBusy = false;
   bool _musicPlaying = false;
   bool _musicPlaying = false;
+  MediaPlayback _media = const MediaPlayback();
   bool _disposed = false;
   bool _disposed = false;
   int _lastNonZeroVolume = 0;
   int _lastNonZeroVolume = 0;
 
 
@@ -33,6 +35,10 @@ class SystemController extends ChangeNotifier {
   bool get isMuted => _volume.index <= 0;
   bool get isMuted => _volume.index <= 0;
   bool get isMusicPlaying => _musicPlaying;
   bool get isMusicPlaying => _musicPlaying;
 
 
+  MediaPlayback get media => _media;
+
+  bool get hasMediaAccess => _media.hasAccess;
+
   Future<void> start() async {
   Future<void> start() async {
     if (_active) return;
     if (_active) return;
     _active = true;
     _active = true;
@@ -78,11 +84,15 @@ class SystemController extends ChangeNotifier {
     if (_musicBusy || !_active) return;
     if (_musicBusy || !_active) return;
     _musicBusy = true;
     _musicBusy = true;
     try {
     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 {
     } finally {
       _musicBusy = false;
       _musicBusy = false;
     }
     }
@@ -115,6 +125,10 @@ class SystemController extends ChangeNotifier {
 
 
   Future<void> adjustVolume(int delta) => setVolume(_volume.index + delta);
   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.
   /// Sends a pause key immediately. The next poll reconciles the real state.
   Future<void> pauseMusic() async {
   Future<void> pauseMusic() async {
     try {
     try {
@@ -135,11 +149,15 @@ class SystemController extends ChangeNotifier {
       final sent = await _platform.sendMediaKey('play');
       final sent = await _platform.sendMediaKey('play');
       if (!sent) return false;
       if (!sent) return false;
       await Future<void>.delayed(const Duration(milliseconds: 450));
       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;
       return playing;
     } catch (_) {
     } catch (_) {
       return false;
       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/app_icon.dart';
 import '../../../core/widgets/pressable.dart';
 import '../../../core/widgets/pressable.dart';
 import '../../../core/widgets/surface_card.dart';
 import '../../../core/widgets/surface_card.dart';
+import '../../../data/models/media_playback.dart';
 import '../../../data/models/system_status.dart';
 import '../../../data/models/system_status.dart';
+import 'marquee_text.dart';
 
 
 /// The primary media control: a play/pause ring, a slider and step/mute
 /// 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.
 /// 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.onToggleMute,
     required this.isMusicPlaying,
     required this.isMusicPlaying,
     required this.onPlayPause,
     required this.onPlayPause,
+    required this.media,
     this.compact = false,
     this.compact = false,
   });
   });
 
 
@@ -26,6 +29,7 @@ class VolumeCard extends StatefulWidget {
   final VoidCallback onToggleMute;
   final VoidCallback onToggleMute;
   final bool isMusicPlaying;
   final bool isMusicPlaying;
   final VoidCallback onPlayPause;
   final VoidCallback onPlayPause;
+  final MediaPlayback media;
   final bool compact;
   final bool compact;
 
 
   @override
   @override
@@ -40,6 +44,11 @@ class _VolumeCardState extends State<VolumeCard> {
   bool get _muted => _value == 0;
   bool get _muted => _value == 0;
   int get _percent => ((_value / _max) * 100).round();
   int get _percent => ((_value / _max) * 100).round();
 
 
+  String get _nowPlayingLabel {
+    return widget.media.displayTitle ??
+        (widget.isMusicPlaying ? 'Playing' : 'Nothing playing');
+  }
+
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     final colors = context.colors;
     final colors = context.colors;
@@ -60,7 +69,7 @@ class _VolumeCardState extends State<VolumeCard> {
             semanticLabel: widget.isMusicPlaying ? 'Pause music' : 'Play music',
             semanticLabel: widget.isMusicPlaying ? 'Pause music' : 'Play music',
             pressedScale: 0.94,
             pressedScale: 0.94,
             child: _VolumeRing(
             child: _VolumeRing(
-              fraction: displayValue / _max,
+              progress: widget.media.progress,
               isPlaying: widget.isMusicPlaying,
               isPlaying: widget.isMusicPlaying,
               size: compact ? 78 : 92,
               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 {
 class _VolumeRing extends StatelessWidget {
   const _VolumeRing({
   const _VolumeRing({
-    required this.fraction,
+    required this.progress,
     required this.isPlaying,
     required this.isPlaying,
     required this.size,
     required this.size,
   });
   });
 
 
-  final double fraction;
+  final double progress;
   final bool isPlaying;
   final bool isPlaying;
   final double size;
   final double size;
 
 
@@ -231,8 +268,8 @@ class _VolumeRing extends StatelessWidget {
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     final colors = context.colors;
     final colors = context.colors;
     return TweenAnimationBuilder<double>(
     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,
       curve: Curves.easeOutCubic,
       builder: (context, value, _) {
       builder: (context, value, _) {
         return SizedBox(
         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 '../../data/models/slot_type.dart';
 import '../apps/app_picker_sheet.dart';
 import '../apps/app_picker_sheet.dart';
 import '../home/launcher_controller.dart';
 import '../home/launcher_controller.dart';
+import '../home/system_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
 import '../lock/pin_setup_screen.dart';
 import 'car_data_section.dart';
 import 'car_data_section.dart';
@@ -24,6 +25,7 @@ class SettingsScreen extends StatelessWidget {
     final theme = Theme.of(context);
     final theme = Theme.of(context);
     final themeController = context.watch<ThemeController>();
     final themeController = context.watch<ThemeController>();
     final launcher = context.watch<LauncherController>();
     final launcher = context.watch<LauncherController>();
+    final system = context.watch<SystemController>();
     final lock = context.watch<LockController>();
     final lock = context.watch<LockController>();
 
 
     return Scaffold(
     return Scaffold(
@@ -71,7 +73,7 @@ class SettingsScreen extends StatelessWidget {
                             ),
                             ),
                             const SizedBox(height: 2),
                             const SizedBox(height: 2),
                             Text(
                             Text(
-                              'Theme, car data and access',
+                              'Theme, media, car data and access',
                               style: theme.textTheme.bodySmall?.copyWith(
                               style: theme.textTheme.bodySmall?.copyWith(
                                 color: colors.inkMuted,
                                 color: colors.inkMuted,
                               ),
                               ),
@@ -215,6 +217,13 @@ class SettingsScreen extends StatelessWidget {
                       ),
                       ),
                     ),
                     ),
                     const SizedBox(height: 30),
                     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(
                     _SectionTitle(
                       title: 'Car data',
                       title: 'Car data',
                       colors: colors,
                       colors: colors,
@@ -318,7 +327,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     const SizedBox(height: 28),
                     Center(
                     Center(
                       child: Text(
                       child: Text(
-                        'CarDash 0.7.0 · Android 10+',
+                        'CarDash 0.8.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                           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 {
 class _ActionRow extends StatelessWidget {
   const _ActionRow({
   const _ActionRow({
     required this.iconAsset,
     required this.iconAsset,

+ 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: 0.7.0+7
+version: 0.8.0+8
 
 
 environment:
 environment:
   sdk: ^3.13.7
   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/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/slot_type.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
@@ -41,6 +42,12 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
 
   @override
   @override
   Future<bool> sendMediaKey(String action) async => true;
   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) {
 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/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/models/system_status.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/features/home/system_controller.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 {
 class _MediaPlatform implements LauncherPlatform {
   bool playing = false;
   bool playing = false;
+  MediaPlayback media = const MediaPlayback();
   final List<String> keys = [];
   final List<String> keys = [];
 
 
   @override
   @override
@@ -42,6 +44,12 @@ class _MediaPlatform implements LauncherPlatform {
     if (action == 'pause') playing = false;
     if (action == 'pause') playing = false;
     return true;
     return true;
   }
   }
+
+  @override
+  Future<MediaPlayback> getMediaState() async => media;
+
+  @override
+  Future<bool> openNotificationAccessSettings() async => true;
 }
 }
 
 
 void main() {
 void main() {
@@ -58,6 +66,28 @@ void main() {
     controller.stop();
     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 {
   test('play resumes and reports whether audio started', () async {
     final platform = _MediaPlatform();
     final platform = _MediaPlatform();
     final controller = SystemController(platform);
     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/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/data/models/system_status.dart';
 import 'package:cardash_launcher/features/home/widgets/volume_card.dart';
 import 'package:cardash_launcher/features/home/widgets/volume_card.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 
 void main() {
 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 {
   testWidgets('the ring switches between play and pause', (tester) async {
     var playing = false;
     var playing = false;
     var taps = 0;
     var taps = 0;
@@ -20,6 +72,7 @@ void main() {
                 onSetVolume: (_) {},
                 onSetVolume: (_) {},
                 onToggleMute: () {},
                 onToggleMute: () {},
                 isMusicPlaying: playing,
                 isMusicPlaying: playing,
+                media: const MediaPlayback(),
                 onPlayPause: () {
                 onPlayPause: () {
                   taps++;
                   taps++;
                   setState(() => playing = !playing);
                   setState(() => playing = !playing);
@@ -41,3 +94,6 @@ void main() {
     expect(find.byIcon(Icons.play_arrow_rounded), findsNothing);
     expect(find.byIcon(Icons.play_arrow_rounded), findsNothing);
   });
   });
 }
 }
+
+void _ignore() {}
+void _ignoreVolume(int _) {}