JELLYFIN_1.2.0_PLAN.md 37 KB

CarDash Jellyfin 1.2.0 — Dashboard, Album Page and Playback

Status: released 2026-10-31 (Gogs tag v1.2.0, commit 2715eeb). 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, 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).
  • 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="<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 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<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=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):

{
  "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 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.
  • 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:

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

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.