Re-anchor the block whenever playback lands on a different context track
(skip, previous, jump, shuffle toggle, restore), so "Up next" always
plays next instead of stranding the block behind a track the listener
has moved past. reanchorFor reports whether the anchor actually moved,
since the current track's combined position can be unchanged even when
the block shifts.
Split playback into a shuffled context and a manual up-next list.
`up_next_entries` holds the same reference-plus-range rows as the
context but is never shuffled; `playback_cursor.upNextAnchor` pins the
block between a context track and its successor. `QueueRepository`
resolves a combined position by mapping through the context order and
splicing the block after the anchor, so the controller's window and
next/previous keep working.
PlaybackController gains addToQueue/playNext, consumes a queued track
when playback advances out of it, resumes the context when the block
empties, and clears the block on a new context. The queue view gets an
editable "Up next" section and a read-only context section, and the
context menu gains Play next / Add to queue for songs, albums and
playlists.
It made no perceivable difference to the first-play hitch, so it only cost buffering
audio at launch that the user may never play. loadWindow prepares when it plays again,
and the two tests go back to their original assertions.
The tab bar was laid out before playback was known, so the restored mini player arrived
later and re-laid the whole bottom area: a couple of frames of bump, and the mini player
"loading in" after the library. SubtracksRoot now waits for PlaybackController to finish
connecting and restoring (a `ready` flag, set after restore or on disconnect) before it
composes the library, so the first library frame already includes the mini player and
nothing moves.
With playback resolved up front, the `restored` flag and its special enter/inset specs
are pointless, so they are reverted; playback the user starts animates as before.
The mini player's enter spec was decided from `firstReveal`, which was false at the first
composition (the player is not visible yet) and flipped back to `expandVertically` in the
same frame the visibility changed, so the expand animation always ran. PlaybackState now
carries `restored`, set by restore() and cleared on any user playback, and the mini
player uses it to appear in place for the whole restored session.
The first play after a restart could still pay for a fresh buffer because a stream-quality
reload rebuilds the window, and loadWindow only prepared when autoplay was set. It now
always prepares, so a reload (or the restore) leaves the current track buffered.
Restore set the window with autoplay = false and never prepared, so the first tap paid
for the connection, the first range request and the startup buffer. Prepare once the
window is set; toggling play then only starts playback.
The mini player and tab bar no longer expand or animate their inset when playback is
restored at launch, and the first artwork colour lands without a 600ms fade from the
base scheme. The last active artwork seed is persisted and read back synchronously so
the mini player can paint with it on its first frame.
beyondViewportPageCount = 1 keeps the next tab's paging flow subscribed, so Artists
replays cached data instead of showing a spinner the first time it is opened.
Flag the field that is actually blank instead of one combined "Name and address are
required", and focus it so it scrolls clear of the keyboard. The connection/save status
moves above the buttons in the ime-padded bottom bar, where the keyboard cannot hide it.
- treat flac/wav/alac as lossless and ignore the bitrate for them, so the declared length never drops below the real encoded size and truncates a lossless transcode
- read the effective stream quality once per media item and use it for both the URL and the clip, closing the window where they disagreed
- honour a bounded DataSpec length in KnownLengthDataSource and reject non-positive length fragments
- drop MetadataDurationPlayer: clipping already gives the timeline the real duration, and the forwarder could publish a wrong metadata duration for direct play
- drop the dead PlaybackState.error/lastError now that errors are toast-only, and tighten the floor test to assert the exact value
The declared upper-bound length stopped the stall where Media3 saw no seek map duration, but it also made the track look longer than the audio: the player reached the last sample still short of the declared end and waited for data that never came, so it never advanced. Clip each transcoded item to the library duration, which Media3 applies through ClippingMediaSource, so the timeline ends where the audio ends and the next track advances. The declared length still makes the stream on-demand (seekable) and never bounds reads below the real size.
Media3 treats a progressive stream as live, and ignores seeks, when neither the HTTP length nor the extractor duration is known. A transcoded response is chunked with no length and ffmpeg writes no MP3 duration header when streaming, so seeks stopped working once estimateContentLength was dropped. The server estimate is not a fix either: it under-counts (23% at mp3/24) and truncates the track.
Transcoded URLs now carry a declared upper-bound length in the fragment (never sent to the server). KnownLengthDataSource hands that length to Media3, keeping the stream seekable while never bounding reads below the real size. MetadataDurationPlayer reports the library duration to the session so the notification and UI show the true length rather than the upper bound.
The server's byte estimate can be shorter than the transcoded stream, so the player stops at Content-Length and some tracks end early. Let the server stream the true length instead.
- label the rows 'Stream quality: Wi-Fi' / 'Stream quality: Mobile'
- default mobile to 192kbps/mp3 while Wi-Fi stays unlimited/server default
- apply the selection only when Done is pressed, cancelling discards the draft
- fade the bottom of the dialog list so the remaining options are visible as scrollable
- fall back to the legacy max_bitrate/stream_format keys so existing settings survive the upgrade
- keep the last network mode when there is no active network instead of reporting mobile, so a connectivity gap does not flip modes and cause spurious reloads
- skip the quality reload when the player has ended or advanced mid-read
- keep the reload collector alive across transient failures
- edit stream quality from a local dialog draft so a fast second tap is not lost
- cover the mode mapping, migration, paused reload and equal-quality no-op with tests
Combine max bitrate and transcode format into one StreamQuality and store a separate value per network mode (Wi-Fi vs mobile data). NetworkMonitor reports metered vs unmetered via ConnectivityManager, SourceRepository selects and exposes the active quality, and PlaybackController reloads the window at the current position when it changes, so a network switch re-issues stream URLs with the new settings. Reset Coil's singleton in SubtracksApp.onCreate alongside Koin, since ArtworkSeedCache's background scope can call get() across Robolectric tests and poison the next Application.
The index to pixel arithmetic was inline and fiddly (it was missing the row gap), so pull it
out as estimateScrollPx and pin the 2-column cases. ROW_GAP is shared with the grid spacing.
heroBarColor was given the raw content scroll while HeroGradient used the image-bottom
anchor, so an opaque bar did not match the background it covered. Both now use
scrollPx - imageHeightPx. Also add the first row gap the scroll estimate was missing.
The artist background used a static gradient and a scroll-0 bar colour. It now scrolls with
the grid (fading to a dark single colour) and the bar colour comes from heroBarColor at the
real scroll, like the album/playlist hero. The gradient's scroll origin is the hero image
bottom, since the image is full-width and covers the top.
A small primary star shows next to the title on album/playlist detail song rows, the queue
and the artists tab, and in the corner of the album grid. The now playing track info stays
centred.
currentBackStack is @RestrictTo(LIBRARY_GROUP), which CI's Android Lint rejected. There is
no public API that matches a back-stack entry by its arguments, so keep it and suppress the
check rather than fall back to argument-blind popBackStack(route).
- setStar no longer treats cancellation as a rejection: rethrow CancellationException
instead of toasting/rolling back, and roll back under NonCancellable on a real failure
- serialise setStar so overlapping toggles cannot clobber each other's rollback
- the menu star item reverts its label when the request fails (setStar now returns the
result), so the open menu cannot drift from the row
- ContextMenuHost.dismiss clears the actions so a popped screen's ViewModel is released
- one shared setStar from the app shell instead of five per-ViewModel copies
- extract the nav-focus decision into focusPops and unit-test it
- navigateDetail scans the whole back stack and pops back to a matching album/artist/
playlist, not just the top, so artist -> album -> now playing -> view artist focuses the
existing artist instead of pushing a second one
- raise the sheet container to surfaceContainerHigh so it reads against the background
behind it
- menu actions run before the sheet collapses, so tapping an album/artist navigates
immediately instead of waiting for the dismiss; the sheet still animates closed
- host the sheet in the app shell so it survives the navigation it triggers
- now playing fades out (rather than sliding) when its menu navigates to an album/artist,
and navigates first
- navigating to a detail screen already on top of the stack focuses it instead of pushing
a duplicate
- drop the drag handle from both sheets, even the menu header's left/top margins, and add
a bottom margin
- star/unstar updates the local row immediately and rolls back with a toast if the server
rejects it, instead of waiting on the response
- the menu collapses with an animation on dismiss
- star/unstar keeps the menu open and toggles its item in place
- the album/artist rows show the target's name instead of "View album"/"View artist"
- the menu header shows the cover art thumbnail
Album, artist and playlist detail screens get a more button plus long-press on their song
and album rows; now playing gets a more button for the current song. Song menus can reach
the album and artist and star; the now-playing song is read from Room so its star state is
current.
Star is a new write path: MusicSource.setStar posts star.view/unstar.view and
LibraryRepository updates the local row on success (toast on failure). Long-press an
album, artist, song or playlist in the library to open a bottom sheet with play/shuffle,
star/unstar and view-album/view-artist.
The title effect was keyed on both the queue context and the per-track fallback
(album name), so advancing a track reset the resolved playlist/album name to the
album fallback until the async lookup returned. Key only on the queue context;
`remember(queueContext)` already resets it synchronously when the context changes.
- scope QueueViewModel to the queue overlay so its rows are released on close
and a reopened queue cannot show two overlapping lists; key open() on the
overlay visibility so a fast reopen still rescans and refocuses
- reuse row ids by song identity when a window (re)loads so Compose does not
cross-fade the old rows out while the new ones fade in
- fade the artist name scrim in and key its busy check on the thumbnail, the
same ref the theme already uses, so a cached seed shows it immediately
- start detail gradients from the neutral base artwork until the seed resolves
instead of inheriting the now playing theme and snapping to the artist colours
- fade the now playing and detail status bar scrims by putting the graphicsLayer
before statusBarScrim(), which otherwise drew the background outside the layer
- decode full-size and hero covers with FilterQuality.High to stop the moire
during overscroll stretch
Track the active source id and probe when it changes, instead of a remembered probed id.
The behaviour is the same for switching sources, but the intent is now explicit: switch
source, probe it.
An existing token-auth source with an already-synced library would fail playback before
any API call ran: stream and cover-art URLs are built by uri() and fetched by
ExoPlayer/Coil, outside stream(). Probe the active source once when it uses token auth so
the 41 flips and persists before any URL is built.
Also base the retry on the flag the request actually used (concurrent requests no longer
lose their retry), report the fallback from ping() so Add Source flips the toggle instead
of reporting success and saving token auth on, and make the callback non-null.
Navidrome refuses the Subsonic token scheme for LDAP users with error code 41. Catch it
once in SubsonicClient: flip that client to the password scheme, tell SourceRepository to
persist the change for the source and show a toast, then retry the request. Only 41 is
retried, the flip is idempotent, and a client never retries more than once.
Spell out the sync contract: browsing, filtering and sorting never touch the network,
only media bytes and images are on-demand, and a server-derived sort key has to land in a
local column at sync time rather than being requested lazily.
- resolve() no longer discards a stored seed when the bitmap cannot be decoded (it used to
return null, dropping the theme for pre-v12 rows); it falls back to the stored pair
- fold the busy flag into the seed cache value so eviction is atomic (a busy entry could be
evicted from the separate flag LRU while its seed stayed, silently disabling the scrim)
- the artist top bar samples heroBarColor at scroll 0 to match the static HeroGradient, and
drops the wrong scroll estimate and per-pixel recomposition
- rememberOverlaidNameBusy keys its state so the cached flag is read on key change, not only
the first composition; bottomBandBusy bails out on an empty bitmap
- ArtworkSeedStore now carries the flag; artwork_seeds gains a nullable nameBusy column
(DB v12, migration 11->12) so it survives cold starts with no decode
- resolve() computes the flag from the same bitmap it extracts the colour seed from, so
there is no second decode, and persists it alongside the seed
- overlaidNameBusy reuses the seed job and reads the cached flag synchronously, so the
scrim is known before the art shows; drop the fade
- bottomBandBusy now uses perceptual (gamma-space) luma, edge density and a bright
fraction, which catches busy art on dark backgrounds (issue #40) that mean/variance missed
The busy check can only resolve after the art's first decode, so the scrim appeared in a
single frame. Animate it over 180ms so it eases in behind the name.
ArtworkTheme swapped between a bare content() and a MaterialTheme wrapper depending on
whether a scheme existed. When the first track's artwork (or a screen's) resolved, that
branch change remounted the whole subtree, reloading images, resetting scroll and
restarting the mini player. Always provide a MaterialTheme, falling back to the ambient
scheme when there is no artwork, and cover it with a test.
- the artist top bar now uses the default app-bar title style and the art-derived
heroBarColor instead of a hardcoded headline/black/white, so it follows the artist theme
- add a bottom scrim behind the overlaid artist name only when the art under it is bright
or noisy, measured from the seed bitmap (so no extra decode)
- SyncManager now uses a conflated request channel instead of tryLock, so a
request that arrives during a sync (e.g. add-source switching the active
server) is queued and runs afterwards against the then-active source
instead of being dropped.
- Paged loops count raw server rows, not accepted entities, so an empty-id
row on an otherwise full page no longer ends the scan early and prunes
the unseen tail.
- The cap now fails when the page just past it is non-empty, not only when
it is full, matching the bound the error message reports.
- fetchRank rethrows the cap instead of downgrading an over-cap album scan
to empty ranks.
- Docs: the mutex lives in SyncManager, and the prune still holds the
existing-id set.
A full page at the cap is not proof of truncation, so a library whose
size is an exact multiple of the page size failed for no reason. Each
paged loop now keeps going one page past the cap and only throws when
that page comes back non-empty; a library up to the cap syncs normally.
Add a boundary test for the exact-cap case.
Also note the accepted per-batch observer invalidation churn in the sync
code and the architecture doc.
- Paged loops now throw when they hit maxPages with a full last page, so a
truncated enumeration aborts the entity's collect and skips its prune
instead of deleting rows the sync never saw. The cap is injectable for
tests.
- Move sync serialization into SyncManager (tryLock coalesces duplicate
requests) and construct SyncService there; SourceRepository only exposes
the active MusicSource. Removes the repo <-> sync import cycle.
- Re-throw CancellationException instead of reporting a cancelled sync as
failed.
- Add buffer(1) to the outer albums/songs/playlistSongs flows so their
flowOn channel cannot hold 64 batches.
- Rank prefetch is best-effort (missing frequent/recent no longer aborts
the album sync).
- Skip playlist entries without an id, keeping positions contiguous.
- Chunk stale-disc deletes like the other prunes.
- Update the implementation plan line that still described the old
single-transaction sync.
- SourceRepository now holds a Mutex around sync(), so overlapping
requestSync() calls can no longer interleave per-batch writes and
stale-set prunes. The per-call SyncService lock could never do this.
- parse() wraps SAXParseException/IOException as SubsonicException, so a
truncated or malformed response is a clean sync failure instead of a
raw parser exception escaping the empty-search fallback.
- A response with no status attribute is accepted again, matching the
old DOM behaviour (only status="failed" fails).
- Simplify the SAX handler (drop sawRoot and StatusHandler), make the
entity mappers internal, and drop an unreachable guard in write().
Tests: pagination and empty-search caching for songs, album pagination,
empty-id filtering, cancellation, SAX error paths and structural edge
cases, cross-source prune isolation, emptied playlists, disc-set shrink,
multi-chunk deletes, change detection via total_changes, playlist id
filtering, and SyncManager cache invalidation on failure.
The DOM-based sync materialised the whole library in memory before
writing: SubsonicSource accumulated every paged response into one list
and SubsonicClient built a full DOM per response, so peak memory grew
with the library size (hundreds of MB for large libraries).
Replace it with a streaming pipeline:
- SubsonicXml is now a SAX reader that maps entities straight from the
stream. Album disc titles are accumulated from child elements and
attached when the album closes.
- SubsonicClient exposes stream()/check() instead of returning a DOM.
- MusicSource returns batches as Flow<List<T>>; SubsonicSource emits one
batch per response page with backpressure so only one batch is held.
- SyncService syncs each entity sequentially, commits one transaction
per batch, and prunes with an existing-minus-seen stale set. Playlist
song tails are pruned once the whole flow has been counted, and
orphaned playlist_songs rows are removed with a single delete.
Sync is no longer atomic: on failure the committed batches remain and
the queue's library cache is now invalidated on failure too.
- QueueRepository.sourceName returns null for a blank album/playlist name so the now
playing title fallback applies instead of rendering empty
- resolve the now playing source title with remember + LaunchedEffect instead of
produceState so it resets synchronously when the queue context changes
- extract the repeated status bar scrim into Modifier.statusBarScrim() and use it in
AppRoot, LibraryScreen and HeroDetailScaffold
On the library no navigation happens, so ours was registered before the NavHost's and
lost; keying the DisposableEffect on the overlay flags re-adds it last as soon as the
overlay opens.
The NavHost registers its own back callback after ours, so on a pushed route it won
the back and popped the route under the now playing overlay. Re-register our callback
on every navigation (DisposableEffect on the current route) so it is always the most
recent, and toggle its enabled state with the overlay/queue.
Keeps the NavHost composed (the dropped-NavHost version left the still-transparent
overlay over black) while stopping the controller from winning the back over the
overlay's BackHandler. Back now: queue -> now playing -> pop the route.
- restore the now playing overlay (the destination had too much moving during the
transition); while the overlay is open, omit the NavHost so it has no back callback
to steal the back from the overlay's BackHandler
- first disc header gets a little top padding (16dp), the rest keep 40dp
- keep the fab glyph container 28dp so the filter dot aligns as before
The overlay fought the NavHost over the back: OnBackPressedDispatcher hands the back
to the last-registered enabled callback, and the NavHost registers its own as part of
its composition, so it intermittently popped the route underneath. A destination lets
the back stack own it.
- add Routes.NOW_PLAYING with a vertical slide, opened by tapping the mini player or
flicking it up; back or a downward fling pops it
- hide the mini player while on the now-playing route so it fills the screen
- the static scrim now follows the route instead of a drag progress
- drop the nowPlayingOpen/progress overlay state and its test
- register the now playing and queue back callback from a LaunchedEffect instead of BackHandler: the NavHost registers its own callback from a LaunchedEffect, so a BackHandler composed before it loses the back and pops the route underneath
- NowPlayingBackTest registers the overlay handler before the NavHost (the losing order) and asserts back closes the overlay without popping the route
- more top padding on disc headers, except the first which sits flush
- LIST_OPTIONS_WEIGHT 0.15 -> 0.11
- Now Playing: the type line reads "songs" for the library songs list, source line stays "Library"
- Album detail: align the disc icon over the track numbers and the disc label with the track titles (16dp start + 18dp icon + 16dp gap, matching the ListItem columns)
- Add NowPlayingBackTest covering back-from-overlay not popping the destination
The bundled Material icon set has no weight axis, so the FAB now draws the
three-bar glyph with a configurable stroke thickness instead of using Segment.
- FAB: use the Rounded Segment icon so the glyph reads thick
- Now Playing: two-line header, an uppercase "Now playing: <type>" label (disc-title style) above the source, both left aligned
- CoverArt: remember the aspect ratio every image loads at and seed the non-square placeholder from it, so the detail hero does not start square and snap
- derive the now playing fallback title from the current item for every kind except the songs list, so a track reordered into the album queue (now a Song entry) still shows its album
- reset the resolved title to the fallback when the queue context changes, so crossing albums does not briefly show the previous name
- start the now playing scrim fade later (0.85) so it darkens the sheet, not the library, while the sheet is still sliding up
- reuse the thumbnail's onSuccess painter ratio instead of loading a second thumbnail painter for the aspect ratio
- drop the now-unneeded ExperimentalLayoutApi opt-in on AddSourceScreen (imePadding is stable)
- CoverArt: use the thumbnail's intrinsic ratio until the full-res painter loads, so non-square art no longer snaps
- Now Playing: move the top scrim out of the sliding screen into MainNavigation, static under the status bar, fading in as the screen settles at the top
- Now Playing: titlebar shows the playback source (album/playlist name, or Library) instead of "Now playing"
- Add server: keep the test/save bar above the keyboard on first launch
- Titlebars: use the theme default title style instead of headlineMedium
- Library: clicking an item in a searched tab dismisses the search
- Library FAB: use the AutoMirrored Sort icon at 28dp so the glyph reads a little thicker
The header was the same size as the track title, so render it uppercase
labelLarge with letter spacing to separate it from the tracks. Use the
outlined FiberSmartRecord icon.
DiscFull reads as an error icon, so use FiberSmartRecord, render it on
the "Disc N" fallback too, sit it 8dp from the edge with 8dp before the
text, and size it 20dp.
Use Icons.Rounded.DiscFull, which is distinct from the album tab's
Icons.Rounded.Album, and only beside a real title rather than the
"Disc N" fallback.
When two discs of an album share a title, showing "Live" alone is
ambiguous, so render "Live: Disc 1"/"Live: Disc 2". Unique titles stay
as-is. Also drop the header from titleLarge to a semibold titleMedium so
longer titles fit on one line.
Navidrome and gonic expose OpenSubsonic discTitles on the album response.
Parse them into a transient Album.discTitles map and persist each album's
titles in a child discs table (sourceId, albumId, disc, title) cascading
from albums. Songs keep only their disc number, and the album screen joins
by disc to render a title when one exists, falling back to "Disc N".
Titles are read opportunistically: a server that doesn't send discTitles
just leaves the table empty, so no API version negotiation is needed.
Titles apply to single- and multi-disc albums, are pruned when removed,
and are stored per album.
Schema 10 -> 11.
Drives the pager across the Artists to Playlists transition and asserts the
Songs icon only moves one way. Fails on the min-width clamp version (738-727-745)
and passes on the natural-48dp version. LibraryTabs is internal so the test can
drive its PagerState.
A min-width clamp kept an incoming tab at 48dp until its label passed ~4dp while the outgoing label shrank immediately, so the tab row dipped left and then grew right. With the tab naturally 48dp wide (8dp/16dp padding around the 24dp icon) the width tracks the label continuously and the row moves one way.
Replace minimumInteractiveComponentSize (which centred the whole tab content and drifted as the active label expanded) with an explicit 48dp touch target whose content is aligned to the start. The icon position no longer depends on the label width.
Tracking the content position per frame made the inactive-tab colour split chase the layout instead of only following the indicator, so the icons jittered on a swipe. Go back to the stable tab-relative offset.
- offer a Sync button on a truly empty library tab so an empty library is not stranded (pull-to-refresh needs scrollable content)
- do not close the search field when it auto-syncs from a tab with an empty term
- make rememberArtworkColors nullable so the no-track fallbacks are live and the idle theme is the monochrome background
- measure the tab overlay content offset so the colour clip accounts for the touch-target centering
- put the pull indicator below the status-bar scrim, and seed the tab bar height estimate to avoid a first-frame inset jump
- collapse the two equal clearance constants, drop the redundant search imePadding, reuse the hoisted artwork for the mini player, and de-duplicate the mini player screenshots
- the indicator now uses the artwork primary and player surface instead of the default onSurfaceVariant
- keep the tab bar height in the list inset while searching so the content does not expand for a frame before the keyboard lands
- replace the library sync button with pull-to-refresh on every tab
- hide the tab bar while searching so only the search field sits above the keyboard
- animate the mini player progress bar between full bleed and 8dp inset rounded ends as the tab bar comes and goes
- make the warm-hue saturation damping continuous across the 0/360 hue wrap
- restore the library sync control removed when the tab bar was reworked, with a test that clicks it
- lift the search field above the tab bar instead of overlapping it
- give the tabs and settings/sync buttons 48dp touch targets
- drop the duplicated label semantics on tabs
- animate the library bottom inset so the bar does not jump when the mini player enters/exits
- remove imports and a parameter left dead by the header rework