Commit Graph
864 Commits
Author SHA1 Message Date
austinried 3eef4e79b6 try a white play glyph over art 2026-10-04 00:30:02 +00:00
austinried e222a76500 back to a primary play glyph with a white art border 2026-10-04 00:30:02 +00:00
austinried 077a2ced77 tint the playing indicator with the artwork's secondary 2026-10-04 00:30:02 +00:00
austinried 94c514cdd9 Revert "replace the play arrow with an animated bar indicator"
This reverts commit c67569a15a7c7150de3601b57a59e2ed2ba302ef.
2026-10-04 00:30:02 +00:00
austinried a23d9a3c31 replace the play arrow with an animated bar indicator
Three equaliser bars wave with staggered phases to draw the eye to the
playing track, keeping the soft shadow over cover art and flat bars in
the queue.
2026-10-04 00:30:02 +00:00
austinried c68b631d06 strengthen the inset border shadow 2026-10-04 00:30:02 +00:00
austinried 7555344057 draw the play indicator shadow with a framework shadow layer
Works below API 31, unlike Modifier.blur. Also give the playing cover
art an inset shadow along its border, drawn from the same layer.
2026-10-04 00:30:02 +00:00
austinried c3874ed1c3 give the playing indicator a soft halo over art
Replace the offset drop copy with a slightly scaled, blurred dark copy so
the arrow lifts off busy artwork on all sides.
2026-10-04 00:30:02 +00:00
austinried 8a279dcec2 put the artist star and more back at 14dp 2026-10-04 00:30:02 +00:00
austinried 57b15b1765 nudge the artist star and more further down 2026-10-04 00:30:02 +00:00
austinried f08d34e822 drop the artist star and more to the bio line 2026-10-04 00:30:02 +00:00
austinried 1fdbc8c28b centre the artist shuffle over the star and more buttons
Shift the pill 6dp left to align with the controls' centre and widen the
title's right reserve to keep the same gap.
2026-10-04 00:30:02 +00:00
austinried 284ccaa77b adjust artist details margins 2026-10-04 00:30:02 +00:00
austinried e0345302b6 drop the artist album controls below the shuffle
Move the bio up under the album count and drop the star and more buttons
20dp so they clear the floating shuffle with room to spare.
2026-10-04 00:30:02 +00:00
austinried f6f9648cce tuck the artist album row and its controls tight to the image
Keep the star and more pinned to the right of the row, top-align the row
so the album count sits close under the image, and let the centred
shuffle button's lower half pass just above the controls.
2026-10-04 00:30:02 +00:00
austinried 56379d4433 tighten the artist album row against the image
Bring the album count row up to a 16dp top margin and inset its controls
past the floating shuffle button's column so the two cannot overlap.
2026-10-04 00:30:02 +00:00
austinried 6f4a55b23c float the artist shuffle button on the image edge
Move the shuffle out of the title block so the wrapped name keeps an even
bottom margin, and anchor the button centred on the image's bottom edge.
The title's last line still reserves room on the right, and the album
count row starts below the button's hanging half.
2026-10-04 00:30:02 +00:00
austinried 5c3daed968 keep the artist title's top line clear of the shuffle button
Render the wrapped artist name as two rows: the first line spans the full
width on its own row above, and the last line shares a row with the
shuffle button, so the text can never run under it.
2026-10-04 00:30:02 +00:00
austinried af9b4e304c size up the artist shuffle and keep the title clear of it
- make the shuffle a filled pill matching the album/playlist controls
- wrap the artist name with a hanging indent: the first line may run the
  full width over the button, while the last line stops short of it
