Sfoglia il codice sorgente

feat: add Jellyfin dashboard and media playback

Optional Jellyfin Home button after Music with a dashboard (recently played, recently added, grouped-genre random, six cards when they fit), album page, track list and transport. Media3 foreground playback, media notification, audio focus, exclusive Home equalizers and clipped tile labels. Updates the 1.2.0 docs, tests and version.
Krasimir Kazakov 6 giorni fa
parent
commit
e8fc0cb3e6
54 ha cambiato i file con 6803 aggiunte e 284 eliminazioni
  1. 18 14
      AGENTS.md
  2. 107 49
      README.md
  3. 5 0
      android/app/build.gradle.kts
  4. 13 0
      android/app/src/main/AndroidManifest.xml
  5. 285 0
      android/app/src/main/kotlin/com/wasp/cardash_launcher/JellyfinPlaybackBridge.kt
  6. 115 0
      android/app/src/main/kotlin/com/wasp/cardash_launcher/JellyfinPlaybackReporter.kt
  7. 319 0
      android/app/src/main/kotlin/com/wasp/cardash_launcher/JellyfinPlaybackService.kt
  8. 11 0
      android/app/src/main/kotlin/com/wasp/cardash_launcher/MainActivity.kt
  9. 9 0
      android/app/src/main/res/drawable/ic_jellyfin_playback.xml
  10. 4 0
      android/app/src/main/res/values/strings.xml
  11. 18 0
      assets/icons/refresh-cw.svg
  12. 16 0
      assets/icons/skip-back.svg
  13. 16 0
      assets/icons/skip-forward.svg
  14. 74 16
      docs/IMPLEMENTATION_PLAN.md
  15. 59 31
      docs/JELLYFIN_1.2.0_PLAN.md
  16. 19 0
      lib/app.dart
  17. 1 1
      lib/core/app_version.dart
  18. 295 0
      lib/data/models/jellyfin_library.dart
  19. 39 0
      lib/data/models/jellyfin_models.dart
  20. 235 0
      lib/data/models/jellyfin_playback.dart
  21. 11 1
      lib/data/models/slot_type.dart
  22. 40 1
      lib/data/repositories/settings_repository.dart
  23. 299 0
      lib/data/services/jellyfin_genre_catalog.dart
  24. 234 0
      lib/data/services/jellyfin_library_service.dart
  25. 81 0
      lib/data/services/jellyfin_playback_bridge.dart
  26. 29 0
      lib/data/services/jellyfin_playback_platform.dart
  27. 283 4
      lib/data/services/jellyfin_service.dart
  28. 53 8
      lib/features/home/home_screen.dart
  29. 28 2
      lib/features/home/launcher_controller.dart
  30. 93 90
      lib/features/home/widgets/shortcut_tile.dart
  31. 124 40
      lib/features/home/widgets/volume_card.dart
  32. 482 0
      lib/features/jellyfin/jellyfin_album_screen.dart
  33. 168 0
      lib/features/jellyfin/jellyfin_dashboard_controller.dart
  34. 571 0
      lib/features/jellyfin/jellyfin_dashboard_screen.dart
  35. 294 0
      lib/features/jellyfin/jellyfin_playback_controller.dart
  36. 34 0
      lib/features/jellyfin/widgets/jellyfin_back_button.dart
  37. 34 0
      lib/features/settings/jellyfin_controller.dart
  38. 341 0
      lib/features/settings/jellyfin_genre_dialog.dart
  39. 161 2
      lib/features/settings/jellyfin_settings_card.dart
  40. 33 12
      lib/features/settings/settings_screen.dart
  41. 19 9
      lib/features/settings/settings_widgets.dart
  42. 21 3
      lib/main.dart
  43. 1 1
      pubspec.yaml
  44. 95 0
      test/jellyfin_album_screen_test.dart
  45. 175 0
      test/jellyfin_dashboard_controller_test.dart
  46. 145 0
      test/jellyfin_dashboard_screen_test.dart
  47. 422 0
      test/jellyfin_fakes.dart
  48. 255 0
      test/jellyfin_genre_catalog_test.dart
  49. 82 0
      test/jellyfin_genre_dialog_test.dart
  50. 226 0
      test/jellyfin_library_service_test.dart
  51. 201 0
      test/jellyfin_playback_controller_test.dart
  52. 75 0
      test/launcher_controller_test.dart
  53. 6 0
      test/settings_screen_test.dart
  54. 29 0
      test/shortcut_grid_test.dart

+ 18 - 14
AGENTS.md

@@ -34,14 +34,15 @@ 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`, `DisplayController` (keep-awake), reusable widgets.
-- `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
-- `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, Jellyfin Quick Connect gateway, secure-store adapter.
-- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, speed/temperature choices, and the Jellyfin server URL/enable flag.
-- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, `TelemetrySettingsController` (speed/temperature visibility and sources), equalizer, night overlay.
+- `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, Jellyfin library/connection/playback models, OBD/weather models.
+- `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, Jellyfin Quick Connect + library gateway, `JellyfinGenreCatalog`, `JellyfinLibraryService`, `JellyfinPlaybackPlatform`/`NativeJellyfinPlayback`, secure-store adapter.
+- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, speed/temperature choices, the Jellyfin server URL/enable flag and the selected genre families.
+- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, `TelemetrySettingsController` (speed/temperature visibility and sources), equalizer, night overlay, Jellyfin Home tile and source-aware `VolumeCard`.
+- `lib/features/jellyfin/` — dashboard controller/screen, album screen, app-level `JellyfinPlaybackController`, and the Jellyfin-specific widgets.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
-- `lib/features/settings/` — General, Media, Jellyfin, OBD-II, Speed, Temperature, Security and Updates cards; `JellyfinController` owns the Quick Connect session and its active/inactive state.
+- `lib/features/settings/` — General, Media, Jellyfin, OBD-II, Speed, Temperature, Security and Updates cards; `JellyfinController` owns the Quick Connect session and its active/inactive state; the genre dialog and playback-notification row live with the Jellyfin card.
 - `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), `MediaBridge`, `CardashNotificationListener`; `SystemBridge` also dispatches media keys and applies the keep-awake flag.
+- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`, `JellyfinPlaybackService` (Media3), `JellyfinPlaybackBridge`, `JellyfinPlaybackReporter`; `SystemBridge` also dispatches media keys and applies the keep-awake flag.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
 
 Rules:
@@ -63,9 +64,9 @@ notifyListeners();
 
 - Use `context.colors` / `AppTheme` tokens, not hard-coded colors in widgets.
 - All UI copy is sentence case (except the CarDash name). Keep copy short and functional.
-- New home buttons: extend `SlotType` for fixed slots or use `LauncherController` pinned-app APIs; fixed slots stay stylized, pinned apps default to their own icon, and icon edits use `style_icon_choices.dart`.
-- New native methods: add them to `LauncherPlatform`, `NativeBridge`, the relevant Kotlin bridge, and the test fake if used.
-- Tests: fake `LauncherPlatform`/`SecureStore`; `SharedPreferences.setMockInitialValues({})`; widget tests wrap in `MaterialApp(theme: AppTheme.light())`. Test files live in `test/` and end with `_test.dart`.
+- New home buttons: extend `SlotType` for fixed slots or use `LauncherController` pinned-app APIs; fixed slots stay stylized, pinned apps default to their own icon, and icon edits use `style_icon_choices.dart`. `SlotType.jellyfin` is a fixed system slot with no app assignment; it opens the dashboard and is gated by `LauncherController.jellyfinConfigured`.
+- New native methods: add them to `LauncherPlatform`, `NativeBridge`, the relevant Kotlin bridge, and the test fake if used. The Media3 playback commands are the exception: they live behind `JellyfinPlaybackPlatform`/`NativeJellyfinPlayback` and `JellyfinPlaybackBridge`, so package/volume fakes do not grow playback stubs.
+- Tests: fake `LauncherPlatform`/`SecureStore`; `SharedPreferences.setMockInitialValues({})`; widget tests wrap in `MaterialApp(theme: AppTheme.light())`. Jellyfin tests use `test/jellyfin_fakes.dart` (`FakeJellyfinLibraryGateway`, `FakeJellyfinPlaybackPlatform`, `configuredJellyfinController`). Test files live in `test/` and end with `_test.dart`.
 - Run `dart format lib test` before committing. Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`). After every commit, push to `origin` (`git push origin main`); never leave commits unpushed and never force-push.
 - Update this file, `README.md`, and `docs/IMPLEMENTATION_PLAN.md` in the same commit as any behavior change.
 - Keep the UI fast: no large blur layers, no continuously running decorative animation, prefer implicit animations under ~250 ms.
@@ -77,7 +78,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 OBD, Open-Meteo weather, the GPS speed fallback, the Jellyfin server the user configures, and the public release check; notification access only for MediaSession title/progress; no notification content, scanning or background location.
+- INTERNET, location and Bluetooth permissions are approved only for OBD, Open-Meteo weather, the GPS speed fallback, the Jellyfin server the user configures, and the public release check; notification access only for MediaSession title/progress; `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_MEDIA_PLAYBACK` and `POST_NOTIFICATIONS` only for CarDash's own Jellyfin playback notification; 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.
 
@@ -96,11 +97,14 @@ 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, unless `AudioManager.isVolumeFixed` is true; on fixed-volume car units the keys pass through to the vehicle amplifier and the on-screen slider is disabled with an explanation. `SystemBridge.setVolume` falls back to relative `adjustStreamVolume` steps when a unit ignores absolute `setStreamVolume`. Long-pressing **Media volume** toggles `SystemController.volumeDiagnostics`, which shows the raw `index/max`, fixed flag, all stream levels and the last write result; the native side logs the same data under `CarDashVolume`. Do not remove this path without providing another sync path; otherwise Android controls the ring stream when no media is playing.
 - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock.
-- The Music equalizer follows `AudioManager.isMusicActive`. Now-playing title/progress use the notification-listener component and active MediaSession; never read or store notification content.
+- The Music and Jellyfin equalizers are exclusive. The Jellyfin tile animates only while `JellyfinPlaybackController.playing`; the Music tile animates only when the system reports media playing and Jellyfin is not playing. Jellyfin title/progress come from the native playback controller even without notification access; other apps use the notification-listener component and active MediaSession. Never read or store notification content.
 - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock.
 - Speed is shown only when `TelemetrySettingsController.speedEnabled` is on. OBD-II is preferred when connected and fresh; the GPS fallback is used when OBD speed is off or unavailable. Enabling speed with GPS as the source (no adapter selected or OBD speed off) asks for location permission; the normal resume path never prompts. GPS starts only with fine location and GPS, and clamps stationary noise below 2 km/h to 0.
 - Temperature is shown only when `TelemetrySettingsController.temperatureEnabled` is on. OBD ambient is preferred when connected and fresh; Open-Meteo/location weather is the fallback, and the source switch only appears while an OBD-II adapter is selected.
-- Jellyfin lives only in Settings → Jellyfin. Quick Connect is the only sign-in path: the user enters the code in Jellyfin, and CarDash keeps the returned access token in secure storage and reuses it on the next start. A rejected token or server error disables the integration, clears the token and shows **Pair again**; an unreachable server keeps the token and retries with 5–60 s backoff, so never clear the token for a network error. Use `JellyfinController.connected` as the active/inactive state for later features; do not add a username/password form.
+- Jellyfin is optional: Settings → Jellyfin owns pairing, the Home switch lives in General → Home buttons, and the Home tile opens the dashboard/player. Quick Connect is the only sign-in path: the user enters the code in Jellyfin, and CarDash keeps the returned access token in secure storage and reuses it on the next start. A rejected token or server error disables the integration, clears the token and shows **Pair again**; an unreachable server keeps the token and retries with 5–60 s backoff, so never clear the token for a network error. `JellyfinController.configured` gates the Home switch/tile (saved URL + token, survives outages); `connected` is for network actions. The Home slot default is off, counts toward the 16-button cap, is cleared on disconnect, and has no app assignment or long-press. Do not add a username/password form.
+- Jellyfin 12.2 API contract: `/Items?userId=...` (there is no `/Users/{userId}/Items` path); `/Genres` requires `parentId` of a `music` view from `/UserViews`; `genres` is pipe-delimited and OR-ed; `/Items/Latest` returns a bare array; auth is the `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=..., Token=...` header (`X-Emby-Token` and `api_key` were rejected on 12.2). Album `UserData` is not updated by track playback, so "recently played" is derived from played Audio tracks sorted by `DatePlayed`, deduped by `AlbumId`, and capped at 6. Stream URLs use `/Audio/{id}/universal`; the native data source adds the auth header and the token never goes in a URL or log.
+- Jellyfin playback is Media3 1.11.1 in `JellyfinPlaybackService` (foreground `mediaPlayback`). `JellyfinPlaybackController` sends the queue, polls `getState` every ~700 ms, and keeps the Home card alive; the service owns the `MediaSession`, audio focus and `JellyfinPlaybackReporter`. Focus loss pauses and keeps position, a transient loss resumes on gain, a permanent loss abandons focus and does not resume. The silent `jellyfin_playback` notification needs `POST_NOTIFICATIONS` on Android 13+; the Settings card's **Allow** row requests it, and playback still works when denied. The service keeps playing while CarDash is backgrounded.
+- Dashboard layout: the controller asks for 6 albums per row; the screen shows 6 when each of six cards is at least 160 px wide, otherwise 5. Album names/artists share the album art's left edge, the album page cover is top-aligned with the track list/controls, and selecting a Random album re-rolls the Random row after playback starts. The Home equalizer is exclusive: Jellyfin playing → Jellyfin tile only; another app playing while Jellyfin is paused → Music tile only. The Home `VolumeCard` prefers the Jellyfin state while it is playing, or while it has a queue and no other app is playing; otherwise it uses the system session/media keys.
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - 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.
@@ -109,7 +113,7 @@ notifyListeners();
 - Connection attempts are bounded (three failures, 12 s connect watchdog). After they are exhausted the card shows **Retry connection**; do not restore an infinite reconnect loop.
 - When Bluetooth is off, the OBD-II card shows **Open Bluetooth settings**. On resume the controller checks adapter power state and reuses the cached bonded list; it only lists devices when the list is unknown, after **Find**, or when the card opens.
 - Pairing happens in Android settings, never in CarDash.
-- 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.
+- The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Jellyfin playback title/progress comes from `JellyfinPlaybackController`, so it works without notification access; other apps still need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing. The album page reuses `VolumeCard` with previous/next and a `large` ring; the Home card keeps the normal size.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.
 
 ## 7. Releasing and deploying updates
@@ -127,7 +131,7 @@ Release checklist:
    - Preferred: `./tool/gogs_release.sh v<version> "CarDash <version>" releases/<version>.md`. It uses the web form (the API cannot create releases), reads credentials locally, and never prints them.
    - Or use `/releases/new` in the web UI: tag `v<version>`, target `main`, title/body, link the raw APK:
    `https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-<version>.apk`.
-   Gogs attachments cap at 32 MB, so the ~54 MB universal APK is linked from the release body instead of attached.
+   Gogs attachments cap at 32 MB, so the ~59 MB universal APK is linked from the release body instead of attached.
 6. Publishing the Gogs release is the deployment for in-app updates. The app queries
    `https://git.krasi.net/api/v1/repos/kkazakov/car-interface-android/releases`,
    selects the highest published semver tag, and downloads

+ 107 - 49
README.md

@@ -4,8 +4,9 @@ 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, a now-playing media card, and up to 16 buttons —
-**Navigation**, **Music**, **Radio**, **All apps**, plus any apps you pin from
-All Apps. The first three can be shown, hidden and assigned to installed apps;
+**Navigation**, **Music**, optional **Jellyfin**, **Radio**, **All apps**, plus
+any apps you pin from All Apps. The fixed slots can be shown, hidden and
+assigned to installed apps; Jellyfin is a built-in music source when configured.
 Settings opens from the gear next to Wi-Fi. External apps open full-screen using
 the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 
