Commit Graph
291 Commits
Author SHA1 Message Date
austinried a97a5e0687 bound artwork seeds and size the now-playing prefetch 2026-10-04 00:29:58 +00:00
austinried ca87a38276 address review: cover tab re-entry and the paused position ticker 2026-10-04 00:29:58 +00:00
austinried 0b5946e09b invalidate the library cache before publishing the sync result 2026-10-04 00:29:58 +00:00
austinried 8429a4529f collect only the visible tab and split the position ticker from playback state 2026-10-04 00:29:58 +00:00
austinried 97ca4230da address review: offload artwork decode, drop the redundant metadata override, cancel the loader 2026-10-04 00:29:58 +00:00
austinried 4095d038a9 load notification artwork through Coil by cover-art identity 2026-10-04 00:29:58 +00:00
austinried a356226e48 address the review: keep the stored seed on a failed decode, one cache, static bar colour
- 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
2026-10-04 00:29:58 +00:00
austinried f4ca0fd6d4 compute and persist the artist name busy flag with the artwork seed
- 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
2026-10-04 00:29:58 +00:00
austinried 658b7f9452 fade the artist name scrim in instead of popping it in
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.
2026-10-04 00:29:58 +00:00
austinried 6081a5c009 keep the theme wrapper stable so the layout doesn't remount when artwork arrives
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.
2026-10-04 00:29:58 +00:00
austinried 2ba87114da align the artist top bar with the themed screens and scrim a busy name
- 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)
2026-10-04 00:29:58 +00:00
austinried 8b43de25f7 address final review: coalesce sync requests, fix page-cap edge
- 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.
2026-10-04 00:29:58 +00:00
austinried 7b06b23860 confirm the end of a full last page before failing the page cap
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.
2026-10-04 00:29:58 +00:00
austinried c48ec670d8 address arch review: guard the page cap, coalesce syncs, tighten buffers
- 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.
2026-10-04 00:29:58 +00:00
austinried be6bcd11dc address review: serialize syncs, classify parse errors, widen coverage
- 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.
2026-10-04 00:29:58 +00:00
austinried b171b0b33c stream sync with SAX and write the library per batch
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.
2026-10-04 00:29:58 +00:00
austinried bce074ecdc address the review: blank source name, synchronous title reset, shared scrim
- 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
2026-10-04 00:29:58 +00:00
austinried b16fe0f8d6 nudge the playlists tab glyph so it reads the same size as the neighbours
The PlaylistPlay glyph sits smaller and left in its viewport; scale it up slightly and
shift it right, like the download glyph's vertical nudge.
2026-10-04 00:29:58 +00:00
austinried d28c6c77dd re-register the overlay back callback when the overlay state changes
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.
2026-10-04 00:29:58 +00:00
austinried ad5660381f keep the overlay back callback last in the dispatcher
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.
2026-10-04 00:29:58 +00:00
austinried 6e53b05973 disable the NavHost back handling and drive back ourselves
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.
2026-10-04 00:29:58 +00:00
austinried b849bd5c6f revert the now playing destination; drop the NavHost while the overlay is up
- 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
2026-10-04 00:29:58 +00:00
austinried e8db675470 make now playing a NavHost destination
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
2026-10-04 00:29:58 +00:00
austinried fd37c3ad86 fix back popping the route under now playing; disc header spacing; dial back the fab glyph
- 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
2026-10-04 00:29:58 +00:00
austinried d43dd51b15 songs type label, disc header alignment, now playing back test
- 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
2026-10-04 00:29:58 +00:00
austinried b10006cba1 draw the library list-options glyph at a heavier weight
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.
2026-10-04 00:29:58 +00:00
austinried f490ca8fa7 thicker library fab icon, two-line now playing header, cached art ratio
- 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
2026-10-04 00:29:58 +00:00
austinried 216990181e address the review: source title after a queue reorder, scrim timing, thumbnail painter
- 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)
2026-10-04 00:29:58 +00:00
austinried 2e925f2f78 ui nits: cover placeholder ratio, now playing scrim/title, add-server keyboard, titlebars, search dismiss, fab icon
- 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
2026-10-04 00:29:58 +00:00
austinried 2552e1a10c match the disc header text case-insensitively in the screenshot test 2026-10-04 00:29:58 +00:00
austinried 64ec65d746 add a little more space above disc headers 2026-10-04 00:29:58 +00:00
austinried b8f8297c9b make disc headers read as section labels
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.
2026-10-04 00:29:58 +00:00
austinried 5fe32c8766 show the disc icon on every disc header
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.
2026-10-04 00:29:58 +00:00
austinried 68a5787d9f add a disc icon next to disc titles
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.
2026-10-04 00:29:57 +00:00
austinried 33e47e9654 suffix repeated disc titles with their disc number
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.
2026-10-04 00:29:57 +00:00
austinried 863c17be7d show album disc titles instead of generic disc numbers
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.
2026-10-04 00:29:57 +00:00
austinried d36c752c72 test the tab row reflows in a single direction during a switch
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.
2026-10-04 00:29:57 +00:00
austinried 57957880d9 size the collapsed tab to 48dp so the label reflow never clamps
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.
2026-10-04 00:29:57 +00:00
austinried 7009aa8ef5 keep the tab content left-aligned so min-size centring cannot shift the icons
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.
2026-10-04 00:29:57 +00:00
austinried ef349f0346 revert the tab overlay clip measurement
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.
2026-10-04 00:29:57 +00:00
austinried 5b88792f72 address the code review findings
- 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
2026-10-04 00:29:57 +00:00
austinried 60acdbeae3 theme the pull-to-refresh indicator from the artwork and stop the library reflow on search
- 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
2026-10-04 00:29:57 +00:00
austinried e031e92e2d pull to refresh, hide the bar while searching, animate the progress inset, circular warm damping
- 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
2026-10-04 00:29:57 +00:00
austinried 10b21303d2 address the adversarial review findings
- 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
2026-10-04 00:29:57 +00:00
austinried 13568b9bee raise the library fab elevation for a visible shadow 2026-10-04 00:29:57 +00:00
austinried 639c86bccc square the progress track ends, keep the rounded head 2026-10-04 00:29:57 +00:00
austinried c8231c88b4 make the mini player progress bar full width again 2026-10-04 00:29:57 +00:00
austinried 4e9932fc1e 3dp progress bar and 8/8/8/12 mini player padding 2026-10-04 00:29:57 +00:00
austinried 2a14f3bde5 try a 2dp mini player progress bar 2026-10-04 00:29:57 +00:00
austinried 675f235035 inset and round the mini player progress bar; align its cover art at 8dp 2026-10-04 00:29:57 +00:00