2026-10-04 00:30:02 +00:00
austinried 581c5e20a3 rework the artist detail header
- drop the passive star badge from the artists list
- add an album count, star, more and placeholder bio under the artist image
- move the more action down from the top bar into that row
- put a shuffle button beside the artist name, which can now wrap to two lines
- remove the now-unused StarredBadge component
2026-10-04 00:30:02 +00:00
austinried 9cb93d37e1 polish the play indicator, queue rows and detail spacing
- shadow the play indicator over album art so it reads against busy art
- drop the download indicator from the queue view
- enlarge the now playing star toggle
- leave a little more room between the detail controls and the song list
2026-10-04 00:30:02 +00:00
austinried 83d0bafeb9 note the notification star's Media3 unstable API dependency 2026-10-04 00:30:02 +00:00
austinried f037e595ed fix lint and session access from the notification star review
- opt in to the Media3 unstable API on the service and answer
  unsupported custom commands with SessionError.ERROR_NOT_SUPPORTED
- add the star command on top of the trust-aware connection defaults
  instead of granting untrusted controllers the full command set
- number tracks within their disc, not across the whole album
- leave the star button to the library flow so a failed write cannot
  leave the notification flipped
- omit the leading cell entirely when a row has no art and no track
2026-10-04 00:30:02 +00:00
austinried 060cbd2a91 rework song rows and now playing, add star to the notification
- star/unstar from the context menu fills the icon, then dismisses
- drop the star badge from album art and song rows
- song rows keep a track-number column (natural order fallback), shrink
  four-digit numbers, and gain right-hand download and duration columns
- the play indicator always sits in the left column, overlaid on cover art
  with an inset primary outline when art is shown
- now playing is left aligned with a star toggle
- the media notification gets a star button backed by a session command
2026-10-04 00:30:02 +00:00
austinried 5ade5073f8 address review: test the source wiring, one default, clamp the stored value
- Add a `SourceRepositoryTest` that sets the preference to 1 and proves the
  active source fetches sequentially; removing the `maxConcurrentFetches`
  wiring now fails it.
- `playlistSongsRespectsASmallerFetchBound` now holds the first `bound`
  fetches and asserts no `(bound + 1)`th starts, instead of timing an
  overlap; it fails when the semaphore ignores the bound.
- One `DEFAULT_FETCH_CONCURRENCY` (in `data.source`) backs both the source
  default and the preference default, so they cannot drift.
- Clamp the stored preference in `syncConcurrency()` too, so a bad stored
  value can't make `SubsonicSource` throw on construction.
2026-10-04 00:30:02 +00:00
austinried a4149690e5 wire the sync fetch concurrency to a Settings option
The bound was only a constructor default with no production consumer. Add a
"Sync concurrency" preference (Settings -> Network, default 4) and pass it
through SourceRepository to SubsonicSource, so sync parallelism is tunable
without a rebuild. Both the app-scoped source and the on-demand
`activeMusicSource()` used by sync read the preference.

- UserPreferences.syncConcurrency()/setSyncConcurrency() (clamped >= 1)
- SourceRepository combines the preference into toMusicSource
- Settings row + dialog; SettingsViewModel state and setter
- Preferences test for default/round-trip/clamp
2026-10-04 00:30:02 +00:00
austinried adcdc442e4 address review: mark the fetch bound test-only; fix the concurrency test
- The concurrency bound already existed; this parameter only exists so tests
  can force the sequential path, and no production path passes it. Mark it
  `@VisibleForTesting` so that is explicit rather than reading as a wired
  setting.
- `playlistSongsUseTheSameRowsAtAnyFetchBound` could not fail (bound has no
  effect on the rows) — delete it and its `normalized` helper.
- `playlistSongsRespectsASmallerFetchBound` relied on a 300ms overlap
  window; use latches so the bound is proven deterministically (fails when
  the semaphore ignores the bound).
2026-10-04 00:30:01 +00:00
austinried dd092ed7ad make the sync fetch concurrency bound configurable 2026-10-04 00:30:01 +00:00
austinried 8ba9e92fa6 export the v19 schema after the queue keyset index migration 2026-10-04 00:30:01 +00:00
austinried 729737895f address review: offset fallback for far backward album seeks; pin the playlist keyset
- The NULL-aware backward album predicate is not sargable, so a backward seek
  from a far cursor scans the whole album partition. Fall back to the offset
  query when the anchor is more than twice the offset away; near-cursor
  backward reads still use keyset.
