Status: released 2026-10-31 (Gogs tag v1.2.0, commit 2715eeb; patch
v1.2.1, commit aef4581). 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.
SettingsSwitch (not a literal checkbox).assets/icons/headphones.svg,mediaPlayback foreground service,Post-plan refinements implemented on 2026-10-31:
In scope:
Jellyfin Home slot visibility, ordering, gating and 16-button cap.Out of scope for 1.2.0:
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./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.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./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.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.
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.
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.
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.
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.
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.
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.
POST {base}Sessions/Playing
POST {base}Sessions/Playing/Progress
POST {base}Sessions/Playing/Stopped
Minimal body verified on 12.2 (SessionId omitted):
{
"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.Sessions/Playing.Sessions/Playing/Progress (with
IsPaused).Sessions/Playing/Stopped for the previous
item, with the last position.Failed is set on Stopped when a stream error ended playback.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).CarDashApp calls
launcher.setJellyfinConfigured(jellyfin.configured) whenever the Jellyfin
controller notifies, so the General switch and Home tile react immediately.Normalize raw genres to lower case, trim, collapse whitespace and strip surrounding punctuation. Apply, in order:
-, /, &).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:
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.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.
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).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.slot.jellyfin.enabled (bool, default false; existing slot prefix).jellyfin.genreFamilies (JSON list of {"id","label"}; default []).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.
bool _jellyfinConfigured.void setJellyfinConfigured(bool value):
slot.jellyfin.enabled = false and persist it,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.SlotType.values and includes Jellyfin.assets/icons/headphones.svg,Keep the existing integration switch, URL, Quick Connect, status, offline and repair states. Add:
{id,label} list; the caller
persists it and refreshes the Random row.colors.inkFaint, no tap.Image.network with auth headers, rounded corners, memory cache,
and a headphones placeholder on error.VolumeCard, extended with optional previous/next callbacks and a
large/expanded flag for the album page,VolumeCard keeps its current appearance.VolumeCard state selection:
MediaPlayback and route play/pause to the
Jellyfin controller,SystemController volume.Add to android/app/build.gradle.kts:
androidx.media3:media3-exoplayerandroidx.media3:media3-sessionPin compatible stable versions during implementation and verify the build with JDK 21.
Manifest additions:
<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.
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 ...>.MediaController.setMediaItems with one MediaItem per track.
mediaId = track id,mediaSourceId, index, playSessionId.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.AUDIOFOCUS_GAIN with AudioAttributes usage MEDIA.resumeOnFocusGain = false,
keep the position.resumeOnFocusGain = wasPlaying; on gain, resume.POST_NOTIFICATIONS requested once from Settings → Jellyfin; playback is not
blocked when denied. On Android 10 no runtime prompt is needed.JellyfinPlaybackReporter with a single background executor and
HttpURLConnection, 5–8 s timeouts.Stopped with Failed=true,getState restores
the current queue UI,JellyfinAlbum/JellyfinTrack JSON parsing from real 12.2 payloads.ParentId, take up to 6.Метъл/Рок/Хард рок, 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:
JellyfinDashboardController: loading, populated, empty, error, random
refresh, no genres, optimistic recently-played update.JellyfinPlaybackController with a fake playback platform:
VolumeCard Jellyfin branch: uses Jellyfin media/title/progress and routes
play/pause when active; falls back when another app is playing.Use the local Jellyfin 12.2 container for repeatable API/playback tests and the real server for final library/genre checks.
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
pubspec.yaml: version: 1.2.0+20.lib/core/app_version.dart: appVersion = '1.2.0'.AGENTS.md, README.md, docs/IMPLEMENTATION_PLAN.md and this
plan's status in the same commits as the behavior../tool/check.sh.JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release --target-platform android-arm,android-arm64,android-x64.releases/cardash-android-1.2.0.apk, write the .sha256, update
releases/README.md.git push origin main.v1.2.0 with ./tool/gogs_release.sh and verify it is
not a draft.| 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. |