From e7cfe7a84028bd8472bf96bef12156b69e1a10ba Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:35:03 +0000 Subject: [PATCH] document the playback queue model and mark generated files --- .gitattributes | 2 ++ AGENTS.md | 2 ++ docs/architecture.md | 28 ++++++++++++++++++++++++---- docs/implementation-plan.md | 9 ++++++--- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 4f6b9c6c..ef352538 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ app/src/test/screenshots/** filter=lfs diff=lfs merge=lfs -text +app/schemas/** linguist-generated +app/src/test/screenshots/** linguist-generated diff --git a/AGENTS.md b/AGENTS.md index 7dc3c4c5..855f3f77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand. ## Commands - Build: `gradle :app:assembleDebug` +- Release build: `gradle :app:assembleRelease` only when build config, R8/ProGuard or dependencies change, and once before a merge; not part of routine verification - Unit tests: `gradle :app:testDebugUnitTest` - Lint: `gradle :app:ktlintCheck :app:lintDebug` - Format: `gradle :app:ktlintFormat` @@ -41,6 +42,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand. - Room 3 (`androidx.room3`) on AndroidX `sqlite-bundled` (`BundledSQLiteDriver`, SQLite 3.50+, FTS5). Do not reintroduce SQLDelight or the platform SQLite. - Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default. - Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table. +- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL. - UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests. - UI and playback preferences go in DataStore (`UserPreferences`), not Room. - Verify current dependency versions and their compatibility before adding or pinning anything. diff --git a/docs/architecture.md b/docs/architecture.md index 7d5ecdd0..d5ece519 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,11 +21,11 @@ Subtracks is a native Android client for Subsonic-compatible servers (Navidrome, | Networking | OkHttp + DOM XML parsing | | Async | Kotlin coroutines / `Flow` | | Images | Coil 3 | -| Media | AndroidX Media3 / ExoPlayer (planned) | +| Media | AndroidX Media3 (`media3-exoplayer`, `media3-session`) | | Settings | DataStore Preferences (UI and playback prefs) | | Build | AGP 9.4.1, Gradle 9.7.1, Kotlin 2.3.10 (AGP built-in Kotlin), compileSdk/targetSdk 37 | -Versions live in `gradle/libs.versions.toml`; check current releases before bumping (Koin 4.2.2, Navigation Compose 2.10.1, Paging 3.5.1, DataStore 1.2.1, Coil 3.6.3 at the time of writing). +Versions live in `gradle/libs.versions.toml`; check current releases before bumping (Koin 4.2.2, Navigation Compose 2.10.1, Paging 3.5.1, DataStore 1.2.1, Coil 3.6.3, Media3 1.11.1 at the time of writing). ## Source layout @@ -36,11 +36,15 @@ app/src/main/kotlin/com/subtracks MainActivity.kt, SubtracksApp.kt Composition root: starts Koin and Coil di/AppModule.kt Koin module ui/ - AppRoot.kt Root gate and NavHost + AppRoot.kt Root gate, NavHost and the mini player library/ Tabbed LibraryScreen, album/playlist detail + playback/ Mini player and Now Playing screen settings/ Server list and add-server form components/ Cover art and empty/loading states theme/ Material 3 theme (stark black/white) + playback/ + PlaybackService.kt MediaSessionService hosting ExoPlayer + PlaybackController.kt MediaController wrapper: state, queue, transport data/ model/Models.kt Room entities, including the search_index FTS5 table db/ SubtracksDatabase, DAOs, bundled-SQLite builder @@ -66,18 +70,34 @@ Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic) ## UI shell -The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Songs, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. (The Flutter app also had a Now Playing bar pinned to the bottom; that returns with playback in Phase 3.) +The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Songs, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition. The Albums tab is a three-column grid of covers only — a later preference will toggle captions, and sorting will hang off a FAB, so Settings only manages servers and neither shows sort nor sync controls. The theme is monochrome — black surface, white content and accent — with no colour extracted from cover art (that was a Flutter feature and is not ported yet). +## Playback + +Audio plays through an `ExoPlayer` owned by `PlaybackService` (a `MediaSessionService`), so the system media notification, headset/Bluetooth controls and background playback come from the framework. The UI never touches the player directly: `PlaybackController` is an app-scoped singleton that connects a `MediaController` to the session, exposes a `StateFlow` (current item, playing flag, position, has-next/previous) and the transport calls. It reaches the session through a small `PlayerConnection`/`PlayerHandle` seam rather than Media3 types, so the queue and window logic is covered by JVM unit tests with a fake player. It connects lazily on first observation or play, and restores the persisted queue (paused) on launch. + +The queue lives in SQL, not memory. `queue_entries` is an ordered list of *references* — a whole playlist, an album, or a single song — each with an optional inclusive ordinal range (`rangeStart`/`rangeEnd`; both null means the whole referenced list). A queue can therefore be "album X tracks 1-5, song Y, then album X tracks 6-10" without copying a row per track, and inserting, moving or removing an entry is a single row. A one-row `playback_cursor` table holds the current position; both are written as playback advances. + +`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` come from the SQL queue size, not the player's playlist. The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server. + +Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. + +Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. + +The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. Timed lyrics/gapless format preferences, bitrate/format preferences, scrobbling and downloads are not wired yet, and the queue view (reordering) is not built, though the schema supports it. + ## Data model `sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`. `search_index` is an FTS5 table (trigram tokenizer) over titles, maintained by sync. UI and playback preferences live in DataStore (`user_prefs`), not in SQLite: they are not relational, nothing joins against them, and keeping them out of Room avoids a schema migration per preference. Room is for the library only. +Every schema version step ships a hand-written `Migration` (`data/db/Migrations.kt`), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under `app/schemas/` are the source of truth for the migration SQL, and `MigrationsTest` runs each step against a real SQLite connection and asserts the resulting tables, columns and indices. + Library screens page over Room with `PagingSource` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Paging is local, but it keeps memory bounded on large libraries and lets a huge song or playlist list render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this. Song rows show the album's cover art, fetched by joining `songs` to `albums` into a `SongListItem` rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 5ce7add1..ebc7c1cf 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -28,10 +28,13 @@ Goal: a usable app for browsing a server, end to end. Not done yet in this slice (deliberately): artist detail, cover-art tonal colour extraction, long-press menus, editing an existing server, and per-section sort/filter controls (only album sort is wired). -## Phase 3 - Playback +## Phase 3 - Playback (in progress) -- Media3/ExoPlayer playback service, queue and now-playing UI. -- Streaming with `maxBitRate`/format taken from settings, a gapless queue, media notifications and headset controls. +- (done) Media3/ExoPlayer playback service, queue and now-playing UI: a `MediaSessionService` plus a mini player and a Now Playing screen. +- (done) Media notifications and headset/Bluetooth controls, from the media session. +- (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player. +- Streaming with `maxBitRate`/format taken from settings. +- Playing from the Songs tab, the queue view (add to queue / play next / reorder), and gapless format preferences. - Scrobbling and "now playing" back to the server. - Optional download/caching.