- Cover the playlist keyset path: a DAO test for `playlistSongsBefore` and a
  repository test scanning a playlist forward and backward. Both fail if the
  playlist order key stops driving the seek.
2026-10-04 00:30:01 +00:00
austinried 87dc4499ca split the song projections; the queue carries the playlist key
`SongListItem` carried a nullable `position` that only playlist rows have,
so the album/song queries projected a fake `NULL AS position` just to map
the shared type — a leak of the playlist concept into unrelated reads.

Split it into `AlbumSongItem` (song, coverArt) and `PlaylistSongItem` (song,
coverArt, position), plus a `SongItem` interface so the queue stays generic
over kinds. The queue's `QueueWindowItem` now carries the optional
`playlistPosition` for the keyset cursor, so no projection fabricates it.
Playlist detail and its paging use `PlaylistSongItem`.
2026-10-04 00:30:01 +00:00
austinried 649528e338 address review: NULL disc/track in backward album seeks; drop redundant index
- `albumSongsBefore` used the null-intolerant row-value `<`, so an album
  with NULL `disc`/`track` (untagged files) dropped exactly the rows a
  backward window needs, returning the wrong track at a position. Use a
  NULL-aware predicate (NULL sorts first, so it belongs to any `before`
  set); `albumSongsFrom` is unaffected.
- The new `(sourceId, albumId, disc, track, id)` index has the old
  `(sourceId, albumId)` index as a prefix, so drop the latter in the
  migration and from the entity.
- Tests: mixed NULL disc/track album for the before-window and a full
  backward scan; both fail on the previous commit.
2026-10-04 00:30:01 +00:00
austinried 4aaeb4871b resolve queue rows with keyset cursor seeks instead of offsets 2026-10-04 00:30:01 +00:00
austinried d710a3185c clear the compile warnings the downloads work left behind
- ArtworkStore's fetcher still did `response.body ?: throw` from before the body
  size limit landed; OkHttp's `body` is not nullable, so the elvis was dead.
- DownloadRepository's `downloadStates` uses `flatMapLatest` without the opt-in
  its callers annotate.
- HeroHeader's subtitle tap invoked `onSubtitleClick?.invoke()` inside the branch
  where it is already known to be non-null.
- MediaItemTest used a safe call on `clippingConfiguration`, which never returns
  null.

No behaviour change: a clean full rebuild now compiles without warnings.
2026-10-04 00:30:01 +00:00
austinried 6a311638c2 finish the queue view test row race fix
#71 converted only two reads; the rest of `QueueViewModelTest` still read
`viewModel.rows` — a snapshot list mutated on the main dispatcher — from the
test thread, so `removeAndUndoKeepTheBoundedWindowConsistent` could map a torn
list (the removed position missing) under load. Route every test-thread read
through `rowsSnapshot()` and run the synchronous `reorder` mutations on the
dispatcher too.
2026-10-04 00:30:01 +00:00
austinried 65ed658433 address the artwork review: snapshots, atomic writes, bounded reads
- SourceRepository.coverArt and networkCoverArt read the source id and the
  source object separately, so a source switch between the two could pair one
  source's cache key with another source's URL; for the store that meant writing
  the wrong server's artwork into the per-source file for good. Both now resolve
  from a single active snapshot.
- ArtworkStore wrote through a fixed `<name>.part`, so two downloads of songs
  from one album could interleave: one publishing the file while the other still
  had the shared inode open, which leaves a torn image for Coil to decode. Each
  write gets its own partial now.
- OkHttpArtworkFetcher read the whole body into memory whatever its size. It
  refuses anything past 8 MB, checked by declared length and again while reading.
- Artwork whose library rows could not all be resolved is no longer swept:
  pruning an album while its songs remain used to delete art a download still
  held. A source with no download rows still sweeps everything.
