# CarDash Jellyfin 1.2.0 — Dashboard, Album Page and Playback Status: **released 2026-10-31** (Gogs tag `v1.2.0`, commit `2715eeb`). Patch **1.2.1** fixes the brief Music-tile equalizer flash after pausing Jellyfin from Home. Target release: **CarDash 1.2.0+20**; patch **1.2.1+21**. 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, 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. - 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. - 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. 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). CarDash's own stale MediaSession is ignored, so a Jellyfin pause cannot briefly animate the Music tile. - 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: - `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="", Version="", 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 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. ### 3.2 Recently added ``` GET {base}Items/Latest ?userId={userId} &includeItemTypes=MusicAlbum &limit=6 &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`; 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=6 &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 six 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": "", "MediaSourceId": "", "PlaySessionId": "", "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:`. 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 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 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: 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, - 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 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). ### 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), 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: - 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. - 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. - The Music tile also requires the active system media session to be an external package. CarDash's own Jellyfin MediaSession is excluded, so pausing Jellyfin cannot flash the Music equalizer while the system snapshot catches up. - `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 ``` 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: `. - 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 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, 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: 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 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. ### 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, 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 ```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–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 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. | ## 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.