Răsfoiți Sursa

docs: plan Jellyfin 1.2.0 integration

Krasimir Kazakov 6 zile în urmă
părinte
comite
113e84e669
1 a modificat fișierele cu 861 adăugiri și 0 ștergeri
  1. 861 0
      docs/JELLYFIN_1.2.0_PLAN.md

+ 861 - 0
docs/JELLYFIN_1.2.0_PLAN.md

@@ -0,0 +1,861 @@
+# CarDash Jellyfin 1.2.0 — Dashboard, Album Page and Playback
+
+Status: **approved for implementation** (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
+6-album / 24-track / 24-raw-genre test library.
+
+This document is the implementation plan. The user-facing README and
+`docs/IMPLEMENTATION_PLAN.md` are updated in the same commit as the behavior
+changes.
+
+## 1. Approved product decisions
+
+- Jellyfin becomes an optional **Music** source, not just a Settings section.
+- A new fixed **Jellyfin** Home button appears **after Music and before Radio**
+  when all of the following are true:
+  - the Jellyfin integration is enabled,
+  - a server URL and access token are saved ("configured"),
+  - the Home button's switch is on.
+- The Home switch lives in **Settings → General → Home buttons**, next to the
+  Navigation / Music / Radio switches.
+  - It is a normal `SettingsSwitch` (not a literal checkbox).
+  - When Jellyfin is disabled or not configured, the switch is disabled and
+    shown off, and the Home button is hidden.
+  - Turning the integration off, clearing the pairing, or a rejected token
+    clears the saved Home-button preference. Re-pairing does not silently turn
+    it back on.
+  - The button counts toward the 16-button cap. Enabling it when the Home
+    screen is full shows the existing "Home screen is full" message.
+- The Jellyfin Home button:
+  - is titled **Jellyfin**, uses `assets/icons/headphones.svg`,
+  - subtitle is **Music library** when idle and **Artist — Album** while a
+    Jellyfin queue is active,
+  - shows the playing equalizer while Jellyfin is playing,
+  - has **no long-press action**,
+  - opens the Jellyfin dashboard.
+- The dashboard is a full-screen route with a back arrow, title **Jellyfin**,
+  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
+     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).
+- 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.
+- Tapping an album:
+  - if nothing is playing, or a different album is playing: replace the queue,
+    start the album from track 1, and push the album page,
+  - if the same album is already the active queue: push the album page without
+    restarting,
+  - if the same album had ended: start it again from track 1.
+- The album page contains:
+  - back button, album title title/artist header,
+  - a large album cover,
+  - the current track title/artist and progress,
+  - previous / play-pause / next controls,
+  - the existing volume slider + mute controls,
+  - the album's track list; tapping a track plays it,
+  - the current track highlighted.
+- Playback:
+  - in-app Media3 playback, not a hand-off to the Jellyfin app,
+  - continues with the screen off / another app in the foreground via a
+    `mediaPlayback` foreground service,
+  - pauses and keeps its position when another app takes audio focus,
+  - resumes after a short interruption (navigation prompt) only if it was
+    playing when focus was lost,
+  - does not auto-resume after another music app takes over,
+  - reports playback to Jellyfin so "Recently played" updates and Jellyfin shows
+    CarDash as the player,
+  - stops when the album ends (no autoplay, no repeat).
+- No offline library data in 1.2.0. If the server is unreachable, show an
+  inline error and Retry; selected genres still persist.
+- 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.
+
+## 2. Scope and non-goals
+
+In scope:
+
+- `Jellyfin` Home slot visibility, ordering, gating and 16-button cap.
+- Jellyfin library API for recently played, recently added, genres, random
+  albums, album tracks and artwork.
+- Genre grouping rules, persistence and picker.
+- Dashboard, album page, track list and transport controls.
+- Native Media3 queue, background playback, audio focus, media notification
+  and Jellyfin playback reporting.
+- Media state integration with the Home volume/play-pause card.
+- Settings copy, permissions, tests, docs and the 1.2.0 release.
+
+Out of scope for 1.2.0:
+
+- Artists tab and artist detail.
+- Offline downloads or cached library rows.
+- Search inside the Jellyfin library.
+- User-editable genre mapping.
+- Gapless/crossfade, equalizer, scrobbling beyond Jellyfin's own played state.
+- Multiple user profiles; the signed-in Quick Connect user is used.
+- Data-aware/metered streaming quality.
+- Jellyfin video playback.
+
+## 3. Verified Jellyfin 12.2 API contract
+
+Verified against the live 12.2 OpenAPI document
+(`/api-docs/openapi.json`) and a local 12.2.0 server. The app keeps using the
+existing `MediaBrowser` authorization header:
+
+```
+Authorization: MediaBrowser Client="CarDash", Device="CarDash",
+  DeviceId="<device-id>", Version="<app-version>", Token="<token>"
+```
+
+Notes that matter for the implementation:
+
+- `X-Emby-Token` and `api_key` were rejected for normal API calls on 12.2;
+  the `MediaBrowser ... Token="..."` header works.
+- 12.2 no longer exposes `/Users/{userId}/Items`. Use `/Items?userId=...`.
+- `/Genres` returns nothing without `parentId`; pass the music library's item
+  id, obtained from `/UserViews` where `CollectionType == "music"`.
+- `/Items` `genres` is **pipe-delimited** and multiple values are OR-ed.
+  Verified: `genres=Jazz|Rock` returned one Jazz and one Rock album.
+- `/Items/Latest` returns a raw JSON array, not a `QueryResult` object.
+- Album `UserData` is **not** updated by track playback in 12.2. The album-level
+  `filters=IsPlayed&sortBy=DatePlayed` query is not reliable for "recently
+  played". Derive the row from played **Audio** tracks sorted by `DatePlayed`,
+  dedupe by `AlbumId`, and take the newest 5.
+- Playback reporting marks the **track** played on `/Sessions/Playing/Stopped`;
+  `SessionId` is optional. The `MediaSourceId` is the track's
+  `MediaSources[0].Id`.
+- `/Audio/{id}/universal` with the `MediaBrowser` header returns
+  `audio/mpeg` directly for a local MP3, no redirect. The image endpoint also
+  accepts the header.
+- `UserViews` item fields: `Id`, `Name`, `CollectionType`.
+
+### 3.1 Recently played (derived from tracks)
+
+```
+GET {base}Items
+  ?userId={userId}
+  &recursive=true
+  &includeItemTypes=Audio
+  &filters=IsPlayed
+  &sortBy=DatePlayed
+  &sortOrder=Descending
+  &limit=50
+  &fields=Genres,AlbumArtist,Artists,Album,AlbumId,ParentId,RunTimeTicks
+  &enableUserData=true
+  &enableImageTypes=Primary
+  &imageTypeLimit=1
+```
+
+Dedupe in server order by `AlbumId` (fallback `ParentId`), keep the first
+occurrence, take 5. 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.
+
+### 3.2 Recently added
+
+```
+GET {base}Items/Latest
+  ?userId={userId}
+  &includeItemTypes=MusicAlbum
+  &limit=5
+  &fields=Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio
+  &enableUserData=true
+  &enableImageTypes=Primary
+  &imageTypeLimit=1
+```
+
+Response is a JSON array.
+
+### 3.3 Music library and genres
+
+```
+GET {base}UserViews?userId={userId}
+```
+
+Filter `Items[].CollectionType == "music"`. If there are several, merge genres
+and query albums without a `parentId` so all music libraries are covered.
+
+```
+GET {base}Genres?userId={userId}&parentId={musicLibraryId}
+```
+
+Returns `QueryResult<BaseItemDto>`; use `Items[].Name` as the raw genre strings.
+The picker groups these raw strings with the rules in section 5.
+
+### 3.4 Random albums
+
+```
+GET {base}Items
+  ?userId={userId}
+  &recursive=true
+  &includeItemTypes=MusicAlbum
+  &genres={raw-1|raw-2|...}
+  &sortBy=Random
+  &limit=5
+  &fields=Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio
+  &enableUserData=true
+  &enableImageTypes=Primary
+  &imageTypeLimit=1
+```
+
+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
+distinct albums, allow repeats rather than showing empty cards.
+
+### 3.5 Album tracks
+
+```
+GET {base}Items
+  ?userId={userId}
+  &parentId={albumId}
+  &recursive=false
+  &includeItemTypes=Audio
+  &sortBy=ParentIndexNumber,IndexNumber
+  &fields=MediaSources,MediaStreams,Genres,AlbumArtist,Artists,
+          IndexNumber,ParentIndexNumber,RunTimeTicks
+  &enableUserData=true
+```
+
+Each track supplies `Id`, `IndexNumber`, `ParentIndexNumber`,
+`RunTimeTicks` (100 ns ticks), `MediaSources[0].Id` (the playback
+`MediaSourceId`), `MediaSources[0].Container`, and
+`MediaSources[0].MediaStreams[0].Codec`.
+
+### 3.6 Stream URL
+
+```
+GET {base}Audio/{trackId}/universal
+  ?UserId={userId}
+  &DeviceId={deviceId}
+  &MediaSourceId={mediaSourceId}
+  &MaxStreamingBitrate=1500000
+  &Container=mp3,m4a,aac,flac,ogg,opus,wav
+  &TranscodingContainer=mp3
+  &TranscodingProtocol=http
+  &AudioCodec=mp3,aac,flac,opus,vorbis
+  &EnableRedirection=true
+```
+
+The native player sets the `MediaBrowser` `Authorization` header on the data
+source; the token is never placed in the URL and never logged. If the server
+rejects the broad `Container`/`AudioCodec` list on a real library, fall back to
+`Container=mp3,m4a` / `AudioCodec=mp3,aac` and let those transcode. Direct play
+is preferred; no user quality setting in 1.2.0.
+
+### 3.7 Artwork
+
+```
+GET {base}Items/{albumId}/Images/Primary?maxHeight=400&tag={tag}
+```
+
+The request carries the `MediaBrowser` header. Flutter uses
+`Image.network(headers: authHeaders)`. For the media notification, the Dart
+controller downloads the current album's artwork once and passes the bytes to
+native; if it fails, the notification shows title/artist without art.
+
+### 3.8 Playback reporting
+
+```
+POST {base}Sessions/Playing
+POST {base}Sessions/Playing/Progress
+POST {base}Sessions/Playing/Stopped
+```
+
+Minimal body verified on 12.2 (SessionId omitted):
+
+```json
+{
+  "ItemId": "<track-id>",
+  "MediaSourceId": "<media-source-id>",
+  "PlaySessionId": "<uuid>",
+  "PositionTicks": 0,
+  "IsPaused": false,
+  "IsMuted": false,
+  "PlayMethod": "DirectPlay",
+  "CanSeek": true,
+  "PlaylistIndex": 0,
+  "PlaylistLength": 4
+}
+```
+
+- `PositionTicks = positionMs * 10000`.
+- On track start/resume: `Sessions/Playing`.
+- Every ~10 s while playing and on pause: `Sessions/Playing/Progress` (with
+  `IsPaused`).
+- On track change/end/service stop: `Sessions/Playing/Stopped` for the previous
+  item, with the last position.
+- `Failed` is set on `Stopped` when a stream error ended playback.
+- Reporting is best-effort; a failed report never interrupts playback and
+  never logs the token.
+
+## 4. Architecture
+
+```
+Dart
+  JellyfinController        existing Quick Connect/session; now exposes
+                            `configured` and an auth-only `JellyfinConnection`
+  JellyfinLibraryService    typed library calls + recent-played dedupe +
+                            genre fetching + artwork bytes
+  JellyfinGenreCatalog      raw genre -> family rules, family expansion
+  JellyfinDashboardController
+                            latest played/added, random, loading/error/refresh
+  JellyfinPlaybackController
+                            album queue, play/pause/next/prev/seek, polls native
+                            state, own MediaPlayback snapshot
+  HomeScreen                Jellyfin tile + merged VolumeCard media state
+  JellyfinDashboardScreen   full-screen Albums/Artists page
+  JellyfinAlbumScreen       large cover, track list, transport + volume
+  SettingsScreen            General switch row + Jellyfin genre picker
+Android
+  MainActivity
+    JellyfinPlaybackBridge  MethodChannel; MediaController binding; permission
+                            result forwarding
+  JellyfinPlaybackService   MediaSessionService + ExoPlayer + audio focus +
+                            media notification + Jellyfin reporter
+  JellyfinPlaybackReporter  HTTP POSTs to /Sessions/Playing*
+```
+
+Providers in `main.dart`:
+
+- `JellyfinController` (existing).
+- `JellyfinLibraryService` (app-level, uses the controller connection).
+- `JellyfinGenreCatalog` (stateless + cached raw list).
+- `JellyfinDashboardController` (created per dashboard route, or app-level with
+  `load()` on open; preferably per route so rows are not stale).
+- `JellyfinPlaybackController` (app-level; survives dashboard/album routes).
+- A small sync listener in `CarDashApp` calls
+  `launcher.setJellyfinConfigured(jellyfin.configured)` whenever the Jellyfin
+  controller notifies, so the General switch and Home tile react immediately.
+
+## 5. Genre families
+
+Normalize raw genres to lower case, trim, collapse whitespace and strip
+surrounding punctuation. Apply, in order:
+
+1. Exact alias table (including Cyrillic spellings).
+2. Head-noun rules on the last token (split on space, `-`, `/`, `&`).
+3. A set of explicit whole-name aliases for hybrids.
+4. No match: the raw genre becomes its own standalone family.
+
+The canonical families for this library:
+
+| Family | Raw genres |
+|---|---|
+| Metal | Alternative Metal; Atmospheric Black Metal; Avant-Garde Metal; Black; Black Metal; Death; Death Metal; Death-Doom Metal; Depressive Black Metal; Doom Metal; Folk Metal; Funeral Doom Metal; Glam Metal; Gothic Doom Metal; Gothic Metal; Groove Metal; Heavy Metal; Industrial Metal; Melodic Black Metal; Melodic Death Metal; Melodic Metal; Metal; Pagan Black Metal; Progressive Metal; Symphonic Metal; Symphonic Power Metal; Thrash Metal; Viking; Viking Metal; Метъл |
+| Rock | Alternative; Alternative Rock; Blues Rock; Country Rock; Gothic Rock; Grunge; Hard Rock; Indie Rock; Pop-Rock; Progressive; Progressive Rock; Rock; Rock & Roll; Rock Singer-Songwriter; Rockabilly; Symphonic Rock; Рок; Хард рок |
+| Jazz | Fusion; Jazz; Swing; Vocal Jazz |
+| Pop | International Pop; Pop; Rock Pop (head noun Pop) |
+| Folk | Dark Folk; Folk; Medieval; Neofolk; Pagan Folk |
+| Blues | Blues |
+| Soul | Soul; Soul & Funk |
+| Funk | Funk |
+| R&B | R&B |
+| Country | Country |
+| Classical | Classical; Composer; Conductor |
+| Soundtrack | Film Scores; Films/Games |
+| Electronic | Dance; Electro; Electronic |
+| Ambient | Ambient; Dark Ambient; Dungeon Synth; New Age; Ritual Ambient |
+| Punk | Hardcore; Punk |
+| Latin | Latin; Latin Music |
+| Acoustic | Acoustic |
+| Avant-Garde | Avant-Garde |
+| Gothic | Gothic |
+| Pagan | Pagan |
+| Singer-Songwriter | Singer & Songwriter |
+| Comedy | Comedy |
+| 1989 | 1989 (standalone raw tag) |
+| Canzone Italiana | Canzone Italiana (standalone) |
+
+Special cases and priorities:
+
+- "Metal" wins over "Rock" when the last noun is Metal, even for
+  "Alternative Metal", "Gothic Metal", "Folk Metal", "Pagan Black Metal".
+- "Rock" wins for "Blues Rock", "Country Rock", "Gothic Rock", "Hard Rock",
+  "Indie Rock", "Pop-Rock", "Progressive Rock", "Symphonic Rock".
+- "Rock Pop" maps to Pop because Pop is the head noun; "Rock
+  Singer-Songwriter" maps to Rock because it starts with "Rock".
+- `Alternative`, `Grunge` and `Progressive` are explicit Rock aliases.
+- `Fusion` and `Swing` are explicit Jazz aliases.
+- `Hardcore` is an explicit Punk alias.
+- `Black`, `Death` and `Viking` are explicit Metal aliases.
+- `Medieval` is a Folk alias; `Composer`/`Conductor` are Classical aliases;
+  `Film Scores`/`Films/Games` are Soundtrack aliases.
+- `Dungeon Synth` and `New Age` are Ambient aliases.
+- `Soul & Funk` maps to Soul; `Latin Music` maps to Latin.
+- `Метъл` -> Metal, `Рок` -> Rock, `Хард рок` -> Rock. Add `Джаз`, `Поп`,
+  `Фолк`, `Блус`, `Кънтри`, `Класическа`, `Електронна`, `Ембиънт`, `Пънк`,
+  `Соул`, `Фънк`, `Латино` as future aliases.
+- Raw genres that match no rule stay standalone with their original label.
+
+Storage stores `{id, label}` for each selected family. IDs for canonical
+families are stable slugs (`metal`, `rock`, `jazz`, ...); unmatched families use
+`raw:<normalized name>`. On the next dashboard/settings open, the saved
+selection is intersected with the families that currently exist on the server.
+If a selected family disappears, it is dropped and the user can reselect.
+
+The selection is capped at 5. The picker shows the family label and the number
+of raw variants in the current library. A search field appears when the family
+list is long.
+
+## 6. Data models and persistence
+
+### 6.1 Models
+
+`JellyfinConnection` (never displayed or logged):
+
+- `Uri baseUrl`, `String accessToken`, `String userId`, `String deviceId`,
+  `String userName`.
+- `String get authorizationHeader` builds the `MediaBrowser` header.
+
+`JellyfinAlbum`:
+
+- `id`, `name`, `artist`, `year?`, `imageTag?`, `childCount?`,
+  `dateCreated?`, `lastPlayed?`.
+- `Uri? imageUri(int maxHeight)` (built by the service; not a widget concern).
+- JSON factories for album results and track-derived albums.
+
+`JellyfinTrack`:
+
+- `id`, `name`, `albumId`, `album`, `artist`, `indexNumber?`,
+  `parentIndexNumber?`, `duration`, `mediaSourceId`, `container`,
+  `codec?`, `genres`, `lastPlayed?`.
+- `Duration` conversion from `RunTimeTicks` (`ticks ~/ 10` microseconds).
+
+`JellyfinGenre`:
+
+- `name`, `familyId`, `familyLabel`.
+
+`JellyfinGenreFamily`:
+
+- `id`, `label`, `rawGenres`, `isStandalone`.
+
+`JellyfinGenreSelection`:
+
+- `id`, `label`; JSON for prefs.
+
+`JellyfinQueueItem`:
+
+- `trackId`, `mediaSourceId`, `url`, `title`, `artist`, `album`,
+  `durationMs`, `index`, `playSessionId`.
+
+`JellyfinPlaybackSnapshot`:
+
+- `available`, `playing`, `buffering`, `itemId`, `albumId`, `title`,
+  `artist`, `album`, `durationMs`, `positionMs`, `index`,
+  `hasPrevious`, `hasNext`, `error`.
+
+`JellyfinPlaybackController` exposes:
+
+- `bool get isActive` (queue exists, playing or paused),
+- `bool get hasQueue`,
+- `JellyfinAlbum? get currentAlbum`,
+- `JellyfinTrack? get currentTrack`,
+- `MediaPlayback get mediaPlayback` (for `VolumeCard`),
+- `String? get error`.
+
+### 6.2 SettingsRepository keys
+
+- `slot.jellyfin.enabled` (bool, default **false**; existing slot prefix).
+- `jellyfin.genreFamilies` (JSON list of `{"id","label"}`; default `[]`).
+- Existing Jellyfin keys stay unchanged.
+
+`slotEnabled(SlotType.jellyfin)` defaults to false; the other three default to
+true. `slot.` keys are unchanged for existing users, so no migration is needed.
+
+No library/cache data is persisted. The access token remains in secure storage;
+the native service receives it in memory only.
+
+### 6.3 LauncherController contract
+
+- `bool _jellyfinConfigured`.
+- `void setJellyfinConfigured(bool value)`:
+  - no-op if unchanged,
+  - when false, force `slot.jellyfin.enabled = false` and persist it,
+  - notify.
+- `isSlotEnabled(jellyfin)` returns
+  `_jellyfinConfigured && (_slotEnabled[jellyfin] ?? false)`.
+- `homeButtonCount` uses `isSlotEnabled`, so an unconfigured Jellyfin button
+  does not consume a slot.
+- `initialize()` skips reading an app assignment for Jellyfin.
+- `_validateAssignments()` skips Jellyfin.
+- `assign`, `clearSlot`, `launchSlot` guard on `slot.isAppSlot`.
+- `setSlotEnabled(jellyfin, true)` returns a message if Jellyfin is not
+  configured; the UI disables the switch anyway.
+- The General "Home buttons" list shows Jellyfin between Music and Radio; its
+  row has no app picker and no long-press action.
+
+## 7. UI
+
+### 7.1 General → Home buttons
+
+- The existing list iterates `SlotType.values` and includes Jellyfin.
+- Jellyfin row:
+  - icon `assets/icons/headphones.svg`,
+  - title **Jellyfin**,
+  - subtitle **Music library** when configured, **Configure Jellyfin first**
+    when not,
+  - switch off and disabled when not configured,
+  - no app picker, no long-press.
+- The count includes Jellyfin when it is enabled.
+- The old Jellyfin card no longer hosts a Home-button toggle.
+
+### 7.2 Jellyfin settings card
+
+Keep the existing integration switch, URL, Quick Connect, status, offline and
+repair states. Add:
+
+- **Random genres** row:
+  - value: selected family labels, or **Not chosen**,
+  - **Choose** button opens the genre dialog,
+  - disabled while not configured.
+- **Playback notifications** row on Android 13+ when the permission is not
+  granted:
+  - short explanation, **Allow** button,
+  - never blocks playback; if denied, the media notification may be hidden,
+  - checked on resume.
+- Footer copy updated to mention music-library reads, playback reporting and
+  token removal.
+
+### 7.3 Genre dialog
+
+- Modal dialog, max width ~680, max height ~600.
+- Loading → family list; error → message + Retry.
+- Each family row: checkbox, label, raw-variant count.
+- Counter **N / 5**; at 5, unselected rows are disabled.
+- Search field when the family list is long.
+- Save / Cancel. Save returns the selected `{id,label}` list; the caller
+  persists it and refreshes the Random row.
+- If Jellyfin is not configured, the row is disabled before opening.
+
+### 7.4 Dashboard
+
+- Full-screen route; back arrow, title **Jellyfin**.
+- Small **Albums** / **Artists** strip; Artists in `colors.inkFaint`, no tap.
+- Three sections with 5 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.
+- 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.
+- States:
+  - loading skeleton/spinner,
+  - server error: inline message + Retry,
+  - empty library: a quiet "No albums in the library." message,
+  - no history: "Nothing played yet.",
+  - no selected genres: Random section shows "Choose up to 5 genres in
+    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.
+- Returning from an album page may refresh Recently played (or the controller
+  optimistically moves the started album to the front).
+
+### 7.5 Album page
+
+- Full-screen route pushed from the dashboard.
+- 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,
+  - center: track list in a bounded scroll area,
+  - right: current track info + transport + volume controls.
+- Track list:
+  - track number (or disc.track), title, duration,
+  - current track highlighted with the accent,
+  - tap a track to play it,
+  - a scrollbar/scrollable list for long albums.
+- Transport:
+  - previous, play/pause (large), next,
+  - disabled previous/next at the ends,
+  - progress ring/position from the current track,
+  - current track title/artist.
+- Volume:
+  - reuse `VolumeCard`, extended with optional previous/next callbacks and a
+    `large`/expanded flag for the album page,
+  - the Home `VolumeCard` keeps its current appearance.
+- If the album is still loading: show the cover/info and a loading state; if it
+  fails, show the error and Retry.
+- Back returns to the dashboard.
+- At 1024×600, the three columns collapse to cover + controls with the track
+  list below in a bounded scroll area.
+
+### 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.
+- `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
+    Jellyfin controller,
+  - otherwise use the existing system media state / media-key path,
+  - the volume slider always uses `SystemController` volume.
+- This makes the ring title/progress work even when notification access is not
+  granted.
+- Hardware/Bluetooth media keys go through CarDash's MediaSession.
+
+## 8. Native playback
+
+### 8.1 Dependencies and manifest
+
+Add to `android/app/build.gradle.kts`:
+
+- `androidx.media3:media3-exoplayer`
+- `androidx.media3:media3-session`
+
+Pin compatible stable versions during implementation and verify the build with
+JDK 21.
+
+Manifest additions:
+
+```xml
+<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" />
+
+<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>
+```
+
+No `SYSTEM_ALERT_WINDOW`, no kiosk, no display wake lock. If screen-off
+playback stalls in testing, revisit a partial `WAKE_LOCK` with human
+confirmation; the default assumption is that the FGS + audio track is enough.
+
+### 8.2 Service
+
+- `MediaSessionService` owning `ExoPlayer` and `MediaSession`.
+- `ExoPlayer` audio attributes: usage `MEDIA`, content type `MUSIC`;
+  disable built-in focus handling and manage focus in the service.
+- `DefaultHttpDataSource.Factory` default header
+  `Authorization: <MediaBrowser ...>`.
+- Queue: `MediaController.setMediaItems` with one `MediaItem` per track.
+  - `mediaId` = track id,
+  - URI = universal stream URL,
+  - metadata title/artist/album/artwork,
+  - extras: `mediaSourceId`, `index`, `playSessionId`.
+- Context: a `SET_CONTEXT` custom command carries `baseUrl`, `accessToken`,
+  `userId`, `deviceId`, `playSessionId`, album name/artist and artwork bytes
+  once per queue.
+- `MediaSession.Callback` handles play/pause/next/previous and the custom
+  commands.
+- `getState` (via the bridge) reads `isPlaying`, `isBuffering`,
+  `currentMediaItemIndex`, `currentPosition`, `duration`,
+  `hasPreviousMediaItem`, `hasNextMediaItem`, metadata and `playerError`.
+
+### 8.3 Audio focus
+
+- Request `AUDIOFOCUS_GAIN` with `AudioAttributes` usage `MEDIA`.
+- Permanent loss (another music app): pause, `resumeOnFocusGain = false`,
+  keep the position.
+- Transient loss (navigation prompt): pause,
+  `resumeOnFocusGain = wasPlaying`; on gain, resume.
+- Transient can-duck: duck to ~20% and restore on gain.
+- The player never requests focus again just to fight another app.
+- The service abandons focus when playback stops/is destroyed.
+
+### 8.4 Media notification
+
+- Media3 media notification on a **Jellyfin playback** channel.
+- Small icon: a dedicated white vector drawable (not the adaptive launcher
+  icon).
+- Title/artist/album art from the current item.
+- `POST_NOTIFICATIONS` requested once from Settings → Jellyfin; playback is not
+  blocked when denied. On Android 10 no runtime prompt is needed.
+- The service starts in the foreground when playback starts and removes the
+  notification when the queue ends/stops.
+
+### 8.5 Reporting
+
+- Native `JellyfinPlaybackReporter` with a single background executor and
+  `HttpURLConnection`, 5–8 s timeouts.
+- Tracks the current item, media source id, play session id, queue index/length
+  and last position.
+- Sends start/progress/pause/stop as described in section 3.8.
+- Stops reporting on service destroy; ignores network/HTTP errors.
+- Never logs or persists the token or full stream URL.
+
+## 9. Error handling and lifecycle
+
+- Jellyfin integration disabled/not configured:
+  - Home switch off + disabled, Home button hidden, saved slot flag cleared.
+- Configured but offline:
+  - Home switch stays enabled, Home button visible,
+  - dashboard shows inline error + Retry,
+  - genre row/dialog show an error; selected genres remain stored.
+- Token rejected:
+  - existing behavior disables the integration and clears the token,
+  - the sync listener clears the Home slot.
+- Playback error:
+  - the album page and dashboard show a short error,
+  - the service reports `Stopped` with `Failed=true`,
+  - the previous queue stays paused for retry; no infinite retry loop.
+- Album end:
+  - stop playback, no autoplay, no repeat; the queue remains paused so the
+    user can restart from the album page/Home ring.
+- App lifecycle:
+  - playback continues in the service when CarDash is backgrounded,
+  - the Dart playback controller stops polling on pause and queries state on
+    resume,
+  - if the activity is recreated, the bridge reconnects and `getState` restores
+    the current queue UI,
+  - process death stops playback.
+
+## 10. Testing plan
+
+### 10.1 Dart unit tests
+
+- `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.
+- 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,
+  including `Метъл`/`Рок`/`Хард рок`, `Rock Pop`, `Pop-Rock`, `Metalcore`
+  policy (none in the current list), standalone tags and unknown genres.
+- `SettingsRepository` genre JSON round-trip and `slot.jellyfin.enabled`
+  default false.
+- `LauncherController`:
+  - Jellyfin configured/unconfigured,
+  - clearing the slot flag on disconnect,
+  - Home count includes/excludes Jellyfin,
+  - 16-button cap,
+  - app picker is never offered for Jellyfin,
+  - no assignment read/write for Jellyfin.
+- `JellyfinDashboardController`: loading, populated, empty, error, random
+  refresh, no genres, optimistic recently-played update.
+- `JellyfinPlaybackController` with a fake playback platform:
+  - play album builds the queue and starts,
+  - same album does not rebuild,
+  - different album replaces,
+  - play/pause, next/previous, track tap, end of queue,
+  - state polling, error state, media snapshot mapping.
+
+### 10.2 Widget tests
+
+- General Home buttons: Jellyfin switch off/disabled when unconfigured;
+  enabled when configured; subtitle; no app picker; cap message.
+- 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.
+- 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.
+- `VolumeCard` Jellyfin branch: uses Jellyfin media/title/progress and routes
+  play/pause when active; falls back when another app is playing.
+
+### 10.3 Emulator / device
+
+Use the local Jellyfin 12.2 container for repeatable API/playback tests and the
+real server for final library/genre checks.
+
+- Pair via Quick Connect on the local server (scripted approval is possible with
+  an admin token) and on the real server (user approves the code).
+- Dashboard with the generated library: 6 albums, 24 tracks, 24 raw genres.
+- Play an album, next/previous, track tap, volume, pause/resume, album end.
+- Home ring title/progress and play/pause with and without notification access.
+- Background playback with another app in the foreground.
+- Audio focus: start a second music app; Jellyfin pauses and keeps position;
+  resume and verify the position.
+- Navigation prompt: transient focus; Jellyfin pauses and resumes.
+- 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.
+- Release build install and cold start with Jellyfin configured.
+
+### 10.4 Commands
+
+```bash
+dart format lib test
+flutter analyze
+flutter test
+JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --debug
+./tool/install.sh debug
+./tool/check.sh
+```
+
+## 11. Release checklist (1.2.0)
+
+1. `pubspec.yaml`: `version: 1.2.0+20`.
+2. `lib/core/app_version.dart`: `appVersion = '1.2.0'`.
+3. Update `AGENTS.md`, `README.md`, `docs/IMPLEMENTATION_PLAN.md` and this
+   plan's status in the same commits as the behavior.
+4. `./tool/check.sh`.
+5. Build universal APK with JDK 21:
+   `JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release --target-platform android-arm,android-arm64,android-x64`.
+6. Copy to `releases/cardash-android-1.2.0.apk`, write the `.sha256`, update
+   `releases/README.md`.
+7. Commit with Conventional Commits and `git push origin main`.
+8. Publish Gogs release `v1.2.0` with `./tool/gogs_release.sh` and verify it is
+   not a draft.
+9. Optional remote ADB install following the two-attempt rule.
+
+## 12. Future work
+
+- **Data-aware streaming quality** (explicitly deferred): detect Wi-Fi vs
+  metered connections, choose a bitrate cap, and expose an optional stream
+  quality setting. No mobile-data handling in 1.2.0.
+- Artists tab and artist/album browsing.
+- Offline caching and downloads.
+- User-editable genre family mapping.
+- Queue persistence across process death and CarDash restarts.
+- Gapless playback and richer now-playing metadata.
+- Optional playback notification permission prompt improvements based on the
+  real tablet behavior.
+
+## 13. Risks and mitigations
+
+| Risk | Mitigation |
+|---|---|
+| Jellyfin 12.2 API differences from older 10.x docs | Verified against the 12.2 OpenAPI and local server; keep the endpoint constants in one service and parse defensively. |
+| Album `UserData` not updated by playback | Derive recently played from played Audio tracks, dedupe by `AlbumId`; verified on 12.2. |
+| 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. |
+| 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. |
+| 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. |
+
+## 14. Implementation order
+
+1. Plan doc, version planning, local 12.2 fixtures.
+2. Data layer: connection, models, gateway methods, library service, recent
+   played dedupe, genre catalog and tests.
+3. Settings: slot enum/launcher gating, Home switch row, genre persistence and
+   picker; tests.
+4. Dashboard and album page against the local server, with the playback
+   controller faked; widget tests.
+5. Media3 service, bridge, manifest/permissions, reporter; debug build.
+6. Playback controller, Home integration, notification permission.
+7. Emulator/tablet end-to-end fixes.
+8. Docs, version 1.2.0, release build, commit/push, Gogs release.