- fetchArtworkSafely and storeArtwork no longer swallow CancellationException,
  and the poll loop fetches a resumed row's artwork off the tick instead of
  holding up the next one.
- Reconcile reads the download rows once rather than twice, and download() no
  longer repeats the check enqueueIfAbsent makes under the lock.
2026-10-04 00:30:01 +00:00
austinried 6942798bf2 start the download before fetching its artwork
- The artwork step no longer holds up the media: the song goes to the platform
  first and the art is fetched afterwards, in the same foreground window. A
  stalled artwork server can no longer delay a download, and a process death
  during the fetch can no longer lose the download entirely.
- Resuming a queued download after a restart fetches its artwork now too; only a
  fresh download did before.
- The artwork budget is a minute rather than ten seconds, which costs nothing but
  a live coroutine now that nothing waits on it for the media.
- OkHttpArtworkFetcher derives a client with a per-call timeout. A blocking
  OkHttp call is not interrupted by a coroutine timeout, so without it the outer
  budget bounded our wait but not the socket.
2026-10-04 00:30:01 +00:00
austinried 11cc5e9ab8 download a song's artwork with it and use it before the network
Second block of #65.

- ArtworkStore keeps the album's and the artist's cover art, thumbnail and full,
  in downloads/<sourceId>/art, keyed by the cache key the rest of the app already
  uses for Coil and for palettes. Keys are base64url encoded because cover art ids
  come from the server and must not reach outside the art directory.
- Downloading a song fetches that artwork first, in our process and while the app
  is in the foreground, under a timeout, and hands the media to the platform
  afterwards whether or not the artwork arrived. A song whose album or artist has
  no art, or whose art fetch fails, still downloads.
- SourceRepository.coverArt resolves a stored file before the server, at the one
  point every CoverArt, the notification bitmap loader and the palette prefetch go
  through, so downloaded artwork works without the network.
- The sweep prunes art that no download references, so removing the last download
  of an album drops its art while art another download still needs stays.
2026-10-04 00:30:01 +00:00
austinried 033ac56cf1 read queue view rows on the main dispatcher in the view model tests
`QueueViewModelTest` read `viewModel.rows` (a `mutableStateListOf` mutated
on the main dispatcher) from the test thread, racing the view model: CI saw
`ConcurrentModificationException` in `removingWhileShuffledReconcilesTheRows`
and a torn read in `removingATrackUnderTheBlockReloadsRows`. Copy the rows
on the dispatcher via a `rowsSnapshot()` helper before asserting.
2026-10-04 00:30:01 +00:00
austinried 7ee7dc474a drop the indicator placement test
It measured pixel bounds to assert which line the indicator sits on and which
side of the artist name it lands. That is a presentational choice with exactly
one place in the code that makes it, so the test could only fail when someone
deliberately changed that line, and would break on unrelated layout changes.
The state-to-indicator tests and the accessibility descriptions stay.
2026-10-04 00:30:01 +00:00
austinried b9e16d8377 put the download indicator on the artist line, tight to its left
The glyph and the progress ring sat in the title row after the star, spaced 8dp;
they now lead the subtitle row, 4dp from the artist name, which is where the
artist line has room for them. `theDownloadedIndicatorSitsOnTheArtistLineToItsLeft`
measures the unmerged bounds and fails if the indicator moves back up to the
title line or to the artist's right.
2026-10-04 00:30:01 +00:00
austinried 9cdbc92975 bind the download engine by its interface in Koin
`single { SystemDownloadEngine(...) }` registers the concrete class, but
DownloadRepository asks for the DownloadEngine interface, so resolving
PlaybackController threw NoDefinitionFoundException and the app died on launch
with a Koin InstanceCreationException. It is now `single<DownloadEngine>`, the
way the other interface singletons in the module are declared.

