Commit Graph
100 Commits
Author SHA1 Message Date
austinried e6386ebddd keep the up-next block reachable when the playing context track is removed while shuffled
Review found that removing the playing context track while shuffled with a
non-empty up-next block stranded the block behind the cursor: the
repository moves `upNextAnchor` to the preceding track, but the cursor's
re-derived slot position can land after the block, so the queued tracks
were skipped. Re-anchor the block to the track the cursor lands on, same
as `playAt`/`toggleShuffle`.

- regression test loops the sequence over fresh random seeds and asserts
  the block stays reachable; fails without the fix ("stranded behind the
  cursor").
- `removingTheCurrentTrackWhileShuffled...` now asserts the canonical slot
  the current track occupies instead of recomputing the production position
  formula (which only checked the implementation against itself).
- `QueueRepository.removeAt` returns Unit; every caller discarded the
  snapshot and re-read it, so it no longer builds one.
2026-10-04 00:30:01 +00:00
austinried 93742e8584 rework: derive the shuffle order from a seed on top of the up-next queue
Replaces the per-track `shuffle_order` table and the retained whole-order
arrays with `playback_cursor.shuffleSeed`/`shuffleSize` and a seekable
Feistel permutation of `[0, size)` (`Shuffle.kt`). Positions map through
`flatContext`/`playContext`; a snapshot is shuffled only while the stored
size matches the context, so a stale size is ignored. Queue migration
16->17 adds `shuffleSize` and drops `shuffle_order`.

Drops the "dragging a context row while shuffled reorders the canonical
queue" behaviour: it cannot be made WYSIWYG without materialising the
order. The context is playback-only while shuffled (the view already
disables context drags) and `QueueRepository.move` no-ops for a context
move while shuffled; manual ordering goes through the up-next block.

Removing a context track while shuffled re-derives the permutation at the
smaller size, keeping the same seed and the playhead on the current track,
or on whatever now fills the removed track's slot when it was playing.
2026-10-04 00:30:01 +00:00
austinried 0a11585205 Revert the downloads block 2026-10-04 00:30:01 +00:00
austinried ae41b0e66a publish the download map before signalling it is loaded
Second review round on the downloads block:

- `loaded` was completed upstream of the state flow that publishes the map, so
  `awaitLoaded()` could return while the map was still the initial empty one and
  the first window after a cold start would stream a track that is already on
  disk. The collector now assigns the map before completing, and the map is a
  plain state flow instead of a lazy eager `stateIn` with a bare touch to start
  it.
- A failed download now deletes its partial file, in both the engine-failure
  and the recovery paths. A failed row used to protect its own partial bytes
  from the sweep, and the UI offers no delete for it.
- A queued row is left queued when the download URL cannot be resolved yet,
  instead of being failed. At launch the repository's reconcile can run before
  the source repository has built its client, and failing there turned a
  resumable download into a dead one.
- The poller only considers the active source's rows, so a queued row belonging
  to another source no longer keeps it polling the database every second.
- `SourceRepository.downloadUri` takes the expected source id and returns null
  unless it is the active one, so a download cannot be enqueued against the
  previous server's URL during a source switch.
- Tests: a stream with no content length still gets its declared length through
  the production data source factory (the http half of the chain, which the
  first round left uncovered), and an engine failure is asserted to leave no
  partial file. The engine's status mapping now runs as a plain unit test.
2026-10-04 00:30:01 +00:00
austinried e037f3ef37 fix downloaded playback and tighten the download state machine
Review findings on the first downloads block:

- ExoPlayer could not open a downloaded file at all: the service's data source
  factory was OkHttp only, so a file:// URI went to the HTTP stack and failed
  with a malformed URL. Wrap it in DefaultDataSource so the file, asset and
  content schemes are resolved before the stream chain, and cover it with a
  test that reads a local file through the production factory.
- Reconcile no longer restarts a failed download, or a running one whose engine
  entry vanished: only a queued row is started again, and the rest become failed
  so the explicit Retry in the UI means something.
- song_downloads also references songs with CASCADE now, so a song pruned from
  the mirror takes its download row with it and the sweep can reclaim the file.
- The mutating methods run on the injected dispatcher rather than the caller's,
  which was the main thread for every call site, and the repository exposes one
  shared state flow instead of a second map plus five subscriptions.
- Playback stops when the active source disappears, including deleting the last
  source while a downloaded track is playing.
- Dropped the unused request mime type, the queued-at column nothing read, the
  metered constructor parameter nobody varied, and a dead index guard.
- The running indicator is asserted positively, the engine's status and reason
  mapping has a test, and the failure paths use the fake's failure and forget
  helpers.
2026-10-04 00:30:01 +00:00
austinried 7ac79288b3 download a song to the device and play it from there
First block of #65: the state machine, the platform download engine, a
single-song download from the song context menu, the downloaded indicator in
song lists, and local-first playback.

- `song_downloads`, keyed `(sourceId, songId)` with CASCADE on the source, and
  the 16 -> 17 migration.
- `DownloadEngine` over `android.app.DownloadManager`, with a fake for tests.
  Files land in app-specific external storage at
  `.../Music/downloads/<sourceId>/<songId>`, plus a `.nomedia` so media scanners
  on pre-Android-10 devices leave them alone.
- `DownloadRepository` enqueues, mirrors progress by polling, cancels, deletes
  and reconciles on start and on source change: a queued row whose engine entry
  is gone is started again, a completed row whose file is gone becomes failed
  rather than being resurrected, and files with no row are swept.
- Playback prefers the downloaded file: the media item build takes the local URI
  when the download is complete and leaves it unclipped.
- Deleting a download reloads the player window at its position, so the item
  falls back to streaming instead of failing mid-read.
- Deleting a source cancels its downloads and removes their files.

Downloads are the system's own notifications and stay off metered networks for
now. Artwork, bulk downloads and the management screen are later blocks.
2026-10-04 00:30:01 +00:00
austinried 07fb07762c keep the artist targets narrow and clear of the controls
Both targets were too greedy. They now hug the text instead of spanning the row, so a tap
beside the line does nothing, and the album detail target stops short of the controls
instead of reaching down to them.

Album detail: the line plus its 4dp lead-in and 8dp below (32dp), with the original
spacing kept, so the layout is exactly where it was. Now playing keeps the title+artist
block (58dp) but is no longer full width.

Both are shorter than the 48dp recommendation on purpose, to stay clear of the slider and
the play/shuffle buttons. Tests assert the measured geometry: no full-width target, a
12dp gap above the controls, and the now playing block staying at its own 58dp.
2026-10-04 00:30:01 +00:00
austinried 17a41fd579 give the album detail artist line a full touch target
The subtitle was a bare bodyMedium line: 20dp tall and only as wide as the text. It now
sits in a full-width target spanning the 4dp lead-in and the 20dp gap below it, so the
row clears 48dp while the text stays where it was. The album title is deliberately not
part of it, unlike now playing: it can wrap to two lines, so tapping it should not jump
to the artist. The controls row moves down 4dp, the only layout delta.

AlbumDetailTapsTest covers the tap, the 48dp minimum (fails on a 20dp node) and that the
title carries no click action.
2026-10-04 00:30:01 +00:00
austinried a296443109 make the now playing artist target the whole title block
The artist line is a single bodyMedium line, 20dp, well under the 48dp minimum touch
target. A 48dp artist-only target cannot fit next to the 32dp title inside the 58dp
block without borrowing from the title's bottom edge or from the slider's top padding,
so the block itself becomes the target: 58dp and full width, with no layout change and
nothing to overlap (the slider keeps its own 20dp of top padding before the track).

The trade-off is that tapping the song title now opens the artist too. The test asserts
the measured target is at least 48dp, which fails on a 20dp node.
2026-10-04 00:30:00 +00:00
austinried 7b1696da5e address the review of the tap targets
The "stays inert" test was tautological: the render helper always passed non-null no-op
lambdas, so its assertion held for any implementation. It now passes nullable callbacks
and asserts the targets are disabled, which fails if the gate is removed.

Accessibility: both taps carry onClickLabel ("Open album"/"Open artist") and the artist
box spans the full row, and the now playing cover passes showPlaceholder = coverArt ==
null so the always-present placeholder letter is not merged into the tap target's label
(the same flash the artist detail cells had).

Left as-is: the artist target is still one line tall (~20dp), and the album detail
subtitle tap covers the year too. Both are layout/scope trade-offs.
2026-10-04 00:30:00 +00:00
austinried b55e5cc5e8 make the now playing art and artist names tap targets
Tapping the cover art on now playing opens its album and tapping the artist name opens
the artist, matching the context menu actions; the album detail subtitle does the same
for its artist. Both are inert when the queue item has no album/artist id.

Artist detail's album cells also stop drawing the letter placeholder underneath: with a
thumbnail underlay and the full image on top, the letter only showed because the default
`showPlaceholder = true` was left on, so a cold cell flashed a letter before the art.
They now match the hero's `showPlaceholder = art == null`.
2026-10-04 00:30:00 +00:00
austinried edd6d850dd drop add to queue / play next from the now playing context menu 2026-10-04 00:30:00 +00:00
austinried b9e5e07506 use the delete-sweep icon for clearing the up-next queue 2026-10-04 00:30:00 +00:00
austinried 06cf49f4e8 queue view: disable drag auto-scroll; queue plays next when its anchor is removed
- removing the context track the up-next block sits behind now moves the
  block back behind the preceding track (or to the front when the anchor
  was first), so the queued track plays next instead of the following
  context track.
- disable sh.calvin.reorderable auto-scroll for every run
  (scrollThreshold 0). It keeps scrolling past a run's last valid target
  and can strand the dragged item off-screen; scroll first, then drag.
2026-10-04 00:30:00 +00:00
austinried 42840cd9ad queue view: stop context drags crossing the block, stabilise the title
- split the context into a reorder state either side of the up-next block.
  A context drag can no longer target across the block, so dragging from
  the context over a long queued block stops at the block instead of
  jumping over it and force-dropping.
- the context header's fallback is now the playing track's album, so it
  no longer flashes "Next from here" while the async name lookup runs
  when tapping between manually queued and context tracks.
2026-10-04 00:30:00 +00:00
austinried 718dcf31bd allow queue loading during drag; keep the album title after a reorder
- the mid-drag load guard also stopped the window extending while
  auto-scrolling, so dragging a context row over a long up-next block hit
  the loaded edge and dropped. Removed it: loadOlder/loadNewer now extend
  from the min/max loaded position, which is what prevents the duplicates.
- a reordered track becomes an explicit song entry, so the context kind
  switches to Song and the queue title fell back to "Next from here".
  sourceName now resolves a song entry to its album so the title keeps
  the album name.
2026-10-04 00:30:00 +00:00
austinried b189ae0cc5 stop the queue view extending itself mid-drag; restore context drag scroll
- the visible-items flow fired loadOlder/loadNewer during a drag, and the
  live reorder leaves row positions out of order, so the window re-added
  rows it already had — the queue duplicated under the dragged item.
  Skip loading while a drag is active, and extend from the min/max loaded
  position rather than the first/last row.
- auto-scroll is back for the context reorder state (a normal queue
  scrolls while dragging again); the up-next state keeps it disabled
  because its block is followed by context rows the scroller would run
  past with nowhere to drop.
2026-10-04 00:30:00 +00:00
austinried 5abc48848b name the reorderable library in the queue view comment 2026-10-04 00:30:00 +00:00
austinried eb5f5703f4 document why the queue view keeps a per-section reorder state and index-keyed headers 2026-10-04 00:30:00 +00:00
austinried 34f0e15a7a address review: anchor relocation and empty-context positions
- clamp anchorPlay to the context size, so an empty context with a queued
  block puts the block at combined 0 (previously position 0 was
  unresolvable and the queue view emptied).
- move/removeAt now derive the playing track's new position from its id
  after an edit, instead of assuming a fixed list delta; moving or
  removing the anchor relocates the whole block, which movedCursor
  ignored.
- removing the currently playing track reloads at the model's next
  position (the anchor may have relocated) rather than removing it from
  the player in place.
- the queue view's remove reconciles its rows from the repository instead
  of decrementing positions by one, so a block relocation stays in sync.
2026-10-04 00:30:00 +00:00
austinried 996b0f8f48 avoid reloading the window on queue edits; add clear up next
- moves that straddle the player window, and deletes that are not the
  current track, rebuilt the playlist with setWindow, restarting the
  playing track (a pause). They now reuse rebuildWindow, which re-lays
  the window around the playing item without replacing it.
- deleting the currently playing queued track now removes it from the
  player in place so it advances to the next track without a reload.
- the Up next section header has a clear button that drops the whole
  queued block and resumes the context after the anchor.
2026-10-04 00:30:00 +00:00
austinried bff16e0dd1 consume a finished queued track without reloading the window
Advancing naturally out of an up-next track rebuilt the whole window,
which reset the already-playing next track back to its start — audible
as the first second repeating. Remove the finished item from the player
in place (as the context edit path already does) and recentre instead,
so the current item and its prepared source survive. Regression test
asserts no setWindow on a natural advance out of a queued track.
2026-10-04 00:30:00 +00:00
austinried 3e459e4c84 keep lastPosition in sync when a track move or delete shifts the current
move/removeAt/undo (and the window shift) changed the current track's
position without updating lastPosition, so the next transition compared
against a stale position and consumed whatever now sat there — moving a
queued track in front of the playing one deleted the moved track on the
following skip. Added an eager-dispatcher regression test.
2026-10-04 00:30:00 +00:00
austinried e23f78aa47 add play next / add to queue to the artist detail album menu 2026-10-04 00:30:00 +00:00
austinried 50be4e79e0 queue view: stop reloading on edits, fix drag boundaries
- deleting a track no longer bumps the layout version, so the view does
  not reload and jump to the current track; the ViewModel already
  updates its rows.
- layout changes (re-anchor, enqueue, consume) now reconcile positions in
  place instead of calling open(), which cleared the rows and scrolled
  back to the cursor.
- section headers are separate list items and reordering maps by key, so
  the header no longer rides along with a dragged row (top flicker).
- disable the reorderable auto-scroll (scrollThreshold = 0). The library
  scrolls past the section boundary with no valid target, then abandons
  the scroll on release; without auto-scroll a drag stays put and drops
  in the last reachable slot.
- keep the current track when moving a queued track before it (eager
  dispatcher regression test).
2026-10-04 00:30:00 +00:00
austinried 5462b0f4ba fix up-next drag and previous behaviour
- set lastPosition before seeking in jumpTo. With Dispatchers.Main
  .immediate the media transition runs synchronously inside the seek, so
  onPositionChanged saw the old position as an auto-advance and consumed
  the track you were leaving; previous from a queued track removed it and
  played the next one.
- give the up-next and context sections their own reorderable states. The
  library only targets items whose key is in that state's reorderableKeys,
  so a drag can no longer target a row in the other section: no more
  fighting the section boundary when dragging to the top and no no-op drop
  below the block.
- disable context reordering while shuffled via ReorderableItem(enabled).
2026-10-04 00:30:00 +00:00
austinried e7a1927ccd address review: re-anchoring and consume edge cases
- jumpTo no longer returns early on a stale current position after a
  re-anchor, which could leave the player and snapshot unsynchronised.
- playNext from inside the block inserts after the entry holding the
  current track, not always at index 1.
- expose a layout version in PlaybackState so the queue view reloads
  when a re-anchor shifts the combined positions under it.
- removeAt recomputes the current track's combined position from the
  refreshed snapshot instead of assuming a one-down shift.
- restore keeps the saved resume position when the anchor moves.
- next on the last queued track with repeat off no longer consumes it
  and leaves it playing; repeat wraps instead.
- drop a redundant sort in range; revert a rejected cross-section drag
  by reloading.
2026-10-04 00:30:00 +00:00
austinried 9832f7c217 float the up-next block behind the current track
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.
2026-10-04 00:30:00 +00:00
austinried d5a9a11cef add a separate, editable up-next queue (#61)
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.
2026-10-04 00:30:00 +00:00
austinried 50b99979e1 drop the first-play pre-buffering
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.
2026-10-04 00:30:00 +00:00
austinried ffbbf46b43 gate the library on playback restore so the bottom bar lands in place
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.
2026-10-04 00:30:00 +00:00
austinried f7a5502240 fix the startup reveal and the first-play hitch for real
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.
2026-10-04 00:30:00 +00:00
austinried 3bcf87b5b4 prepare the restored track so the first play is instant
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.
2026-10-04 00:30:00 +00:00
austinried ea09f24844 skip the startup reveal animation and seed the first colour
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.
2026-10-04 00:30:00 +00:00
austinried c7bab22dc5 preload the neighbouring library tab
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.
2026-10-04 00:30:00 +00:00
austinried 9f250d49f2 fix the server form's required-field errors
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.
2026-10-04 00:30:00 +00:00
austinried d21b646858 drop the legacy stream-quality fallback
No need to migrate the old max_bitrate/stream_format keys.
2026-10-04 00:30:00 +00:00
austinried a0647deaae address playback review findings
- 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
2026-10-04 00:30:00 +00:00
austinried ebaa486126 clip transcoded tracks to their real duration
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.
2026-10-04 00:30:00 +00:00
austinried 22feb2372d keep transcoded streams seekable without cutting them short
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.
2026-10-04 00:30:00 +00:00
austinried 525c3fa0d4 show playback errors only as a toast
Remove the inline error line so the now playing layout never has to resize around it; the toast already surfaces the message.
2026-10-04 00:30:00 +00:00
austinried 7fb832e116 toast a friendly message for playback connection errors
Map the Media3 network/HTTP error codes to readable text and show it as a toast, instead of leaving an opaque ERR_ code on the now playing screen.
2026-10-04 00:29:59 +00:00
austinried 258dde6c86 drop estimateContentLength from stream URLs
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.
2026-10-04 00:29:59 +00:00
austinried 95216143dc polish stream quality settings
- 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
2026-10-04 00:29:59 +00:00
austinried 9e25a971e6 address network quality review findings
- 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
2026-10-04 00:29:59 +00:00
austinried 117e8a69fb add network-dependent stream quality
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.
2026-10-04 00:29:59 +00:00
austinried 8ba0aa5192 address review: renumber moves from the window start and version the length cache 2026-10-04 00:29:59 +00:00
austinried 8ae5ba5e4b bound the queue view and compact edited queue ranges 2026-10-04 00:29:59 +00:00
austinried 3f4dc5f866 shadow the starred badge with the icon shape, not a disc
Modifier.shadow only takes a Shape, so over artwork the star now draws with a dark copy of
the same glyph offset behind it instead of a circular halo.
2026-10-04 00:29:59 +00:00
austinried 73db0681b5 give the starred badge a shadow when it sits over artwork
Over a cover the primary star was hard to read, so the album grid and artist album cells now
draw it with a small circular shadow.
2026-10-04 00:29:59 +00:00
austinried 7af87d63d9 extract the artist grid scroll estimate and test it
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.
2026-10-04 00:29:59 +00:00
austinried 8d8c8be2a8 address review: share one scroll origin for the artist bar and gradient
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.
2026-10-04 00:29:59 +00:00
austinried 63279dec50 scroll the artist detail background and match its bar to the hero
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.
2026-10-04 00:29:59 +00:00
austinried dcdcf553e2 show the starred indicator on artist album cells too 2026-10-04 00:29:59 +00:00
austinried 525ab87c9e add a starred indicator and drop the album from the now playing subtitle
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.
2026-10-04 00:29:59 +00:00
austinried d703b8ec5b index the album, artist and playlist list sorts 2026-10-04 00:29:59 +00:00
austinried f05a1c79a8 address review: save the selected tab by name and drop the dead songIds query 2026-10-04 00:29:59 +00:00
austinried e3af5ed0ac drop the songs tab and the whole-library song queue 2026-10-04 00:29:59 +00:00
austinried 9cf2c99fb5 suppress the restricted NavController.currentBackStack in navigateDetail
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).
2026-10-04 00:29:59 +00:00
austinried 7fe6637436 give the FAB options sheet a top margin and drop the rule under the search row 2026-10-04 00:29:59 +00:00
austinried a43f9f3412 keep the menu icons on their content colour so they are not black
Color.Unspecified disables Icon's colour filter, which fell back to the vector's black; only
the filled star should be the primary colour.
2026-10-04 00:29:59 +00:00
austinried da1c6f37d8 tint the filled star with the primary colour 2026-10-04 00:29:59 +00:00
austinried ccdeca563f address review: cancellation-safe star, one star handler, focus logic tested
- 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
2026-10-04 00:29:59 +00:00
austinried 38679c4959 address feedback: focus detail screens anywhere in the stack, brighter sheet
- 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
2026-10-04 00:29:59 +00:00
austinried 5aea1d6b4e address feedback: immediate navigation, now-playing fade, stack focus, sheet polish
- 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
2026-10-04 00:29:59 +00:00
austinried c28271590e address feedback: optimistic star and menu polish
- 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
2026-10-04 00:29:59 +00:00
austinried d9e9a6b865 add item menus to the detail screens and now playing
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.
2026-10-04 00:29:59 +00:00
austinried fb9e985680 add star/unstar and long-press item menus to the library lists
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.
2026-10-04 00:29:59 +00:00
austinried ed7a2e7390 stop the now playing title flickering on every track change
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.
2026-10-04 00:29:59 +00:00
austinried da71bccb58 fix queue overlay, focus and artwork loading glitches
- 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
2026-10-04 00:29:59 +00:00
austinried 5fb38a7c9b rebase onto main: move the rank-column drop to 13 to 14 and drop the rank fixture 2026-10-04 00:29:59 +00:00
austinried 0fb2ce2918 address review: drop the dead disc query, pin the fetch bound, cover multi-page disc prune 2026-10-04 00:29:59 +00:00
austinried 34f15176d7 harden sync for large libraries: bounded prune, concurrent fetches, higher page cap 2026-10-04 00:29:59 +00:00
austinried af38f34ecd probe whenever the active source changes
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.
2026-10-04 00:29:59 +00:00
austinried 9c97f64381 address review: probe the active source so media falls back too
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.
2026-10-04 00:29:59 +00:00
austinried 59706f28c1 fall back to password auth when the server rejects the token scheme
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.
2026-10-04 00:29:59 +00:00
austinried 90171468cc address review: keep one unbounded cover axis undefined instead of forcing a square 2026-10-04 00:29:59 +00:00
austinried 43f39300ce decode covers at their display size and bucket the memory cache by size 2026-10-04 00:29:58 +00:00
austinried 11c0276929 store artwork seeds without read-time writes and prune periodically 2026-10-04 00:29:58 +00:00
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 d11bd45fe7 document that the library is mirrored offline in full
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.
2026-10-04 00:29:58 +00:00
austinried e50332bfff rephrase the test bar for clarity 2026-10-04 00:29:58 +00:00
austinried 54db1d7544 document the bar for a test being worth having 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 3c941ef0f2 document the streaming sync in the architecture doc
The Sync section still described fetching the whole library and writing
it in one transaction, and the stack table still said DOM parsing.
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