|
@@ -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.
|