Nothing in the suite ever started Koin — the repository tests build the
repository directly with a fake — which is why a launch-crashing binding got
through. AppModuleTest now resolves the playback graph from the real module, and
it failed with the same NoDefinitionFoundException before this fix.
2026-10-04 00:30:01 +00:00
austinried 2520c60f5a 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 31c1fec322 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 2b104f1387 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 b21c297964 keep the block in place when the anchor is removed while a queued track plays
`adjustAnchorOnRemove` moved the anchor to the canonical predecessor of the
removed track, which in a shuffled queue is an unrelated play position, so
removing the context anchor while playing a queued track teleported the
up-next block somewhere random (and out of view).

When the removed context track is the anchor, move the anchor to the track
before it in play order (or -1), so the block stays where it sits. This also
covers removing the playing context anchor, so the controller's separate
re-anchor is no longer needed and is removed.
2026-10-04 00:30:01 +00:00
austinried 72330387c8 fix resuming after removing the playing track in a shuffled queue
The "play whatever now fills the removed track's slot" branch was written
for the old re-derive model: it mapped the removed canonical slot through
the new permutation, which now (with the fixed shuffle order) picks an
unrelated track. Removing the playing track should just resume at the same
slot, which holds whatever was next.

- Drop the special slot branch: `position == current` resumes at `current`
  for shuffled and unshuffled alike. Without a block that is the next
  shuffled track; with one, re-anchor the block from the removed track onto
  the track now preceding that slot so the block takes the slot and plays
  next (previously it was stranded behind a random track).
- Tests assert the immediate playing track, not just what is upcoming, which
  is why they missed this. Both new tests fail on the previous commit.
- Cover advancing out of a block track and moving a block track while
  shuffled (the last items from the QA review).
2026-10-04 00:30:01 +00:00
austinried eb6b08ff56 replace the seed-flaky block test and cover clearing the block while shuffled
`removingThePlayingTrackWhileShuffledLeavesTheBlockReachable` looped 30
unseeded shuffles and only checked a weak invariant; it is now a single
deterministic case (fixed seed) that asserts the cursor resolves to the
playing track and the queued track still plays next. Add clearing the block
while shuffled, asserting the cursor stays on the playing track.
2026-10-04 00:30:01 +00:00
austinried 7b1a2a8f95 cover the queue/shuffle invariants the QA review flagged
The shuffled-removal tests mostly removed the *current* track (a path that
was already correct) and asserted `state.item`, which `rebuildWindow` keeps
regardless of the computed cursor, so a wrong position went unnoticed. Add:

- deterministic shuffle setup (`seedShuffledQueue`, or replace + setShuffle
  + setCursor + connect) so shuffle tests assert exact behaviour instead of
  looping over random seeds;
- controller tests that assert the queue position still resolves to the
  playing track after removing a non-current track, with and without a block,
  and that undo restores the shuffled order;
- a repository property test removing every position with a block present,
  checking the play/flat bijection, the combined/contextPlay inverse, the
  block span and that no track repeats;
- a `QueueRow.isPlaying` predicate (position AND song id) extracted and
  unit-tested, and QueueViewModel tests for starting a shuffled queue at the
  top and reconciling after a shuffled removal;
- Shuffle degenerate/out-of-range/MAX_SIZE coverage.
2026-10-04 00:30:01 +00:00
austinried 736e59fec6 keep the shuffle order stable when removing a track
Removing a track re-derived the permutation from (seed, size), so every
remaining track moved to a new position and the queue visibly reshuffled
(in an album-heavy playlist, the removed row and its neighbours were
replaced by other tracks from the same album).

Keep the permutation fixed over the domain it was created with and subtract
removals from it instead: `ShuffleState` holds the seed, the domain size and
the removed domain slots, kept in memory only. `flatContext`/`playContext`
skip those slots so the surviving tracks keep their positions; the DB still
stores just the seed and domain, so the old per-track `shuffle_order` rows
stay gone. A new shuffle or a library change clears the removal list; on
restart the removals are gone, so a queue that was edited falls back to
unshuffled rather than guessing an order.
2026-10-04 00:30:01 +00:00