@@ -18,10 +19,10 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 ## Download
 
 - [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
-- [cardash-android-1.0.7.apk](releases/cardash-android-1.0.7.apk) — universal Android release
+- [cardash-android-1.2.0.apk](releases/cardash-android-1.2.0.apk) — universal Android release
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 
-Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
+Gogs limits release attachments to 32 MB, so the 59 MB universal APK is linked
 directly from the repository.
 
 ## Screenshots
@@ -81,12 +82,19 @@ live vehicle data.
   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.
-- **Jellyfin (Settings only)** — optional music-server integration in
-  Settings → Jellyfin. Turn it on, enter the server URL and pair it with a
-  Quick Connect code; the saved token is reused on later starts. The card shows
-  connected, offline and error states. A rejected token is cleared and asks for
-  pairing again; a missing network pauses the connection and retries
-  automatically. Nothing on Home uses it yet.
+- **Jellyfin** — optional music-server integration in Settings → Jellyfin. Turn
+  it on, enter the server URL and pair it with a Quick Connect code; the saved
+  token is reused on later starts. Once configured, turn on the **Jellyfin** Home
+  button in Settings → General → Home buttons. The tile opens a full-screen
+  dashboard with **Recently played**, **Recently added** and **Random** album
+  rows (six cards when they fit, five otherwise), grouped genre selection and a
+  full album page with track list, previous/next, play/pause and volume.
+  Playback runs in CarDash through Media3, keeps playing in the background, shows
+  a media notification when allowed, pauses on audio-focus loss and reports to
+  Jellyfin. Selecting a Random album re-rolls the row. The home equalizer is
+  exclusive: Jellyfin while it plays, Music only for another app. A rejected
+  token is cleared and asks for pairing again; an unreachable server keeps the
+  token and retries with backoff.
 - **Update check** — on startup, CarDash queries the public Gogs release list
   without blocking the dashboard. Settings → Updates shows the current version,
   a Check button, and an Update action that downloads the APK and opens Android’s
@@ -97,8 +105,9 @@ live vehicle data.
   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.
+  Music, Jellyfin and Radio; the Jellyfin switch is disabled and off until the
+  integration is configured. **All apps** is always the last button; **Settings**
+  sits next to Wi-Fi in the top-right status area and is no longer a grid tile.
 - **Pin any app from All Apps** — long-press an app to add it to Home. The home
   page holds up to **16 buttons including All apps**; the grid shrinks as it
   fills and stays anchored to the bottom edge.
@@ -110,10 +119,13 @@ live vehicle data.
 - **One-time launcher tip** — the first run shows a short notification that the
   default-launcher setting lives in Settings. It is not shown again.
 - **All apps** — searchable grid of every launchable app, launched full-screen.
-- **Media-aware Music tile** — while media audio is active and the Music shortcut
-  is assigned, the stylized music icon shows a small animated equalizer. Android
-  exposes media-active state, not the playing package, to a
-  normal launcher; see the limitation below.
+- **Media-aware Music and Jellyfin tiles** — the stylized music icon shows a
+  small animated equalizer while another app is playing; the Jellyfin tile shows
+  it only while CarDash's Jellyfin player is playing. The two are mutually
+  exclusive. Android exposes media-active state, not the playing package, to a
+  normal launcher; Jellyfin is known because CarDash owns that player. The tile
+  labels are clipped to the rounded button width so long album names cannot
+  widen the grid.
 - **Six accent colors** — Green, Blue, Amber, Rose, Violet or Teal, applied
   across the whole interface.
 - **Prevent CarDash from sleeping** — a Settings switch, on by default. Turn it off to use the system display timeout.
@@ -128,9 +140,9 @@ live vehicle data.
   cold-starts**, never when returning from an app.
 - **Standard Android home role** — no overlay, kiosk or root.
 - **Android 10+**, landscape-only, tablet-first (verified at 1280×800).
-- No accounts, no telemetry, no T&C screen. Network access is used only for the
-  optional Open-Meteo temperature fallback, the Jellyfin server you configure,
-  and the public release check.
+- No CarDash accounts, no telemetry, no T&C screen. Network access is used only
+  for the optional Open-Meteo temperature fallback, the Jellyfin server you
+  configure (library, artwork and audio), and the public release check.
 
 ## Requirements
 
@@ -142,6 +154,7 @@ live vehicle data.
 - The prepared tablet AVDs use the **host GPU**; a working GPU driver is recommended
 - Optional: a Bluetooth Classic ELM327 adapter already paired in Android
 - Optional: location access for the Open-Meteo temperature fallback
+- Optional: a Jellyfin 12.x server and account for the built-in music source
 
 The Android application ID is `com.wasp.cardash_launcher`. Android package
 segments cannot contain hyphens, so the requested `com.wasp.cardash-launcher`
@@ -197,14 +210,19 @@ first run shows a one-time tip pointing to this setting.
 | Action | Result |
 |---|---|
 | Tap Navigation / Music / Radio | Open the assigned app full-screen |
+| Tap Jellyfin | Open the Jellyfin dashboard (Albums) |
 | Long-press Navigation / Music / Radio | Pick a different installed app |
+| Long-press Jellyfin | Nothing; Jellyfin is a built-in source |
 | Tap an unassigned shortcut | Open the app picker |
 | Settings → Accent | Pick Green, Blue, Amber, Rose, Violet or Teal |
 | Settings → Battery & charging | Show or hide battery level and charging bolt |
 | Settings → Prevent CarDash from sleeping | Keep the display on, or use the system timeout |
 | Long-press an empty part of Home | Enter black night mode with a dim clock |
 | Tap the night screen | Return to the dashboard |
-| Settings → Home buttons switches | Show or hide Navigation, Music, Radio |
+| Settings → Home buttons switches | Show or hide Navigation, Music, Jellyfin, Radio |
+| Settings → Jellyfin → Allow | Enable the media notification on Android 13+ |
+| Jellyfin dashboard → album | Start the album and open its track list |
+| Jellyfin dashboard → Random → album | Start it and re-roll the Random row |
 | Long-press an app in All Apps | Add it to Home (up to 16 buttons) |
 | Long-press an added app on Home | Rename, Change icon or Remove |
 | Settings → OBD-II | Pick a paired ELM327 or None; open Bluetooth settings |
@@ -214,7 +232,8 @@ first run shows a one-time tip pointing to this setting.
 | Temperature row | OBD ambient, or location weather when OBD is off/unavailable |
 | Settings | Theme, accent, media access, battery, home buttons, OBD/speed/temperature, 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 |
+| Music tile while another app plays | Shows the selected app icon + equalizer |
+| Jellyfin tile while Jellyfin plays | Shows the headphones 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 |
@@ -235,8 +254,10 @@ the app resumes.
 - Battery & charging: one switch hides both the level and charging bolt.
 - Prevent CarDash from sleeping: keep the display on; on by default.
 - Auto night window (default dark from 19:00, light from 07:00).
-- Home buttons: show/hide Navigation, Music, Radio; live count out of 16.
-- Assign the app for each slot; All apps is fixed and last.
+- Home buttons: show/hide Navigation, Music, Jellyfin, Radio; live count out of
+  16. Jellyfin is disabled and off until configured.
+- Assign the app for Navigation, Music and Radio; Jellyfin is built-in, and All
+  apps is fixed and last.
 - Settings is always available from the gear next to Wi-Fi.
 - Home role status and shortcut to the system prompt.
 
@@ -259,8 +280,23 @@ the app resumes.
   **Pair again**.
 - Without a network the card shows **No internet — retrying** and retries with
   backoff until the server is reachable again.
-- Nothing on Home uses the connection yet; the active state is exposed for a
-  later feature.
+- **Random genres**: choose up to five grouped families (Metal, Rock, Jazz, Pop,
+  Folk and so on) in the Jellyfin card. Raw tags such as `Метъл`, `Рок`,
+  `Melodic metal` and `Pop-Rock` are grouped into those families. If none are
+  selected, the dashboard asks you to choose genres in Settings.
+- **Home button**: after pairing, turn on **Jellyfin** in Settings → General →
+  Home buttons. The tile sits after Music and before Radio and opens the
+  dashboard.
+- **Dashboard**: Recently played (derived from played tracks across your Jellyfin
+  devices), Recently added, and Random. Artists is shown but disabled for a
+  later release. Six albums per row when they fit, otherwise five.
+- **Album page**: large cover, track list, previous / play-pause / next, current
+  track and the same volume/mute controls as Home.
+- **Playback**: in-app Media3 with background playback, a media notification on
+  Android 13+ when allowed, audio-focus pause/resume, and playback reporting to
+  Jellyfin. Stops at the end of the album; no autoplay or repeat.
+- **Allow notifications** asks for Android's notification permission; denying it
+  only hides the playback notification, not playback itself.
 
 **OBD-II**
 - Adapter dropdown: None plus paired Bluetooth Classic devices that look like
@@ -344,7 +380,7 @@ flutter test
 ./tool/check.sh
 ```
 
-The 89 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 134 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, the ten-minute
 onboarding hint, slot visibility, pinned apps, custom labels/icons, the
 16-button cap at 1280×800, 1280×720 and 1024×600, the dense shortcut grid, the
@@ -353,18 +389,22 @@ metadata/progress, the marquee, the play/pause ring, fixed-volume handling,
 volume-write diagnostics, the keep-awake preference, the night overlay, GPS
 speed fallback and its permission prompt, speed/temperature source persistence,
 weather fallback, the update version/availability logic and the Settings update
-dot. 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. 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. The 1.0.7 universal APK is built from
-this tree and published; devices on 1.0.6 can pick it up through
-Settings → Updates. The emulator has verified the in-app update path. 1.0.7
-moves the Impeller opt-out into the manifest's `<application>` block; Flutter
-ignored it under `<activity>`.
+dot. Jellyfin coverage includes genre grouping, recent-played dedupe, the
+12.2 request contract, the dashboard controller and widget states, the genre
+picker and five-family cap, album page/track list/transport, playback queue and
+state mapping, the settings gating and the Home tile width clipping. 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.
+Now-playing title, artist, progress and pause/resume were verified against a
+live Chrome MediaSession. Jellyfin was verified end-to-end on API 34 against a
+real 12.2 server (Quick Connect, grouped genres, 6/5-card dashboard, album page,
+background playback, notification, media keys, exclusive equalizers, audio-focus
+handover, Random re-shuffle) and against a local 12.2 container. The OBD hardware
+path compiles and is covered by the parser/economy design, but no ELM327 adapter
+is available on this machine; socket behavior still needs a car test. The 1.2.0
+universal APK is built from this tree and published. The emulator has verified
+the in-app update path.
 
 ## Troubleshooting the emulator
 
@@ -419,20 +459,36 @@ Android 15 AVD is left running headlessly after development; use
   disappears after ten minutes. It is migrated to expired for upgraded installs
   and never returns.
 - **No embedded third-party app UI.** Navigation/Music/Radio are launched in
-  their own full-screen windows.
+  their own full-screen windows. Jellyfin is the exception: its music library
+  and player are a first-party CarDash dashboard, because it is an API/music
+  source rather than an embedded app.
 - **Renaming changes the Home label only.** Android does not allow a launcher to
   rename another app's installed label.
 - **Custom icons** are bundled monochrome SVGs, so they follow the light/dark
   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). The ring shows Play whenever audio is inactive.
+- **Play/pause uses the Jellyfin player when it is active**, otherwise Android
+  media keys. With no active session it opens the app assigned to the Music slot
+  (or asks you to choose one). The ring shows Play whenever the selected source
+  is paused/inactive.
 - **Updates download and invoke Android’s installer.** CarDash never silently
   installs. Android requires the user to confirm each install and, once, to allow
   CarDash as an unknown-app source.
-- **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.
+- **Song progress/title for other apps 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. Jellyfin's
+  own title/progress come from CarDash's player and work without it.
+- **Jellyfin is online-only in 1.2.0.** There is no offline library cache or
+  download. An unreachable server shows an inline Retry state; selected genres
+  and the token persist. The Artists tab is disabled for a later release.
+- **Jellyfin genre grouping is heuristic.** Built-in family rules group known
+  raw tags (including Cyrillic aliases); unusual tags become standalone families.
+  The picker is capped at five families.
+- **Jellyfin notification permission is optional.** On Android 13+, allow it in
+  Settings → Jellyfin to see the playback notification. Playback continues
+  without it; the Home card and dashboard still control the player.
+- **Jellyfin drives the car audio focus.** It pauses on a permanent focus loss
+  (another music app) and stays paused; a short transient loss (navigation
+  prompt) resumes only if it was playing. It stops at the end of the album.
 - **Speed source** is selectable only while an OBD adapter is selected. GPS
   fallback requires fine location and the GPS provider enabled; enabling speed
   with GPS as the source asks for location permission. Stale/no-fix GPS values
@@ -446,14 +502,16 @@ Android 15 AVD is left running headlessly after development; use
   Weather fallback uses Open-Meteo and the device location; it requires location
   permission and network access, and hides the row when unavailable.
 - **Location data is not stored.** Only the selected adapter address, the
-  speed/temperature choices, the weather preference and the Jellyfin URL are
-  persisted. The Jellyfin access token is kept in Keystore-backed storage.
+  speed/temperature choices, the weather preference, the Jellyfin URL and the
+  selected genre families are persisted. The Jellyfin access token is kept in
+  Keystore-backed storage.
 - **`QUERY_ALL_PACKAGES`** is used to list all launchable apps. It is reasonable
   for a launcher but requires a Play policy declaration if the app is ever
   distributed; this is a local/sideload build for now.
 - **Network access** is used for the optional Open-Meteo temperature fallback,
-  the Jellyfin server you configure and the Gogs release check. There are no
-  accounts, ads, telemetry or silent auto-updates.
+  the Jellyfin server you configure (library, artwork and audio) and the Gogs
+  release check. There are no CarDash accounts, ads, telemetry or silent
+  auto-updates.
 - The launcher cannot replace Android's lock screen. The PIN does not protect a
   running session, by design.
 

+ 5 - 0
android/app/build.gradle.kts

@@ -51,6 +51,11 @@ kotlin {
     }
 }
 
+dependencies {
+    implementation("androidx.media3:media3-exoplayer:1.11.1")
+    implementation("androidx.media3:media3-session:1.11.1")
+}
+
 flutter {
     source = "../.."
 }

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

@@ -11,6 +11,9 @@
     <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
     <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
 
     <uses-feature android:name="android.hardware.bluetooth" android:required="false" />
     <uses-feature android:name="android.hardware.location.gps" android:required="false" />
@@ -67,6 +70,16 @@
             </intent-filter>
         </service>
 
+        <service
+            android:name=".JellyfinPlaybackService"
+            android:exported="false"
+            android:foregroundServiceType="mediaPlayback"
+            android:stopWithTask="false">
+            <intent-filter>
+                <action android:name="androidx.media3.session.MediaSessionService" />
+            </intent-filter>
+        </service>
+
         <meta-data
             android:name="flutterEmbedding"
             android:value="2" />

+ 285 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/JellyfinPlaybackBridge.kt

@@ -0,0 +1,285 @@
+package com.wasp.cardash_launcher
+
+import android.Manifest
+import android.app.Activity
+import android.content.ComponentName
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionToken
+import com.google.common.util.concurrent.ListenableFuture
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+
+/**
+ * The Dart <-> MediaController bridge. The service owns playback; this class
+ * only sends commands, reads the current state and requests the Android 13+
+ * notification permission.
+ */
+class JellyfinPlaybackBridge(
+    private val activity: Activity,
+) : MethodChannel.MethodCallHandler {
+    private var channel: MethodChannel? = null
+    private var controllerFuture: ListenableFuture<MediaController>? = null
+    private var controller: MediaController? = null
+    private val pending = ArrayDeque<(MediaController) -> Unit>()
+    private var pendingPermissionResult: MethodChannel.Result? = null
+
+    fun attach(messenger: BinaryMessenger) {
+        channel = MethodChannel(messenger, CHANNEL).also {
+            it.setMethodCallHandler(this)
+        }
+
+        val token = SessionToken(
+            activity,
+            ComponentName(activity, JellyfinPlaybackService::class.java),
+        )
+        val future = MediaController.Builder(activity, token)
+            .setListener(
+                object : MediaController.Listener {
+                    override fun onDisconnected(controller: MediaController) {
+                        [email protected] = null
+                    }
+                },
+            )
+            .buildAsync()
+        controllerFuture = future
+        future.addListener(
+            {
+                try {
+                    controller = future.get()
+                    while (pending.isNotEmpty()) {
+                        pending.removeFirst()(controller!!)
+                    }
+                } catch (_: Throwable) {
+                    // The service may not be available yet; later commands queue.
+                }
+            },
+            ContextCompat.getMainExecutor(activity),
+        )
+    }
+
+    fun dispose() {
+        channel?.setMethodCallHandler(null)
+        channel = null
+        controllerFuture?.let { MediaController.releaseFuture(it) }
+        controllerFuture = null
+        controller = null
+        pending.clear()
+        pendingPermissionResult?.success(false)
+        pendingPermissionResult = null
+    }
+
+    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+        when (call.method) {
+            "setQueue" -> result.success(setQueue(call.arguments as? Map<*, *>))
+            "play" -> {
+                withController { it.play() }
+                result.success(true)
+            }
+
+            "pause" -> {
+                withController { it.pause() }
+                result.success(true)
+            }
+
+            "next" -> {
+                withController {
+                    if (it.hasNextMediaItem()) it.seekToNextMediaItem()
+                }
+                result.success(true)
+            }
+
+            "previous" -> {
+                withController {
+                    if (it.hasPreviousMediaItem()) it.seekToPreviousMediaItem()
+                }
+                result.success(true)
+            }
+
+            "seekToIndex" -> {
+                val index = call.argument<Int>("index") ?: 0
+                withController {
+                    if (index in 0 until it.mediaItemCount) {
+                        it.seekToDefaultPosition(index)
+                        it.play()
+                    }
+                }
+                result.success(true)
+            }
+
+            "seek" -> {
+                val position = call.argument<Number>("positionMs")?.toLong() ?: 0L
+                withController { it.seekTo(position.coerceAtLeast(0L)) }
+                result.success(true)
+            }
+
+            "stop" -> {
+                withController { it.stop() }
+                result.success(true)
+            }
+
+            "getState" -> result.success(state())
+            "notificationPermissionGranted" -> result.success(
+                notificationPermissionGranted(),
+            )
+
+            "requestNotificationPermission" -> requestNotificationPermission(result)
+            else -> result.notImplemented()
+        }
+    }
+
+    fun onRequestPermissionsResult(
+        requestCode: Int,
+        permissions: Array<out String>,
+        grantResults: IntArray,
+    ): Boolean {
+        if (requestCode != REQUEST_NOTIFICATIONS) return false
+        val granted = grantResults.isNotEmpty() &&
+            grantResults[0] == PackageManager.PERMISSION_GRANTED
+        pendingPermissionResult?.success(granted)
+        pendingPermissionResult = null
+        return true
+    }
+
+    private fun setQueue(args: Map<*, *>?): Boolean {
+        if (args == null) return false
+        val baseUrl = args["baseUrl"] as? String ?: return false
+        val authorization = args["authorization"] as? String ?: return false
+        val playSessionId = args["playSessionId"] as? String ?: ""
+        val albumId = args["albumId"] as? String ?: ""
+        val albumName = args["albumName"] as? String ?: ""
+        val artistName = args["artistName"] as? String ?: ""
+        val artwork = args["artwork"] as? ByteArray
+        val rawItems = args["items"] as? List<*> ?: return false
+        if (rawItems.isEmpty()) return false
+
+        val items = rawItems.mapNotNull { raw ->
+            val item = raw as? Map<*, *> ?: return@mapNotNull null
+            val trackId = item["trackId"] as? String ?: return@mapNotNull null
+            val url = item["url"] as? String ?: return@mapNotNull null
+            val extras = Bundle().apply {
+                putString("trackId", trackId)
+                putString(
+                    "mediaSourceId",
+                    item["mediaSourceId"] as? String ?: trackId,
+                )
+                putString("albumId", albumId)
+                putString("albumName", albumName)
+                putString("artistName", artistName)
+                putInt(
+                    "playlistIndex",
+                    (item["index"] as? Number)?.toInt() ?: 0,
+                )
+                putInt("playlistLength", rawItems.size)
+            }
+            val metadata = MediaMetadata.Builder()
+                .setTitle(item["title"] as? String)
+                .setArtist(item["artist"] as? String)
+                .setAlbumTitle(item["album"] as? String)
+                .setExtras(extras)
+                .apply {
+                    if (artwork != null && artwork.isNotEmpty()) {
+                        setArtworkData(
+                            artwork,
+                            MediaMetadata.PICTURE_TYPE_FRONT_COVER,
+                        )
+                    }
+                }
+                .build()
+            MediaItem.Builder()
+                .setMediaId(trackId)
+                .setUri(url)
+                .setMediaMetadata(metadata)
+                .build()
+        }
+        if (items.isEmpty()) return false
+
+        val contextArgs = Bundle().apply {
+            putString("baseUrl", baseUrl)
+            putString("authorization", authorization)
+            putString("playSessionId", playSessionId)
+            putString("userId", args["userId"] as? String ?: "")
+            putString("deviceId", args["deviceId"] as? String ?: "")
+        }
+        withController { controller ->
+            controller.sendCustomCommand(
+                SessionCommand(ACTION_SET_CONTEXT, Bundle.EMPTY),
+                contextArgs,
+            )
+            controller.setMediaItems(items)
+            controller.prepare()
+            controller.play()
+        }
+        return true
+    }
+
+    private fun state(): Map<String, Any?> {
+        val controller = controller ?: return mapOf("available" to false)
+        val item = controller.currentMediaItem ?: return mapOf("available" to false)
+        val metadata = item.mediaMetadata
+        val extras = metadata.extras
+        val duration = if (controller.duration > 0) controller.duration else 0L
+        return mapOf(
+            "available" to true,
+            "playing" to controller.isPlaying,
+            "buffering" to (controller.playbackState == Player.STATE_BUFFERING),
+            "itemId" to item.mediaId,
+            "albumId" to extras?.getString("albumId"),
+            "title" to metadata.title?.toString(),
+            "artist" to metadata.artist?.toString(),
+            "album" to metadata.albumTitle?.toString(),
+            "durationMs" to duration,
+            "positionMs" to controller.currentPosition.coerceAtLeast(0L),
+            "index" to controller.currentMediaItemIndex,
+            "hasPrevious" to controller.hasPreviousMediaItem(),
+            "hasNext" to controller.hasNextMediaItem(),
+            "error" to controller.playerError?.message,
+        )
+    }
+
+    private fun withController(action: (MediaController) -> Unit) {
+        val controller = controller
+        if (controller != null) {
+            action(controller)
+            return
+        }
+        pending.addLast(action)
+    }
+
+    private fun notificationPermissionGranted(): Boolean {
+        if (Build.VERSION.SDK_INT < 33) return true
+        return ContextCompat.checkSelfPermission(
+            activity,
+            Manifest.permission.POST_NOTIFICATIONS,
+        ) == PackageManager.PERMISSION_GRANTED
+    }
+
+    private fun requestNotificationPermission(result: MethodChannel.Result) {
+        if (notificationPermissionGranted()) {
+            result.success(true)
+            return
+        }
+        pendingPermissionResult?.success(false)
+        pendingPermissionResult = result
+        ActivityCompat.requestPermissions(
+            activity,
+            arrayOf(Manifest.permission.POST_NOTIFICATIONS),
+            REQUEST_NOTIFICATIONS,
+        )
+    }
+
+    private companion object {
+        const val CHANNEL = "com.wasp.cardash_launcher/jellyfin_playback"
+        const val ACTION_SET_CONTEXT = "SET_CONTEXT"
+        const val REQUEST_NOTIFICATIONS = 4712
+    }
+}

+ 115 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/JellyfinPlaybackReporter.kt

@@ -0,0 +1,115 @@
+package com.wasp.cardash_launcher
+
+import android.util.Log
+import androidx.media3.common.MediaItem
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.Executors
+
+/** The authenticated details the reporter needs for one album queue. */
+data class JellyfinPlaybackContext(
+    val baseUrl: String,
+    val authorization: String,
+    val playSessionId: String,
+    val userId: String,
+    val deviceId: String,
+)
+
+/**
+ * Best-effort Jellyfin playback reporting. The access token only travels in the
+ * Authorization header; this class never logs the token or the full URL.
+ */
+class JellyfinPlaybackReporter {
+    private val executor = Executors.newSingleThreadExecutor()
+
+    @Volatile
+    private var context: JellyfinPlaybackContext? = null
+
+    fun setContext(context: JellyfinPlaybackContext) {
+        this.context = context
+    }
+
+    fun clearContext() {
+        context = null
+    }
+
+    fun start(item: MediaItem?, positionMs: Long) =
+        post(item, positionMs, "Playing", paused = false, failed = false)
+
+    fun progress(item: MediaItem?, positionMs: Long, paused: Boolean) =
+        post(item, positionMs, "Progress", paused, failed = false)
+
+    fun stop(item: MediaItem?, positionMs: Long, failed: Boolean) =
+        post(item, positionMs, "Stopped", paused = false, failed = failed)
+
+    private fun post(
+        item: MediaItem?,
+        positionMs: Long,
+        phase: String,
+        paused: Boolean,
+        failed: Boolean,
+    ) {
+        val ctx = context ?: return
+        if (item == null) return
+        val extras = item.mediaMetadata.extras
+        val body = JSONObject().apply {
+            put("ItemId", item.mediaId)
+            put("MediaSourceId", extras?.getString("mediaSourceId") ?: item.mediaId)
+            put("PlaySessionId", ctx.playSessionId)
+            put("PositionTicks", positionMs.coerceAtLeast(0L) * 10_000L)
+            put("IsMuted", false)
+            put("CanSeek", true)
+            put("PlayMethod", "DirectPlay")
+            put("PlaylistIndex", extras?.getInt("playlistIndex", 0) ?: 0)
+            put("PlaylistLength", extras?.getInt("playlistLength", 1) ?: 1)
+            if (phase == "Stopped") {
+                put("Failed", failed)
+            } else {
+                put("IsPaused", paused)
+            }
+        }
+        executor.execute { send(ctx, phase, body) }
+    }
+
+    private fun send(ctx: JellyfinPlaybackContext, phase: String, body: JSONObject) {
+        val suffix = when (phase) {
+            "Progress" -> "/Progress"
+            "Stopped" -> "/Stopped"
+            else -> ""
+        }
+        val base = ctx.baseUrl.trimEnd('/')
+        if (base.isEmpty()) return
+        var connection: HttpURLConnection? = null
+        try {
+            connection = (URL("$base/Sessions/Playing$suffix").openConnection() as HttpURLConnection).apply {
+                requestMethod = "POST"
+                connectTimeout = 6_000
+                readTimeout = 6_000
+                doOutput = true
+                setRequestProperty("Content-Type", "application/json")
+                setRequestProperty("Authorization", ctx.authorization)
+            }
+            connection.outputStream.use {
+                it.write(body.toString().toByteArray(Charsets.UTF_8))
+            }
+            val status = connection.responseCode
+            if (status >= 400) {
+                Log.w(TAG, "Playback $phase returned $status")
+            }
+        } catch (_: Throwable) {
+            // Reporting is best-effort; playback must continue.
+        } finally {
+            connection?.disconnect()
+        }
+    }
+
+    fun shutdown() {
+        context = null
+        executor.shutdownNow()
+    }
+
+    private companion object {
+        const val TAG = "CarDashJellyfin"
+    }
+}

+ 319 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/JellyfinPlaybackService.kt

@@ -0,0 +1,319 @@
+package com.wasp.cardash_launcher
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import androidx.annotation.OptIn
+import androidx.media3.common.AudioAttributes as Media3AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.datasource.HttpDataSource
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.session.DefaultMediaNotificationProvider
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionResult
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Media3 playback for one Jellyfin album queue. It owns the MediaSession,
+ * audio focus, the media notification and Jellyfin playback reporting. The
+ * Dart layer only sends the queue and reads the current state.
+ */
+@OptIn(UnstableApi::class)
+class JellyfinPlaybackService : MediaSessionService() {
+    private lateinit var player: ExoPlayer
+    private var mediaSession: MediaSession? = null
+    private val authHeader = AtomicReference<String?>(null)
+    private var playbackContext: JellyfinPlaybackContext? = null
+    private val reporter = JellyfinPlaybackReporter()
+    private val handler = Handler(Looper.getMainLooper())
+    private var audioManager: AudioManager? = null
+    private var focusRequest: AudioFocusRequest? = null
+    private var resumeOnFocusGain = false
+    private var ducked = false
+    private var savedVolume = 1f
+    private var currentItem: MediaItem? = null
+    private var lastPositionMs = 0L
+
+    private val progressRunnable = object : Runnable {
+        override fun run() {
+            if (!player.isPlaying) return
+            lastPositionMs = player.currentPosition
+            reporter.progress(currentItem, lastPositionMs, paused = false)
+            handler.postDelayed(this, PROGRESS_INTERVAL_MS)
+        }
+    }
+
+    private val playerListener = object : Player.Listener {
+        override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+            val previous = currentItem
+            if (previous != null && previous.mediaId != mediaItem?.mediaId) {
+                reporter.stop(previous, lastPositionMs, failed = false)
+                lastPositionMs = 0L
+            }
+            currentItem = mediaItem
+            if (mediaItem != null) {
+                reporter.start(mediaItem, 0L)
+                scheduleProgress()
+            }
+        }
+
+        override fun onPositionDiscontinuity(
+            oldPosition: Player.PositionInfo,
+            newPosition: Player.PositionInfo,
+            reason: Int,
+        ) {
+            if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
+                lastPositionMs = oldPosition.positionMs.coerceAtLeast(0L)
+            }
+        }
+
+        override fun onIsPlayingChanged(isPlaying: Boolean) {
+            if (isPlaying) {
+                if (!requestFocus()) {
+                    player.pause()
+                    return
+                }
+                if (ducked) {
+                    player.volume = savedVolume
+                    ducked = false
+                }
+                reporter.start(currentItem, player.currentPosition)
+                scheduleProgress()
+            } else {
+                handler.removeCallbacks(progressRunnable)
+                reporter.progress(
+                    currentItem,
+                    player.currentPosition,
+                    paused = true,
+                )
+            }
+        }
+
+        override fun onPlaybackStateChanged(playbackState: Int) {
+            if (playbackState == Player.STATE_ENDED) {
+                handler.removeCallbacks(progressRunnable)
+                val end = if (player.duration > 0) player.duration else lastPositionMs
+                reporter.stop(currentItem, end, failed = false)
+                lastPositionMs = end
+            }
+        }
+
+        override fun onPlayerError(error: PlaybackException) {
+            handler.removeCallbacks(progressRunnable)
+            reporter.stop(currentItem, player.currentPosition, failed = true)
+        }
+    }
+
+    private val sessionCallback = object : MediaSession.Callback {
+        override fun onConnectAsync(
+            session: MediaSession,
+            controller: MediaSession.ControllerInfo,
+        ): ListenableFuture<MediaSession.ConnectionResult> {
+            val commands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
+                .buildUpon()
+                .add(SessionCommand(ACTION_SET_CONTEXT, Bundle.EMPTY))
+                .build()
+            return Futures.immediateFuture(
+                MediaSession.ConnectionResult.AcceptedResultBuilder(
+                    session,
+                    controller,
+                ).setAvailableSessionCommands(commands).build(),
+            )
+        }
+
+        override fun onCustomCommand(
+            session: MediaSession,
+            controller: MediaSession.ControllerInfo,
+            customCommand: SessionCommand,
+            args: Bundle,
+        ): ListenableFuture<SessionResult> {
+            if (customCommand.customAction == ACTION_SET_CONTEXT) {
+                val baseUrl = args.getString("baseUrl")
+                val authorization = args.getString("authorization")
+                if (baseUrl.isNullOrBlank() || authorization.isNullOrBlank()) {
+                    return Futures.immediateFuture(
+                        SessionResult(SessionResult.RESULT_ERROR_UNKNOWN),
+                    )
+                }
+                val context = JellyfinPlaybackContext(
+                    baseUrl = baseUrl,
+                    authorization = authorization,
+                    playSessionId = args.getString("playSessionId") ?: "",
+                    userId = args.getString("userId") ?: "",
+                    deviceId = args.getString("deviceId") ?: "",
+                )
+                playbackContext = context
+                authHeader.set(authorization)
+                reporter.setContext(context)
+                return Futures.immediateFuture(
+                    SessionResult(SessionResult.RESULT_SUCCESS),
+                )
+            }
+            return super.onCustomCommand(
+                session,
+                controller,
+                customCommand,
+                args,
+            )
+        }
+    }
+
+    override fun onCreate() {
+        super.onCreate()
+        audioManager = getSystemService(AudioManager::class.java)
+
+        val dataSourceFactory = object : HttpDataSource.Factory {
+            override fun createDataSource(): HttpDataSource {
+                val factory = DefaultHttpDataSource.Factory()
+                authHeader.get()?.let {
+                    factory.setDefaultRequestProperties(
+                        mapOf("Authorization" to it),
+                    )
+                }
+                return factory.createDataSource()
+            }
+
+            override fun setDefaultRequestProperties(
+                defaultRequestProperties: Map<String, String>,
+            ): HttpDataSource.Factory = this
+        }
+
+        player = ExoPlayer.Builder(this)
+            .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
+            .setAudioAttributes(
+                Media3AudioAttributes.Builder()
+                    .setUsage(C.USAGE_MEDIA)
+                    .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+                    .build(),
+                false,
+            )
+            .setHandleAudioBecomingNoisy(true)
+            .build()
+        player.addListener(playerListener)
+
+        val sessionIntent = Intent(this, MainActivity::class.java)
+        val sessionActivity = PendingIntent.getActivity(
+            this,
+            0,
+            sessionIntent,
+            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+        )
+        mediaSession = MediaSession.Builder(this, player)
+            .setSessionActivity(sessionActivity)
+            .setCallback(sessionCallback)
+            .build()
+
+        val notificationProvider = DefaultMediaNotificationProvider.Builder(this)
+            .setChannelId(CHANNEL_ID)
+            .setChannelName(R.string.jellyfin_playback_channel)
+            .build()
+        notificationProvider.setSmallIcon(R.drawable.ic_jellyfin_playback)
+        setMediaNotificationProvider(notificationProvider)
+    }
+
+    override fun onGetSession(
+        controllerInfo: MediaSession.ControllerInfo,
+    ): MediaSession? = mediaSession
+
+    override fun onTaskRemoved(rootIntent: Intent?) {
+        if (!player.isPlaying) stopSelf()
+    }
+
+    private fun requestFocus(): Boolean {
+        if (focusRequest != null) return true
+        val manager = audioManager ?: return true
+        val attributes = android.media.AudioAttributes.Builder()
+            .setUsage(android.media.AudioAttributes.USAGE_MEDIA)
+            .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC)
+            .build()
+        val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+            .setAudioAttributes(attributes)
+            .setWillPauseWhenDucked(false)
+            .setOnAudioFocusChangeListener { change ->
+                handler.post { handleFocusChange(change) }
+            }
+            .build()
+        val result = manager.requestAudioFocus(request)
+        if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) return false
+        focusRequest = request
+        return true
+    }
+
+    private fun abandonFocus() {
+        val request = focusRequest ?: return
+        audioManager?.abandonAudioFocusRequest(request)
+        focusRequest = null
+    }
+
+    private fun handleFocusChange(change: Int) {
+        when (change) {
+            AudioManager.AUDIOFOCUS_LOSS -> {
+                resumeOnFocusGain = false
+                player.pause()
+                abandonFocus()
+            }
+
+            AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
+                resumeOnFocusGain = player.isPlaying
+                player.pause()
+            }
+
+            AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
+                if (player.isPlaying) {
+                    savedVolume = player.volume
+                    player.volume = 0.2f
+                    ducked = true
+                }
+            }
+
+            AudioManager.AUDIOFOCUS_GAIN -> {
+                if (ducked) {
+                    player.volume = savedVolume
+                    ducked = false
+                }
+                if (resumeOnFocusGain) {
+                    resumeOnFocusGain = false
+                    player.play()
+                }
+            }
+        }
+    }
+
+    private fun scheduleProgress() {
+        handler.removeCallbacks(progressRunnable)
+        if (player.isPlaying) {
+            handler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
+        }
+    }
+
+    override fun onDestroy() {
+        handler.removeCallbacksAndMessages(null)
+        abandonFocus()
+        reporter.clearContext()
+        reporter.shutdown()
+        mediaSession?.release()
+        mediaSession = null
+        player.release()
+        super.onDestroy()
+    }
+
+    private companion object {
+        const val ACTION_SET_CONTEXT = "SET_CONTEXT"
+        const val CHANNEL_ID = "jellyfin_playback"
+        const val PROGRESS_INTERVAL_MS = 10_000L
+    }
+}

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

@@ -11,6 +11,7 @@ class MainActivity : FlutterActivity() {
     private var systemBridge: SystemBridge? = null
     private var telemetryBridge: TelemetryBridge? = null
     private var mediaBridge: MediaBridge? = null
+    private var jellyfinPlaybackBridge: JellyfinPlaybackBridge? = null
 
     override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
         super.configureFlutterEngine(flutterEngine)
@@ -27,6 +28,9 @@ class MainActivity : FlutterActivity() {
         mediaBridge = MediaBridge(this).also {
             it.attach(flutterEngine.dartExecutor.binaryMessenger)
         }
+        jellyfinPlaybackBridge = JellyfinPlaybackBridge(this).also {
+            it.attach(flutterEngine.dartExecutor.binaryMessenger)
+        }
     }
 
     override fun onRequestPermissionsResult(
@@ -36,6 +40,11 @@ class MainActivity : FlutterActivity() {
     ) {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
         telemetryBridge?.onRequestPermissionsResult(requestCode, grantResults)
+        jellyfinPlaybackBridge?.onRequestPermissionsResult(
+            requestCode,
+            permissions,
+            grantResults,
+        )
     }
 
     /**
@@ -90,10 +99,12 @@ class MainActivity : FlutterActivity() {
         systemBridge?.dispose()
         telemetryBridge?.dispose()
         mediaBridge?.dispose()
+        jellyfinPlaybackBridge?.dispose()
         launcherBridge = null
         systemBridge = null
         telemetryBridge = null
         mediaBridge = null
+        jellyfinPlaybackBridge = null
         super.onDestroy()
     }
 }

+ 9 - 0
android/app/src/main/res/drawable/ic_jellyfin_playback.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M8,5v14l11,-7z" />
+</vector>

+ 4 - 0
android/app/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="jellyfin_playback_channel">Jellyfin playback</string>
+</resources>

+ 18 - 0
assets/icons/refresh-cw.svg

@@ -0,0 +1,18 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-refresh-cw"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
+  <path d="M3 3v5h5" />
+  <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
+  <path d="M16 16h5v5" />
+</svg>

+ 16 - 0
assets/icons/skip-back.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-skip-back"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <path d="M19 20 9 12l10-8v16Z" />
+  <path d="M5 19V5" />
+</svg>

+ 16 - 0
assets/icons/skip-forward.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-skip-forward"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <path d="m5 4 10 8-10 8V4Z" />
+  <path d="M19 5v14" />
+</svg>

+ 74 - 16
docs/IMPLEMENTATION_PLAN.md

@@ -1,6 +1,6 @@
 # CarDash implementation plan
 
-_Last updated: 2026-10-30_
+_Last updated: 2026-10-31_
 
 ## 1. What we are building
 
@@ -12,11 +12,13 @@ compatibility:
 - Clock/date, battery/charging and Wi-Fi on the page.
 - 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.
+- Up to **16 home buttons**: Navigation, Music, optional Jellyfin, Radio,
+  All apps, plus apps pinned from All Apps. Settings lives next to Wi-Fi in the
+  top-right status area, outside the grid.
 - Navigation/Music/Radio are **app slots** with show/hide switches. They can be
-  assigned, changed and cleared. All apps is always present and last.
+  assigned, changed and cleared. Jellyfin is a built-in optional music source
+  (no app assignment): a dashboard, album page, grouped genres and an in-app
+  Media3 player. All apps is always present and last.
 - Pinned apps are added by long-pressing an app in All Apps and removed by
   long-pressing the added button on Home.
   Only pinned apps show their own icon by default; the fixed buttons and
@@ -27,8 +29,8 @@ compatibility:
 - Settings: light/dark/automatic theme, six accent colors, battery visibility,
   fixed night window, home-button switches, slot assignment, HOME-role setup,
   four-digit PIN, plus separate **OBD-II**, **Speed** and **Temperature** cards.
-- The stylized Music tile shows an animated equalizer while media audio is
-  active.
+- The stylized Music tile shows an animated equalizer while another app is
+  playing; the Jellyfin tile shows it only while CarDash's Jellyfin player plays.
 - Settings → Media grants notification access for the active MediaSession title,
   duration and position. CarDash does not read notification content.
 - The long-press hint is visible only during the first ten minutes after a fresh
@@ -109,7 +111,11 @@ Flutter
     GpsSpeedController  -> GPS speed fallback (permission requested from Speed settings)
     TelemetrySettingsController -> speed/temperature visibility + sources
     UpdateController    -> public Gogs release check + release-page link
-    JellyfinController  -> Quick Connect pairing, token reuse, retry/offline state
+    JellyfinController  -> Quick Connect pairing, token reuse, retry/offline state,
+                           genre selections
+  JellyfinDashboardScreen -> JellyfinDashboardController -> JellyfinLibraryService
+  JellyfinAlbumScreen     -> track list + JellyfinPlaybackController
+  JellyfinPlaybackController -> JellyfinPlaybackPlatform -> NativeJellyfinPlayback
     HomeScreen          -> NightModeOverlay on empty-background long press
   AllAppsScreen / AppPickerSheet
   SettingsScreen -> General / Media / Jellyfin / OBD-II / Speed / Temperature /
@@ -122,12 +128,15 @@ Android (Kotlin)
                -> MediaBridge (MediaSession title/progress) + NotificationListener
                -> TelemetryBridge (Bluetooth devices, OBD, location)
                -> ObdService (Classic SPP ELM327 worker)
+               -> JellyfinPlaybackBridge -> MediaController -> JellyfinPlaybackService
+                    (Media3 ExoPlayer, MediaSession, notification, JellyfinPlaybackReporter)
 ```
 
 - `SettingsRepository` is the only persistence path for theme, night window,
   slot visibility/assignments, pinned apps, onboarding time, speed/temperature
-  visibility and source choices, and the Jellyfin switch/URL. The Jellyfin
-  access token and the salted PIN hash live in `flutter_secure_storage`.
+  visibility and source choices, the Jellyfin switch/URL and the selected genre
+  families. The Jellyfin access token and the salted PIN hash live in
+  `flutter_secure_storage`.
 - Package enumeration and icon rendering happen on a worker thread in Kotlin and
   are returned as PNG bytes.
 - Timers for volume/status are stopped when the app leaves the foreground.
@@ -135,8 +144,19 @@ Android (Kotlin)
   locked cold start.
 - Jellyfin uses Quick Connect only. The pairing check runs after `runApp`, and a
   missing network retries with backoff instead of blocking the dashboard.
+  `configured` (URL + saved token) gates the Home switch; `connected` gates
+  network actions.
+- The Jellyfin dashboard asks for 6 albums per row and shows 6 columns when each
+  card is at least 160 px wide, otherwise 5. Album labels share the cover's left
+  edge; the album page cover is top-aligned; a Random selection re-rolls that
+  row after playback starts.
+- Jellyfin playback is Media3 1.11.1 in a foreground `mediaPlayback` service.
+  The Dart controller owns the queue and polls state; the service owns audio
+  focus, the notification and playback reporting. It pauses on permanent focus
+  loss, resumes after transient loss, and stops at album end.
 - Network access is limited to the optional Open-Meteo weather fallback, the
-  user-configured Jellyfin server and the Gogs release check; no telemetry.
+  user-configured Jellyfin server (library, artwork, audio), and the Gogs release
+  check; no telemetry.
 
 ## 4. Milestones
 
@@ -166,6 +186,7 @@ Android (Kotlin)
 | M21 | On-screen volume diagnostics and stream snapshots; 1.0.5 | Done |
 | M22 | Bluetooth/OBD freeze hardening: UI-thread offload, device filtering, bounded retries, resume debounce; 1.0.6 | Done |
 | M23 | Move the Impeller opt-out into `<application>` so Skia is actually used; 1.0.7 | Done |
+| M24 | Jellyfin 1.2.0: Home slot, dashboard/genres, album page, Media3 playback, background notification, audio focus, tests and release | Done |
 
 ## 5. Verification checklist
 
@@ -229,6 +250,13 @@ Android (Kotlin)
 - [x] 66 tests pass, including the onboarding window and source preferences.
 - [x] 1.0.3 universal APK built and published for in-app update.
 - [x] Jellyfin Settings card: enable switch, URL entry, Quick Connect code, saved-token reuse, status, offline retry and token invalidation covered by unit/widget tests.
+- [x] Jellyfin Home switch after Music, disabled/off until configured, counts toward the 16-button cap and clears on disconnect.
+- [x] Jellyfin dashboard: Albums/Artists strip, Recently played / Recently added / Random, grouped genres, 6 cards when they fit and 5 otherwise, Random re-shuffle after selection.
+- [x] Jellyfin album page: top-aligned cover, track list, current track, previous/next, play/pause and volume.
+- [x] Media3 playback: background foreground service, media notification, hardware media keys, audio-focus pause/resume, end-of-album stop and Jellyfin playback reporting.
+- [x] Exclusive Music/Jellyfin equalizers and the Jellyfin tile subtitle clipped to the button width.
+- [x] 134 tests pass, including the Jellyfin API contract, genre catalog, controllers, album page, dashboard and genre picker.
+- [x] 1.2.0 universal APK built and published for in-app update.
 - [x] Tablet has the 1.0.0 installer build; unknown-app confirmation is user-controlled.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
@@ -251,9 +279,11 @@ 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 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.
+8. **Media metadata**: the Music equalizer follows `AudioManager.isMusicActive`
+   and is suppressed while Jellyfin plays; Jellyfin title/progress come from its
+   own controller. Other apps' title/progress query the active MediaSession
+   through the notification-listener component, which 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.
@@ -264,6 +294,20 @@ Android (Kotlin)
     the row; OBD ambient is always preferred when available.
 12. **Uninstall/reinstall** may drop the HOME role; Settings and the installer
     script can restore it.
+13. **Jellyfin 12.2 API differences** from older docs: keep the verified
+    endpoints and auth header in `JellyfinService`; recently played is derived
+    from played Audio tracks because album `UserData` is not updated by
+    playback. Parse defensively.
+14. **Media3/audio focus**: the service pauses and keeps position on permanent
+    focus loss and resumes only after a transient loss. The notification needs
+    POST_NOTIFICATIONS on Android 13+; playback works when it is denied. Test on
+    API 29/34/35 and a real tablet.
+15. **Jellyfin layout density**: 6 cards only when each is at least 160 px wide;
+    otherwise 5. Labels are clipped to the card/button width so long album names
+    cannot break the grid.
+16. **Jellyfin genre grouping is heuristic**: built-in families cover the known
+    tags (including Cyrillic); unknown tags become standalone. The picker is
+    capped at five families.
 
 ## 7. Out of scope (for now)
 
@@ -273,16 +317,30 @@ Android (Kotlin)
 - Calls, climate, reverse camera.
 - An app drawer with folders/pages; a single searchable All Apps page is enough.
 - Play Store packaging/signing, remote git, CI.
+- Jellyfin Artists tab, artist/album browsing and search.
+- Jellyfin offline caching/downloads; queue persistence across process death.
+- Data-aware streaming quality (Wi-Fi vs metered, bitrate caps).
+- User-editable genre mapping, gapless playback and richer metadata.
 
 ## 8. Change log
 
+- 2026-10-31: CarDash 1.2.0+20 adds the full optional Jellyfin music
+  source. The earlier Settings-only Quick Connect work now feeds a Home tile
+  after Music, a dashboard (Recently played / Recently added / Random, six cards
+  when they fit and five otherwise, grouped genre families, Random re-shuffle),
+  and an album page with a top-aligned cover, track list, previous/next,
+  play/pause and volume. Playback is Media3 1.11.1 in a foreground
+  `mediaPlayback` service with a media notification, background playback,
+  hardware media keys, audio-focus pause/resume, end-of-album stop and Jellyfin
+  playback reporting. The Music/Jellyfin equalizers are exclusive and the home
+  tile subtitle is clipped to the button width. 134 tests pass; the universal
+  release is `releases/cardash-android-1.2.0.apk`.
 - 2026-10-31: Added the optional Settings → Jellyfin integration. A switch and
   server URL drive the Quick Connect flow; the returned token is kept in secure
   storage and reused on later starts. The card shows connecting, waiting,
   connected, offline and error states. A rejected token disables the integration
   and clears the token; an unreachable server retries with 5–60 s backoff.
-  Nothing on Home consumes the connection yet. The controller exposes
-  `connected` as the active/inactive state for the next step. 84 tests pass.
+  84 tests passed at this intermediate step.
 - 2026-10-29: Pivoted from an overlay/head-unit chrome design to a
   launcher-native single home page, no top/bottom bars, full-screen external
   apps. Implemented core UI, Kotlin bridges, settings, PIN and documentation.

+ 59 - 31
docs/JELLYFIN_1.2.0_PLAN.md

@@ -1,6 +1,6 @@
 # CarDash Jellyfin 1.2.0 — Dashboard, Album Page and Playback
 
-Status: **approved for implementation** (2026-10-31).
+Status: **implemented; release 1.2.0 pending** (2026-10-31).
 Target release: **CarDash 1.2.0+20**.
 Server used for API verification: **Jellyfin 12.2.0** at `https://jellyfin.drun.net/`
 (server `DarkTower`), plus a local 12.2.0 container with a generated
@@ -39,15 +39,18 @@ changes.
   and a small **Albums / Artists** strip.
   - **Albums** is selected.
   - **Artists** is greyed out and inert (planned later).
-- The Albums tab has three rows, five albums each:
-  1. **Recently played** — latest 5 distinct albums from this user's played
-     tracks on any device.
-  2. **Recently added** — latest 5 albums added to the music library.
-  3. **Random** — 5 albums drawn from the union of the selected genre
+- The Albums tab has three rows, up to six albums each:
+  1. **Recently played** — latest up to 6 distinct albums from this user's
+     played tracks on any device.
+  2. **Recently added** — latest up to 6 albums added to the music library.
+  3. **Random** — up to 6 albums drawn from the union of the selected genre
      families. A small refresh button in the row header re-rolls it.
-- All 15 cards are visible at 1280×800; no horizontal carousels. The page may
-  scroll vertically only if the 1024×600 layout cannot fit (to be verified and
-  adjusted before release).
+- The row uses **6 columns when six cards fit** (each card at least 160 px
+  wide) and **5 columns otherwise**. At 1280×800 that is six; at 1024×600 it is
+  five. The data query asks for six and the row hides the sixth when the layout
+  only fits five.
+- All cards are visible at 1280×800; no horizontal carousels. The page may
+  scroll vertically only if the 1024×600 layout cannot fit.
 - Genres are grouped by built-in rules. The picker shows grouped families, not
   every raw tag. The user selects up to **5** families. If none are selected,
   the Random row shows a **Choose genres in Settings** prompt.
@@ -81,6 +84,19 @@ changes.
 - One release: 1.2.0. Patch versions are for bug fixes after the release.
 - Data-aware streaming quality is **out of scope** and recorded in Future work.
 
+Post-plan refinements implemented on 2026-10-31:
+
+- Six cards per row when they fit; otherwise five.
+- Album names and artists share the album art's left edge on the dashboard.
+- The album cover is top-aligned with the track list and controls on the album
+  page.
+- Music and Jellyfin equalizers are exclusive (the source that is actually
+  playing).
+- The Jellyfin Home tile's artist/album subtitle is clipped to the rounded
+  button width, so a long album title cannot widen the shortcut grid.
+- Selecting an album from the Random row re-rolls the Random row once playback
+  starts.
+
 ## 2. Scope and non-goals
 
 In scope:
@@ -157,7 +173,7 @@ GET {base}Items
 ```
 
 Dedupe in server order by `AlbumId` (fallback `ParentId`), keep the first
-occurrence, take 5. Build the album card from the track's `AlbumId`, `Album`,
+occurrence, take up to 6. Build the album card from the track's `AlbumId`, `Album`,
 `AlbumArtist`/`Artists`, `AlbumPrimaryImageTag`, `ParentId` and
 `UserData.LastPlayedDate`. This row must include plays from other Jellyfin
 devices.
@@ -168,7 +184,7 @@ devices.
 GET {base}Items/Latest
   ?userId={userId}
   &includeItemTypes=MusicAlbum
-  &limit=5
+  &limit=6
   &fields=Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio
   &enableUserData=true
   &enableImageTypes=Primary
@@ -202,7 +218,7 @@ GET {base}Items
   &includeItemTypes=MusicAlbum
   &genres={raw-1|raw-2|...}
   &sortBy=Random
-  &limit=5
+  &limit=6
   &fields=Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio
   &enableUserData=true
   &enableImageTypes=Primary
@@ -211,7 +227,7 @@ GET {base}Items
 
 Build the `genres` value from every raw genre in every selected family, joined
 with `|`, and URL-encode it. If no family is selected, do not call the server.
-Prefer albums not duplicated on the page; if the library cannot supply five
+Prefer albums not duplicated on the page; if the library cannot supply six
 distinct albums, allow repeats rather than showing empty cards.
 
 ### 3.5 Album tracks
@@ -543,17 +559,18 @@ repair states. Add:
 
 - Full-screen route; back arrow, title **Jellyfin**.
 - Small **Albums** / **Artists** strip; Artists in `colors.inkFaint`, no tap.
-- Three sections with 5 cards each:
+- Three sections with up to 6 cards each:
   - **Recently played**
   - **Recently added**
   - **Random** + refresh icon in the section header
 - Album card: cover, album name, artist, playing indicator/border when it is
-  the active queue's album.
+  the active queue's album. The cover and the two labels share the same left
+  edge; the labels are not aligned to a wider card box.
 - Cover uses `Image.network` with auth headers, rounded corners, memory cache,
   and a headphones placeholder on error.
-- Layout: 5 columns, compact card size computed from the available height.
-  All 15 visible at 1280×800; verify 1024×600 and adjust card size/scroll
-  fallback before release.
+- Layout: 6 columns when six cards of at least 160 px fit, otherwise 5 columns.
+  The card size is computed from the available height. All cards are visible at
+  1280×800; the 1024×600 layout uses five.
 - States:
   - loading skeleton/spinner,
   - server error: inline message + Retry,
@@ -563,7 +580,9 @@ repair states. Add:
     Settings." and no cards,
   - random refresh in progress: disable the refresh button and show a small
     spinner.
-- Random refresh: re-roll 5 from the union; keep the rest of the page.
+- Random refresh: re-roll 6 from the union; keep the rest of the page.
+- Selecting an album from the Random row re-rolls the Random row once playback
+  starts, so returning to the dashboard shows a fresh set.
 - Returning from an album page may refresh Recently played (or the controller
   optimistically moves the started album to the front).
 
@@ -573,8 +592,8 @@ repair states. Add:
 - Header: back arrow, eyebrow **Album**, title = album name,
   subtitle = artist.
 - Layout at 1280×800:
-  - left: large square cover (max ~400 px), album/artist and a small
-    playing/paused label,
+  - left: large square cover (max ~400 px), aligned to the top with the other
+    two columns,
   - center: track list in a bounded scroll area,
   - right: current track info + transport + volume controls.
 - Track list:
@@ -600,8 +619,14 @@ repair states. Add:
 ### 7.6 Home integration
 
 - Jellyfin tile inserted between Music and Radio when visible.
-- `showEqualizer` on the Jellyfin tile while Jellyfin is playing.
-- The Music tile keeps its existing "any audio playing" equalizer behavior.
+- The tile's title and **Artist — Album** subtitle are constrained to the
+  rounded button width, one line with an ellipsis; the full text remains in the
+  accessibility label.
+- The equalizers are exclusive:
+  - Jellyfin playing → equalizer on the Jellyfin tile only,
+  - another app playing while Jellyfin is paused → equalizer on the Music tile
+    only,
+  - nothing playing → neither.
 - `VolumeCard` state selection:
   - if Jellyfin is playing, or Jellyfin has a queue and no other music is
     playing: show the Jellyfin `MediaPlayback` and route play/pause to the
@@ -731,7 +756,7 @@ confirmation; the default assumption is that the FGS + audio track is enough.
 
 - `JellyfinAlbum`/`JellyfinTrack` JSON parsing from real 12.2 payloads.
 - Recent-played dedupe: multiple tracks of one album, newest album first,
-  fallback to `ParentId`, take 5.
+  fallback to `ParentId`, take up to 6.
 - Random genre query building: pipe-delimited, URL-encoded, empty selection
   makes no request.
 - Genre catalog: all user-provided raw genres map to the expected family,
@@ -762,11 +787,13 @@ confirmation; the default assumption is that the FGS + audio track is enough.
 - Jellyfin settings card: genre row disabled/enabled; notification row;
   footer copy.
 - Genre dialog: grouped list, max 5, counter, save/cancel, error/retry.
-- Dashboard: five cards per row, three rows, Artists disabled, random refresh,
-  empty/error/no-genres states.
+- Dashboard: six cards per row on a wide screen, five when the width only fits
+  five, Artists disabled, random refresh, Random re-shuffle after selection,
+  empty/error/no-genres states, labels aligned to the cover's left.
 - Album page: cover, track list, current track, prev/next/play/pause/volume,
   track tap, loading/error.
-- Home Jellyfin tile: visible/hidden, subtitle, equalizer, tap opens dashboard.
+- Home Jellyfin tile: visible/hidden, subtitle clipped to the tile width,
+  equalizer, tap opens dashboard.
 - `VolumeCard` Jellyfin branch: uses Jellyfin media/title/progress and routes
   play/pause when active; falls back when another app is playing.
 
@@ -787,8 +814,8 @@ real server for final library/genre checks.
 - Android 13+ notification permission allowed and denied.
 - API 29 and API 35 emulators; real tablet.
 - Offline server, rejected token, empty library, no genre selection.
-- 1280×800 and 1024×600 Home/dashboard/album layouts; 16-button cap with the
-  Jellyfin button on.
+- 1280×800, 1280×720 and 1024×600 Home/dashboard/album layouts; 16-button cap
+  with the Jellyfin button on.
 - Release build install and cold start with Jellyfin configured.
 
 ### 10.4 Commands
@@ -840,9 +867,10 @@ JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --debug
 | Audio focus on the real radio unit | Service-managed focus with pause/keep-position; test with a real second player on the tablet. |
 | Foreground service/notification on API 29/35 | Manifest service type + runtime permission; test both AVDs with allowed/denied notification permission. |
 | Media3/AGP 9.1/JDK 21 build friction | Add dependencies early, build a debug APK before UI work, pin a compatible Media3 version. |
-| 15-card layout at 1024×600 | Compute card size from constraints; verify; allow vertical scroll only as a last resort. |
+| 15–18 card layout (6/5 columns) at tablet sizes | Compute the column count from the width (six if each card is at least 160 px), size from the height; verified at 1280×800 and 1024×600. |
 | Token leaks into logs/URLs | Auth header only; no URL token; redact reporter logs; never log the connection. |
-| Random row repeats or empties | Prefer distinct albums, allow repeats only when the library cannot fill 5; show a clear empty state. |
+| Random row repeats or empties | Prefer distinct albums, allow repeats only when the library cannot fill 6; show a clear empty state. |
+| Equalizer source attribution | Jellyfin playing wins; the Music tile only animates when another app is playing. The 700 ms controller poll may briefly lag the system session. |
 | Album page/notification artwork auth | Use the auth header for Flutter images; pass artwork bytes to native for the notification; placeholder on failure. |
 | Multiple music libraries | Query all albums globally; merge `/Genres` per `music` view; use the first/merged set for the picker. |
 

+ 19 - 0
lib/app.dart

@@ -13,6 +13,8 @@ import 'features/home/launcher_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/weather_controller.dart';
+import 'features/jellyfin/jellyfin_playback_controller.dart';
+import 'features/settings/jellyfin_controller.dart';
 import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
 import 'features/lock/lock_screen.dart';
@@ -25,23 +27,38 @@ class CarDashApp extends StatefulWidget {
 }
 
 class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
+  late final JellyfinController _jellyfin;
+
   @override
   void initState() {
     super.initState();
+    _jellyfin = context.read<JellyfinController>();
+    _jellyfin.addListener(_syncJellyfinConfigured);
     WidgetsBinding.instance.addObserver(this);
     WidgetsBinding.instance.addPostFrameCallback((_) {
       if (!mounted) return;
+      _syncJellyfinConfigured();
       _applyImmersive();
       context.read<SystemController>().start();
       unawaited(context.read<ObdController>().start());
       unawaited(context.read<GpsSpeedController>().start());
       unawaited(context.read<UpdateController>().start());
       unawaited(context.read<WeatherController>().start());
+      unawaited(context.read<JellyfinPlaybackController>().restore());
     });
   }
 
+  void _syncJellyfinConfigured() {
+    unawaited(
+      context.read<LauncherController>().setJellyfinConfigured(
+        _jellyfin.configured,
+      ),
+    );
+  }
+
   @override
   void dispose() {
+    _jellyfin.removeListener(_syncJellyfinConfigured);
     WidgetsBinding.instance.removeObserver(this);
     super.dispose();
   }
@@ -61,6 +78,7 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         unawaited(context.read<UpdateController>().start());
         unawaited(context.read<WeatherController>().start());
         context.read<LauncherController>().refresh();
+        context.read<JellyfinPlaybackController>().onAppResumed();
       case AppLifecycleState.inactive:
         // The system volume panel and permission dialogs can make the activity
         // inactive without hiding it. Keep polling so the volume ring stays in
@@ -73,6 +91,7 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         context.read<ObdController>().stop();
         context.read<GpsSpeedController>().stop();
         context.read<WeatherController>().stop();
+        context.read<JellyfinPlaybackController>().onAppPaused();
     }
   }
 

+ 1 - 1
lib/core/app_version.dart

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

+ 295 - 0
lib/data/models/jellyfin_library.dart

@@ -0,0 +1,295 @@
+import 'package:flutter/foundation.dart';
+
+/// A saved Jellyfin selection in the genre picker.
+@immutable
+class JellyfinGenreSelection {
+  const JellyfinGenreSelection({required this.id, required this.label});
+
+  final String id;
+  final String label;
+
+  factory JellyfinGenreSelection.fromJson(Map<String, dynamic> json) {
+    return JellyfinGenreSelection(
+      id: (json['id'] as String?) ?? '',
+      label: (json['label'] as String?) ?? '',
+    );
+  }
+
+  Map<String, Object?> toJson() => {'id': id, 'label': label};
+
+  @override
+  bool operator ==(Object other) {
+    return other is JellyfinGenreSelection &&
+        other.id == id &&
+        other.label == label;
+  }
+
+  @override
+  int get hashCode => Object.hash(id, label);
+
+  @override
+  String toString() => label;
+}
+
+/// A canonical genre family such as Metal, Rock or Jazz. Raw genres keep their
+/// server spelling; [rawGenres] is what the random query expands to.
+@immutable
+class JellyfinGenreFamily {
+  const JellyfinGenreFamily({
+    required this.id,
+    required this.label,
+    required this.rawGenres,
+    required this.isStandalone,
+  });
+
+  final String id;
+  final String label;
+  final List<String> rawGenres;
+  final bool isStandalone;
+
+  int get rawCount => rawGenres.length;
+
+  @override
+  bool operator ==(Object other) {
+    return other is JellyfinGenreFamily && other.id == id;
+  }
+
+  @override
+  int get hashCode => id.hashCode;
+}
+
+/// One raw genre on the server and the family it belongs to.
+@immutable
+class JellyfinGenre {
+  const JellyfinGenre({
+    required this.name,
+    required this.familyId,
+    required this.familyLabel,
+  });
+
+  final String name;
+  final String familyId;
+  final String familyLabel;
+}
+
+/// An album row in the Jellyfin dashboard.
+@immutable
+class JellyfinAlbum {
+  const JellyfinAlbum({
+    required this.id,
+    required this.name,
+    required this.artist,
+    this.year,
+    this.imageTag,
+    this.childCount,
+    this.dateCreated,
+    this.lastPlayed,
+  });
+
+  final String id;
+  final String name;
+  final String artist;
+  final int? year;
+  final String? imageTag;
+  final int? childCount;
+  final DateTime? dateCreated;
+  final DateTime? lastPlayed;
+
+  bool get hasArtwork => (imageTag?.isNotEmpty ?? false);
+
+  factory JellyfinAlbum.fromJson(Map<String, dynamic> json) {
+    return JellyfinAlbum(
+      id: _string(json['Id']),
+      name: _string(json['Name']),
+      artist: _albumArtist(json),
+      year: _int(json['ProductionYear']),
+      imageTag:
+          _imageTag(json['ImageTags']) ??
+          _stringOrNull(json['AlbumPrimaryImageTag']),
+      childCount: _int(json['ChildCount']),
+      dateCreated: _date(json['DateCreated']),
+      lastPlayed: _lastPlayed(json['UserData']),
+    );
+  }
+
+  /// Builds an album card from a played Audio item.
+  factory JellyfinAlbum.fromTrack(JellyfinTrack track) {
+    return JellyfinAlbum(
+      id: track.albumId,
+      name: track.album.isEmpty ? track.name : track.album,
+      artist: track.artist,
+      year: track.year,
+      imageTag: track.albumImageTag,
+      lastPlayed: track.lastPlayed,
+    );
+  }
+
+  /// Builds an album card from a played Audio item. Jellyfin 12.2 updates the
+  /// track's user data, not the album's, so the recently-played row is built
+  /// from tracks and deduped by album id.
+  factory JellyfinAlbum.fromTrackJson(Map<String, dynamic> json) {
+    final albumId = _string(json['AlbumId']);
+    return JellyfinAlbum(
+      id: albumId.isNotEmpty ? albumId : _string(json['ParentId']),
+      name: _stringOrNull(json['Album']) ?? _string(json['Name']),
+      artist: _albumArtist(json),
+      year: _int(json['ProductionYear']),
+      imageTag:
+          _stringOrNull(json['AlbumPrimaryImageTag']) ??
+          _imageTag(json['ImageTags']),
+      childCount: _int(json['ChildCount']),
+      dateCreated: _date(json['DateCreated']),
+      lastPlayed: _lastPlayed(json['UserData']),
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return other is JellyfinAlbum && other.id == id;
+  }
+
+  @override
+  int get hashCode => id.hashCode;
+
+  @override
+  String toString() => name;
+}
+
+/// A playable audio track. [mediaSourceId] is what Jellyfin playback reporting
+/// and the stream endpoint expect; [duration] comes from `RunTimeTicks`.
+@immutable
+class JellyfinTrack {
+  const JellyfinTrack({
+    required this.id,
+    required this.name,
+    required this.albumId,
+    required this.album,
+    required this.artist,
+    required this.indexNumber,
+    required this.parentIndexNumber,
+    required this.duration,
+    required this.mediaSourceId,
+    this.container,
+    this.codec,
+    this.genres = const [],
+    this.lastPlayed,
+    this.albumImageTag,
+    this.year,
+  });
+
+  final String id;
+  final String name;
+  final String albumId;
+  final String album;
+  final String artist;
+  final int? indexNumber;
+  final int? parentIndexNumber;
+  final Duration duration;
+  final String mediaSourceId;
+  final String? container;
+  final String? codec;
+  final List<String> genres;
+  final DateTime? lastPlayed;
+  final String? albumImageTag;
+  final int? year;
+
+  String get displayIndex {
+    final track = indexNumber;
+    if (track == null) return '';
+    final disc = parentIndexNumber;
+    if (disc != null && disc > 1) return '$disc.$track';
+    return '$track';
+  }
+
+  factory JellyfinTrack.fromJson(Map<String, dynamic> json) {
+    final sources = json['MediaSources'];
+    final source = sources is List && sources.isNotEmpty ? sources.first : null;
+    final sourceMap = source is Map ? source : null;
+    final streams = sourceMap?['MediaStreams'];
+    final stream = streams is List && streams.isNotEmpty ? streams.first : null;
+    final streamMap = stream is Map ? stream : null;
+    final ticks = _int(json['RunTimeTicks']) ?? 0;
+    return JellyfinTrack(
+      id: _string(json['Id']),
+      name: _string(json['Name']),
+      albumId:
+          _stringOrNull(json['AlbumId']) ??
+          _stringOrNull(json['ParentId']) ??
+          '',
+      album: _string(json['Album']),
+      artist: _albumArtist(json),
+      indexNumber: _int(json['IndexNumber']),
+      parentIndexNumber: _int(json['ParentIndexNumber']),
+      duration: Duration(microseconds: ticks ~/ 10),
+      mediaSourceId: _string(sourceMap?['Id']),
+      container: _stringOrNull(sourceMap?['Container']),
+      codec: _stringOrNull(streamMap?['Codec']),
+      genres: _stringList(json['Genres']),
+      lastPlayed: _lastPlayed(json['UserData']),
+      albumImageTag: _stringOrNull(json['AlbumPrimaryImageTag']),
+      year: _int(json['ProductionYear']),
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return other is JellyfinTrack && other.id == id;
+  }
+
+  @override
+  int get hashCode => id.hashCode;
+
+  @override
+  String toString() => name;
+}
+
+String _string(Object? value) => value is String ? value : '';
+
+String? _stringOrNull(Object? value) {
+  if (value is! String) return null;
+  final trimmed = value.trim();
+  return trimmed.isEmpty ? null : trimmed;
+}
+
+int? _int(Object? value) {
+  if (value is num) return value.toInt();
+  if (value is String) return int.tryParse(value);
+  return null;
+}
+
+DateTime? _date(Object? value) {
+  if (value is! String) return null;
+  return DateTime.tryParse(value);
+}
+
+DateTime? _lastPlayed(Object? userData) {
+  if (userData is! Map) return null;
+  return _date(userData['LastPlayedDate']);
+}
+
+String? _imageTag(Object? imageTags) {
+  if (imageTags is! Map) return null;
+  return _stringOrNull(imageTags['Primary']);
+}
+
+String _albumArtist(Map<String, dynamic> json) {
+  final albumArtist = _stringOrNull(json['AlbumArtist']);
+  if (albumArtist != null) return albumArtist;
+  final artists = _stringList(json['Artists']);
+  if (artists.isNotEmpty) return artists.join(', ');
+  final albumArtists = json['AlbumArtists'];
+  if (albumArtists is List) {
+    final names = albumArtists
+        .whereType<Map>()
+        .map((entry) => _stringOrNull(entry['Name']))
+        .whereType<String>()
+        .toList(growable: false);
+    if (names.isNotEmpty) return names.join(', ');
+  }
+  return '';
+}
+
+List<String> _stringList(Object? value) {
+  if (value is! List) return const [];
+  return value.whereType<String>().toList(growable: false);
+}

+ 39 - 0
lib/data/models/jellyfin_models.dart

@@ -1,3 +1,42 @@
+import '../../core/app_version.dart';
+
+/// The authenticated details needed by the library and playback services.
+/// Keep this out of widgets; [toString] deliberately omits the token.
+class JellyfinConnection {
+  const JellyfinConnection({
+    required this.baseUrl,
+    required this.accessToken,
+    required this.userId,
+    required this.deviceId,
+    required this.userName,
+  });
+
+  final Uri baseUrl;
+  final String accessToken;
+  final String userId;
+  final String deviceId;
+  final String userName;
+
+  String get authorizationHeader {
+    final parts = [
+      'Client="CarDash"',
+      'Device="CarDash"',
+      'DeviceId="$deviceId"',
+      'Version="$appVersion"',
+      'Token="$accessToken"',
+    ];
+    return 'MediaBrowser ${parts.join(', ')}';
+  }
+
+  Map<String, String> get apiHeaders => {
+    'Authorization': authorizationHeader,
+    'Accept': 'application/json',
+  };
+
+  @override
+  String toString() => 'JellyfinConnection($baseUrl, user: $userName)';
+}
+
 /// Public identity of a Jellyfin server.
 class JellyfinServerInfo {
   const JellyfinServerInfo({required this.name, required this.version});

+ 235 - 0
lib/data/models/jellyfin_playback.dart

@@ -0,0 +1,235 @@
+import 'package:flutter/foundation.dart';
+
+import 'media_playback.dart';
+
+/// One track in the native Jellyfin playback queue.
+@immutable
+class JellyfinQueueItem {
+  const JellyfinQueueItem({
+    required this.trackId,
+    required this.mediaSourceId,
+    required this.url,
+    required this.title,
+    required this.artist,
+    required this.album,
+    required this.durationMs,
+    required this.index,
+  });
+
+  final String trackId;
+  final String mediaSourceId;
+  final String url;
+  final String title;
+  final String artist;
+  final String album;
+  final int durationMs;
+  final int index;
+
+  Map<String, Object?> toMap() => {
+    'trackId': trackId,
+    'mediaSourceId': mediaSourceId,
+    'url': url,
+    'title': title,
+    'artist': artist,
+    'album': album,
+    'durationMs': durationMs,
+    'index': index,
+  };
+}
+
+/// Everything the native service needs to start one album. [authorization] and
+/// [accessToken] are kept in memory and are never logged or persisted.
+@immutable
+class JellyfinPlaybackQueue {
+  const JellyfinPlaybackQueue({
+    required this.baseUrl,
+    required this.authorization,
+    required this.accessToken,
+    required this.userId,
+    required this.deviceId,
+    required this.playSessionId,
+    required this.albumId,
+    required this.albumName,
+    required this.artistName,
+    required this.items,
+    this.artwork,
+  });
+
+  final Uri baseUrl;
+  final String authorization;
+  final String accessToken;
+  final String userId;
+  final String deviceId;
+  final String playSessionId;
+  final String albumId;
+  final String albumName;
+  final String artistName;
+  final List<JellyfinQueueItem> items;
+  final Uint8List? artwork;
+
+  Map<String, Object?> toMap() => {
+    'baseUrl': baseUrl.toString(),
+    'authorization': authorization,
+    'accessToken': accessToken,
+    'userId': userId,
+    'deviceId': deviceId,
+    'playSessionId': playSessionId,
+    'albumId': albumId,
+    'albumName': albumName,
+    'artistName': artistName,
+    'artwork': artwork,
+    'items': items.map((item) => item.toMap()).toList(growable: false),
+  };
+}
+
+/// The native player's current state. Positions are milliseconds.
+@immutable
+class JellyfinPlaybackSnapshot {
+  const JellyfinPlaybackSnapshot({
+    this.available = false,
+    this.playing = false,
+    this.buffering = false,
+    this.itemId,
+    this.albumId,
+    this.title,
+    this.artist,
+    this.album,
+    this.durationMs = 0,
+    this.positionMs = 0,
+    this.index = 0,
+    this.hasPrevious = false,
+    this.hasNext = false,
+    this.error,
+  });
+
+  static const empty = JellyfinPlaybackSnapshot();
+
+  final bool available;
+  final bool playing;
+  final bool buffering;
+  final String? itemId;
+  final String? albumId;
+  final String? title;
+  final String? artist;
+  final String? album;
+  final int durationMs;
+  final int positionMs;
+  final int index;
+  final bool hasPrevious;
+  final bool hasNext;
+  final String? error;
+
+  bool get playingOrBuffering => playing || buffering;
+
+  bool get ended =>
+      available && !playing && durationMs > 0 && positionMs >= durationMs - 500;
+
+  double get progress {
+    if (durationMs <= 0) return 0;
+    return (positionMs / durationMs).clamp(0.0, 1.0);
+  }
+
+  JellyfinPlaybackSnapshot copyWith({
+    bool? available,
+    bool? playing,
+    bool? buffering,
+    int? positionMs,
+    int? index,
+    bool? hasPrevious,
+    bool? hasNext,
+    String? error,
+  }) {
+    return JellyfinPlaybackSnapshot(
+      available: available ?? this.available,
+      playing: playing ?? this.playing,
+      buffering: buffering ?? this.buffering,
+      itemId: itemId,
+      albumId: albumId,
+      title: title,
+      artist: artist,
+      album: album,
+      durationMs: durationMs,
+      positionMs: positionMs ?? this.positionMs,
+      index: index ?? this.index,
+      hasPrevious: hasPrevious ?? this.hasPrevious,
+      hasNext: hasNext ?? this.hasNext,
+      error: error ?? this.error,
+    );
+  }
+
+  factory JellyfinPlaybackSnapshot.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 JellyfinPlaybackSnapshot(
+      available: map['available'] == true,
+      playing: map['playing'] == true,
+      buffering: map['buffering'] == true,
+      itemId: string('itemId'),
+      albumId: string('albumId'),
+      title: string('title'),
+      artist: string('artist'),
+      album: string('album'),
+      durationMs: integer('durationMs'),
+      positionMs: integer('positionMs'),
+      index: integer('index'),
+      hasPrevious: map['hasPrevious'] == true,
+      hasNext: map['hasNext'] == true,
+      error: string('error'),
+    );
+  }
+
+  /// Snapshot consumed by the Home volume card and the existing media widgets.
+  MediaPlayback toMediaPlayback() {
+    return MediaPlayback(
+      hasAccess: available,
+      hasSession: available && itemId != null,
+      playing: playing,
+      title: title,
+      artist: artist,
+      packageName: 'com.wasp.cardash_launcher',
+      durationMs: durationMs,
+      positionMs: positionMs,
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return other is JellyfinPlaybackSnapshot &&
+        other.available == available &&
+        other.playing == playing &&
+        other.buffering == buffering &&
+        other.itemId == itemId &&
+        other.albumId == albumId &&
+        other.title == title &&
+        other.artist == artist &&
+        other.album == album &&
+        other.durationMs == durationMs &&
+        other.positionMs == positionMs &&
+        other.index == index &&
+        other.hasPrevious == hasPrevious &&
+        other.hasNext == hasNext &&
+        other.error == error;
+  }
+
+  @override
+  int get hashCode => Object.hash(
+    available,
+    playing,
+    buffering,
+    itemId,
+    albumId,
+    title,
+    artist,
+    album,
+    durationMs,
+    positionMs,
+    index,
+    hasPrevious,
+    hasNext,
+    error,
+  );
+}

+ 11 - 1
lib/data/models/slot_type.dart

@@ -1,4 +1,5 @@
-/// The three configurable shortcut slots on the home screen.
+/// The fixed shortcut slots on the home screen. Jellyfin is a system slot with
+/// no app assignment; it opens the CarDash music dashboard instead.
 enum SlotType {
   navigation(
     key: 'navigation',
@@ -6,6 +7,11 @@ enum SlotType {
     iconAsset: 'assets/icons/navigation.svg',
   ),
   music(key: 'music', label: 'Music', iconAsset: 'assets/icons/music.svg'),
+  jellyfin(
+    key: 'jellyfin',
+    label: 'Jellyfin',
+    iconAsset: 'assets/icons/headphones.svg',
+  ),
   radio(key: 'radio', label: 'Radio', iconAsset: 'assets/icons/radio.svg');
 
   const SlotType({
@@ -17,4 +23,8 @@ enum SlotType {
   final String key;
   final String label;
   final String iconAsset;
+
+  /// Only app slots have a `SlotAssignment` and an app picker. Jellyfin is
+  /// always backed by the configured server.
+  bool get isAppSlot => this != SlotType.jellyfin;
 }

+ 40 - 1
lib/data/repositories/settings_repository.dart

@@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
 
 import '../../core/theme/accent_choice.dart';
 import '../../core/theme/theme_preference.dart';
+import '../models/jellyfin_library.dart';
 import '../models/slot_assignment.dart';
 import '../models/slot_type.dart';
 
@@ -38,6 +39,7 @@ class SettingsRepository {
   static const _jellyfinServerNameKey = 'jellyfin.serverName';
   static const _jellyfinUserIdKey = 'jellyfin.userId';
   static const _jellyfinUserNameKey = 'jellyfin.userName';
+  static const _jellyfinGenreFamiliesKey = 'jellyfin.genreFamilies';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -208,6 +210,39 @@ class SettingsRepository {
     await _prefs.remove(_jellyfinUserNameKey);
   }
 
+  /// Genres selected for the Random row, stored as `{id,label}` pairs so the
+  /// picker and dashboard can show labels while offline. At most five.
+  List<JellyfinGenreSelection> get jellyfinGenreSelections {
+    final raw = _prefs.getString(_jellyfinGenreFamiliesKey);
+    if (raw == null || raw.isEmpty) return const [];
+    try {
+      final decoded = jsonDecode(raw);
+      if (decoded is! List) return const [];
+      return decoded
+          .whereType<Map>()
+          .map(
+            (entry) => JellyfinGenreSelection.fromJson(
+              Map<String, dynamic>.from(entry),
+            ),
+          )
+          .where((selection) => selection.id.isNotEmpty)
+          .take(5)
+          .toList(growable: false);
+    } catch (_) {
+      return const [];
+    }
+  }
+
+  Future<void> setJellyfinGenreSelections(
+    List<JellyfinGenreSelection> selections,
+  ) {
+    final capped = selections.take(5).toList(growable: false);
+    return _prefs.setString(
+      _jellyfinGenreFamiliesKey,
+      jsonEncode(capped.map((selection) => selection.toJson()).toList()),
+    );
+  }
+
   /// A stable per-install device id for Quick Connect. Jellyfin shows it in
   /// its device list, so it must not change between launches.
   Future<String> ensureJellyfinDeviceId() async {
@@ -251,7 +286,11 @@ class SettingsRepository {
   }
 
   bool slotEnabled(SlotType slot) {
-    return _prefs.getBool('$_slotPrefix.${slot.key}.enabled') ?? true;
+    final stored = _prefs.getBool('$_slotPrefix.${slot.key}.enabled');
+    if (stored != null) return stored;
+    // The three app slots stay visible by default; Jellyfin is opt-in once the
+    // integration is paired.
+    return slot != SlotType.jellyfin;
   }
 
   Future<void> setSlotEnabled(SlotType slot, bool enabled) {

+ 299 - 0
lib/data/services/jellyfin_genre_catalog.dart

@@ -0,0 +1,299 @@
+import '../models/jellyfin_library.dart';
+
+/// A raw genre plus the canonical family it belongs to.
+class JellyfinGenreMatch {
+  const JellyfinGenreMatch({required this.id, required this.label});
+
+  final String id;
+  final String label;
+}
+
+/// Built-in grouping for Jellyfin genre tags. The rules intentionally prefer
+/// the head noun ("Folk Metal" -> Metal, "Blues Rock" -> Rock) and then a
+/// small alias table for hybrids and Cyrillic spellings. Anything unmatched
+/// stays its own family so no library tag is lost.
+class JellyfinGenreCatalog {
+  const JellyfinGenreCatalog();
+
+  static const _labels = <String, String>{
+    'metal': 'Metal',
+    'rock': 'Rock',
+    'jazz': 'Jazz',
+    'pop': 'Pop',
+    'folk': 'Folk',
+    'blues': 'Blues',
+    'soul': 'Soul',
+    'funk': 'Funk',
+    'rnb': 'R&B',
+    'country': 'Country',
+    'classical': 'Classical',
+    'soundtrack': 'Soundtrack',
+    'electronic': 'Electronic',
+    'ambient': 'Ambient',
+    'punk': 'Punk',
+    'latin': 'Latin',
+    'acoustic': 'Acoustic',
+    'avant-garde': 'Avant-Garde',
+    'gothic': 'Gothic',
+    'pagan': 'Pagan',
+    'singer-songwriter': 'Singer-Songwriter',
+    'comedy': 'Comedy',
+  };
+
+  static const _order = <String>[
+    'metal',
+    'rock',
+    'jazz',
+    'pop',
+    'folk',
+    'blues',
+    'soul',
+    'funk',
+    'rnb',
+    'country',
+    'classical',
+    'soundtrack',
+    'electronic',
+    'ambient',
+    'punk',
+    'latin',
+    'acoustic',
+    'avant-garde',
+    'gothic',
+    'pagan',
+    'singer-songwriter',
+    'comedy',
+  ];
+
+  /// Whole-name aliases that cannot be inferred from the last token.
+  static const _aliases = <String, String>{
+    'black': 'metal',
+    'death': 'metal',
+    'viking': 'metal',
+    'doom': 'metal',
+    'alternative': 'rock',
+    'grunge': 'rock',
+    'progressive': 'rock',
+    'rockabilly': 'rock',
+    'rock & roll': 'rock',
+    'rock singer-songwriter': 'rock',
+    'neofolk': 'folk',
+    'medieval': 'folk',
+    'composer': 'classical',
+    'conductor': 'classical',
+    'film scores': 'soundtrack',
+    'films/games': 'soundtrack',
+    'dungeon synth': 'ambient',
+    'new age': 'ambient',
+    'dance': 'electronic',
+    'electro': 'electronic',
+    'fusion': 'jazz',
+    'swing': 'jazz',
+    'hardcore': 'punk',
+    'metalcore': 'metal',
+    'deathcore': 'metal',
+    'black metal': 'metal',
+    'death metal': 'metal',
+    'heavy metal': 'metal',
+    'soul & funk': 'soul',
+    'r&b': 'rnb',
+    'latin music': 'latin',
+    'rock pop': 'pop',
+    'acoustic': 'acoustic',
+    'avant-garde': 'avant-garde',
+    'gothic': 'gothic',
+    'pagan': 'pagan',
+    'singer & songwriter': 'singer-songwriter',
+    'comedy': 'comedy',
+    'neoclassical': 'classical',
+    'метъл': 'metal',
+    'рок': 'rock',
+    'хард рок': 'rock',
+    'джаз': 'jazz',
+    'поп': 'pop',
+    'фолк': 'folk',
+    'блус': 'blues',
+    'кънтри': 'country',
+    'класическа': 'classical',
+    'електронна': 'electronic',
+    'ембиънт': 'ambient',
+    'пънк': 'punk',
+    'соул': 'soul',
+    'фънк': 'funk',
+    'латино': 'latin',
+  };
+
+  /// Head nouns. The last token wins, which handles every "X Metal",
+  /// "X Rock", "X Jazz" and "X Folk" variant without enumerating them.
+  static const _headNouns = <String, String>{
+    'metal': 'metal',
+    'метъл': 'metal',
+    'rock': 'rock',
+    'рок': 'rock',
+    'jazz': 'jazz',
+    'джаз': 'jazz',
+    'pop': 'pop',
+    'поп': 'pop',
+    'folk': 'folk',
+    'фолк': 'folk',
+    'blues': 'blues',
+    'блус': 'blues',
+    'soul': 'soul',
+    'соул': 'soul',
+    'funk': 'funk',
+    'фънк': 'funk',
+    'country': 'country',
+    'кънтри': 'country',
+    'classical': 'classical',
+    'electronic': 'electronic',
+    'ambient': 'ambient',
+    'ембиънт': 'ambient',
+    'punk': 'punk',
+    'пънк': 'punk',
+    'latin': 'latin',
+    'латино': 'latin',
+    'soundtrack': 'soundtrack',
+  };
+
+  List<JellyfinGenreFamily> group(Iterable<String> rawGenres) {
+    final byFamily = <String, List<String>>{};
+    final labels = <String, String>{};
+    for (final raw in rawGenres) {
+      final name = raw.trim();
+      if (name.isEmpty) continue;
+      final genreMatch = match(name);
+      byFamily.putIfAbsent(genreMatch.id, () => []).add(name);
+      labels[genreMatch.id] = genreMatch.label;
+    }
+
+    final families = <JellyfinGenreFamily>[];
+    for (final id in _order) {
+      final raws = byFamily[id];
+      if (raws == null || raws.isEmpty) continue;
+      families.add(
+        JellyfinGenreFamily(
+          id: id,
+          label: labels[id] ?? _labels[id]!,
+          rawGenres: List.unmodifiable(_sorted(raws)),
+          isStandalone: false,
+        ),
+      );
+    }
+
+    final standalone =
+        byFamily.keys
+            .where((id) => !_order.contains(id))
+            .toList(growable: false)
+          ..sort(
+            (a, b) => (labels[a] ?? a).toLowerCase().compareTo(
+              (labels[b] ?? b).toLowerCase(),
+            ),
+          );
+    for (final id in standalone) {
+      families.add(
+        JellyfinGenreFamily(
+          id: id,
+          label: labels[id] ?? id,
+          rawGenres: List.unmodifiable(_sorted(byFamily[id]!)),
+          isStandalone: true,
+        ),
+      );
+    }
+    return families;
+  }
+
+  JellyfinGenreMatch match(String raw) {
+    final name = raw.trim();
+    final normalized = _normalize(name);
+    final alias = _aliases[normalized];
+    if (alias != null) {
+      return JellyfinGenreMatch(id: alias, label: _labels[alias]!);
+    }
+
+    final tokens = normalized.split(RegExp(r'[\s\-/&]+'));
+    for (var i = tokens.length - 1; i >= 0; i--) {
+      final token = tokens[i];
+      if (token.isEmpty) continue;
+      final head = _headNouns[token];
+      if (head != null) {
+        return JellyfinGenreMatch(id: head, label: _labels[head]!);
+      }
+      // Only the last meaningful token decides the head noun; stop after it.
+      break;
+    }
+
+    for (final entry in _headNouns.entries) {
+      if (normalized.endsWith(entry.key)) {
+        return JellyfinGenreMatch(
+          id: entry.value,
+          label: _labels[entry.value]!,
+        );
+      }
+    }
+
+    return JellyfinGenreMatch(
+      id: 'raw:$normalized',
+      label: name.isEmpty ? raw : name,
+    );
+  }
+
+  /// Expands saved family selections to the raw server tags the query needs.
+  List<String> rawGenresFor({
+    required List<JellyfinGenreFamily> families,
+    required Iterable<String> selectedIds,
+  }) {
+    final selected = selectedIds.toSet();
+    final result = <String>[];
+    for (final family in families) {
+      if (!selected.contains(family.id)) continue;
+      for (final raw in family.rawGenres) {
+        if (!result.contains(raw)) result.add(raw);
+      }
+    }
+    return result;
+  }
+
+  /// Drops selections whose family disappeared from the server and refreshes
+  /// their labels from the current catalog.
+  List<JellyfinGenreSelection> resolveSelections({
+    required List<JellyfinGenreFamily> families,
+    required Iterable<JellyfinGenreSelection> selections,
+  }) {
+    final byId = {for (final family in families) family.id: family};
+    final resolved = <JellyfinGenreSelection>[];
+    for (final selection in selections) {
+      final family = byId[selection.id];
+      if (family == null) continue;
+      resolved.add(JellyfinGenreSelection(id: family.id, label: family.label));
+    }
+    return resolved;
+  }
+
+  JellyfinGenreFamily? familyById(
+    List<JellyfinGenreFamily> families,
+    String id,
+  ) {
+    for (final family in families) {
+      if (family.id == id) return family;
+    }
+    return null;
+  }
+
+  static List<String> _sorted(List<String> values) {
+    final copy = values.toList(growable: true)
+      ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
+    return copy;
+  }
+
+  static String _normalize(String value) {
+    var text = value.trim().toLowerCase();
+    text = text.replaceAll('ё', 'е');
+    text = text.replaceAll(RegExp(r'\s+'), ' ');
+    text = text.replaceAll(RegExp(r'[\[\]\(\)\{\}"“”„]'), '');
+    text = text.replaceAll(RegExp(r'\s*/\s*'), '/');
+    text = text.replaceAll(RegExp(r'\s*&\s*'), ' & ');
+    text = text.replaceAll(RegExp(r'\s*-\s*'), '-');
+    text = text.replaceAll(RegExp(r'[.!?]+$'), '');
+    return text.trim();
+  }
+}

+ 234 - 0
lib/data/services/jellyfin_library_service.dart

@@ -0,0 +1,234 @@
+import 'dart:math';
+import 'dart:typed_data';
+
+import '../models/jellyfin_library.dart';
+import '../models/jellyfin_models.dart';
+import '../models/jellyfin_playback.dart';
+import 'jellyfin_genre_catalog.dart';
+import 'jellyfin_service.dart';
+
+/// A random hex id for one album playback session. It is reported to Jellyfin
+/// with playback progress; it carries no user information.
+String generateJellyfinPlaySessionId() {
+  final random = Random.secure();
+  return List.generate(
+    16,
+    (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
+  ).join();
+}
+
+/// Typed access to the Jellyfin music library. The access token stays inside
+/// the [JellyfinConnection]; widgets receive image URLs/headers, never the
+/// connection itself.
+class JellyfinLibraryService {
+  JellyfinLibraryService({
+    required this.connectionProvider,
+    required this.gateway,
+    this.catalog = const JellyfinGenreCatalog(),
+    this.genreCacheTtl = const Duration(minutes: 5),
+  });
+
+  final JellyfinConnection? Function() connectionProvider;
+  final JellyfinLibraryGateway gateway;
+  final JellyfinGenreCatalog catalog;
+  final Duration genreCacheTtl;
+
+  List<JellyfinGenreFamily>? _cachedFamilies;
+  DateTime? _cachedFamiliesAt;
+
+  JellyfinConnection? get connection => connectionProvider();
+
+  bool get isConfigured => connectionProvider() != null;
+
+  JellyfinConnection _require() {
+    final connection = connectionProvider();
+    if (connection == null) {
+      throw JellyfinException('Pair Jellyfin first.');
+    }
+    return connection;
+  }
+
+  /// Latest 5 distinct albums from played Audio tracks. Jellyfin 12.2 updates
+  /// track user data, not album user data, so the album query is not reliable.
+  Future<List<JellyfinAlbum>> latestPlayed({int limit = 5}) async {
+    final connection = _require();
+    final tracks = await gateway.playedTracks(connection, limit: 50);
+    final seen = <String>{};
+    final albums = <JellyfinAlbum>[];
+    for (final track in tracks) {
+      if (track.albumId.isEmpty) continue;
+      if (!seen.add(track.albumId)) continue;
+      albums.add(JellyfinAlbum.fromTrack(track));
+      if (albums.length >= limit) break;
+    }
+    return albums;
+  }
+
+  Future<List<JellyfinAlbum>> latestAdded({int limit = 5}) {
+    return gateway.latestAddedAlbums(_require(), limit: limit);
+  }
+
+  /// Raw genre names across every music library the user can see. The server
+  /// needs a `parentId` for /Genres, so each `music` view is queried.
+  Future<List<String>> rawGenres() async {
+    final connection = _require();
+    final libraries = await gateway.musicLibraryIds(connection);
+    final names = <String>[];
+    final seen = <String>{};
+    for (final libraryId in libraries) {
+      final libraryGenres = await gateway.genreNames(connection, libraryId);
+      for (final name in libraryGenres) {
+        if (seen.add(name)) names.add(name);
+      }
+    }
+    names.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
+    return names;
+  }
+
+  /// Grouped families, cached briefly so the dashboard and picker do not refetch
+  /// the genre list for every action.
+  Future<List<JellyfinGenreFamily>> genreFamilies({bool force = false}) async {
+    final cached = _cachedFamilies;
+    final cachedAt = _cachedFamiliesAt;
+    if (!force &&
+        cached != null &&
+        cachedAt != null &&
+        DateTime.now().difference(cachedAt) < genreCacheTtl) {
+      return cached;
+    }
+    final families = catalog.group(await rawGenres());
+    _cachedFamilies = families;
+    _cachedFamiliesAt = DateTime.now();
+    return families;
+  }
+
+  void clearGenreCache() {
+    _cachedFamilies = null;
+    _cachedFamiliesAt = null;
+  }
+
+  Future<List<JellyfinAlbum>> randomAlbums({
+    required Iterable<JellyfinGenreSelection> selections,
+    int limit = 5,
+  }) async {
+    final families = await genreFamilies();
+    final raw = catalog.rawGenresFor(
+      families: families,
+      selectedIds: selections.map((selection) => selection.id),
+    );
+    return randomAlbumsForGenres(raw, limit: limit);
+  }
+
+  Future<List<JellyfinAlbum>> randomAlbumsForGenres(
+    List<String> rawGenres, {
+    int limit = 5,
+  }) {
+    if (rawGenres.isEmpty) return Future.value(const []);
+    return gateway.randomAlbums(_require(), genres: rawGenres, limit: limit);
+  }
+
+  Future<List<JellyfinTrack>> albumTracks(String albumId) {
+    return gateway.albumTracks(_require(), albumId);
+  }
+
+  /// Builds the direct/transcode URL. The token is never added here; the
+  /// native player sends the Authorization header.
+  Uri streamUrl(JellyfinTrack track, {int maxStreamingBitrate = 1500000}) {
+    final connection = _require();
+    return connection.baseUrl
+        .resolve('Audio/${track.id}/universal')
+        .replace(
+          queryParameters: {
+            'UserId': connection.userId,
+            'DeviceId': connection.deviceId,
+            'MediaSourceId': track.mediaSourceId,
+            'MaxStreamingBitrate': '$maxStreamingBitrate',
+            'Container': 'mp3,m4a,aac,flac,ogg,opus,wav',
+            'TranscodingContainer': 'mp3',
+            'TranscodingProtocol': 'http',
+            'AudioCodec': 'mp3,aac,flac,opus,vorbis',
+            'EnableRedirection': 'true',
+          },
+        );
+  }
+
+  Uri? imageUrl(String albumId, String? tag, {int maxHeight = 400}) {
+    if (albumId.isEmpty) return null;
+    final connection = connectionProvider();
+    if (connection == null) return null;
+    return connection.baseUrl
+        .resolve('Items/$albumId/Images/Primary')
+        .replace(
+          queryParameters: {
+            'maxHeight': '$maxHeight',
+            if (tag != null && tag.isNotEmpty) 'tag': tag,
+          },
+        );
+  }
+
+  /// Headers for `Image.network`; the token stays in the controller/service.
+  Map<String, String> get imageHeaders {
+    final connection = connectionProvider();
+    if (connection == null) return const {};
+    return Map<String, String>.unmodifiable({
+      'Authorization': connection.authorizationHeader,
+    });
+  }
+
+  Future<Uint8List?> albumArtwork(
+    String albumId, {
+    String? tag,
+    int maxHeight = 400,
+  }) {
+    final connection = connectionProvider();
+    if (connection == null) return Future.value();
+    return gateway.albumArtwork(
+      connection,
+      albumId,
+      tag: tag,
+      maxHeight: maxHeight,
+    );
+  }
+
+  /// Resolves the album's tracks and builds everything the native service
+  /// needs. Returns null when the connection or playable tracks are missing.
+  Future<JellyfinPlaybackQueue?> buildQueue(
+    JellyfinAlbum album, {
+    String? playSessionId,
+  }) async {
+    final connection = connectionProvider();
+    if (connection == null) return null;
+    final tracks = await albumTracks(album.id);
+    final items = <JellyfinQueueItem>[];
+    for (final track in tracks) {
+      if (track.mediaSourceId.isEmpty) continue;
+      items.add(
+        JellyfinQueueItem(
+          trackId: track.id,
+          mediaSourceId: track.mediaSourceId,
+          url: streamUrl(track).toString(),
+          title: track.name,
+          artist: track.artist,
+          album: track.album.isEmpty ? album.name : track.album,
+          durationMs: track.duration.inMilliseconds,
+          index: items.length,
+        ),
+      );
+    }
+    if (items.isEmpty) return null;
+    final artwork = await albumArtwork(album.id, tag: album.imageTag);
+    return JellyfinPlaybackQueue(
+      baseUrl: connection.baseUrl,
+      authorization: connection.authorizationHeader,
+      accessToken: connection.accessToken,
+      userId: connection.userId,
+      deviceId: connection.deviceId,
+      playSessionId: playSessionId ?? generateJellyfinPlaySessionId(),
+      albumId: album.id,
+      albumName: album.name,
+      artistName: album.artist,
+      items: items,
+      artwork: artwork,
+    );
+  }
+}

+ 81 - 0
lib/data/services/jellyfin_playback_bridge.dart

@@ -0,0 +1,81 @@
+import 'package:flutter/services.dart';
+
+import '../models/jellyfin_playback.dart';
+import 'jellyfin_playback_platform.dart';
+
+/// MethodChannel implementation of the Media3 service. The Dart side never
+/// reads the token back; [JellyfinPlaybackQueue.toMap] carries it into the
+/// service and the native reporter keeps it in memory.
+class NativeJellyfinPlayback implements JellyfinPlaybackPlatform {
+  static const _channel = MethodChannel(
+    'com.wasp.cardash_launcher/jellyfin_playback',
+  );
+
+  Future<bool> _invokeBool(String method, [Object? arguments]) async {
+    try {
+      return await _channel.invokeMethod<bool>(method, arguments) ?? false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
+  @override
+  Future<bool> setQueue(JellyfinPlaybackQueue queue) {
+    return _invokeBool('setQueue', queue.toMap());
+  }
+
+  @override
+  Future<bool> play() => _invokeBool('play');
+
+  @override
+  Future<bool> pause() => _invokeBool('pause');
+
+  @override
+  Future<bool> next() => _invokeBool('next');
+
+  @override
+  Future<bool> previous() => _invokeBool('previous');
+
+  @override
+  Future<bool> seekToIndex(int index) {
+    return _invokeBool('seekToIndex', {'index': index});
+  }
+
+  @override
+  Future<bool> seek(Duration position) {
+    return _invokeBool('seek', {'positionMs': position.inMilliseconds});
+  }
+
+  @override
+  Future<bool> stop() => _invokeBool('stop');
+
+  @override
+  Future<bool> notificationPermissionGranted() {
+    return _invokeBool('notificationPermissionGranted');
+  }
+
+  @override
+  Future<bool> requestNotificationPermission() {
+    return _invokeBool('requestNotificationPermission');
+  }
+
+  @override
+  Future<JellyfinPlaybackSnapshot> getState() async {
+    try {
+      final raw = await _channel.invokeMethod<dynamic>('getState');
+      if (raw is Map) {
+        return JellyfinPlaybackSnapshot.fromMap(raw);
+      }
+    } on PlatformException {
+      // The service may not be attached yet.
+    } on MissingPluginException {
+      // Widget tests and non-Android targets.
+    }
+    return JellyfinPlaybackSnapshot.empty;
+  }
+
+  @override
+  void dispose() {}
+}

+ 29 - 0
lib/data/services/jellyfin_playback_platform.dart

@@ -0,0 +1,29 @@
+import '../models/jellyfin_playback.dart';
+
+/// Android playback boundary. The native implementation owns the Media3
+/// service; tests use a fake that records commands and returns snapshots.
+abstract class JellyfinPlaybackPlatform {
+  Future<bool> setQueue(JellyfinPlaybackQueue queue);
+
+  Future<bool> play();
+
+  Future<bool> pause();
+
+  Future<bool> next();
+
+  Future<bool> previous();
+
+  Future<bool> seekToIndex(int index);
+
+  Future<bool> seek(Duration position);
+
+  Future<bool> stop();
+
+  Future<JellyfinPlaybackSnapshot> getState();
+
+  Future<bool> notificationPermissionGranted();
+
+  Future<bool> requestNotificationPermission();
+
+  void dispose() {}
+}

+ 283 - 4
lib/data/services/jellyfin_service.dart

@@ -1,8 +1,10 @@
 import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
+import 'dart:typed_data';
 
 import '../../core/app_version.dart';
+import '../models/jellyfin_library.dart';
 import '../models/jellyfin_models.dart';
 
 /// The small part of the Jellyfin API CarDash needs for Quick Connect.
@@ -27,10 +29,49 @@ abstract class JellyfinGateway {
   void close() {}
 }
 
-/// Talks to Jellyfin over HTTPS using the documented Quick Connect endpoints.
-/// The access token is never logged; it only travels in the Authorization
-/// header and is stored by the caller in secure storage.
-class HttpJellyfinGateway implements JellyfinGateway {
+/// The music-library and playback-metadata calls layered on the same session.
+/// The access token travels in the Authorization header only.
+abstract class JellyfinLibraryGateway {
+  Future<List<JellyfinTrack>> playedTracks(
+    JellyfinConnection connection, {
+    int limit = 50,
+  });
+
+  Future<List<JellyfinAlbum>> latestAddedAlbums(
+    JellyfinConnection connection, {
+    int limit = 5,
+  });
+
+  Future<List<JellyfinAlbum>> randomAlbums(
+    JellyfinConnection connection, {
+    required List<String> genres,
+    int limit = 5,
+  });
+
+  Future<List<JellyfinTrack>> albumTracks(
+    JellyfinConnection connection,
+    String albumId,
+  );
+
+  Future<List<String>> musicLibraryIds(JellyfinConnection connection);
+
+  Future<List<String>> genreNames(
+    JellyfinConnection connection,
+    String libraryId,
+  );
+
+  Future<Uint8List?> albumArtwork(
+    JellyfinConnection connection,
+    String albumId, {
+    String? tag,
+    int maxHeight = 400,
+  });
+}
+
+/// Talks to Jellyfin over HTTPS. Quick Connect, library calls and artwork all
+/// share one client; the token is never logged and only travels in the
+/// Authorization header.
+class HttpJellyfinGateway implements JellyfinGateway, JellyfinLibraryGateway {
   HttpJellyfinGateway({
     HttpClient Function()? clientFactory,
     this.timeout = const Duration(seconds: 10),
@@ -169,6 +210,198 @@ class HttpJellyfinGateway implements JellyfinGateway {
     return JellyfinUser(id: id, name: name);
   }
 
+  @override
+  Future<List<JellyfinTrack>> playedTracks(
+    JellyfinConnection connection, {
+    int limit = 50,
+  }) async {
+    final uri = connection.baseUrl
+        .resolve('Items')
+        .replace(
+          queryParameters: {
+            'userId': connection.userId,
+            'recursive': 'true',
+            'includeItemTypes': 'Audio',
+            'filters': 'IsPlayed',
+            'sortBy': 'DatePlayed',
+            'sortOrder': 'Descending',
+            'limit': '$limit',
+            'fields': 'Genres,AlbumArtist,Artists,Album,AlbumId,ParentId,RunTimeTicks',
+            'enableUserData': 'true',
+            'enableImageTypes': 'Primary',
+            'imageTypeLimit': '1',
+          },
+        );
+    final json = await _getJson(connection, uri);
+    return _items(json)
+        .map(JellyfinTrack.fromJson)
+        .where((track) => track.id.isNotEmpty)
+        .toList(growable: false);
+  }
+
+  @override
+  Future<List<JellyfinAlbum>> latestAddedAlbums(
+    JellyfinConnection connection, {
+    int limit = 5,
+  }) async {
+    final uri = connection.baseUrl
+        .resolve('Items/Latest')
+        .replace(
+          queryParameters: {
+            'userId': connection.userId,
+            'includeItemTypes': 'MusicAlbum',
+            'limit': '$limit',
+            'fields': 'Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio',
+            'enableUserData': 'true',
+            'enableImageTypes': 'Primary',
+            'imageTypeLimit': '1',
+          },
+        );
+    final list = await _getList(connection, uri);
+    return list
+        .whereType<Map>()
+        .map(
+          (entry) => JellyfinAlbum.fromJson(Map<String, dynamic>.from(entry)),
+        )
+        .where((album) => album.id.isNotEmpty)
+        .toList(growable: false);
+  }
+
+  @override
+  Future<List<JellyfinAlbum>> randomAlbums(
+    JellyfinConnection connection, {
+    required List<String> genres,
+    int limit = 5,
+  }) async {
+    if (genres.isEmpty) return const [];
+    final uri = connection.baseUrl
+        .resolve('Items')
+        .replace(
+          queryParameters: {
+            'userId': connection.userId,
+            'recursive': 'true',
+            'includeItemTypes': 'MusicAlbum',
+            'genres': genres.join('|'),
+            'sortBy': 'Random',
+            'limit': '$limit',
+            'fields': 'Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio',
+            'enableUserData': 'true',
+            'enableImageTypes': 'Primary',
+            'imageTypeLimit': '1',
+          },
+        );
+    final json = await _getJson(connection, uri);
+    return _items(json)
+        .map(JellyfinAlbum.fromJson)
+        .where((album) => album.id.isNotEmpty)
+        .toList(growable: false);
+  }
+
+  @override
+  Future<List<JellyfinTrack>> albumTracks(
+    JellyfinConnection connection,
+    String albumId,
+  ) async {
+    if (albumId.isEmpty) return const [];
+    final uri = connection.baseUrl
+        .resolve('Items')
+        .replace(
+          queryParameters: {
+            'userId': connection.userId,
+            'parentId': albumId,
+            'recursive': 'false',
+            'includeItemTypes': 'Audio',
+            'sortBy': 'ParentIndexNumber,IndexNumber',
+            'fields': 'MediaSources,MediaStreams,Genres,AlbumArtist,Artists,IndexNumber,ParentIndexNumber,RunTimeTicks',
+            'enableUserData': 'true',
+          },
+        );
+    final json = await _getJson(connection, uri);
+    return _items(json)
+        .map(JellyfinTrack.fromJson)
+        .where((track) => track.id.isNotEmpty)
+        .toList(growable: false);
+  }
+
+  @override
+  Future<List<String>> musicLibraryIds(JellyfinConnection connection) async {
+    final uri = connection.baseUrl
+        .resolve('UserViews')
+        .replace(queryParameters: {'userId': connection.userId});
+    final json = await _getJson(connection, uri);
+    final ids = <String>[];
+    for (final item in _items(json)) {
+      if (item['CollectionType'] != 'music') continue;
+      final id = (item['Id'] as String?)?.trim();
+      if (id == null || id.isEmpty || ids.contains(id)) continue;
+      ids.add(id);
+    }
+    return ids;
+  }
+
+  @override
+  Future<List<String>> genreNames(
+    JellyfinConnection connection,
+    String libraryId,
+  ) async {
+    if (libraryId.isEmpty) return const [];
+    final uri = connection.baseUrl
+        .resolve('Genres')
+        .replace(
+          queryParameters: {'userId': connection.userId, 'parentId': libraryId},
+        );
+    final json = await _getJson(connection, uri);
+    final names = <String>[];
+    for (final item in _items(json)) {
+      final name = (item['Name'] as String?)?.trim();
+      if (name == null || name.isEmpty || names.contains(name)) continue;
+      names.add(name);
+    }
+    return names;
+  }
+
+  /// Album artwork is binary; the text decoder in [_send] cannot be used.
+  @override
+  Future<Uint8List?> albumArtwork(
+    JellyfinConnection connection,
+    String albumId, {
+    String? tag,
+    int maxHeight = 400,
+  }) async {
+    if (albumId.isEmpty) return null;
+    final uri = connection.baseUrl
+        .resolve('Items/$albumId/Images/Primary')
+        .replace(
+          queryParameters: {
+            'maxHeight': '$maxHeight',
+            if (tag != null && tag.isNotEmpty) 'tag': tag,
+          },
+        );
+    try {
+      final request = await _http.getUrl(uri).timeout(timeout);
+      request.headers.set(
+        HttpHeaders.authorizationHeader,
+        connection.authorizationHeader,
+      );
+      request.headers.set(HttpHeaders.acceptHeader, 'image/jpeg,image/png');
+      final response = await request.close().timeout(timeout);
+      if (response.statusCode != HttpStatus.ok) {
+        await response.drain<void>();
+        return null;
+      }
+      final builder = await response
+          .fold<BytesBuilder>(BytesBuilder(copy: false), (buffer, chunk) {
+            buffer.add(chunk);
+            return buffer;
+          })
+          .timeout(timeout);
+      final bytes = builder.takeBytes();
+      return bytes.isEmpty ? null : bytes;
+    } catch (_) {
+      return null;
+    }
+  }
+
   @override
   void close() {
     _client?.close(force: true);
@@ -226,6 +459,35 @@ class HttpJellyfinGateway implements JellyfinGateway {
     return 'MediaBrowser ${parts.join(', ')}';
   }
 
+  Future<Map<String, dynamic>> _getJson(
+    JellyfinConnection connection,
+    Uri uri,
+  ) async {
+    final response = await _send(
+      'GET',
+      uri,
+      deviceId: connection.deviceId,
+      token: connection.accessToken,
+    );
+    if (response.status != HttpStatus.ok) {
+      throw _statusError(response);
+    }
+    return _decode(response.body);
+  }
+
+  Future<List<dynamic>> _getList(JellyfinConnection connection, Uri uri) async {
+    final response = await _send(
+      'GET',
+      uri,
+      deviceId: connection.deviceId,
+      token: connection.accessToken,
+    );
+    if (response.status != HttpStatus.ok) {
+      throw _statusError(response);
+    }
+    return _decodeList(response.body);
+  }
+
   Map<String, dynamic> _decode(String body) {
     final decoded = jsonDecode(body);
     if (decoded is! Map<String, dynamic>) {
@@ -234,6 +496,23 @@ class HttpJellyfinGateway implements JellyfinGateway {
     return decoded;
   }
 
+  List<dynamic> _decodeList(String body) {
+    final decoded = jsonDecode(body);
+    if (decoded is! List) {
+      throw JellyfinException('The server returned an unexpected response.');
+    }
+    return decoded;
+  }
+
+  List<Map<String, dynamic>> _items(Map<String, dynamic> json) {
+    final items = json['Items'];
+    if (items is! List) return const [];
+    return items
+        .whereType<Map>()
+        .map((entry) => Map<String, dynamic>.from(entry))
+        .toList(growable: false);
+  }
+
   JellyfinException _statusError(
     _JellyfinResponse response, {
     String? notFound,

+ 53 - 8
lib/features/home/home_screen.dart

@@ -9,6 +9,8 @@ import '../../data/models/slot_assignment.dart';
 import '../../data/models/slot_type.dart';
 import '../apps/all_apps_screen.dart';
 import '../apps/app_picker_sheet.dart';
+import '../jellyfin/jellyfin_dashboard_screen.dart';
+import '../jellyfin/jellyfin_playback_controller.dart';
 import '../settings/settings_screen.dart';
 import 'gps_speed_controller.dart';
 import 'launcher_controller.dart';
@@ -59,6 +61,7 @@ class _HomeScreenState extends State<HomeScreen> {
     final weather = context.watch<WeatherController>();
     final telemetrySettings = context.watch<TelemetrySettingsController>();
     final update = context.watch<UpdateController>();
+    final jellyfinPlayback = context.watch<JellyfinPlaybackController>();
     final screen = MediaQuery.sizeOf(context);
     final compact = screen.height < 700 || screen.width < 1100;
     final horizontalPadding = compact ? 30.0 : 44.0;
@@ -90,10 +93,13 @@ class _HomeScreenState extends State<HomeScreen> {
     final showGpsSpeed =
         telemetrySettings.speedEnabled && !showObdSpeed && gps.hasSpeed;
 
+    final jellyfinPlaying = jellyfinPlayback.playing;
+    final otherMusicPlaying = system.isMusicPlaying && !jellyfinPlaying;
+
     ShortcutSpec slotSpec(SlotType slot) {
       final assignment = launcher.assignmentFor(slot);
       final playing =
-          slot == SlotType.music && system.isMusicPlaying && assignment != null;
+          slot == SlotType.music && otherMusicPlaying && assignment != null;
 
       return ShortcutSpec(
         title: slot.label,
@@ -107,6 +113,22 @@ class _HomeScreenState extends State<HomeScreen> {
       );
     }
 
+    ShortcutSpec jellyfinSpec() {
+      final album = jellyfinPlayback.nowPlayingAlbum;
+      final artist = jellyfinPlayback.nowPlayingArtist;
+      final subtitle = jellyfinPlayback.isActive && album != null
+          ? (artist != null && artist.isNotEmpty ? '$artist — $album' : album)
+          : 'Music library';
+      return ShortcutSpec(
+        title: 'Jellyfin',
+        subtitle: subtitle,
+        iconAsset: 'assets/icons/headphones.svg',
+        assigned: true,
+        showEqualizer: jellyfinPlaying,
+        onTap: () => _openJellyfin(context),
+      );
+    }
+
     ShortcutSpec pinnedSpec(SlotAssignment pinned) {
       final app = launcher.appForAssignment(pinned);
       final customIcon = pinned.iconAsset;
@@ -124,7 +146,8 @@ class _HomeScreenState extends State<HomeScreen> {
 
     final shortcuts = <ShortcutSpec>[
       for (final slot in SlotType.values)
-        if (launcher.isSlotEnabled(slot)) slotSpec(slot),
+        if (launcher.isSlotEnabled(slot))
+          slot == SlotType.jellyfin ? jellyfinSpec() : slotSpec(slot),
       for (final pinned in launcher.pinnedApps) pinnedSpec(pinned),
       ShortcutSpec(
         title: 'All apps',
@@ -135,6 +158,16 @@ class _HomeScreenState extends State<HomeScreen> {
       ),
     ];
 
+    final jellyfinActive =
+        jellyfinPlayback.isActive &&
+        (jellyfinPlaying || !system.isMusicPlaying);
+    final cardMedia = jellyfinActive
+        ? jellyfinPlayback.mediaPlayback
+        : system.media;
+    final cardPlaying = jellyfinActive
+        ? jellyfinPlayback.playing
+        : system.isMusicPlaying;
+
     return PopScope(
       canPop: false,
       child: Scaffold(
@@ -240,10 +273,12 @@ class _HomeScreenState extends State<HomeScreen> {
                                   compact: compact,
                                   onSetVolume: system.setVolume,
                                   onToggleMute: system.toggleMute,
-                                  isMusicPlaying: system.isMusicPlaying,
-                                  onPlayPause: () =>
-                                      unawaited(_toggleMusic(context)),
-                                  media: system.media,
+                                  isMusicPlaying: cardPlaying,
+                                  onPlayPause: jellyfinActive
+                                      ? () =>
+                                            unawaited(jellyfinPlayback.toggle())
+                                      : () => unawaited(_toggleMusic(context)),
+                                  media: cardMedia,
                                   diagnostics: system.volumeDiagnostics,
                                   onToggleDiagnostics:
                                       system.toggleVolumeDiagnostics,
@@ -344,8 +379,12 @@ class _HomeScreenState extends State<HomeScreen> {
   }
 
   void _openSettings(BuildContext context) {
-    Navigator.of(context)
-        .push(MaterialPageRoute<void>(builder: (_) => const SettingsScreen()));
+    final playback = context.read<JellyfinPlaybackController>();
+    Navigator.of(context).push(
+      MaterialPageRoute<void>(
+        builder: (_) => SettingsScreen(playback: playback),
+      ),
+    );
   }
 
   void _openAllApps(BuildContext context) {
@@ -353,6 +392,12 @@ class _HomeScreenState extends State<HomeScreen> {
         .push(MaterialPageRoute<void>(builder: (_) => const AllAppsScreen()));
   }
 
+  void _openJellyfin(BuildContext context) {
+    Navigator.of(context).push(
+      MaterialPageRoute<void>(builder: (_) => const JellyfinDashboardScreen()),
+    );
+  }
+
   void _showMessage(BuildContext context, String message) {
     final messenger = ScaffoldMessenger.of(context);
     messenger.hideCurrentSnackBar();

+ 28 - 2
lib/features/home/launcher_controller.dart

@@ -20,6 +20,7 @@ class LauncherController extends ChangeNotifier {
   final Map<SlotType, SlotAssignment> _slots = {};
   final Map<SlotType, bool> _slotEnabled = {};
   final List<SlotAssignment> _pinnedApps = [];
+  bool _jellyfinConfigured = false;
   List<AppEntry> _apps = const [];
   bool _appsLoaded = false;
   bool _loadingApps = false;
@@ -39,10 +40,28 @@ class LauncherController extends ChangeNotifier {
 
   SlotAssignment? assignmentFor(SlotType slot) => _slots[slot];
 
-  bool isSlotEnabled(SlotType slot) => _slotEnabled[slot] ?? true;
+  bool get jellyfinConfigured => _jellyfinConfigured;
+
+  bool isSlotEnabled(SlotType slot) {
+    if (slot == SlotType.jellyfin && !_jellyfinConfigured) return false;
+    return _slotEnabled[slot] ?? (slot != SlotType.jellyfin);
+  }
+
+  /// Called by the Jellyfin controller sync. A missing/invalid pairing turns
+  /// the Jellyfin Home button off and clears the preference, so the 16-button
+  /// count cannot reserve an invisible slot.
+  Future<void> setJellyfinConfigured(bool configured) async {
+    if (_jellyfinConfigured == configured) return;
+    _jellyfinConfigured = configured;
+    if (!configured && (_slotEnabled[SlotType.jellyfin] ?? false)) {
+      _slotEnabled[SlotType.jellyfin] = false;
+      await _settings.setSlotEnabled(SlotType.jellyfin, false);
+    }
+    _notify();
+  }
 
   /// All apps is always present. Settings lives in the top-right status area,
-  /// so the three fixed slots (while enabled) and each pinned app fill the cap.
+  /// so the fixed slots (while enabled) and each pinned app fill the cap.
   int get homeButtonCount =>
       1 + SlotType.values.where(isSlotEnabled).length + _pinnedApps.length;
 
@@ -72,6 +91,7 @@ class LauncherController extends ChangeNotifier {
   void initialize() {
     for (final slot in SlotType.values) {
       _slotEnabled[slot] = _settings.slotEnabled(slot);
+      if (!slot.isAppSlot) continue;
       final assignment = _settings.slot(slot);
       if (assignment != null) _slots[slot] = assignment;
     }
@@ -158,6 +178,7 @@ class LauncherController extends ChangeNotifier {
   }
 
   Future<void> assign(SlotType slot, AppEntry app) async {
+    if (!slot.isAppSlot) return;
     final assignment = app.toAssignment();
     _slots[slot] = assignment;
     await _settings.setSlot(slot, assignment);
@@ -165,6 +186,7 @@ class LauncherController extends ChangeNotifier {
   }
 
   Future<void> clearSlot(SlotType slot) async {
+    if (!slot.isAppSlot) return;
     _slots.remove(slot);
     await _settings.clearSlot(slot);
     _notify();
@@ -173,6 +195,9 @@ class LauncherController extends ChangeNotifier {
   /// Hides or shows a predefined home button. Disabled slots keep their app
   /// assignment, so re-enabling restores the same shortcut.
   Future<String?> setSlotEnabled(SlotType slot, bool enabled) async {
+    if (slot == SlotType.jellyfin && !_jellyfinConfigured) {
+      return 'Pair Jellyfin in Settings first.';
+    }
     if (isSlotEnabled(slot) == enabled) return null;
     if (enabled && homeButtonCount >= maxHomeButtons) {
       return 'Home screen is full ($maxHomeButtons buttons). '
@@ -245,6 +270,7 @@ class LauncherController extends ChangeNotifier {
   }
 
   Future<String?> launchSlot(SlotType slot) async {
+    if (!slot.isAppSlot) return null;
     final assignment = _slots[slot];
     if (assignment == null) return null;
     return launchAssignment(assignment);

+ 93 - 90
lib/features/home/widgets/shortcut_tile.dart

@@ -58,103 +58,106 @@ class ShortcutTile extends StatelessWidget {
       onTap: onTap,
       onLongPress: onLongPress,
       semanticLabel: semantics.toString(),
-      child: Column(
-        mainAxisSize: MainAxisSize.min,
-        children: [
-          Container(
-            width: size,
-            height: size,
-            decoration: BoxDecoration(
-              color: colors.surface,
-              borderRadius: BorderRadius.circular(size * 0.17),
-              border: Border.all(
-                color: placeholder
-                    ? colors.accent.withValues(alpha: 0.16)
-                    : colors.border,
+      child: SizedBox(
+        width: size,
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Container(
+              width: size,
+              height: size,
+              decoration: BoxDecoration(
+                color: colors.surface,
+                borderRadius: BorderRadius.circular(size * 0.17),
+                border: Border.all(
+                  color: placeholder
+                      ? colors.accent.withValues(alpha: 0.16)
+                      : colors.border,
+                ),
+                boxShadow: [
+                  BoxShadow(
+                    color: colors.shadow.withValues(
+                      alpha: Theme.of(context).brightness == Brightness.dark
+                          ? 0.2
+                          : 0.04,
+                    ),
+                    blurRadius: 26,
+                    offset: const Offset(0, 10),
+                  ),
+                ],
               ),
-              boxShadow: [
-                BoxShadow(
-                  color: colors.shadow.withValues(
-                    alpha: Theme.of(context).brightness == Brightness.dark
-                        ? 0.2
-                        : 0.04,
+              child: Stack(
+                children: [
+                  Center(
+                    child: iconBytes != null
+                        ? AppIconImage(
+                            bytes: iconBytes,
+                            size: (size * 0.36).clamp(28.0, 54.0),
+                          )
+                        : AppIcon(
+                            iconAsset,
+                            size: iconSize,
+                            color: placeholder ? colors.inkMuted : colors.ink,
+                          ),
                   ),
-                  blurRadius: 26,
-                  offset: const Offset(0, 10),
-                ),
-              ],
-            ),
-            child: Stack(
-              children: [
-                Center(
-                  child: iconBytes != null
-                      ? AppIconImage(
-                          bytes: iconBytes,
-                          size: (size * 0.36).clamp(28.0, 54.0),
-                        )
-                      : AppIcon(
-                          iconAsset,
-                          size: iconSize,
-                          color: placeholder ? colors.inkMuted : colors.ink,
+                  if (placeholder)
+                    Positioned(
+                      top: size * 0.09,
+                      right: size * 0.09,
+                      child: Container(
+                        width: badgeSize,
+                        height: badgeSize,
+                        decoration: BoxDecoration(
+                          color: colors.accent,
+                          shape: BoxShape.circle,
+                        ),
+                        alignment: Alignment.center,
+                        child: AppIcon(
+                          'assets/icons/plus.svg',
+                          size: badgeSize * 0.54,
+                          color: colors.accentInk,
                         ),
-                ),
-                if (placeholder)
-                  Positioned(
-                    top: size * 0.09,
-                    right: size * 0.09,
-                    child: Container(
-                      width: badgeSize,
-                      height: badgeSize,
-                      decoration: BoxDecoration(
-                        color: colors.accent,
-                        shape: BoxShape.circle,
-                      ),
-                      alignment: Alignment.center,
-                      child: AppIcon(
-                        'assets/icons/plus.svg',
-                        size: badgeSize * 0.54,
-                        color: colors.accentInk,
                       ),
                     ),
-                  ),
-                if (showEqualizer)
-                  Positioned(
-                    left: 0,
-                    right: 0,
-                    bottom: size * 0.07,
-                    child: Center(child: MusicEqualizer(compact: size < 130)),
-                  ),
-              ],
+                  if (showEqualizer)
+                    Positioned(
+                      left: 0,
+                      right: 0,
+                      bottom: size * 0.07,
+                      child: Center(child: MusicEqualizer(compact: size < 130)),
+                    ),
+                ],
+              ),
             ),
-          ),
-          const SizedBox(height: 12),
-          Text(
-            title,
-            maxLines: 1,
-            overflow: TextOverflow.ellipsis,
-            textAlign: TextAlign.center,
-            style: theme.textTheme.titleMedium?.copyWith(fontSize: titleSize),
-          ),
-          const SizedBox(height: 2),
-          SizedBox(
-            height: 17,
-            child: subtitle == null
-                ? null
-                : Text(
-                    subtitle!,
-                    maxLines: 1,
-                    overflow: TextOverflow.ellipsis,
-                    textAlign: TextAlign.center,
-                    style: theme.textTheme.bodySmall?.copyWith(
-                      color: placeholder ? colors.accent : colors.inkMuted,
-                      fontWeight: placeholder
-                          ? FontWeight.w600
-                          : FontWeight.w400,
-                      fontSize: subtitleSize,
+            const SizedBox(height: 12),
+            Text(
+              title,
+              maxLines: 1,
+              overflow: TextOverflow.ellipsis,
+              textAlign: TextAlign.center,
+              style: theme.textTheme.titleMedium?.copyWith(fontSize: titleSize),
+            ),
+            const SizedBox(height: 2),
+            SizedBox(
+              height: 17,
+              child: subtitle == null
+                  ? null
+                  : Text(
+                      subtitle!,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      textAlign: TextAlign.center,
+                      style: theme.textTheme.bodySmall?.copyWith(
+                        color: placeholder ? colors.accent : colors.inkMuted,
+                        fontWeight: placeholder
+                            ? FontWeight.w600
+                            : FontWeight.w400,
+                        fontSize: subtitleSize,
+                      ),
                     ),
-                  ),
-          ),
-        ],
+            ),
+          ],
+        ),
       ),
     );
   }

+ 124 - 40
lib/features/home/widgets/volume_card.dart

@@ -22,6 +22,11 @@ class VolumeCard extends StatefulWidget {
     required this.onPlayPause,
     required this.media,
     this.compact = false,
+    this.large = false,
+    this.onPrevious,
+    this.onNext,
+    this.canPrevious = false,
+    this.canNext = false,
     this.diagnostics,
     this.onToggleDiagnostics,
   });
@@ -33,6 +38,11 @@ class VolumeCard extends StatefulWidget {
   final VoidCallback onPlayPause;
   final MediaPlayback media;
   final bool compact;
+  final bool large;
+  final VoidCallback? onPrevious;
+  final VoidCallback? onNext;
+  final bool canPrevious;
+  final bool canNext;
   final String? diagnostics;
   final VoidCallback? onToggleDiagnostics;
 
@@ -73,17 +83,42 @@ class _VolumeCardState extends State<VolumeCard> {
           Row(
             crossAxisAlignment: CrossAxisAlignment.center,
             children: [
-              Pressable(
-                onTap: widget.onPlayPause,
-                semanticLabel: widget.isMusicPlaying
-                    ? 'Pause music'
-                    : 'Play music',
-                pressedScale: 0.94,
-                child: _VolumeRing(
-                  progress: widget.media.progress,
-                  isPlaying: widget.isMusicPlaying,
-                  size: compact ? 78 : 92,
-                ),
+              Row(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  if (widget.onPrevious != null) ...[
+                    _SkipButton(
+                      asset: 'assets/icons/skip-back.svg',
+                      semanticLabel: 'Previous track',
+                      onTap: widget.canPrevious ? widget.onPrevious : null,
+                      compact: compact,
+                      large: widget.large,
+                    ),
+                    SizedBox(width: compact ? 4 : 6),
+                  ],
+                  Pressable(
+                    onTap: widget.onPlayPause,
+                    semanticLabel: widget.isMusicPlaying
+                        ? 'Pause music'
+                        : 'Play music',
+                    pressedScale: 0.94,
+                    child: _VolumeRing(
+                      progress: widget.media.progress,
+                      isPlaying: widget.isMusicPlaying,
+                      size: widget.large ? 124 : (compact ? 78 : 92),
+                    ),
+                  ),
+                  if (widget.onNext != null) ...[
+                    SizedBox(width: compact ? 4 : 6),
+                    _SkipButton(
+                      asset: 'assets/icons/skip-forward.svg',
+                      semanticLabel: 'Next track',
+                      onTap: widget.canNext ? widget.onNext : null,
+                      compact: compact,
+                      large: widget.large,
+                    ),
+                  ],
+                ],
               ),
               SizedBox(width: compact ? 18 : 24),
               Expanded(
@@ -96,14 +131,18 @@ class _VolumeCardState extends State<VolumeCard> {
                       onLongPress: widget.onToggleDiagnostics,
                       child: Row(
                         children: [
-                          Text(
-                            'Media volume',
-                            style: theme.textTheme.labelLarge?.copyWith(
-                              color: colors.inkMuted,
-                              fontSize: compact ? 12 : 13,
+                          Flexible(
+                            child: Text(
+                              'Media volume',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: theme.textTheme.labelLarge?.copyWith(
+                                color: colors.inkMuted,
+                                fontSize: compact ? 12 : 13,
+                              ),
                             ),
                           ),
-                          const Spacer(),
+                          const SizedBox(width: 6),
                           Text(
                             '$_percent%',
                             style: theme.textTheme.headlineMedium?.copyWith(
@@ -187,29 +226,33 @@ class _VolumeCardState extends State<VolumeCard> {
                                         : colors.border,
                                   ),
                                 ),
-                                child: Row(
-                                  mainAxisAlignment: MainAxisAlignment.center,
-                                  children: [
-                                    AppIcon(
-                                      _muted
-                                          ? 'assets/icons/volume-x.svg'
-                                          : 'assets/icons/volume-2.svg',
-                                      size: compact ? 16 : 18,
-                                      color: _muted
-                                          ? colors.accent
-                                          : colors.inkMuted,
-                                    ),
-                                    const SizedBox(width: 8),
-                                    Text(
-                                      _muted ? 'Unmute' : 'Mute',
-                                      style: theme.textTheme.labelMedium
-                                          ?.copyWith(
-                                            color: _muted
-                                                ? colors.accent
-                                                : colors.inkMuted,
-                                          ),
-                                    ),
-                                  ],
+                                child: FittedBox(
+                                  fit: BoxFit.scaleDown,
+                                  child: Row(
+                                    mainAxisSize: MainAxisSize.min,
+                                    mainAxisAlignment: MainAxisAlignment.center,
+                                    children: [
+                                      AppIcon(
+                                        _muted
+                                            ? 'assets/icons/volume-x.svg'
+                                            : 'assets/icons/volume-2.svg',
+                                        size: compact ? 16 : 18,
+                                        color: _muted
+                                            ? colors.accent
+                                            : colors.inkMuted,
+                                      ),
+                                      const SizedBox(width: 8),
+                                      Text(
+                                        _muted ? 'Unmute' : 'Mute',
+                                        style: theme.textTheme.labelMedium
+                                            ?.copyWith(
+                                              color: _muted
+                                                  ? colors.accent
+                                                  : colors.inkMuted,
+                                            ),
+                                      ),
+                                    ],
+                                  ),
                                 ),
                               ),
                             ),
@@ -271,6 +314,47 @@ class _VolumeCardState extends State<VolumeCard> {
   }
 }
 
+class _SkipButton extends StatelessWidget {
+  const _SkipButton({
+    required this.asset,
+    required this.semanticLabel,
+    required this.onTap,
+    required this.compact,
+    required this.large,
+  });
+
+  final String asset;
+  final String semanticLabel;
+  final VoidCallback? onTap;
+  final bool compact;
+  final bool large;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final size = large ? 46.0 : (compact ? 36.0 : 40.0);
+    return Opacity(
+      opacity: onTap == null ? 0.4 : 1,
+      child: Pressable(
+        onTap: onTap,
+        semanticLabel: semanticLabel,
+        pressedScale: 0.92,
+        child: Container(
+          width: size,
+          height: size,
+          decoration: BoxDecoration(
+            color: colors.surfaceStrong,
+            shape: BoxShape.circle,
+            border: Border.all(color: colors.border),
+          ),
+          alignment: Alignment.center,
+          child: AppIcon(asset, size: size * 0.45, color: colors.inkMuted),
+        ),
+      ),
+    );
+  }
+}
+
 class _StepButton extends StatelessWidget {
   const _StepButton({
     required this.icon,

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

@@ -0,0 +1,482 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../data/models/jellyfin_library.dart';
+import '../../data/models/media_playback.dart';
+import '../../data/services/jellyfin_library_service.dart';
+import '../home/system_controller.dart';
+import '../home/widgets/volume_card.dart';
+import 'jellyfin_playback_controller.dart';
+import 'widgets/jellyfin_back_button.dart';
+
+class JellyfinAlbumScreen extends StatefulWidget {
+  const JellyfinAlbumScreen({super.key, required this.album});
+
+  final JellyfinAlbum album;
+
+  @override
+  State<JellyfinAlbumScreen> createState() => _JellyfinAlbumScreenState();
+}
+
+class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
+  late final JellyfinLibraryService _library;
+  late Future<List<JellyfinTrack>> _tracksFuture;
+
+  @override
+  void initState() {
+    super.initState();
+    _library = context.read<JellyfinLibraryService>();
+    _tracksFuture = _library.albumTracks(widget.album.id);
+  }
+
+  void _reloadTracks() {
+    setState(() {
+      _tracksFuture = _library.albumTracks(widget.album.id);
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final playback = context.watch<JellyfinPlaybackController>();
+    final system = context.watch<SystemController>();
+
+    final active = playback.isCurrentAlbum(widget.album);
+    final playing = active && playback.playing;
+    final currentItem = active ? playback.currentItem : null;
+    final currentTitle = active
+        ? (currentItem?.title ?? playback.snapshot.title ?? widget.album.name)
+        : playback.loading
+        ? 'Starting playback…'
+        : widget.album.name;
+    final currentArtist = active
+        ? (currentItem?.artist ??
+              playback.snapshot.artist ??
+              widget.album.artist)
+        : widget.album.artist;
+    final currentTrackId = active
+        ? (currentItem?.trackId ?? playback.snapshot.itemId)
+        : null;
+    final media = active
+        ? playback.mediaPlayback
+        : MediaPlayback(
+            hasAccess: true,
+            title: widget.album.name,
+            artist: widget.album.artist,
+          );
+    final imageUrl = _library
+        .imageUrl(widget.album.id, widget.album.imageTag, maxHeight: 600)
+        ?.toString();
+    final headers = _library.imageHeaders;
+
+    Widget cover() {
+      final placeholder = ColoredBox(
+        color: colors.surfaceStrong,
+        child: Center(
+          child: AppIcon(
+            'assets/icons/headphones.svg',
+            size: 54,
+            color: colors.inkFaint,
+          ),
+        ),
+      );
+      return Align(
+        alignment: Alignment.topCenter,
+        child: ConstrainedBox(
+          constraints: const BoxConstraints(maxWidth: 420),
+          child: AspectRatio(
+            aspectRatio: 1,
+            child: Container(
+              clipBehavior: Clip.antiAlias,
+              decoration: BoxDecoration(
+                borderRadius: BorderRadius.circular(24),
+                border: Border.all(color: colors.border),
+              ),
+              child: imageUrl == null
+                  ? placeholder
+                  : Image.network(
+                      imageUrl,
+                      headers: headers,
+                      fit: BoxFit.cover,
+                      errorBuilder: (_, _, _) => placeholder,
+                    ),
+            ),
+          ),
+        ),
+      );
+    }
+
+    Widget controls() {
+      final error = playback.error;
+      return Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Text(
+            active
+                ? (playing ? 'Now playing' : 'Paused')
+                : playback.loading
+                ? 'Starting playback…'
+                : 'Ready',
+            style: theme.textTheme.labelLarge?.copyWith(color: colors.inkMuted),
+          ),
+          const SizedBox(height: 4),
+          Text(
+            currentTitle,
+            maxLines: 2,
+            overflow: TextOverflow.ellipsis,
+            style: theme.textTheme.headlineSmall,
+          ),
+          const SizedBox(height: 2),
+          Text(
+            currentArtist.isEmpty ? 'Unknown artist' : currentArtist,
+            maxLines: 1,
+            overflow: TextOverflow.ellipsis,
+            style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
+          ),
+          if (error != null) ...[
+            const SizedBox(height: 8),
+            Row(
+              children: [
+                Expanded(
+                  child: Text(
+                    error,
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.danger,
+                    ),
+                  ),
+                ),
+                TextButton(
+                  onPressed: () {
+                    unawaited(playback.playAlbum(widget.album));
+                  },
+                  style: TextButton.styleFrom(foregroundColor: colors.accent),
+                  child: const Text('Retry'),
+                ),
+              ],
+            ),
+          ],
+          const SizedBox(height: 12),
+          VolumeCard(
+            volume: system.volume,
+            onSetVolume: system.setVolume,
+            onToggleMute: system.toggleMute,
+            isMusicPlaying: playing,
+            onPlayPause: () {
+              if (playback.loading) return;
+              if (playback.isCurrentAlbum(widget.album)) {
+                unawaited(playback.toggle());
+              } else {
+                unawaited(playback.playAlbum(widget.album));
+              }
+            },
+            media: media,
+            large: true,
+            onPrevious: active ? () => unawaited(playback.previous()) : null,
+            onNext: active ? () => unawaited(playback.next()) : null,
+            canPrevious: active && playback.canGoPrevious,
+            canNext: active && playback.canGoNext,
+          ),
+        ],
+      );
+    }
+
+    Widget trackList() {
+      return Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        children: [
+          Row(
+            children: [
+              Text(
+                'Tracks',
+                style: theme.textTheme.titleMedium?.copyWith(
+                  color: colors.ink,
+                  fontWeight: FontWeight.w700,
+                ),
+              ),
+            ],
+          ),
+          const SizedBox(height: 6),
+          Expanded(
+            child: FutureBuilder<List<JellyfinTrack>>(
+              future: _tracksFuture,
+              builder: (context, snapshot) {
+                if (snapshot.connectionState == ConnectionState.waiting) {
+                  return const Center(child: CircularProgressIndicator());
+                }
+                if (snapshot.hasError) {
+                  return Center(
+                    child: Column(
+                      mainAxisSize: MainAxisSize.min,
+                      children: [
+                        Text(
+                          'Could not load tracks.',
+                          style: theme.textTheme.bodySmall?.copyWith(
+                            color: colors.inkMuted,
+                          ),
+                        ),
+                        TextButton(
+                          onPressed: _reloadTracks,
+                          style: TextButton.styleFrom(
+                            foregroundColor: colors.accent,
+                          ),
+                          child: const Text('Retry'),
+                        ),
+                      ],
+                    ),
+                  );
+                }
+                final tracks = snapshot.data ?? const <JellyfinTrack>[];
+                if (tracks.isEmpty) {
+                  return Center(
+                    child: Text(
+                      'No tracks in this album.',
+                      style: theme.textTheme.bodySmall?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                  );
+                }
+                return ListView.separated(
+                  padding: EdgeInsets.zero,
+                  itemCount: tracks.length,
+                  separatorBuilder: (_, _) =>
+                      Divider(color: colors.border, height: 1),
+                  itemBuilder: (context, index) {
+                    final track = tracks[index];
+                    return _TrackRow(
+                      track: track,
+                      isCurrent: track.id == currentTrackId,
+                      isPlaying: playing,
+                      onTap: () => _playTrack(tracks, index),
+                    );
+                  },
+                );
+              },
+            ),
+          ),
+        ],
+      );
+    }
+
+    Widget wideBody(BoxConstraints constraints) {
+      final coverWidth = (constraints.maxWidth * 0.30).clamp(280.0, 380.0);
+      final controlsWidth = (constraints.maxWidth * 0.44).clamp(520.0, 600.0);
+      return Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          SizedBox(width: coverWidth, child: cover()),
+          const SizedBox(width: 22),
+          Expanded(child: trackList()),
+          const SizedBox(width: 22),
+          SizedBox(
+            width: controlsWidth,
+            child: SingleChildScrollView(child: controls()),
+          ),
+        ],
+      );
+    }
+
+    Widget narrowBody(BoxConstraints constraints) {
+      final coverWidth = (constraints.maxWidth * 0.34).clamp(200.0, 260.0);
+      return SingleChildScrollView(
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          children: [
+            Row(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                SizedBox(width: coverWidth, child: cover()),
+                const SizedBox(width: 18),
+                Expanded(child: controls()),
+              ],
+            ),
+            const SizedBox(height: 16),
+            SizedBox(height: 260, child: trackList()),
+          ],
+        ),
+      );
+    }
+
+    return Scaffold(
+      backgroundColor: colors.canvas,
+      body: Container(
+        decoration: homeBackground(colors, theme.brightness),
+        child: SafeArea(
+          child: Padding(
+            padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                _AlbumHeader(album: widget.album),
+                const SizedBox(height: 14),
+                Expanded(
+                  child: LayoutBuilder(
+                    builder: (context, constraints) {
+                      return constraints.maxWidth >= 1180
+                          ? wideBody(constraints)
+                          : narrowBody(constraints);
+                    },
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Future<void> _playTrack(List<JellyfinTrack> tracks, int index) async {
+    final playback = context.read<JellyfinPlaybackController>();
+    if (playback.loading) {
+      await playback.playTrackAt(index);
+      return;
+    }
+    if (!playback.isCurrentAlbum(widget.album)) {
+      final error = await playback.playAlbum(widget.album);
+      if (error != null) return;
+    }
+    await playback.playTrackAt(index);
+  }
+}
+
+class _AlbumHeader extends StatelessWidget {
+  const _AlbumHeader({required this.album});
+
+  final JellyfinAlbum album;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        const JellyfinBackButton(),
+        const SizedBox(width: 18),
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text(
+                'Album',
+                style: theme.textTheme.labelLarge?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+              const SizedBox(height: 2),
+              Text(
+                album.name,
+                maxLines: 2,
+                overflow: TextOverflow.ellipsis,
+                style: theme.textTheme.headlineLarge,
+              ),
+              const SizedBox(height: 2),
+              Text(
+                album.artist.isEmpty ? 'Unknown artist' : album.artist,
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+class _TrackRow extends StatelessWidget {
+  const _TrackRow({
+    required this.track,
+    required this.isCurrent,
+    required this.isPlaying,
+    required this.onTap,
+  });
+
+  final JellyfinTrack track;
+  final bool isCurrent;
+  final bool isPlaying;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final color = isCurrent ? colors.accent : colors.ink;
+    return Material(
+      color: isCurrent ? colors.accentSoft : Colors.transparent,
+      borderRadius: BorderRadius.circular(12),
+      child: InkWell(
+        borderRadius: BorderRadius.circular(12),
+        onTap: onTap,
+        child: Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
+          child: Row(
+            children: [
+              SizedBox(
+                width: 28,
+                child: Text(
+                  track.displayIndex,
+                  style: theme.textTheme.bodySmall?.copyWith(
+                    color: isCurrent ? colors.accent : colors.inkFaint,
+                  ),
+                ),
+              ),
+              const SizedBox(width: 6),
+              Expanded(
+                child: Text(
+                  track.name,
+                  maxLines: 1,
+                  overflow: TextOverflow.ellipsis,
+                  style: theme.textTheme.bodyMedium?.copyWith(
+                    color: color,
+                    fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
+                  ),
+                ),
+              ),
+              const SizedBox(width: 8),
+              Text(
+                _formatDuration(track.duration),
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: isCurrent ? colors.accent : colors.inkMuted,
+                ),
+              ),
+              if (isCurrent) ...[
+                const SizedBox(width: 8),
+                AppIcon(
+                  isPlaying
+                      ? 'assets/icons/volume-2.svg'
+                      : 'assets/icons/play.svg',
+                  size: 14,
+                  color: colors.accent,
+                ),
+              ],
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+String _formatDuration(Duration duration) {
+  final totalSeconds = duration.inSeconds;
+  final minutes = totalSeconds ~/ 60;
+  final seconds = totalSeconds % 60;
+  final hours = minutes ~/ 60;
+  final remainingMinutes = minutes % 60;
+  String two(int value) => value.toString().padLeft(2, '0');
+  if (hours > 0) {
+    return '$hours:${two(remainingMinutes)}:${two(seconds)}';
+  }
+  return '$minutes:${two(seconds)}';
+}

+ 168 - 0
lib/features/jellyfin/jellyfin_dashboard_controller.dart

@@ -0,0 +1,168 @@
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/jellyfin_library.dart';
+import '../../data/models/jellyfin_models.dart';
+import '../../data/services/jellyfin_library_service.dart';
+import '../settings/jellyfin_controller.dart';
+
+/// Loads the three dashboard rows and owns the Random row's re-roll. No library
+/// data is cached across dashboard openings; the server is the source of truth.
+class JellyfinDashboardController extends ChangeNotifier {
+  JellyfinDashboardController({required this.library, required this.jellyfin});
+
+  /// The wide layout can show six albums per row; the server is asked for the
+  /// maximum and the screen hides the extra card when only five fit.
+  static const maxAlbumsPerRow = 6;
+
+  final JellyfinLibraryService library;
+  final JellyfinController jellyfin;
+
+  List<JellyfinAlbum> _latestPlayed = const [];
+  List<JellyfinAlbum> _latestAdded = const [];
+  List<JellyfinAlbum> _random = const [];
+  bool _loading = false;
+  bool _randomLoading = false;
+  bool _hasLoaded = false;
+  bool _disposed = false;
+  String? _error;
+  String? _randomError;
+
+  List<JellyfinAlbum> get latestPlayed => _latestPlayed;
+
+  List<JellyfinAlbum> get latestAdded => _latestAdded;
+
+  List<JellyfinAlbum> get random => _random;
+
+  bool get loading => _loading;
+
+  bool get randomLoading => _randomLoading;
+
+  bool get hasLoaded => _hasLoaded;
+
+  String? get error => _error;
+
+  String? get randomError => _randomError;
+
+  bool get hasGenres => jellyfin.genreSelections.isNotEmpty;
+
+  List<JellyfinGenreSelection> get selections => jellyfin.genreSelections;
+
+  Future<void> load() async {
+    if (_disposed || _loading) return;
+    if (!library.isConfigured) {
+      _hasLoaded = true;
+      _error = 'Pair Jellyfin first.';
+      _notify();
+      return;
+    }
+    _loading = true;
+    _error = null;
+    _randomError = null;
+    _notify();
+
+    final latestFuture = Future.wait([
+      library.latestPlayed(limit: maxAlbumsPerRow),
+      library.latestAdded(limit: maxAlbumsPerRow),
+    ]);
+    final randomFuture = _randomForSelections();
+
+    try {
+      final latest = await latestFuture;
+      if (_disposed) return;
+      _latestPlayed = latest[0];
+      _latestAdded = latest[1];
+    } catch (error) {
+      if (_disposed) return;
+      _error = _messageFor(error);
+    }
+
+    try {
+      final random = await randomFuture;
+      if (_disposed) return;
+      _random = random;
+    } catch (error) {
+      if (_disposed) return;
+      _randomError = _messageFor(error);
+      _random = const [];
+    }
+
+    _loading = false;
+    _hasLoaded = true;
+    _notify();
+  }
+
+  Future<void> retry() => load();
+
+  Future<void> shuffle() async {
+    if (_disposed || _randomLoading) return;
+    if (jellyfin.genreSelections.isEmpty) {
+      _random = const [];
+      _randomError = null;
+      _notify();
+      return;
+    }
+    _randomLoading = true;
+    _randomError = null;
+    _notify();
+    try {
+      final random = await _randomForSelections();
+      if (_disposed) return;
+      _random = random;
+    } catch (error) {
+      if (_disposed) return;
+      _randomError = _messageFor(error);
+      _random = const [];
+    } finally {
+      if (!_disposed) {
+        _randomLoading = false;
+        _notify();
+      }
+    }
+  }
+
+  /// Moves a started album to the front of Recently played until the next
+  /// server refresh. Keeps the dashboard useful immediately after a tap.
+  void markPlayed(JellyfinAlbum album) {
+    if (_disposed) return;
+    final next = [
+      album,
+      ..._latestPlayed.where((entry) => entry.id != album.id),
+    ];
+    _latestPlayed = next.take(maxAlbumsPerRow).toList(growable: false);
+    _notify();
+  }
+
+  Future<List<JellyfinAlbum>> _randomForSelections({
+    int limit = maxAlbumsPerRow,
+  }) async {
+    final selections = jellyfin.genreSelections;
+    if (selections.isEmpty) return const [];
+    final families = await library.genreFamilies();
+    final raw = library.catalog.rawGenresFor(
+      families: families,
+      selectedIds: selections.map((selection) => selection.id),
+    );
+    return library.randomAlbumsForGenres(raw, limit: limit);
+  }
+
+  String _messageFor(Object error) {
+    if (error is JellyfinUnreachable) {
+      return 'Jellyfin is unreachable. Check the server and try again.';
+    }
+    if (error is JellyfinAuthException) {
+      return 'Pair Jellyfin again.';
+    }
+    if (error is JellyfinException) return error.message;
+    return 'Could not load the music library.';
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    super.dispose();
+  }
+}

+ 571 - 0
lib/features/jellyfin/jellyfin_dashboard_screen.dart

@@ -0,0 +1,571 @@
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/pressable.dart';
+import '../../data/models/jellyfin_library.dart';
+import '../../data/services/jellyfin_library_service.dart';
+import '../settings/jellyfin_controller.dart';
+import 'jellyfin_album_screen.dart';
+import 'jellyfin_dashboard_controller.dart';
+import 'jellyfin_playback_controller.dart';
+import 'widgets/jellyfin_back_button.dart';
+
+class JellyfinDashboardScreen extends StatelessWidget {
+  const JellyfinDashboardScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return ChangeNotifierProvider(
+      create: (context) {
+        final controller = JellyfinDashboardController(
+          library: context.read<JellyfinLibraryService>(),
+          jellyfin: context.read<JellyfinController>(),
+        );
+        unawaited(controller.load());
+        return controller;
+      },
+      child: const _JellyfinDashboardBody(),
+    );
+  }
+}
+
+class _JellyfinDashboardBody extends StatelessWidget {
+  const _JellyfinDashboardBody();
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final dashboard = context.watch<JellyfinDashboardController>();
+    final playback = context.watch<JellyfinPlaybackController>();
+    final jellyfin = context.watch<JellyfinController>();
+    final library = context.read<JellyfinLibraryService>();
+
+    final currentId = playback.currentAlbum?.id;
+    final currentPlaying = playback.playing || playback.buffering;
+    final headers = library.imageHeaders;
+
+    String? imageFor(JellyfinAlbum album) =>
+        library.imageUrl(album.id, album.imageTag)?.toString();
+
+    Widget body(int columns) {
+      if (dashboard.loading && !dashboard.hasLoaded) {
+        return const Center(child: CircularProgressIndicator());
+      }
+      final error = dashboard.error;
+      if (error != null) {
+        return _DashboardError(
+          message: error,
+          onRetry: () => unawaited(dashboard.retry()),
+        );
+      }
+      return Column(
+        children: [
+          Expanded(
+            child: _AlbumSection(
+              title: 'Recently played',
+              albums: dashboard.latestPlayed,
+              emptyText: 'Nothing played yet.',
+              columns: columns,
+              imageFor: imageFor,
+              headers: headers,
+              currentAlbumId: currentId,
+              currentPlaying: currentPlaying,
+              onPlay: (album) =>
+                  _openAlbum(context, dashboard, playback, album),
+            ),
+          ),
+          const SizedBox(height: 6),
+          Expanded(
+            child: _AlbumSection(
+              title: 'Recently added',
+              albums: dashboard.latestAdded,
+              emptyText: 'No albums added yet.',
+              columns: columns,
+              imageFor: imageFor,
+              headers: headers,
+              currentAlbumId: currentId,
+              currentPlaying: currentPlaying,
+              onPlay: (album) =>
+                  _openAlbum(context, dashboard, playback, album),
+            ),
+          ),
+          const SizedBox(height: 6),
+          Expanded(
+            child: _AlbumSection(
+              title: 'Random',
+              albums: dashboard.random,
+              emptyText: dashboard.hasGenres
+                  ? 'No albums for the selected genres.'
+                  : 'Choose up to 5 genres in Settings.',
+              columns: columns,
+              error: dashboard.randomError,
+              shuffling: dashboard.randomLoading,
+              onShuffle: dashboard.hasGenres ? dashboard.shuffle : null,
+              imageFor: imageFor,
+              headers: headers,
+              currentAlbumId: currentId,
+              currentPlaying: currentPlaying,
+              onPlay: (album) => _openAlbum(
+                context,
+                dashboard,
+                playback,
+                album,
+                shuffleAfter: true,
+              ),
+            ),
+          ),
+        ],
+      );
+    }
+
+    return Scaffold(
+      backgroundColor: colors.canvas,
+      body: Container(
+        decoration: homeBackground(colors, theme.brightness),
+        child: SafeArea(
+          child: Padding(
+            padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                _DashboardHeader(serverName: jellyfin.serverName),
+                const SizedBox(height: 10),
+                const _DashboardTabs(),
+                const SizedBox(height: 10),
+                Expanded(
+                  child: LayoutBuilder(
+                    builder: (context, constraints) {
+                      return body(_columnsFor(constraints.maxWidth));
+                    },
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  static const _rowGap = 12.0;
+  static const _minSixColumnWidth = 160.0;
+
+  int _columnsFor(double width) {
+    final sixColumnWidth = (width - _rowGap * 5) / 6;
+    return sixColumnWidth >= _minSixColumnWidth ? 6 : 5;
+  }
+
+  void _openAlbum(
+    BuildContext context,
+    JellyfinDashboardController dashboard,
+    JellyfinPlaybackController playback,
+    JellyfinAlbum album, {
+    bool shuffleAfter = false,
+  }) {
+    dashboard.markPlayed(album);
+    final started = playback.playAlbum(album);
+    if (shuffleAfter) {
+      unawaited(
+        started.then((error) async {
+          if (error == null) await dashboard.shuffle();
+        }),
+      );
+    } else {
+      unawaited(started);
+    }
+    Navigator.of(context).push(
+      MaterialPageRoute<void>(
+        builder: (_) => JellyfinAlbumScreen(album: album),
+      ),
+    );
+  }
+}
+
+class _DashboardHeader extends StatelessWidget {
+  const _DashboardHeader({this.serverName});
+
+  final String? serverName;
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final colors = context.colors;
+    final name = serverName?.trim();
+    return Row(
+      children: [
+        const JellyfinBackButton(),
+        const SizedBox(width: 18),
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('Jellyfin', style: theme.textTheme.headlineLarge),
+              const SizedBox(height: 2),
+              Text(
+                name == null || name.isEmpty
+                    ? 'Music library'
+                    : 'Music library · $name',
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+class _DashboardTabs extends StatelessWidget {
+  const _DashboardTabs();
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final colors = context.colors;
+    return Row(
+      children: [
+        Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Text(
+              'Albums',
+              style: theme.textTheme.titleMedium?.copyWith(
+                color: colors.ink,
+                fontWeight: FontWeight.w700,
+              ),
+            ),
+            const SizedBox(height: 4),
+            Container(
+              width: 38,
+              height: 2,
+              decoration: BoxDecoration(
+                color: colors.accent,
+                borderRadius: BorderRadius.circular(2),
+              ),
+            ),
+          ],
+        ),
+        const SizedBox(width: 28),
+        Text(
+          'Artists',
+          style: theme.textTheme.titleMedium?.copyWith(color: colors.inkFaint),
+        ),
+      ],
+    );
+  }
+}
+
+class _AlbumSection extends StatelessWidget {
+  const _AlbumSection({
+    required this.title,
+    required this.albums,
+    required this.emptyText,
+    required this.columns,
+    required this.imageFor,
+    required this.headers,
+    required this.currentAlbumId,
+    required this.currentPlaying,
+    required this.onPlay,
+    this.error,
+    this.shuffling = false,
+    this.onShuffle,
+  });
+
+  final String title;
+  final List<JellyfinAlbum> albums;
+  final String emptyText;
+  final int columns;
+  final String? Function(JellyfinAlbum album) imageFor;
+  final Map<String, String> headers;
+  final String? currentAlbumId;
+  final bool currentPlaying;
+  final ValueChanged<JellyfinAlbum> onPlay;
+  final String? error;
+  final bool shuffling;
+  final Future<void> Function()? onShuffle;
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final colors = context.colors;
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.stretch,
+      children: [
+        Row(
+          children: [
+            Text(
+              title,
+              style: theme.textTheme.titleMedium?.copyWith(
+                color: colors.ink,
+                fontWeight: FontWeight.w700,
+              ),
+            ),
+            if (onShuffle != null) ...[
+              const Spacer(),
+              _ShuffleButton(
+                loading: shuffling,
+                onTap: () => unawaited(onShuffle!()),
+              ),
+            ],
+          ],
+        ),
+        const SizedBox(height: 6),
+        Expanded(
+          child: error != null
+              ? Center(
+                  child: Text(
+                    error!,
+                    textAlign: TextAlign.center,
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.danger,
+                    ),
+                  ),
+                )
+              : albums.isEmpty
+              ? Center(
+                  child: Text(
+                    emptyText,
+                    textAlign: TextAlign.center,
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                )
+              : LayoutBuilder(
+                  builder: (context, constraints) {
+                    const gap = 12.0;
+                    final width = math.max(
+                      0.0,
+                      (constraints.maxWidth - gap * (columns - 1)) / columns,
+                    );
+                    return Row(
+                      crossAxisAlignment: CrossAxisAlignment.stretch,
+                      children: [
+                        for (var i = 0; i < columns; i++) ...[
+                          if (i > 0) const SizedBox(width: gap),
+                          SizedBox(
+                            width: width,
+                            child: i < albums.length
+                                ? _AlbumCard(
+                                    album: albums[i],
+                                    imageUrl: imageFor(albums[i]),
+                                    headers: headers,
+                                    isCurrent: currentAlbumId == albums[i].id,
+                                    isPlaying:
+                                        currentPlaying &&
+                                        currentAlbumId == albums[i].id,
+                                    onTap: () => onPlay(albums[i]),
+                                  )
+                                : const SizedBox.shrink(),
+                          ),
+                        ],
+                      ],
+                    );
+                  },
+                ),
+        ),
+      ],
+    );
+  }
+}
+
+class _AlbumCard extends StatelessWidget {
+  const _AlbumCard({
+    required this.album,
+    required this.imageUrl,
+    required this.headers,
+    required this.isCurrent,
+    required this.isPlaying,
+    required this.onTap,
+  });
+
+  final JellyfinAlbum album;
+  final String? imageUrl;
+  final Map<String, String> headers;
+  final bool isCurrent;
+  final bool isPlaying;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    return LayoutBuilder(
+      builder: (context, constraints) {
+        final cover = math.max(
+          0.0,
+          math.min(constraints.maxWidth, constraints.maxHeight - 46),
+        );
+        final placeholder = ColoredBox(
+          color: colors.surfaceStrong,
+          child: Center(
+            child: AppIcon(
+              'assets/icons/headphones.svg',
+              size: 28,
+              color: colors.inkFaint,
+            ),
+          ),
+        );
+        return Pressable(
+          onTap: onTap,
+          semanticLabel: 'Open ${album.name}',
+          pressedScale: 0.97,
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Container(
+                width: cover,
+                height: cover,
+                clipBehavior: Clip.antiAlias,
+                decoration: BoxDecoration(
+                  borderRadius: BorderRadius.circular(15),
+                  border: Border.all(
+                    color: isCurrent ? colors.accent : colors.border,
+                    width: isCurrent ? 2 : 1,
+                  ),
+                ),
+                child: Stack(
+                  fit: StackFit.expand,
+                  children: [
+                    if (imageUrl == null)
+                      placeholder
+                    else
+                      Image.network(
+                        imageUrl!,
+                        headers: headers,
+                        fit: BoxFit.cover,
+                        errorBuilder: (_, _, _) => placeholder,
+                      ),
+                    if (isCurrent)
+                      Align(
+                        alignment: Alignment.bottomRight,
+                        child: Container(
+                          width: 26,
+                          height: 26,
+                          margin: const EdgeInsets.all(7),
+                          decoration: BoxDecoration(
+                            color: colors.canvas.withValues(alpha: 0.85),
+                            shape: BoxShape.circle,
+                          ),
+                          child: Center(
+                            child: AppIcon(
+                              isPlaying
+                                  ? 'assets/icons/volume-2.svg'
+                                  : 'assets/icons/play.svg',
+                              size: 13,
+                              color: colors.accent,
+                            ),
+                          ),
+                        ),
+                      ),
+                  ],
+                ),
+              ),
+              const SizedBox(height: 6),
+              Text(
+                album.name,
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                style: theme.textTheme.bodyMedium?.copyWith(
+                  color: colors.ink,
+                  fontWeight: FontWeight.w600,
+                ),
+              ),
+              Text(
+                album.artist.isEmpty ? 'Unknown artist' : album.artist,
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        );
+      },
+    );
+  }
+}
+
+class _ShuffleButton extends StatelessWidget {
+  const _ShuffleButton({required this.loading, required this.onTap});
+
+  final bool loading;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: loading ? null : onTap,
+      semanticLabel: 'Shuffle random albums',
+      pressedScale: 0.9,
+      child: Container(
+        width: 34,
+        height: 34,
+        decoration: BoxDecoration(
+          color: colors.surfaceStrong,
+          shape: BoxShape.circle,
+          border: Border.all(color: colors.border),
+        ),
+        alignment: Alignment.center,
+        child: loading
+            ? SizedBox(
+                width: 14,
+                height: 14,
+                child: CircularProgressIndicator(
+                  strokeWidth: 2,
+                  color: colors.accent,
+                ),
+              )
+            : AppIcon(
+                'assets/icons/refresh-cw.svg',
+                size: 16,
+                color: colors.inkMuted,
+              ),
+      ),
+    );
+  }
+}
+
+class _DashboardError extends StatelessWidget {
+  const _DashboardError({required this.message, required this.onRetry});
+
+  final String message;
+  final VoidCallback onRetry;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    return Center(
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Text(
+            message,
+            textAlign: TextAlign.center,
+            style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
+          ),
+          const SizedBox(height: 10),
+          TextButton(
+            onPressed: onRetry,
+            style: TextButton.styleFrom(foregroundColor: colors.accent),
+            child: const Text('Retry'),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 294 - 0
lib/features/jellyfin/jellyfin_playback_controller.dart

@@ -0,0 +1,294 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/jellyfin_library.dart';
+import '../../data/models/jellyfin_models.dart';
+import '../../data/models/jellyfin_playback.dart';
+import '../../data/models/media_playback.dart';
+import '../../data/services/jellyfin_library_service.dart';
+import '../../data/services/jellyfin_playback_platform.dart';
+import '../settings/jellyfin_controller.dart';
+
+/// Owns one Jellyfin album queue and polls the native Media3 state. The native
+/// service is independent of this object, so playback continues while the app
+/// is backgrounded; [restore] reconnects the UI after an activity restart.
+class JellyfinPlaybackController extends ChangeNotifier {
+  JellyfinPlaybackController(
+    this._platform,
+    this._library,
+    this._jellyfin, {
+    this.pollInterval = const Duration(milliseconds: 700),
+  }) {
+    _jellyfin.addListener(_onJellyfinChanged);
+  }
+
+  final JellyfinPlaybackPlatform _platform;
+  final JellyfinLibraryService _library;
+  final JellyfinController _jellyfin;
+  final Duration pollInterval;
+
+  JellyfinPlaybackQueue? _queue;
+  JellyfinAlbum? _album;
+  JellyfinPlaybackSnapshot _snapshot = JellyfinPlaybackSnapshot.empty;
+  bool _loading = false;
+  bool _polling = false;
+  bool _disposed = false;
+  String? _error;
+  int? _pendingIndex;
+  int _generation = 0;
+  Timer? _timer;
+
+  bool get isActive => _queue != null || _snapshot.available;
+
+  bool get hasQueue => _queue != null;
+
+  bool get loading => _loading;
+
+  bool get playing => _snapshot.playing;
+
+  bool get buffering => _snapshot.buffering;
+
+  bool isCurrentAlbum(JellyfinAlbum album) => _album?.id == album.id;
+
+  JellyfinAlbum? get currentAlbum => _album;
+
+  JellyfinPlaybackSnapshot get snapshot => _snapshot;
+
+  String? get error => _error;
+
+  List<JellyfinQueueItem> get queueItems =>
+      _queue?.items ?? const <JellyfinQueueItem>[];
+
+  JellyfinQueueItem? get currentItem {
+    final queue = _queue;
+    if (queue == null) return null;
+    final index = _snapshot.index;
+    if (index < 0 || index >= queue.items.length) return null;
+    return queue.items[index];
+  }
+
+  String? get nowPlayingAlbum =>
+      currentItem?.album ?? _snapshot.album ?? _album?.name;
+
+  String? get nowPlayingArtist =>
+      currentItem?.artist ?? _snapshot.artist ?? _album?.artist;
+
+  MediaPlayback get mediaPlayback {
+    if (!_snapshot.available) return const MediaPlayback();
+    return _snapshot.toMediaPlayback();
+  }
+
+  bool get canGoNext => _snapshot.hasNext;
+
+  bool get canGoPrevious => _snapshot.hasPrevious;
+
+  /// Starts an album. The same album is not restarted unless it has ended or
+  /// [restart] is true. Returns an error message for the caller, or null.
+  Future<String?> playAlbum(JellyfinAlbum album, {bool restart = false}) async {
+    if (_disposed) return null;
+    if (!_jellyfin.configured) return 'Pair Jellyfin first.';
+
+    final sameAlbum =
+        _album?.id == album.id && (_queue != null || _snapshot.available);
+    if (sameAlbum && !restart) {
+      if (_snapshot.ended) {
+        await _platform.seekToIndex(0);
+        await _platform.play();
+      }
+      _startPolling();
+      await _poll();
+      return null;
+    }
+
+    final generation = ++_generation;
+    _loading = true;
+    _error = null;
+    _pendingIndex = null;
+    _notify();
+
+    try {
+      final queue = await _library.buildQueue(album);
+      if (_disposed || generation != _generation) return null;
+      if (queue == null || queue.items.isEmpty) {
+        throw JellyfinException('No playable tracks in this album.');
+      }
+      final started = await _platform.setQueue(queue);
+      if (_disposed || generation != _generation) return null;
+      if (!started) {
+        throw JellyfinException('Could not start playback.');
+      }
+      _queue = queue;
+      _album = album;
+      _snapshot = await _platform.getState();
+      _loading = false;
+      _startPolling();
+      final pending = _pendingIndex;
+      _pendingIndex = null;
+      if (pending != null) {
+        await playTrackAt(pending);
+      }
+      _notify();
+      return null;
+    } on JellyfinException catch (exception) {
+      if (_disposed || generation != _generation) return null;
+      _loading = false;
+      _error = exception.message;
+      _notify();
+      return exception.message;
+    } catch (_) {
+      if (_disposed || generation != _generation) return null;
+      _loading = false;
+      _error = 'Could not play this album.';
+      _notify();
+      return _error;
+    }
+  }
+
+  Future<void> toggle() async {
+    if (!_snapshot.available && _queue == null) return;
+    if (_snapshot.playing) {
+      await _platform.pause();
+      _snapshot = _snapshot.copyWith(playing: false);
+      _notify();
+    } else {
+      if (_snapshot.ended) {
+        await _platform.seekToIndex(0);
+      }
+      await _platform.play();
+      _snapshot = _snapshot.copyWith(playing: true);
+      _notify();
+    }
+    _startPolling();
+    await _poll();
+  }
+
+  Future<void> next() async {
+    if (!_snapshot.hasNext) return;
+    await _platform.next();
+    await _poll();
+  }
+
+  Future<void> previous() async {
+    if (!_snapshot.hasPrevious) return;
+    await _platform.previous();
+    await _poll();
+  }
+
+  /// Selects an album track. If the queue is still being built, the index is
+  /// applied as soon as it is ready.
+  Future<void> playTrackAt(int index) async {
+    if (_queue == null) {
+      _pendingIndex = index;
+      return;
+    }
+    if (index < 0 || index >= _queue!.items.length) return;
+    final selected = await _platform.seekToIndex(index);
+    if (!selected) {
+      _error = 'Could not play that track.';
+      _notify();
+      return;
+    }
+    await _platform.play();
+    _startPolling();
+    await _poll();
+  }
+
+  Future<void> seek(Duration position) async {
+    if (!_snapshot.available) return;
+    await _platform.seek(position);
+    await _poll();
+  }
+
+  Future<bool> notificationPermissionGranted() {
+    return _platform.notificationPermissionGranted();
+  }
+
+  Future<bool> requestNotificationPermission() {
+    return _platform.requestNotificationPermission();
+  }
+
+  Future<void> stop() async {
+    _generation++;
+    await _platform.stop();
+    _queue = null;
+    _album = null;
+    _snapshot = JellyfinPlaybackSnapshot.empty;
+    _loading = false;
+    _error = null;
+    _pendingIndex = null;
+    _stopPolling();
+    _notify();
+  }
+
+  /// Reconnects to a service that was already playing when the activity was
+  /// recreated. The queue itself is not persisted; the snapshot still drives
+  /// the Home media card.
+  Future<void> restore() async {
+    if (_disposed) return;
+    final snapshot = await _platform.getState();
+    if (_disposed || !snapshot.available) return;
+    _snapshot = snapshot;
+    _startPolling();
+    _notify();
+  }
+
+  void onAppPaused() {
+    _stopPolling();
+  }
+
+  void onAppResumed() {
+    if (!isActive) return;
+    _startPolling();
+    unawaited(_poll());
+  }
+
+  Future<void> _poll() async {
+    if (_disposed || _polling) return;
+    _polling = true;
+    try {
+      final next = await _platform.getState();
+      if (_disposed) return;
+      final changed = next != _snapshot;
+      _snapshot = next;
+      final nextError = next.error;
+      if (nextError != null && nextError != _error) {
+        _error = nextError;
+      }
+      if (changed) _notify();
+    } catch (_) {
+      // Keep the last known state; the next poll retries.
+    } finally {
+      _polling = false;
+    }
+  }
+
+  void _startPolling() {
+    if (_disposed || _timer != null) return;
+    _timer = Timer.periodic(pollInterval, (_) => unawaited(_poll()));
+  }
+
+  void _stopPolling() {
+    _timer?.cancel();
+    _timer = null;
+  }
+
+  void _onJellyfinChanged() {
+    if (!_jellyfin.configured && (_queue != null || _snapshot.available)) {
+      unawaited(stop());
+    }
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _jellyfin.removeListener(_onJellyfinChanged);
+    _stopPolling();
+    _platform.dispose();
+    super.dispose();
+  }
+}

+ 34 - 0
lib/features/jellyfin/widgets/jellyfin_back_button.dart

@@ -0,0 +1,34 @@
+import 'package:flutter/material.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/app_icon.dart';
+import '../../../core/widgets/pressable.dart';
+
+class JellyfinBackButton extends StatelessWidget {
+  const JellyfinBackButton({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: () => Navigator.of(context).pop(),
+      semanticLabel: 'Back',
+      pressedScale: 0.92,
+      child: Container(
+        width: 46,
+        height: 46,
+        decoration: BoxDecoration(
+          color: colors.surface,
+          shape: BoxShape.circle,
+          border: Border.all(color: colors.border),
+        ),
+        alignment: Alignment.center,
+        child: AppIcon(
+          'assets/icons/arrow-left.svg',
+          size: 20,
+          color: colors.ink,
+        ),
+      ),
+    );
+  }
+}

+ 34 - 0
lib/features/settings/jellyfin_controller.dart

@@ -2,6 +2,7 @@ import 'dart:async';
 
 import 'package:flutter/foundation.dart';
 
+import '../../data/models/jellyfin_library.dart';
 import '../../data/models/jellyfin_models.dart';
 import '../../data/repositories/settings_repository.dart';
 import '../../data/services/jellyfin_service.dart';
@@ -87,6 +88,39 @@ class JellyfinController extends ChangeNotifier {
 
   bool get hasSession => _token != null;
 
+  List<JellyfinGenreSelection> get genreSelections =>
+      _settings.jellyfinGenreSelections;
+
+  Future<void> setGenreSelections(
+    List<JellyfinGenreSelection> selections,
+  ) async {
+    await _settings.setJellyfinGenreSelections(selections);
+    _notify();
+  }
+
+  /// A saved server URL plus an access token. Unlike [connected] this stays
+  /// true during a temporary outage, so the Home button and dashboard can show
+  /// an offline state instead of disappearing.
+  bool get configured => _enabled && _serverUrl.isNotEmpty && _token != null;
+
+  /// Authenticated details for the library and playback services. Never pass
+  /// this to a widget; use it only inside controllers/services.
+  JellyfinConnection? get connection {
+    final base = normalizeJellyfinUrl(_serverUrl);
+    final token = _token;
+    final userId = _settings.jellyfinUserId;
+    if (!_enabled || base == null || token == null || userId == null) {
+      return null;
+    }
+    return JellyfinConnection(
+      baseUrl: base,
+      accessToken: token,
+      userId: userId,
+      deviceId: _deviceId,
+      userName: _userName ?? '',
+    );
+  }
+
   JellyfinStatus get status => _status;
 
   String get serverUrl => _serverUrl;

+ 341 - 0
lib/features/settings/jellyfin_genre_dialog.dart

@@ -0,0 +1,341 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../data/models/jellyfin_library.dart';
+import '../../data/models/jellyfin_models.dart';
+import '../../data/services/jellyfin_library_service.dart';
+
+Future<List<JellyfinGenreSelection>?> showJellyfinGenreDialog(
+  BuildContext context, {
+  required JellyfinLibraryService library,
+  required List<JellyfinGenreSelection> selected,
+}) {
+  return showDialog<List<JellyfinGenreSelection>>(
+    context: context,
+    builder: (_) => JellyfinGenreDialog(library: library, selected: selected),
+  );
+}
+
+/// Grouped multi-select for the Random row. At most five families; the server
+/// genre list is fetched each time so new tags appear after a library scan.
+class JellyfinGenreDialog extends StatefulWidget {
+  const JellyfinGenreDialog({
+    super.key,
+    required this.library,
+    required this.selected,
+  });
+
+  final JellyfinLibraryService library;
+  final List<JellyfinGenreSelection> selected;
+
+  @override
+  State<JellyfinGenreDialog> createState() => _JellyfinGenreDialogState();
+}
+
+class _JellyfinGenreDialogState extends State<JellyfinGenreDialog> {
+  static const _maxSelections = 5;
+
+  final TextEditingController _search = TextEditingController();
+  List<JellyfinGenreFamily>? _families;
+  final Set<String> _selectedIds = {};
+  bool _loading = true;
+  String? _error;
+  String _query = '';
+
+  @override
+  void initState() {
+    super.initState();
+    _selectedIds.addAll(widget.selected.map((selection) => selection.id));
+    unawaited(_load());
+  }
+
+  @override
+  void dispose() {
+    _search.dispose();
+    super.dispose();
+  }
+
+  Future<void> _load() async {
+    setState(() {
+      _loading = true;
+      _error = null;
+    });
+    try {
+      final families = await widget.library.genreFamilies(force: true);
+      if (!mounted) return;
+      final ids = families.map((family) => family.id).toSet();
+      setState(() {
+        _families = families;
+        _selectedIds.removeWhere((id) => !ids.contains(id));
+        _loading = false;
+      });
+    } on JellyfinException catch (exception) {
+      if (!mounted) return;
+      setState(() {
+        _loading = false;
+        _error = exception.message;
+      });
+    } catch (_) {
+      if (!mounted) return;
+      setState(() {
+        _loading = false;
+        _error = 'Could not load the music library.';
+      });
+    }
+  }
+
+  void _toggle(JellyfinGenreFamily family) {
+    final selected = _selectedIds.contains(family.id);
+    if (!selected && _selectedIds.length >= _maxSelections) return;
+    setState(() {
+      if (selected) {
+        _selectedIds.remove(family.id);
+      } else {
+        _selectedIds.add(family.id);
+      }
+    });
+  }
+
+  void _save() {
+    final families = _families;
+    if (families == null) return;
+    final selections = families
+        .where((family) => _selectedIds.contains(family.id))
+        .map(
+          (family) =>
+              JellyfinGenreSelection(id: family.id, label: family.label),
+        )
+        .toList(growable: false);
+    Navigator.of(context).pop(selections);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final screen = MediaQuery.sizeOf(context);
+    final width = (screen.width - 120).clamp(420.0, 680.0);
+    final height = (screen.height - 160).clamp(320.0, 500.0);
+
+    return AlertDialog(
+      title: Row(
+        children: [
+          Text('Random genres', style: theme.textTheme.titleLarge),
+          const Spacer(),
+          Text(
+            '${_selectedIds.length} / $_maxSelections',
+            style: theme.textTheme.labelLarge?.copyWith(
+              color: _selectedIds.length >= _maxSelections
+                  ? colors.accent
+                  : colors.inkMuted,
+            ),
+          ),
+        ],
+      ),
+      content: SizedBox(
+        width: width,
+        height: height,
+        child: _buildContent(context),
+      ),
+      actions: [
+        TextButton(
+          onPressed: () => Navigator.of(context).pop(),
+          child: const Text('Cancel'),
+        ),
+        FilledButton(
+          onPressed: _loading || _error != null ? null : _save,
+          child: const Text('Save'),
+        ),
+      ],
+    );
+  }
+
+  Widget _buildContent(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    if (_loading) {
+      return const Center(child: CircularProgressIndicator());
+    }
+    final error = _error;
+    if (error != null) {
+      return Center(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Text(
+              error,
+              textAlign: TextAlign.center,
+              style: theme.textTheme.bodyMedium?.copyWith(
+                color: colors.inkMuted,
+              ),
+            ),
+            const SizedBox(height: 10),
+            TextButton(
+              onPressed: _load,
+              style: TextButton.styleFrom(foregroundColor: colors.accent),
+              child: const Text('Retry'),
+            ),
+          ],
+        ),
+      );
+    }
+
+    final families = _families ?? const <JellyfinGenreFamily>[];
+    if (families.isEmpty) {
+      return Center(
+        child: Text(
+          'No genres found in the music library.',
+          style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
+        ),
+      );
+    }
+
+    final query = _query.trim().toLowerCase();
+    final visible = query.isEmpty
+        ? families
+        : families
+              .where(
+                (family) =>
+                    family.label.toLowerCase().contains(query) ||
+                    family.rawGenres.any(
+                      (raw) => raw.toLowerCase().contains(query),
+                    ),
+              )
+              .toList(growable: false);
+    final atLimit = _selectedIds.length >= _maxSelections;
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.stretch,
+      children: [
+        if (families.length > 12)
+          Padding(
+            padding: const EdgeInsets.only(bottom: 8),
+            child: TextField(
+              controller: _search,
+              onChanged: (value) => setState(() => _query = value),
+              style: theme.textTheme.bodyMedium,
+              decoration: InputDecoration(
+                hintText: 'Search genres',
+                isDense: true,
+                prefixIcon: Padding(
+                  padding: const EdgeInsets.only(left: 2),
+                  child: SizedBox(
+                    width: 18,
+                    height: 18,
+                    child: Center(
+                      child: AppIcon(
+                        'assets/icons/search.svg',
+                        size: 15,
+                        color: colors.inkFaint,
+                      ),
+                    ),
+                  ),
+                ),
+                prefixIconConstraints: const BoxConstraints(
+                  minWidth: 34,
+                  minHeight: 34,
+                ),
+              ),
+            ),
+          ),
+        if (atLimit)
+          Padding(
+            padding: const EdgeInsets.only(bottom: 4),
+            child: Text(
+              'Deselect one to choose another.',
+              style: theme.textTheme.bodySmall?.copyWith(
+                color: colors.inkMuted,
+              ),
+            ),
+          ),
+        Expanded(
+          child: visible.isEmpty
+              ? Center(
+                  child: Text(
+                    'No genres match that search.',
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                )
+              : ListView.builder(
+                  itemCount: visible.length,
+                  itemBuilder: (context, index) {
+                    final family = visible[index];
+                    final selected = _selectedIds.contains(family.id);
+                    final enabled = selected || !atLimit;
+                    return _GenreFamilyRow(
+                      family: family,
+                      selected: selected,
+                      enabled: enabled,
+                      onTap: enabled ? () => _toggle(family) : null,
+                    );
+                  },
+                ),
+        ),
+      ],
+    );
+  }
+}
+
+class _GenreFamilyRow extends StatelessWidget {
+  const _GenreFamilyRow({
+    required this.family,
+    required this.selected,
+    required this.enabled,
+    required this.onTap,
+  });
+
+  final JellyfinGenreFamily family;
+  final bool selected;
+  final bool enabled;
+  final VoidCallback? onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final count = family.rawCount;
+    return InkWell(
+      borderRadius: BorderRadius.circular(12),
+      onTap: onTap,
+      child: Padding(
+        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
+        child: Row(
+          children: [
+            Checkbox(
+              value: selected,
+              onChanged: enabled ? (_) => onTap?.call() : null,
+              activeColor: colors.accent,
+              checkColor: colors.accentInk,
+              side: BorderSide(color: colors.border),
+            ),
+            const SizedBox(width: 4),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text(
+                    family.label,
+                    style: theme.textTheme.bodyLarge?.copyWith(
+                      color: enabled ? colors.ink : colors.inkFaint,
+                    ),
+                  ),
+                  Text(
+                    '$count ${count == 1 ? 'genre' : 'genres'}',
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                ],
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 161 - 2
lib/features/settings/jellyfin_settings_card.dart

@@ -5,13 +5,18 @@ import 'package:provider/provider.dart';
 
 import '../../core/theme/app_colors.dart';
 import '../../core/widgets/app_icon.dart';
+import '../../data/services/jellyfin_library_service.dart';
+import '../jellyfin/jellyfin_playback_controller.dart';
 import 'jellyfin_controller.dart';
+import 'jellyfin_genre_dialog.dart';
 import 'settings_widgets.dart';
 
 /// Settings card for the optional Jellyfin music server. Quick Connect is the
 /// only sign-in path; the token itself never leaves secure storage.
 class JellyfinSettingsCard extends StatefulWidget {
-  const JellyfinSettingsCard({super.key});
+  const JellyfinSettingsCard({super.key, this.playback});
+
+  final JellyfinPlaybackController? playback;
 
   @override
   State<JellyfinSettingsCard> createState() => _JellyfinSettingsCardState();
@@ -249,6 +254,17 @@ class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
               style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
             ),
           ],
+          const SizedBox(height: 4),
+          Divider(color: colors.border, height: 24),
+          _GenreRow(
+            controller: jellyfin,
+            onChoose: () => _openGenres(jellyfin),
+          ),
+          if (jellyfin.configured && widget.playback != null) ...[
+            const SizedBox(height: 4),
+            Divider(color: colors.border, height: 24),
+            _PlaybackNotificationRow(playback: widget.playback!),
+          ],
         ] else if (jellyfin.needsRepair) ...[
           const SizedBox(height: 12),
           Text(
@@ -271,7 +287,8 @@ class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
         ],
         const SizedBox(height: 8),
         Text(
-          'Only the music library is read. The access token is stored on this '
+          'CarDash reads your music library and reports playback so Jellyfin '
+          'can track recently played. The access token is stored on this '
           'device and can be removed at any time.',
           style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
         ),
@@ -279,6 +296,17 @@ class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
     );
   }
 
+  Future<void> _openGenres(JellyfinController jellyfin) async {
+    final library = context.read<JellyfinLibraryService>();
+    final result = await showJellyfinGenreDialog(
+      context,
+      library: library,
+      selected: jellyfin.genreSelections,
+    );
+    if (result == null || !mounted) return;
+    await jellyfin.setGenreSelections(result);
+  }
+
   Color _statusColor(JellyfinController jellyfin, AppColors colors) {
     if (jellyfin.connected) return colors.accent;
     if (jellyfin.error != null ||
@@ -291,6 +319,137 @@ class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
   }
 }
 
+class _PlaybackNotificationRow extends StatefulWidget {
+  const _PlaybackNotificationRow({required this.playback});
+
+  final JellyfinPlaybackController playback;
+
+  @override
+  State<_PlaybackNotificationRow> createState() =>
+      _PlaybackNotificationRowState();
+}
+
+class _PlaybackNotificationRowState extends State<_PlaybackNotificationRow>
+    with WidgetsBindingObserver {
+  bool? _granted;
+
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addObserver(this);
+    unawaited(_check());
+  }
+
+  @override
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    super.dispose();
+  }
+
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed) {
+      unawaited(_check());
+    }
+  }
+
+  Future<void> _check() async {
+    final granted = await widget.playback.notificationPermissionGranted();
+    if (mounted) setState(() => _granted = granted);
+  }
+
+  Future<void> _request() async {
+    final granted = await widget.playback.requestNotificationPermission();
+    if (mounted) setState(() => _granted = granted);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final granted = _granted;
+    if (granted == null) return const SizedBox.shrink();
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    return Row(
+      children: [
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('Playback notifications', style: theme.textTheme.bodyLarge),
+              const SizedBox(height: 2),
+              Text(
+                granted
+                    ? 'Allowed. Android shows the media controls.'
+                    : 'Hidden while Android notifications are off.',
+                maxLines: 2,
+                overflow: TextOverflow.ellipsis,
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: granted ? colors.inkMuted : colors.inkFaint,
+                ),
+              ),
+            ],
+          ),
+        ),
+        if (!granted)
+          TextButton(
+            onPressed: () => unawaited(_request()),
+            style: TextButton.styleFrom(foregroundColor: colors.accent),
+            child: const Text('Allow'),
+          ),
+      ],
+    );
+  }
+}
+
+class _GenreRow extends StatelessWidget {
+  const _GenreRow({required this.controller, required this.onChoose});
+
+  final JellyfinController controller;
+  final VoidCallback onChoose;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final selections = controller.genreSelections;
+    final value = selections.isEmpty
+        ? (controller.configured
+              ? 'Not chosen — the Random row is hidden'
+              : 'Pair Jellyfin first')
+        : selections.map((selection) => selection.label).join(', ');
+
+    return Row(
+      children: [
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('Random genres', style: theme.textTheme.bodyLarge),
+              const SizedBox(height: 2),
+              Text(
+                value,
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: controller.configured
+                      ? colors.inkMuted
+                      : colors.inkFaint,
+                ),
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(width: 8),
+        TextButton(
+          onPressed: controller.configured ? onChoose : null,
+          style: TextButton.styleFrom(foregroundColor: colors.accent),
+          child: const Text('Choose'),
+        ),
+      ],
+    );
+  }
+}
+
 class _QuickConnectCode extends StatelessWidget {
   const _QuickConnectCode({required this.controller});
 

+ 33 - 12
lib/features/settings/settings_screen.dart

@@ -14,6 +14,7 @@ import '../../data/models/slot_type.dart';
 import '../apps/app_picker_sheet.dart';
 import '../home/launcher_controller.dart';
 import '../home/system_controller.dart';
+import '../jellyfin/jellyfin_playback_controller.dart';
 import 'update_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
@@ -24,7 +25,9 @@ import 'speed_settings_card.dart';
 import 'temperature_settings_card.dart';
 
 class SettingsScreen extends StatelessWidget {
-  const SettingsScreen({super.key});
+  const SettingsScreen({super.key, this.playback});
+
+  final JellyfinPlaybackController? playback;
 
   @override
   Widget build(BuildContext context) {
@@ -223,7 +226,16 @@ class SettingsScreen extends StatelessWidget {
                             _ShortcutToggleRow(
                               slot: slot,
                               launcher: launcher,
-                              onPick: () => showAppPicker(context, slot),
+                              canToggle:
+                                  slot.isAppSlot || launcher.jellyfinConfigured,
+                              onPick: slot.isAppSlot
+                                  ? () => showAppPicker(context, slot)
+                                  : null,
+                              subtitle: slot == SlotType.jellyfin
+                                  ? launcher.jellyfinConfigured
+                                        ? 'Music library'
+                                        : 'Configure Jellyfin first'
+                                  : null,
                             ),
                           Divider(color: colors.border, height: 30),
                           _HomeRoleRow(launcher: launcher),
@@ -246,7 +258,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 12),
                     SurfaceCard(
                       padding: const EdgeInsets.fromLTRB(26, 22, 26, 20),
-                      child: const JellyfinSettingsCard(),
+                      child: JellyfinSettingsCard(playback: playback),
                     ),
                     const SizedBox(height: 30),
                     _SectionTitle(
@@ -939,12 +951,16 @@ class _ShortcutToggleRow extends StatelessWidget {
   const _ShortcutToggleRow({
     required this.slot,
     required this.launcher,
-    required this.onPick,
+    this.onPick,
+    this.subtitle,
+    this.canToggle = true,
   });
 
   final SlotType slot;
   final LauncherController launcher;
-  final VoidCallback onPick;
+  final VoidCallback? onPick;
+  final String? subtitle;
+  final bool canToggle;
 
   @override
   Widget build(BuildContext context) {
@@ -952,6 +968,8 @@ class _ShortcutToggleRow extends StatelessWidget {
     final theme = Theme.of(context);
     final enabled = launcher.isSlotEnabled(slot);
     final assignment = launcher.assignmentFor(slot);
+    final label = subtitle ?? assignment?.label ?? 'Select app';
+    final showPickPrompt = canToggle && slot.isAppSlot && assignment == null;
 
     return Row(
       children: [
@@ -960,13 +978,13 @@ class _ShortcutToggleRow extends StatelessWidget {
             color: Colors.transparent,
             child: InkWell(
               borderRadius: BorderRadius.circular(14),
-              onTap: onPick,
+              onTap: canToggle ? onPick : null,
               child: Padding(
                 padding: const EdgeInsets.symmetric(vertical: 6),
                 child: Row(
                   children: [
                     Opacity(
-                      opacity: enabled ? 1 : 0.45,
+                      opacity: enabled ? 1 : (canToggle ? 0.45 : 0.3),
                       child: Container(
                         width: 38,
                         height: 38,
@@ -996,16 +1014,16 @@ class _ShortcutToggleRow extends StatelessWidget {
                           ),
                           const SizedBox(height: 2),
                           Text(
-                            assignment?.label ?? 'Select app',
+                            label,
                             maxLines: 1,
                             overflow: TextOverflow.ellipsis,
                             style: theme.textTheme.bodySmall?.copyWith(
-                              color: !enabled
+                              color: !canToggle || !enabled
                                   ? colors.inkFaint
-                                  : assignment == null
+                                  : showPickPrompt
                                   ? colors.accent
                                   : colors.inkMuted,
-                              fontWeight: assignment == null && enabled
+                              fontWeight: showPickPrompt
                                   ? FontWeight.w600
                                   : FontWeight.w400,
                             ),
@@ -1022,7 +1040,10 @@ class _ShortcutToggleRow extends StatelessWidget {
         const SizedBox(width: 8),
         SettingsSwitch(
           value: enabled,
-          semanticsLabel: enabled
+          enabled: canToggle,
+          semanticsLabel: !canToggle
+              ? '${slot.label} is not configured'
+              : enabled
               ? 'Hide ${slot.label} on Home'
               : 'Show ${slot.label} on Home',
           onChanged: (value) async {

+ 19 - 9
lib/features/settings/settings_widgets.dart

@@ -9,11 +9,13 @@ class SettingsSwitch extends StatelessWidget {
     required this.value,
     required this.onChanged,
     required this.semanticsLabel,
+    this.enabled = true,
   });
 
   final bool value;
   final ValueChanged<bool> onChanged;
   final String semanticsLabel;
+  final bool enabled;
 
   @override
   Widget build(BuildContext context) {
@@ -22,18 +24,26 @@ class SettingsSwitch extends StatelessWidget {
       label: semanticsLabel,
       child: Switch(
         value: value,
-        onChanged: onChanged,
-        thumbColor: WidgetStateProperty.resolveWith<Color?>(
-          (states) => states.contains(WidgetState.selected)
+        onChanged: enabled ? onChanged : null,
+        thumbColor: WidgetStateProperty.resolveWith<Color?>((states) {
+          if (states.contains(WidgetState.disabled)) {
+            return colors.inkFaint.withValues(alpha: 0.45);
+          }
+          return states.contains(WidgetState.selected)
               ? colors.accentInk
-              : colors.inkFaint,
-        ),
-        trackColor: WidgetStateProperty.resolveWith<Color?>(
-          (states) => states.contains(WidgetState.selected)
+              : colors.inkFaint;
+        }),
+        trackColor: WidgetStateProperty.resolveWith<Color?>((states) {
+          if (states.contains(WidgetState.disabled)) {
+            return colors.surfaceStrong.withValues(alpha: 0.5);
+          }
+          return states.contains(WidgetState.selected)
               ? colors.accent
-              : colors.surfaceStrong,
+              : colors.surfaceStrong;
+        }),
+        trackOutlineColor: WidgetStatePropertyAll(
+          colors.border.withValues(alpha: enabled ? 1 : 0.5),
         ),
-        trackOutlineColor: WidgetStatePropertyAll(colors.border),
       ),
     );
   }

+ 21 - 3
lib/main.dart

@@ -7,6 +7,8 @@ import 'app.dart';
 import 'core/display_controller.dart';
 import 'core/theme/theme_controller.dart';
 import 'data/repositories/settings_repository.dart';
+import 'data/services/jellyfin_library_service.dart';
+import 'data/services/jellyfin_playback_bridge.dart';
 import 'data/services/jellyfin_service.dart';
 import 'data/services/native_bridge.dart';
 import 'data/services/pin_service.dart';
@@ -18,6 +20,7 @@ import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/telemetry_settings_controller.dart';
 import 'features/home/weather_controller.dart';
+import 'features/jellyfin/jellyfin_playback_controller.dart';
 import 'features/settings/jellyfin_controller.dart';
 import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
@@ -50,8 +53,7 @@ Future<void> main() async {
   final themeController = ThemeController(settings);
   final displayController = DisplayController(platform, settings);
   await displayController.initialize();
-  final launcherController = LauncherController(platform, settings)
-    ..initialize();
+  final launcherController = LauncherController(platform, settings);
   final systemController = SystemController(platform);
   final obdController = ObdController(telemetry, settings)..initialize();
   final gpsSpeedController = GpsSpeedController(telemetry);
@@ -64,12 +66,26 @@ Future<void> main() async {
   )..initialize();
   final lockController = LockController(PinService(FlutterSecureStore()));
   await lockController.initialize();
+  final jellyfinGateway = HttpJellyfinGateway();
   final jellyfinController = JellyfinController(
     settings,
-    HttpJellyfinGateway(),
+    jellyfinGateway,
     FlutterSecureStore(),
   );
   await jellyfinController.initialize();
+  final jellyfinLibrary = JellyfinLibraryService(
+    connectionProvider: () => jellyfinController.connection,
+    gateway: jellyfinGateway,
+  );
+  final jellyfinPlayback = JellyfinPlaybackController(
+    NativeJellyfinPlayback(),
+    jellyfinLibrary,
+    jellyfinController,
+  );
+  // The launcher needs the Jellyfin paired state before it reads the stored
+  // slot flags so an invisible slot cannot reserve one of the 16 buttons.
+  await launcherController.setJellyfinConfigured(jellyfinController.configured);
+  launcherController.initialize();
   jellyfinController.start();
 
   runApp(
@@ -84,6 +100,8 @@ Future<void> main() async {
         ChangeNotifierProvider.value(value: weatherController),
         ChangeNotifierProvider.value(value: telemetrySettingsController),
         ChangeNotifierProvider.value(value: jellyfinController),
+        Provider<JellyfinLibraryService>.value(value: jellyfinLibrary),
+        ChangeNotifierProvider.value(value: jellyfinPlayback),
         ChangeNotifierProvider.value(value: updateController),
         ChangeNotifierProvider.value(value: lockController),
       ],

+ 1 - 1
pubspec.yaml

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

+ 95 - 0
test/jellyfin_album_screen_test.dart

@@ -0,0 +1,95 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/home/system_controller.dart';
+import 'package:cardash_launcher/features/home/widgets/volume_card.dart';
+import 'package:cardash_launcher/features/jellyfin/jellyfin_album_screen.dart';
+import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+
+import 'jellyfin_fakes.dart';
+
+void main() {
+  testWidgets('shows the album, track list and transport, and changes tracks', (
+    tester,
+  ) async {
+    await tester.binding.setSurfaceSize(const Size(1280, 800));
+    addTearDown(() => tester.binding.setSurfaceSize(null));
+
+    final jellyfin = await configuredJellyfinController();
+    final gateway = FakeJellyfinLibraryGateway()
+      ..tracks = {
+        'album-1': [
+          track('t1', albumId: 'album-1', albumName: 'Album 1', index: 1),
+          track('t2', albumId: 'album-1', albumName: 'Album 1', index: 2),
+          track('t3', albumId: 'album-1', albumName: 'Album 1', index: 3),
+        ],
+      };
+    final library = JellyfinLibraryService(
+      connectionProvider: () => jellyfin.connection,
+      gateway: gateway,
+    );
+    final platform = FakeJellyfinPlaybackPlatform();
+    final playback = JellyfinPlaybackController(
+      platform,
+      library,
+      jellyfin,
+      pollInterval: const Duration(hours: 1),
+    );
+    await playback.playAlbum(album('album-1'));
+
+    final system = SystemController(FakeSystemPlatform());
+
+    await tester.pumpWidget(
+      MultiProvider(
+        providers: [
+          Provider<JellyfinLibraryService>.value(value: library),
+          ChangeNotifierProvider<JellyfinController>.value(value: jellyfin),
+          ChangeNotifierProvider<JellyfinPlaybackController>.value(
+            value: playback,
+          ),
+          ChangeNotifierProvider<SystemController>.value(value: system),
+        ],
+        child: MaterialApp(
+          theme: AppTheme.light(),
+          home: JellyfinAlbumScreen(album: album('album-1')),
+        ),
+      ),
+    );
+    await tester.pumpAndSettle();
+
+    expect(find.text('Album album-1'), findsOneWidget);
+    expect(find.text('Tracks'), findsOneWidget);
+    expect(find.text('Track t1'), findsWidgets);
+    expect(find.text('Track t2'), findsOneWidget);
+    expect(find.text('Track t3'), findsOneWidget);
+    expect(find.text('Now playing'), findsOneWidget);
+    expect(find.byType(VolumeCard), findsOneWidget);
+    expect(find.bySemanticsLabel('Previous track'), findsOneWidget);
+    expect(find.bySemanticsLabel('Next track'), findsOneWidget);
+    expect(tester.takeException(), isNull);
+
+    await tester.tap(find.bySemanticsLabel('Next track'));
+    await tester.pumpAndSettle();
+    expect(platform.commands, contains('next'));
+    expect(playback.currentItem?.trackId, 't2');
+    expect(find.text('Track t2'), findsWidgets);
+
+    await tester.tap(find.text('Track t3'));
+    await tester.pumpAndSettle();
+    expect(platform.commands, contains('seekToIndex:2'));
+    expect(playback.currentItem?.trackId, 't3');
+
+    await tester.tap(find.bySemanticsLabel('Previous track'));
+    await tester.pumpAndSettle();
+    expect(platform.commands, contains('previous'));
+    expect(playback.currentItem?.trackId, 't2');
+
+    await tester.pumpWidget(const SizedBox.shrink());
+    playback.dispose();
+    jellyfin.dispose();
+    system.dispose();
+  });
+}

+ 175 - 0
test/jellyfin_dashboard_controller_test.dart

@@ -0,0 +1,175 @@
+import 'package:cardash_launcher/data/models/jellyfin_library.dart';
+import 'package:cardash_launcher/data/models/jellyfin_models.dart';
+import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/jellyfin/jellyfin_dashboard_controller.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'jellyfin_fakes.dart';
+
+class _Fixture {
+  _Fixture({
+    required this.jellyfin,
+    required this.gateway,
+    required this.library,
+    required this.dashboard,
+  });
+
+  final JellyfinController jellyfin;
+  final FakeJellyfinLibraryGateway gateway;
+  final JellyfinLibraryService library;
+  final JellyfinDashboardController dashboard;
+}
+
+Future<_Fixture> _fixture({List<String> genres = const []}) async {
+  final jellyfin = await configuredJellyfinController();
+  await jellyfin.setGenreSelections([
+    for (final id in genres) JellyfinGenreSelection(id: id, label: id),
+  ]);
+  final gateway = FakeJellyfinLibraryGateway()
+    ..played = [
+      track(
+        'a2',
+        albumId: 'album-a',
+        albumName: 'Album A',
+        lastPlayed: DateTime.utc(2026, 10, 31, 12),
+      ),
+      track('a1', albumId: 'album-a', albumName: 'Album A'),
+      track('b1', albumId: 'album-b', albumName: 'Album B'),
+      track('c1', albumId: 'album-c', albumName: 'Album C'),
+      track('d1', albumId: 'album-d', albumName: 'Album D'),
+      track('e1', albumId: 'album-e', albumName: 'Album E'),
+      track('f1', albumId: 'album-f', albumName: 'Album F'),
+    ]
+    ..added = [
+      album('new-1'),
+      album('new-2'),
+      album('new-3'),
+      album('new-4'),
+      album('new-5'),
+    ]
+    ..random = [album('r1'), album('r2'), album('r3'), album('r4'), album('r5')]
+    ..genres = const ['Metal', 'Melodic Metal', 'Метъл', 'Jazz', 'Рок'];
+  final library = testLibraryService(jellyfin, gateway);
+  final dashboard = JellyfinDashboardController(
+    library: library,
+    jellyfin: jellyfin,
+  );
+  return _Fixture(
+    jellyfin: jellyfin,
+    gateway: gateway,
+    library: library,
+    dashboard: dashboard,
+  );
+}
+
+void main() {
+  test('loads three rows and expands only the selected genres', () async {
+    final fixture = await _fixture(genres: ['metal', 'jazz']);
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.dashboard.load();
+
+    expect(fixture.dashboard.hasLoaded, isTrue);
+    expect(fixture.dashboard.loading, isFalse);
+    expect(fixture.dashboard.error, isNull);
+    expect(fixture.dashboard.latestPlayed.length, 6);
+    expect(fixture.dashboard.latestPlayed.first.id, 'album-a');
+    expect(fixture.dashboard.latestAdded.length, 5);
+    expect(fixture.dashboard.random.length, 5);
+    expect(
+      fixture.gateway.lastRandomGenres,
+      containsAll(['Metal', 'Melodic Metal', 'Метъл', 'Jazz']),
+    );
+    expect(fixture.gateway.lastRandomGenres, isNot(contains('Рок')));
+  });
+
+  test('no selected genres means no random query', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.dashboard.load();
+
+    expect(fixture.dashboard.hasGenres, isFalse);
+    expect(fixture.dashboard.random, isEmpty);
+    expect(fixture.gateway.randomCalls, 0);
+  });
+
+  test('shuffle replaces the random row', () async {
+    final fixture = await _fixture(genres: ['metal']);
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.dashboard.load();
+    expect(fixture.dashboard.random.first.id, 'r1');
+
+    fixture.gateway.random = [album('r9'), album('r10')];
+    await fixture.dashboard.shuffle();
+
+    expect(fixture.dashboard.random.map((entry) => entry.id), ['r9', 'r10']);
+    expect(fixture.gateway.randomCalls, 2);
+    expect(fixture.dashboard.randomLoading, isFalse);
+  });
+
+  test('markPlayed moves an album to the front and trims to five', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+    await fixture.dashboard.load();
+
+    fixture.dashboard.markPlayed(album('album-a'));
+
+    expect(fixture.dashboard.latestPlayed.first.id, 'album-a');
+    expect(fixture.dashboard.latestPlayed.length, 6);
+  });
+
+  test('a server error is surfaced with retry state', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+    fixture.gateway.error = JellyfinUnreachable('offline');
+
+    await fixture.dashboard.load();
+
+    expect(fixture.dashboard.hasLoaded, isTrue);
+    expect(fixture.dashboard.loading, isFalse);
+    expect(fixture.dashboard.error, contains('unreachable'));
+  });
+
+  test('a random-only failure keeps the latest rows', () async {
+    final fixture = await _fixture(genres: ['metal']);
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+    fixture.gateway.randomError = JellyfinUnreachable('offline');
+
+    await fixture.dashboard.load();
+
+    expect(fixture.dashboard.error, isNull);
+    expect(fixture.dashboard.randomError, contains('unreachable'));
+    expect(fixture.dashboard.latestPlayed, isNotEmpty);
+    expect(fixture.dashboard.latestAdded, isNotEmpty);
+    expect(fixture.dashboard.random, isEmpty);
+  });
+
+  test('no connection reports a pairing error', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+    final unconfigured = JellyfinLibraryService(
+      connectionProvider: () => null,
+      gateway: fixture.gateway,
+    );
+    final dashboard = JellyfinDashboardController(
+      library: unconfigured,
+      jellyfin: fixture.jellyfin,
+    );
+    addTearDown(dashboard.dispose);
+
+    await dashboard.load();
+
+    expect(dashboard.hasLoaded, isTrue);
+    expect(dashboard.error, contains('Pair Jellyfin'));
+  });
+}

+ 145 - 0
test/jellyfin_dashboard_screen_test.dart

@@ -0,0 +1,145 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/jellyfin_library.dart';
+import 'package:cardash_launcher/data/models/jellyfin_models.dart';
+import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/jellyfin/jellyfin_dashboard_screen.dart';
+import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+
+import 'jellyfin_fakes.dart';
+
+class _Fixture {
+  _Fixture({
+    required this.jellyfin,
+    required this.gateway,
+    required this.library,
+    required this.playback,
+  });
+
+  final JellyfinController jellyfin;
+  final FakeJellyfinLibraryGateway gateway;
+  final JellyfinLibraryService library;
+  final JellyfinPlaybackController playback;
+}
+
+Future<_Fixture> _fixture({List<String> genres = const ['metal']}) async {
+  final jellyfin = await configuredJellyfinController();
+  await jellyfin.setGenreSelections([
+    for (final id in genres) JellyfinGenreSelection(id: id, label: id),
+  ]);
+  final gateway = FakeJellyfinLibraryGateway()
+    ..played = [
+      track('a2', albumId: 'album-a', albumName: 'Album A'),
+      track('a1', albumId: 'album-a', albumName: 'Album A'),
+      track('b1', albumId: 'album-b', albumName: 'Album B'),
+      track('c1', albumId: 'album-c', albumName: 'Album C'),
+      track('d1', albumId: 'album-d', albumName: 'Album D'),
+      track('e1', albumId: 'album-e', albumName: 'Album E'),
+      track('f1', albumId: 'album-f', albumName: 'Album F'),
+    ]
+    ..added = [
+      album('new-1'),
+      album('new-2'),
+      album('new-3'),
+      album('new-4'),
+      album('new-5'),
+      album('new-6'),
+    ]
+    ..random = [
+      album('r1'),
+      album('r2'),
+      album('r3'),
+      album('r4'),
+      album('r5'),
+      album('r6'),
+    ]
+    ..genres = const ['Metal', 'Jazz'];
+  final library = testLibraryService(jellyfin, gateway);
+  final playback = JellyfinPlaybackController(
+    FakeJellyfinPlaybackPlatform(),
+    library,
+    jellyfin,
+    pollInterval: const Duration(hours: 1),
+  );
+  return _Fixture(
+    jellyfin: jellyfin,
+    gateway: gateway,
+    library: library,
+    playback: playback,
+  );
+}
+
+Future<void> _pump(WidgetTester tester, _Fixture fixture) async {
+  await tester.binding.setSurfaceSize(const Size(1280, 800));
+  addTearDown(() => tester.binding.setSurfaceSize(null));
+  addTearDown(fixture.playback.dispose);
+  addTearDown(fixture.jellyfin.dispose);
+
+  await tester.pumpWidget(
+    MultiProvider(
+      providers: [
+        Provider<JellyfinLibraryService>.value(value: fixture.library),
+        ChangeNotifierProvider<JellyfinController>.value(
+          value: fixture.jellyfin,
+        ),
+        ChangeNotifierProvider<JellyfinPlaybackController>.value(
+          value: fixture.playback,
+        ),
+      ],
+      child: MaterialApp(
+        theme: AppTheme.light(),
+        home: const JellyfinDashboardScreen(),
+      ),
+    ),
+  );
+  await tester.pumpAndSettle();
+}
+
+void main() {
+  testWidgets('renders six cards per row on a wide screen', (tester) async {
+    final fixture = await _fixture();
+    await _pump(tester, fixture);
+
+    expect(find.text('Recently played'), findsOneWidget);
+    expect(find.text('Recently added'), findsOneWidget);
+    expect(find.text('Random'), findsOneWidget);
+    expect(find.text('Artists'), findsOneWidget);
+    expect(find.text('Album A'), findsWidgets);
+    expect(find.text('Album F'), findsOneWidget);
+    expect(find.text('Album new-1'), findsOneWidget);
+    expect(find.text('Album new-6'), findsOneWidget);
+    expect(find.text('Album r1'), findsOneWidget);
+    expect(find.text('Album r6'), findsOneWidget);
+    expect(find.bySemanticsLabel('Shuffle random albums'), findsOneWidget);
+    expect(tester.takeException(), isNull);
+  });
+
+  testWidgets('shows the genre prompt when no genres are selected', (
+    tester,
+  ) async {
+    final fixture = await _fixture(genres: const []);
+    await _pump(tester, fixture);
+
+    expect(find.text('Choose up to 5 genres in Settings.'), findsOneWidget);
+    expect(find.bySemanticsLabel('Shuffle random albums'), findsNothing);
+    expect(fixture.gateway.randomCalls, 0);
+  });
+
+  testWidgets('shows an inline error with retry when the server fails', (
+    tester,
+  ) async {
+    final fixture = await _fixture();
+    fixture.gateway.error = JellyfinUnreachable('offline');
+    await _pump(tester, fixture);
+
+    expect(
+      find.text('Jellyfin is unreachable. Check the server and try again.'),
+      findsOneWidget,
+    );
+    expect(find.text('Retry'), findsOneWidget);
+    expect(find.bySemanticsLabel('Shuffle random albums'), findsNothing);
+  });
+}

+ 422 - 0
test/jellyfin_fakes.dart

@@ -0,0 +1,422 @@
+import 'dart:typed_data';
+
+import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/jellyfin_library.dart';
+import 'package:cardash_launcher/data/models/jellyfin_models.dart';
+import 'package:cardash_launcher/data/models/jellyfin_playback.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/update_info.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/data/services/jellyfin_playback_platform.dart';
+import 'package:cardash_launcher/data/services/jellyfin_service.dart';
+import 'package:cardash_launcher/data/services/native_bridge.dart';
+import 'package:cardash_launcher/data/services/secure_store.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+final testJellyfinConnection = JellyfinConnection(
+  baseUrl: Uri.parse('https://jellyfin.example.com/'),
+  accessToken: 'test-token',
+  userId: 'user-1',
+  deviceId: 'device-1',
+  userName: 'wasp',
+);
+
+JellyfinAlbum album(String id, {String? imageTag}) {
+  return JellyfinAlbum(
+    id: id,
+    name: 'Album $id',
+    artist: 'Artist $id',
+    imageTag: imageTag,
+  );
+}
+
+JellyfinTrack track(
+  String id, {
+  String albumId = 'album-1',
+  String albumName = 'Album 1',
+  String artist = 'Artist 1',
+  int index = 1,
+  Duration duration = const Duration(minutes: 3),
+  String? mediaSourceId,
+  DateTime? lastPlayed,
+}) {
+  return JellyfinTrack(
+    id: id,
+    name: 'Track $id',
+    albumId: albumId,
+    album: albumName,
+    artist: artist,
+    indexNumber: index,
+    parentIndexNumber: 1,
+    duration: duration,
+    mediaSourceId: mediaSourceId ?? 'source-$id',
+    container: 'mp3',
+    codec: 'mp3',
+    lastPlayed: lastPlayed,
+  );
+}
+
+class MemorySecureStore implements SecureStore {
+  final values = <String, String>{};
+
+  @override
+  Future<String?> read(String key) async => values[key];
+
+  @override
+  Future<void> write(String key, String value) async {
+    values[key] = value;
+  }
+
+  @override
+  Future<void> delete(String key) async {
+    values.remove(key);
+  }
+}
+
+class FakeJellyfinGateway implements JellyfinGateway {
+  @override
+  Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
+    return const JellyfinServerInfo(name: 'Test server', version: '12.2.0');
+  }
+
+  @override
+  Future<JellyfinQuickConnect> startQuickConnect(
+    Uri baseUrl, {
+    required String deviceId,
+  }) async {
+    return const JellyfinQuickConnect(code: '123456', secret: 'secret');
+  }
+
+  @override
+  Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
+    return false;
+  }
+
+  @override
+  Future<JellyfinSession> authenticateQuickConnect(
+    Uri baseUrl,
+    String secret, {
+    required String deviceId,
+  }) async {
+    return const JellyfinSession(
+      accessToken: 'test-token',
+      userId: 'user-1',
+      userName: 'wasp',
+    );
+  }
+
+  @override
+  Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
+    return const JellyfinUser(id: 'user-1', name: 'wasp');
+  }
+
+  @override
+  void close() {}
+}
+
+class FakeSystemPlatform implements LauncherPlatform {
+  @override
+  Future<List<AppEntry>> listApps() async => const [];
+
+  @override
+  Future<void> launchApp({
+    required String packageName,
+    required String activityName,
+  }) async {}
+
+  @override
+  Future<bool> isDefaultHome() async => true;
+
+  @override
+  Future<bool> requestDefaultHome() async => true;
+
+  @override
+  Future<SystemStatus> getStatus() async =>
+      const SystemStatus(batteryLevel: 80, charging: false, wifi: true);
+
+  @override
+  Future<VolumeInfo> getVolume() async => const VolumeInfo(index: 5, max: 15);
+
+  @override
+  Future<void> setVolume(int index) async {}
+
+  @override
+  Future<bool> isMusicPlaying() async => false;
+
+  @override
+  Future<bool> sendMediaKey(String action) async => true;
+
+  @override
+  Future<MediaPlayback> getMediaState() async => const MediaPlayback();
+
+  @override
+  Future<bool> openNotificationAccessSettings() async => true;
+
+  @override
+  Future<void> setKeepScreenOn(bool enabled) async {}
+
+  @override
+  Future<bool> openUrl(String url) async => true;
+
+  @override
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
+    return const UpdateInstallResult(started: false, permissionRequired: false);
+  }
+}
+
+class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway {
+  List<JellyfinTrack> played = const [];
+  List<JellyfinAlbum> added = const [];
+  List<JellyfinAlbum> random = const [];
+  Map<String, List<JellyfinTrack>> tracks = const {};
+  List<String> libraryIds = const ['library-1'];
+  List<String> genres = const [];
+  Uint8List? artwork;
+  Object? error;
+  Object? randomError;
+
+  int playedCalls = 0;
+  int addedCalls = 0;
+  int randomCalls = 0;
+  int genreCalls = 0;
+  int trackCalls = 0;
+  int artworkCalls = 0;
+  List<String>? lastRandomGenres;
+  String? lastAlbumId;
+
+  void resetCounters() {
+    playedCalls = 0;
+    addedCalls = 0;
+    randomCalls = 0;
+    genreCalls = 0;
+    trackCalls = 0;
+    artworkCalls = 0;
+  }
+
+  @override
+  Future<List<JellyfinTrack>> playedTracks(
+    JellyfinConnection connection, {
+    int limit = 50,
+  }) async {
+    playedCalls++;
+    if (error != null) throw error!;
+    return played.take(limit).toList(growable: false);
+  }
+
+  @override
+  Future<List<JellyfinAlbum>> latestAddedAlbums(
+    JellyfinConnection connection, {
+    int limit = 5,
+  }) async {
+    addedCalls++;
+    if (error != null) throw error!;
+    return added.take(limit).toList(growable: false);
+  }
+
+  @override
+  Future<List<JellyfinAlbum>> randomAlbums(
+    JellyfinConnection connection, {
+    required List<String> genres,
+    int limit = 5,
+  }) async {
+    randomCalls++;
+    lastRandomGenres = List<String>.from(genres);
+    if (randomError != null) throw randomError!;
+    return random.take(limit).toList(growable: false);
+  }
+
+  @override
+  Future<List<JellyfinTrack>> albumTracks(
+    JellyfinConnection connection,
+    String albumId,
+  ) async {
+    trackCalls++;
+    lastAlbumId = albumId;
+    if (error != null) throw error!;
+    return tracks[albumId] ?? const [];
+  }
+
+  @override
+  Future<List<String>> musicLibraryIds(JellyfinConnection connection) async {
+    return libraryIds;
+  }
+
+  @override
+  Future<List<String>> genreNames(
+    JellyfinConnection connection,
+    String libraryId,
+  ) async {
+    genreCalls++;
+    if (error != null) throw error!;
+    return genres;
+  }
+
+  @override
+  Future<Uint8List?> albumArtwork(
+    JellyfinConnection connection,
+    String albumId, {
+    String? tag,
+    int maxHeight = 400,
+  }) async {
+    artworkCalls++;
+    return artwork;
+  }
+}
+
+Future<JellyfinController> configuredJellyfinController({
+  FakeJellyfinGateway? gateway,
+  MemorySecureStore? store,
+}) async {
+  SharedPreferences.setMockInitialValues({
+    'jellyfin.enabled': true,
+    'jellyfin.serverUrl': 'https://jellyfin.example.com',
+    'jellyfin.userId': 'user-1',
+    'jellyfin.userName': 'wasp',
+    'jellyfin.serverName': 'Test server',
+  });
+  final prefs = await SharedPreferences.getInstance();
+  final settings = SettingsRepository(prefs);
+  final secure = store ?? MemorySecureStore();
+  secure.values[JellyfinController.tokenKey] = 'test-token';
+  final controller = JellyfinController(
+    settings,
+    gateway ?? FakeJellyfinGateway(),
+    secure,
+  );
+  await controller.initialize();
+  return controller;
+}
+
+JellyfinLibraryService testLibraryService(
+  JellyfinController controller,
+  FakeJellyfinLibraryGateway gateway,
+) {
+  return JellyfinLibraryService(
+    connectionProvider: () => controller.connection ?? testJellyfinConnection,
+    gateway: gateway,
+  );
+}
+
+class FakeJellyfinPlaybackPlatform implements JellyfinPlaybackPlatform {
+  JellyfinPlaybackQueue? queue;
+  JellyfinPlaybackSnapshot state = JellyfinPlaybackSnapshot.empty;
+  final List<String> commands = [];
+  int setQueueCalls = 0;
+  bool setQueueResult = true;
+  bool permissionGranted = false;
+
+  @override
+  Future<bool> setQueue(JellyfinPlaybackQueue queue) async {
+    commands.add('setQueue');
+    setQueueCalls++;
+    if (!setQueueResult) return false;
+    this.queue = queue;
+    state = snapshotAt(0, playing: true);
+    return true;
+  }
+
+  JellyfinPlaybackSnapshot snapshotAt(
+    int index, {
+    bool playing = false,
+    int positionMs = 0,
+  }) {
+    final current = queue;
+    if (current == null || current.items.isEmpty) {
+      return JellyfinPlaybackSnapshot.empty;
+    }
+    final safeIndex = index.clamp(0, current.items.length - 1);
+    final item = current.items[safeIndex];
+    return JellyfinPlaybackSnapshot(
+      available: true,
+      playing: playing,
+      itemId: item.trackId,
+      albumId: current.albumId,
+      title: item.title,
+      artist: item.artist,
+      album: item.album,
+      durationMs: item.durationMs,
+      positionMs: positionMs,
+      index: safeIndex,
+      hasPrevious: safeIndex > 0,
+      hasNext: safeIndex < current.items.length - 1,
+    );
+  }
+
+  @override
+  Future<bool> play() async {
+    commands.add('play');
+    state = state.copyWith(playing: true);
+    return true;
+  }
+
+  @override
+  Future<bool> pause() async {
+    commands.add('pause');
+    state = state.copyWith(playing: false);
+    return true;
+  }
+
+  @override
+  Future<bool> next() async {
+    commands.add('next');
+    final current = queue;
+    if (current == null || !state.hasNext) return false;
+    state = snapshotAt(state.index + 1, playing: state.playing, positionMs: 0);
+    return true;
+  }
+
+  @override
+  Future<bool> previous() async {
+    commands.add('previous');
+    if (!state.hasPrevious) return false;
+    state = snapshotAt(state.index - 1, playing: state.playing, positionMs: 0);
+    return true;
+  }
+
+  @override
+  Future<bool> seekToIndex(int index) async {
+    commands.add('seekToIndex:$index');
+    final current = queue;
+    if (current == null || index < 0 || index >= current.items.length) {
+      return false;
+    }
+    state = snapshotAt(index, playing: state.playing, positionMs: 0);
+    return true;
+  }
+
+  @override
+  Future<bool> seek(Duration position) async {
+    commands.add('seek:${position.inMilliseconds}');
+    state = state.copyWith(positionMs: position.inMilliseconds);
+    return true;
+  }
+
+  @override
+  Future<bool> stop() async {
+    commands.add('stop');
+    state = JellyfinPlaybackSnapshot.empty;
+    queue = null;
+    return true;
+  }
+
+  @override
+  Future<JellyfinPlaybackSnapshot> getState() async {
+    commands.add('getState');
+    return state;
+  }
+
+  @override
+  Future<bool> notificationPermissionGranted() async => permissionGranted;
+
+  @override
+  Future<bool> requestNotificationPermission() async {
+    permissionGranted = true;
+    return true;
+  }
+
+  @override
+  void dispose() {}
+}

+ 255 - 0
test/jellyfin_genre_catalog_test.dart

@@ -0,0 +1,255 @@
+import 'package:cardash_launcher/data/models/jellyfin_library.dart';
+import 'package:cardash_launcher/data/services/jellyfin_genre_catalog.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+const _userGenres = <String>[
+  '1989',
+  'Acoustic',
+  'Alternative',
+  'Alternative Metal',
+  'Alternative Rock',
+  'ambient',
+  'atmospheric black metal',
+  'Avant-Garde',
+  'avant-garde metal',
+  'Black',
+  'Black Metal',
+  'Blues',
+  'Blues Rock',
+  'Canzone Italiana',
+  'Classical',
+  'Comedy',
+  'Composer',
+  'Conductor',
+  'Country',
+  'country rock',
+  'Dance',
+  'dark ambient',
+  'dark folk',
+  'Death',
+  'Death Metal',
+  'death-doom metal',
+  'depressive black metal',
+  'Doom Metal',
+  'dungeon synth',
+  'Electro',
+  'electronic',
+  'Film Scores',
+  'Films/Games',
+  'Folk',
+  'Folk Metal',
+  'funeral doom metal',
+  'Funk',
+  'Fusion',
+  'glam metal',
+  'gothic',
+  'Gothic Doom Metal',
+  'Gothic Metal',
+  'Gothic Rock',
+  'groove metal',
+  'Grunge',
+  'Hard Rock',
+  'Hardcore',
+  'Heavy metal',
+  'Indie Rock',
+  'industrial metal',
+  'International Pop',
+  'Jazz',
+  'Latin',
+  'Latin Music',
+  'medieval',
+  'Melodic Black Metal',
+  'Melodic Death Metal',
+  'Melodic Metal',
+  'Metal',
+  'neofolk',
+  'New Age',
+  'Pagan',
+  'pagan black metal',
+  'pagan folk',
+  'Pop',
+  'Pop-Rock',
+  'Progressive',
+  'Progressive Metal',
+  'Progressive Rock',
+  'punk',
+  'R&B',
+  'ritual ambient',
+  'Rock',
+  'Rock & Roll',
+  'Rock Pop',
+  'Rock Singer-Songwriter',
+  'Rockabilly',
+  'Singer & Songwriter',
+  'Soul',
+  'Soul & Funk',
+  'Swing',
+  'Symphonic Metal',
+  'Symphonic Power Metal',
+  'symphonic rock',
+  'Thrash Metal',
+  'Viking',
+  'Viking Metal',
+  'Vocal jazz',
+  'Метъл',
+  'Рок',
+  'Хард рок',
+];
+
+void main() {
+  const catalog = JellyfinGenreCatalog();
+
+  test('groups the whole library without losing a raw genre', () {
+    final families = catalog.group(_userGenres);
+    final total = families.fold<int>(
+      0,
+      (sum, family) => sum + family.rawGenres.length,
+    );
+    expect(total, _userGenres.length);
+    final labels = families.map((family) => family.label).toList();
+    expect(labels.first, 'Metal');
+    expect(labels[1], 'Rock');
+    expect(labels[2], 'Jazz');
+    expect(labels, contains('1989'));
+    expect(labels, contains('Canzone Italiana'));
+  });
+
+  test('maps the requested Metal, Jazz and Rock variants', () {
+    final families = catalog.group(_userGenres);
+    final byLabel = {for (final family in families) family.label: family};
+    expect(
+      byLabel['Metal']!.rawGenres,
+      containsAll([
+        'Metal',
+        'Melodic Metal',
+        'Heavy metal',
+        'Folk Metal',
+        'Gothic Metal',
+        'Метъл',
+        'Black',
+        'Death',
+        'Viking',
+      ]),
+    );
+    expect(
+      byLabel['Rock']!.rawGenres,
+      containsAll([
+        'Rock',
+        'Hard Rock',
+        'Pop-Rock',
+        'Alternative',
+        'Alternative Rock',
+        'Grunge',
+        'Progressive',
+        'Rock & Roll',
+        'Rock Singer-Songwriter',
+        'Рок',
+        'Хард рок',
+      ]),
+    );
+    expect(
+      byLabel['Jazz']!.rawGenres,
+      containsAll(['Jazz', 'Vocal jazz', 'Fusion', 'Swing']),
+    );
+    expect(
+      byLabel['Pop']!.rawGenres,
+      containsAll(['Pop', 'Rock Pop', 'International Pop']),
+    );
+    expect(
+      byLabel['Folk']!.rawGenres,
+      containsAll(['Folk', 'dark folk', 'neofolk', 'pagan folk', 'medieval']),
+    );
+  });
+
+  test('keeps the head noun correct for hybrids', () {
+    expect(catalog.match('Pop-Rock').id, 'rock');
+    expect(catalog.match('Rock Pop').id, 'pop');
+    expect(catalog.match('Blues Rock').id, 'rock');
+    expect(catalog.match('Country Rock').id, 'rock');
+    expect(catalog.match('Folk Metal').id, 'metal');
+    expect(catalog.match('Gothic Metal').id, 'metal');
+    expect(catalog.match('Gothic Rock').id, 'rock');
+    expect(catalog.match('Symphonic Power Metal').id, 'metal');
+    expect(catalog.match('Progressive Metal').id, 'metal');
+    expect(catalog.match('Progressive Rock').id, 'rock');
+  });
+
+  test('maps the remaining canonical families', () {
+    final families = catalog.group(_userGenres);
+    final byLabel = {for (final family in families) family.label: family};
+    expect(byLabel['Blues']!.rawGenres, contains('Blues'));
+    expect(byLabel['Soul']!.rawGenres, containsAll(['Soul', 'Soul & Funk']));
+    expect(byLabel['R&B']!.rawGenres, contains('R&B'));
+    expect(
+      byLabel['Classical']!.rawGenres,
+      containsAll(['Classical', 'Composer', 'Conductor']),
+    );
+    expect(
+      byLabel['Soundtrack']!.rawGenres,
+      containsAll(['Film Scores', 'Films/Games']),
+    );
+    expect(
+      byLabel['Electronic']!.rawGenres,
+      containsAll(['Electro', 'electronic', 'Dance']),
+    );
+    expect(
+      byLabel['Ambient']!.rawGenres,
+      containsAll([
+        'ambient',
+        'dark ambient',
+        'ritual ambient',
+        'dungeon synth',
+        'New Age',
+      ]),
+    );
+    expect(byLabel['Punk']!.rawGenres, containsAll(['punk', 'Hardcore']));
+    expect(byLabel['Latin']!.rawGenres, containsAll(['Latin', 'Latin Music']));
+    expect(byLabel['Acoustic']!.rawGenres, contains('Acoustic'));
+    expect(byLabel['Avant-Garde']!.rawGenres, contains('Avant-Garde'));
+    expect(byLabel['Gothic']!.rawGenres, contains('gothic'));
+    expect(byLabel['Pagan']!.rawGenres, contains('Pagan'));
+    expect(
+      byLabel['Singer-Songwriter']!.rawGenres,
+      contains('Singer & Songwriter'),
+    );
+    expect(byLabel['Comedy']!.rawGenres, contains('Comedy'));
+  });
+
+  test('unknown tags stay standalone', () {
+    final match = catalog.match('Future Bass');
+    expect(match.id, 'raw:future bass');
+    expect(match.label, 'Future Bass');
+    final families = catalog.group(['Future Bass', 'Metal']);
+    expect(families.last.label, 'Future Bass');
+    expect(families.last.isStandalone, isTrue);
+  });
+
+  test('expands selected families to raw server tags', () {
+    final families = catalog.group(_userGenres);
+    final raw = catalog.rawGenresFor(
+      families: families,
+      selectedIds: ['metal', 'jazz'],
+    );
+    expect(raw, contains('Метъл'));
+    expect(raw, contains('Melodic Metal'));
+    expect(raw, contains('Jazz'));
+    expect(raw, contains('Swing'));
+    expect(raw, isNot(contains('Rock')));
+    expect(raw, isNot(contains('Pop')));
+  });
+
+  test('resolves saved selections and drops missing families', () {
+    final families = catalog.group(_userGenres);
+    final resolved = catalog.resolveSelections(
+      families: families,
+      selections: const [
+        JellyfinGenreSelection(id: 'metal', label: 'Old metal label'),
+        JellyfinGenreSelection(id: 'missing', label: 'Missing'),
+        JellyfinGenreSelection(id: 'raw:1989', label: '1989'),
+      ],
+    );
+    expect(resolved.length, 2);
+    expect(resolved.first.label, 'Metal');
+    expect(resolved.last.label, '1989');
+  });
+}

+ 82 - 0
test/jellyfin_genre_dialog_test.dart

@@ -0,0 +1,82 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/jellyfin_library.dart';
+import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_genre_dialog.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'jellyfin_fakes.dart';
+
+void main() {
+  testWidgets('groups genres, caps at five and returns the selection', (
+    tester,
+  ) async {
+    await tester.binding.setSurfaceSize(const Size(1024, 700));
+    addTearDown(() => tester.binding.setSurfaceSize(null));
+
+    final gateway = FakeJellyfinLibraryGateway()
+      ..genres = const ['Metal', 'Rock', 'Jazz', 'Pop', 'Folk', 'Blues'];
+    final library = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+    List<JellyfinGenreSelection>? result;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.light(),
+        home: Scaffold(
+          body: Builder(
+            builder: (context) => Center(
+              child: ElevatedButton(
+                onPressed: () async {
+                  result = await showJellyfinGenreDialog(
+                    context,
+                    library: library,
+                    selected: const [],
+                  );
+                },
+                child: const Text('Open genres'),
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    await tester.tap(find.text('Open genres'));
+    await tester.pumpAndSettle();
+
+    expect(find.text('Random genres'), findsOneWidget);
+    expect(find.text('0 / 5'), findsOneWidget);
+    expect(find.text('Metal'), findsOneWidget);
+    expect(find.text('Rock'), findsOneWidget);
+    expect(find.text('Jazz'), findsOneWidget);
+    expect(find.text('Pop'), findsOneWidget);
+    expect(find.text('Folk'), findsOneWidget);
+    expect(find.text('Blues'), findsOneWidget);
+
+    for (var index = 0; index < 5; index++) {
+      await tester.tap(find.byType(Checkbox).at(index));
+      await tester.pump();
+    }
+
+    expect(find.text('5 / 5'), findsOneWidget);
+    expect(find.text('Deselect one to choose another.'), findsOneWidget);
+    final sixth = tester.widget<Checkbox>(find.byType(Checkbox).at(5));
+    expect(sixth.onChanged, isNull);
+
+    await tester.tap(find.text('Save'));
+    await tester.pumpAndSettle();
+
+    expect(result, isNotNull);
+    expect(result!.length, 5);
+    expect(result!.map((entry) => entry.label), [
+      'Metal',
+      'Rock',
+      'Jazz',
+      'Pop',
+      'Folk',
+    ]);
+  });
+}

+ 226 - 0
test/jellyfin_library_service_test.dart

@@ -0,0 +1,226 @@
+import 'dart:typed_data';
+
+import 'package:cardash_launcher/data/models/jellyfin_library.dart';
+import 'package:cardash_launcher/data/models/jellyfin_models.dart';
+import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'jellyfin_fakes.dart';
+
+void main() {
+  test(
+    'recently played dedupes tracks by album and takes the newest five',
+    () async {
+      final gateway = FakeJellyfinLibraryGateway()
+        ..played = [
+          track(
+            'a2',
+            albumId: 'album-a',
+            albumName: 'Album A',
+            artist: 'Artist A',
+            lastPlayed: DateTime.utc(2026, 10, 31, 12),
+          ),
+          track(
+            'a1',
+            albumId: 'album-a',
+            albumName: 'Album A',
+            artist: 'Artist A',
+            lastPlayed: DateTime.utc(2026, 10, 31, 11),
+          ),
+          track('b1', albumId: 'album-b', albumName: 'Album B', artist: 'B'),
+          track('c1', albumId: 'album-c', albumName: 'Album C', artist: 'C'),
+          track('d1', albumId: 'album-d', albumName: 'Album D', artist: 'D'),
+          track('e1', albumId: 'album-e', albumName: 'Album E', artist: 'E'),
+          track('f1', albumId: 'album-f', albumName: 'Album F', artist: 'F'),
+        ];
+      final service = JellyfinLibraryService(
+        connectionProvider: () => testJellyfinConnection,
+        gateway: gateway,
+      );
+
+      final albums = await service.latestPlayed();
+
+      expect(albums.map((album) => album.id), [
+        'album-a',
+        'album-b',
+        'album-c',
+        'album-d',
+        'album-e',
+      ]);
+      expect(albums.first.name, 'Album A');
+      expect(albums.first.lastPlayed, DateTime.utc(2026, 10, 31, 12));
+      expect(gateway.playedCalls, 1);
+    },
+  );
+
+  test('latest added delegates to the album query', () async {
+    final gateway = FakeJellyfinLibraryGateway()
+      ..added = [album('one'), album('two')];
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+
+    final albums = await service.latestAdded(limit: 2);
+
+    expect(albums.map((entry) => entry.id), ['one', 'two']);
+    expect(gateway.addedCalls, 1);
+  });
+
+  test('genre families are cached and can be forced', () async {
+    final gateway = FakeJellyfinLibraryGateway()
+      ..genres = const ['Metal', 'Jazz', 'Рок'];
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+
+    final first = await service.genreFamilies();
+    expect(first.map((family) => family.label), ['Metal', 'Rock', 'Jazz']);
+    expect(gateway.genreCalls, 1);
+
+    await service.genreFamilies();
+    expect(gateway.genreCalls, 1);
+
+    await service.genreFamilies(force: true);
+    expect(gateway.genreCalls, 2);
+  });
+
+  test('random albums expand only the selected families', () async {
+    final gateway = FakeJellyfinLibraryGateway()
+      ..genres = const ['Metal', 'Melodic Metal', 'Метъл', 'Jazz', 'Рок']
+      ..random = [album('r1'), album('r2')];
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+
+    final albums = await service.randomAlbums(
+      selections: const [
+        JellyfinGenreSelection(id: 'metal', label: 'Metal'),
+        JellyfinGenreSelection(id: 'jazz', label: 'Jazz'),
+      ],
+    );
+
+    expect(albums.map((entry) => entry.id), ['r1', 'r2']);
+    expect(
+      gateway.lastRandomGenres,
+      containsAll(['Metal', 'Melodic Metal', 'Метъл', 'Jazz']),
+    );
+    expect(gateway.lastRandomGenres, isNot(contains('Рок')));
+  });
+
+  test('no selected genres means no random request', () async {
+    final gateway = FakeJellyfinLibraryGateway();
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+
+    final albums = await service.randomAlbums(selections: const []);
+
+    expect(albums, isEmpty);
+    expect(gateway.randomCalls, 0);
+  });
+
+  test('stream URL carries no token and uses the universal endpoint', () {
+    final gateway = FakeJellyfinLibraryGateway();
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+    final url = service.streamUrl(
+      track('a1', mediaSourceId: 'source-a1'),
+      maxStreamingBitrate: 320000,
+    );
+    final text = url.toString();
+
+    expect(text, contains('Audio/a1/universal'));
+    expect(text, contains('MediaSourceId=source-a1'));
+    expect(text, contains('MaxStreamingBitrate=320000'));
+    expect(text, contains('EnableRedirection=true'));
+    expect(text, isNot(contains('test-token')));
+    expect(text, isNot(contains('api_key')));
+  });
+
+  test('buildQueue produces a native queue with artwork and auth', () async {
+    final gateway = FakeJellyfinLibraryGateway()
+      ..tracks = {
+        'album-a': [
+          track('a1', duration: const Duration(seconds: 60)),
+          track('a2', duration: const Duration(seconds: 120)),
+        ],
+      }
+      ..artwork = Uint8List.fromList([1, 2, 3]);
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+
+    final queue = await service.buildQueue(
+      album('album-a', imageTag: 'tag-a'),
+      playSessionId: 'session-1',
+    );
+
+    expect(queue, isNotNull);
+    expect(queue!.items.length, 2);
+    expect(queue.items.first.trackId, 'a1');
+    expect(queue.items.first.durationMs, 60000);
+    expect(queue.items.first.url, contains('Audio/a1/universal'));
+    expect(queue.playSessionId, 'session-1');
+    expect(queue.authorization, contains('Token="test-token"'));
+    expect(queue.artwork, [1, 2, 3]);
+    expect(queue.items.first.url, isNot(contains('test-token')));
+  });
+
+  test(
+    'buildQueue returns null for an album with no playable tracks',
+    () async {
+      final gateway = FakeJellyfinLibraryGateway();
+      final service = JellyfinLibraryService(
+        connectionProvider: () => testJellyfinConnection,
+        gateway: gateway,
+      );
+
+      final queue = await service.buildQueue(album('empty'));
+
+      expect(queue, isNull);
+    },
+  );
+
+  test(
+    'image URL and headers are available without exposing the token in the URL',
+    () {
+      final gateway = FakeJellyfinLibraryGateway();
+      final service = JellyfinLibraryService(
+        connectionProvider: () => testJellyfinConnection,
+        gateway: gateway,
+      );
+      final url = service.imageUrl('album-a', 'tag-a', maxHeight: 300);
+      expect(url, isNotNull);
+      expect(url.toString(), contains('Items/album-a/Images/Primary'));
+      expect(url.toString(), contains('maxHeight=300'));
+      expect(url.toString(), contains('tag=tag-a'));
+      expect(url.toString(), isNot(contains('test-token')));
+      expect(
+        service.imageHeaders['Authorization'],
+        contains('Token="test-token"'),
+      );
+    },
+  );
+
+  test('a missing connection fails without touching the gateway', () async {
+    final gateway = FakeJellyfinLibraryGateway();
+    final service = JellyfinLibraryService(
+      connectionProvider: () => null,
+      gateway: gateway,
+    );
+
+    expect(service.isConfigured, isFalse);
+    await expectLater(
+      service.latestPlayed(),
+      throwsA(isA<JellyfinException>()),
+    );
+    expect(gateway.playedCalls, 0);
+  });
+}

+ 201 - 0
test/jellyfin_playback_controller_test.dart

@@ -0,0 +1,201 @@
+import 'dart:typed_data';
+
+import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'jellyfin_fakes.dart';
+
+class _Fixture {
+  _Fixture({
+    required this.jellyfin,
+    required this.gateway,
+    required this.library,
+    required this.platform,
+    required this.playback,
+  });
+
+  final JellyfinController jellyfin;
+  final FakeJellyfinLibraryGateway gateway;
+  final JellyfinLibraryService library;
+  final FakeJellyfinPlaybackPlatform platform;
+  final JellyfinPlaybackController playback;
+}
+
+Future<_Fixture> _fixture() async {
+  final jellyfin = await configuredJellyfinController();
+  final gateway = FakeJellyfinLibraryGateway()
+    ..tracks = {
+      'album-1': [
+        track('t1', albumId: 'album-1', albumName: 'Album 1', index: 1),
+        track('t2', albumId: 'album-1', albumName: 'Album 1', index: 2),
+        track('t3', albumId: 'album-1', albumName: 'Album 1', index: 3),
+      ],
+      'album-2': [
+        track('u1', albumId: 'album-2', albumName: 'Album 2', index: 1),
+        track('u2', albumId: 'album-2', albumName: 'Album 2', index: 2),
+      ],
+    }
+    ..artwork = Uint8List.fromList([1, 2, 3]);
+  final library = testLibraryService(jellyfin, gateway);
+  final platform = FakeJellyfinPlaybackPlatform();
+  final playback = JellyfinPlaybackController(
+    platform,
+    library,
+    jellyfin,
+    pollInterval: const Duration(hours: 1),
+  );
+  return _Fixture(
+    jellyfin: jellyfin,
+    gateway: gateway,
+    library: library,
+    platform: platform,
+    playback: playback,
+  );
+}
+
+void main() {
+  test('starts an album and exposes the current track', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    final error = await fixture.playback.playAlbum(album('album-1'));
+
+    expect(error, isNull);
+    expect(fixture.platform.setQueueCalls, 1);
+    expect(fixture.playback.isActive, isTrue);
+    expect(fixture.playback.playing, isTrue);
+    expect(fixture.playback.currentItem?.trackId, 't1');
+    expect(fixture.playback.mediaPlayback.title, 'Track t1');
+    expect(fixture.playback.queueItems.length, 3);
+    expect(fixture.platform.queue?.artwork, [1, 2, 3]);
+    expect(
+      fixture.platform.queue?.authorization,
+      contains('Token="test-token"'),
+    );
+  });
+
+  test('the same album is not rebuilt but a different album is', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.playback.playAlbum(album('album-1'));
+    await fixture.playback.playAlbum(album('album-1'));
+    expect(fixture.platform.setQueueCalls, 1);
+
+    await fixture.playback.playAlbum(album('album-2'));
+    expect(fixture.platform.setQueueCalls, 2);
+    expect(fixture.playback.currentItem?.trackId, 'u1');
+  });
+
+  test('toggle pauses and resumes without losing the position', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.playback.playAlbum(album('album-1'));
+    await fixture.playback.toggle();
+    expect(fixture.playback.playing, isFalse);
+    expect(fixture.platform.commands, contains('pause'));
+
+    await fixture.playback.toggle();
+    expect(fixture.playback.playing, isTrue);
+    expect(fixture.platform.commands, contains('play'));
+  });
+
+  test('next and previous move through the queue', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.playback.playAlbum(album('album-1'));
+    await fixture.playback.next();
+    expect(fixture.playback.currentItem?.trackId, 't2');
+    expect(fixture.playback.canGoPrevious, isTrue);
+
+    await fixture.playback.previous();
+    expect(fixture.playback.currentItem?.trackId, 't1');
+  });
+
+  test('selecting a track seeks to its queue index', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.playback.playAlbum(album('album-1'));
+    await fixture.playback.playTrackAt(2);
+
+    expect(fixture.platform.commands, contains('seekToIndex:2'));
+    expect(fixture.playback.currentItem?.trackId, 't3');
+    expect(fixture.playback.playing, isTrue);
+  });
+
+  test('tapping play after the end restarts at track one', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.playback.playAlbum(album('album-1'));
+    fixture.platform.state = fixture.platform.state.copyWith(
+      playing: false,
+      positionMs: fixture.platform.state.durationMs,
+    );
+    await fixture.playback.restore();
+    await fixture.playback.toggle();
+
+    expect(fixture.platform.commands, contains('seekToIndex:0'));
+    expect(fixture.playback.playing, isTrue);
+    expect(fixture.playback.snapshot.positionMs, 0);
+  });
+
+  test('stop clears the queue and snapshot', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    await fixture.playback.playAlbum(album('album-1'));
+    await fixture.playback.stop();
+
+    expect(fixture.playback.isActive, isFalse);
+    expect(fixture.playback.queueItems, isEmpty);
+    expect(fixture.playback.snapshot.available, isFalse);
+  });
+
+  test('notification permission delegates to the platform', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    expect(fixture.platform.permissionGranted, isFalse);
+    expect(await fixture.playback.notificationPermissionGranted(), isFalse);
+    expect(await fixture.playback.requestNotificationPermission(), isTrue);
+    expect(fixture.platform.permissionGranted, isTrue);
+  });
+
+  test('a failed queue start surfaces an error', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+    fixture.platform.setQueueResult = false;
+
+    final error = await fixture.playback.playAlbum(album('album-1'));
+
+    expect(error, isNotNull);
+    expect(fixture.playback.error, isNotNull);
+    expect(fixture.playback.isActive, isFalse);
+  });
+
+  test('an album with no playable tracks reports an error', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.playback.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+
+    final error = await fixture.playback.playAlbum(album('missing'));
+
+    expect(error, contains('No playable tracks'));
+    expect(fixture.playback.error, contains('No playable tracks'));
+  });
+}

+ 75 - 0
test/launcher_controller_test.dart

@@ -217,4 +217,79 @@ void main() {
     expect(second.pinnedApps.first.label, 'App 0');
     expect(second.homeButtonCount, 4);
   });
+
+  test('Jellyfin sits between Music and Radio and is opt-in', () async {
+    final controller = await _controller();
+    addTearDown(controller.dispose);
+
+    expect(SlotType.values.indexOf(SlotType.jellyfin), 2);
+    expect(controller.isSlotEnabled(SlotType.jellyfin), isFalse);
+    expect(controller.homeButtonCount, 4);
+    expect(
+      await controller.setSlotEnabled(SlotType.jellyfin, true),
+      contains('Pair Jellyfin'),
+    );
+  });
+
+  test(
+    'Jellyfin enables only when configured and clears on disconnect',
+    () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final settings = SettingsRepository(prefs);
+      final controller = LauncherController(
+        FakeLauncherPlatform(_apps(3)),
+        settings,
+      );
+      controller.initialize();
+      addTearDown(controller.dispose);
+
+      await controller.setJellyfinConfigured(true);
+      expect(await controller.setSlotEnabled(SlotType.jellyfin, true), isNull);
+      expect(controller.isSlotEnabled(SlotType.jellyfin), isTrue);
+      expect(controller.homeButtonCount, 5);
+      expect(settings.slotEnabled(SlotType.jellyfin), isTrue);
+
+      await controller.setJellyfinConfigured(false);
+      expect(controller.isSlotEnabled(SlotType.jellyfin), isFalse);
+      expect(controller.homeButtonCount, 4);
+      expect(settings.slotEnabled(SlotType.jellyfin), isFalse);
+
+      final second = LauncherController(
+        FakeLauncherPlatform(_apps(3)),
+        SettingsRepository(prefs),
+      );
+      await second.setJellyfinConfigured(true);
+      second.initialize();
+      addTearDown(second.dispose);
+      expect(second.isSlotEnabled(SlotType.jellyfin), isFalse);
+    },
+  );
+
+  test('Jellyfin never receives an app assignment or app launch', () async {
+    final controller = await _controller();
+    addTearDown(controller.dispose);
+    final app = _apps(1).first;
+
+    await controller.assign(SlotType.jellyfin, app);
+    expect(controller.assignmentFor(SlotType.jellyfin), isNull);
+    expect(await controller.launchSlot(SlotType.jellyfin), isNull);
+  });
+
+  test('Jellyfin counts toward the 16 button cap', () async {
+    final controller = await _controller();
+    addTearDown(controller.dispose);
+    final apps = _apps(12);
+    for (final app in apps) {
+      expect(await controller.addPinnedApp(app), isNull);
+    }
+    expect(controller.homeButtonCount, 16);
+
+    await controller.setJellyfinConfigured(true);
+    expect(
+      await controller.setSlotEnabled(SlotType.jellyfin, true),
+      contains('full'),
+    );
+    expect(controller.isSlotEnabled(SlotType.jellyfin), isFalse);
+  });
 }

+ 6 - 0
test/settings_screen_test.dart

@@ -237,6 +237,12 @@ void main() {
 
       expect(find.text('OBD-II'), findsOneWidget);
       expect(find.text('Jellyfin'), findsWidgets);
+      final jellyfinSwitch = find.descendant(
+        of: find.bySemanticsLabel('Jellyfin is not configured'),
+        matching: find.byType(Switch),
+      );
+      expect(jellyfinSwitch, findsOneWidget);
+      expect(tester.widget<Switch>(jellyfinSwitch).onChanged, isNull);
       expect(find.text('Speed'), findsOneWidget);
       expect(find.text('Temperature'), findsOneWidget);
       expect(find.text('Show speed'), findsOneWidget);

+ 29 - 0
test/shortcut_grid_test.dart

@@ -91,4 +91,33 @@ void main() {
     expect(last.bottom, moreOrLessEquals(grid.bottom, epsilon: 0.5));
     expect(wrap.center.dx, moreOrLessEquals(grid.center.dx, epsilon: 0.5));
   });
+
+  testWidgets('a long Jellyfin subtitle is clipped to the tile width', (
+    tester,
+  ) async {
+    await tester.binding.setSurfaceSize(const Size(1280, 400));
+    addTearDown(() => tester.binding.setSurfaceSize(null));
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.light(),
+        home: Scaffold(
+          body: Center(
+            child: ShortcutTile(
+              title: 'Jellyfin',
+              subtitle: 'Paradise Lost — Drown In Darkness - The Early Demos',
+              iconAsset: 'assets/icons/headphones.svg',
+              size: 196,
+              assigned: true,
+              onTap: () {},
+            ),
+          ),
+        ),
+      ),
+    );
+    await tester.pump();
+
+    expect(tester.getSize(find.byType(ShortcutTile)).width, 196);
+    expect(tester.takeException(), isNull);
+  });
 }