The pager's settled-page collector reports its initial value, so a cold start
wrote Albums back before the stored tab had been read - racing the very setting
this branch added, able to pin the app to Albums and overwrite the choice. The
screen waits for the stored tab before composing now, so the pager latches the
right page and the initial report matches it, which also drops the spurious
scroll and the search clear that came with it. The report is guarded anyway.
The data layer no longer names a UI tab from a coincidentally same-named enum:
it returns the raw stored value and the view model owns the Albums fallback.
The selected tab lived in rememberSaveable, which survives a rotation or a
process-death restore but not a cold start, so the app always came back on
Albums. It goes through DataStore now, like the per-tab sort and filters, which
were already durable - landing on a different tab than the one you had filtered
is what made the filters look reset.
Verify the Subsonic token hash and salt freshness, and fall back to per-album
songs when the empty-search probe fails with an I/O error instead of
aborting the whole sync.
Add a real-server sync test that mirrors navidrome and gonic into Room and
reads the rows back, plus SyncManager coverage for the success, coalescing
and mid-sync-failure paths.
Cover the sort mapping and delete/ping ViewModels, the download engine's
request flags, the queue helpers, download prefs and migration data
preservation.
Drop the leaked collectors in the ViewModel/queue tests, document the
screenshot tests as render smoke tests, bind a real search term in the
trigram plan test, and assert a positive in-flight invariant alongside the
concurrency timing checks.
The failure was only recorded as SyncStatus.Failed, which nothing shows, so a
bad username or an unreachable server folded silently. It goes through the same
toast the rest of the data layer uses, with the server's own message.
Only treat a rewind as a new listen when it lands in the first few
seconds, so a small seek-back no longer re-arms and duplicates a
scrobble. Fall back to the player's resolved duration when the queue
item has none, and stop the position flow publishing a value for a
track the state has not caught up to. Submit off the collector so an
offline write cannot stall counting.
Scrobbling can be turned off in Settings (on by default). Now-playing,
scrobbles and stars all go through a ServerActionSink so a persistent
offline outbox can replace the network implementation later without
touching the callers.
Send a now-playing notification when a track starts playing and one
submission once it passes half its duration or four minutes, using the
Subsonic scrobble endpoint's submission and time parameters.
A transcoded stream is chunked with no length, so the app declares an upper-bound
length, and on the first request the server is still producing the stream: a seek
near that bound lands past the data and comes back as POSITION_OUT_OF_RANGE
(Navidrome answers 416 until it has cached the transcode). That first request is
what warms the cache, so give the load one retry after a second: the retry sees a
real Content-Length and the stream plays and seeks. Further out-of-range errors
are given up on rather than looped.
A decoder often gives no bitrate, so the row vanished; for a downloaded file the
size over the track length fills it in. The AAC mime Android and media3 use is
audio/mp4a-latm, which read as "MP4A-LATM" rather than AAC.
Streaming Opus from Navidrome failed the first play with a read out of range,
then worked. Ogg seeks by binary-searching for the last page from the end of the
file, and the length the app estimates for a transcode overshoots the real
output, so that search reads past the data and the server answers 416. Opus,
Ogg and Vorbis now get no estimate: seeking falls back to what is buffered, but
playback is not scuppered by a length we cannot know. Downloads were unaffected
because they never carried the estimate.
Deleting any download re-set the whole queue window, which makes the player
re-prepare the current item and audibly pauses. It now rebuilds only the items
whose local file appeared or went away, comparing what the window reports with
what the repository can resolve, and falls back to the full reload when the
window no longer lines up.
MediaExtractor reports audio/raw for some containers it decodes itself, so a
downloaded FLAC showed "Raw" while the same stream showed FLAC. The file's own
header is now the source of the format when the extractor gives nothing useful,
covering FLAC, MP3, AAC/ALAC, Ogg, Vorbis, Opus, WAV and WebM - and for the
containers, the codec inside them (a WebM is Opus or Vorbis, not "WebM"),
matching what media3 reports for the stream.
The info dialog's label column is a fixed width in the accent colour at a
smaller size, so the values line up. The quality rows read format then bitrate,
the manage row loses its subtitle, and the switches get explicit off colours,
since the themed defaults left the knob invisible against some backgrounds.
Downloads asked the platform for an unmetered network and nothing more, so
there was no way to run them on mobile. A setting under Downloads now decides,
defaulting to Wi-Fi only, and it also governs roaming: the platform permits
roaming by default, which would have contradicted the Wi-Fi-only choice.
The flag rides the enqueue request like the download quality does, cached in the
source repository so the poller does not read the preference per row.
The song context menus gain Info, which lists every field we hold for the song
and, when it is downloaded, the file's format, bitrate, sample rate, channels
and size read from the file itself. For the song currently playing it takes the
encoding from the media3 stream instead, so a transcoded stream reports what is
actually being played. The menu is shared, so this reaches the album, playlist,
now playing and queue menus at once.
The downloads manager shows the same format and bitrate beside the size on each
song row, read lazily for the rows on screen rather than for the whole tree.
Downloads always fetched the original file. The setting takes the same choices
as the stream quality, and when it transcodes the download uses the stream
endpoint with those parameters instead, so the stored file is already the
wanted encoding rather than the original that would be transcoded on each play.
A preference change applied before the source is ready falls back to original,
and the URI is built from the same cached source the stream URL uses.
A Subsonic server answers a refused download with HTTP 200 and an XML error
document, which the platform downloader saves like any payload, so the row went
Completed and playback then tried to decode the error page. No audio container
starts with markup, so the leading bytes tell them apart: a Completed download
that starts with < or { is failed instead, and a stored Completed row is
re-checked on reconcile so existing bad files get cleaned up too.
The cross-source reconcile went too far: a source switched away from can have
waiting rows that promote will never hand over, since it resolves only while
active and downloads are only ever started for the active source. Those rows
kept the loop alive forever, so it scanned the whole table and ran every second
for the life of the process, and the notification sat there claiming to be
downloading songs that were not moving.
Both now count a row as in flight only when the engine owns it or it belongs to
the active source. Reconcile still covers every source, so a download running
for a source you switched away from keeps updating.
The downloaded-filter DAO test gains a second artist that must be excluded (its
first version passed whichever artist clause you wrote), and the preference test
covers the legacy three-field value and a downloaded round trip.
One toggle per tab, persisted with the rest of the list query, narrowing to
entities that have at least one completed download. It rides the existing filter
SQL: every album, artist and playlist paging query takes a flag and the shared
filter constant adds an EXISTS over song_downloads, so it composes with the
sort, search and starred filter rather than being a separate mode.
The filters sheet now renders on every tab, since playlists support Downloaded
but not Starred.
A source switched away from kept downloading on the platform while our poller
ignored it, so its rows froze as Running, the notification vanished and the only
Cancel went with it. The poll now reconciles every source's in-flight rows (the
engine reports status without a source) and the notification reads all of them,
which is also what cancelActive already acted on.
The grouped tab queries drop entities with nothing downloaded, since the UI
treats a missing entry and an all-zero one the same.
The indicator's two-value enum was used as a boolean, so it is one; and the
library screenshot now asserts the ring is drawn, rather than capturing an
image that would stay green if it disappeared.
The platform posts one notification per download, so queueing an album buries
the UI in them and they can be swiped away. Hiding them (VISIBILITY_HIDDEN,
which needs DOWNLOAD_WITHOUT_NOTIFICATION) and posting one aggregated
notification instead gives a single ongoing entry with the overall progress and
a Cancel action that stops everything in flight.
It is refreshed from the download state the poller already maintains, so it
tracks the engine without extra queries, and it is tied to this process: if we
are killed the downloads continue with no notification, which is the trade for
not writing a service.
A finished download no longer draws anything on album covers, artist rows or
playlist rows: the badge is the progress ring alone, so a fully downloaded tile
looks like any other. The ring takes the theme's primary for the arc over a
theme track, with the one soft shadow, instead of the white glyph and its
outline.
The tree read the track artist off each song, so a downloaded album split into
one group per guest performer and every song without a track artist landed in
"Unknown artist". It reads the album's artist now, which is also the artist the
node's delete resolves through, so the two agree.
The badges are white glyphs with a dark outline and a progress ring with a
visible track, after a look at the rendered result; larger and clearer than the
first pass, which the art swallowed.
The split bar read badly; a partly downloaded list goes back to stacked
Download and Delete items.
The badge showed progress for any list with a downloaded song in it, which
made a mostly-downloaded album look like it was fetching. It now shows only
when that list is complete or has a download actually in flight; a partly
downloaded idle list shows nothing.
The album and playlist detail screens' more-menus had no download entries at
all, leaving delete reachable only via the hero's long press. They now take
the same status-driven items as everywhere else.
A Downloads screen reachable from Settings shows the active source's downloads
as an artist > album > song tree with per-node sizes and song counts, each node
collapsible. Artist and album nodes delete with a confirmation showing what they
free; song rows delete straight away, and a summary row offers Delete all.
Sizes come from the files on disk rather than the engine's reported total, so
they agree with the byte count the delete dialog already quotes. The tree is
built from one query joining the download rows to song, album and artist names.
Album tiles, artist rows and playlist rows now carry the badge (lower right on
tiles, trailing end on rows) and their long-press menus take the same bulk
Download / Cancel / Delete entries the hero uses, driven by the shared actions
rule so a partly downloaded item offers both rows and an in-flight one offers
Cancel. The artist page's album cells get the badge and album menu too.
The three list callbacks the artist menu used (and its artist-specific
view-model calls) collapse into one bulkDownload action over a MenuTarget, since
the same menu code now serves album, playlist and artist.
The detail heroes ask for one entity's status at a time. The list surfaces
need every status on screen at once, so add one grouped flow per tab that
returns a lookup of entity id to counts, which the tiles and rows read the way
song rows read the per-song map.
ChoiceGroup emitted its header and option rows as siblings straight into the AlertDialog text slot, and they drew on top of each other, leaving only one radio visible with garbled labels. Wrap the group in its own Column so the rows stack, and cover it with a test that asserts the presets occupy separate rows.
Third review round:
- `promote` read every source's rows, so a single inactive source with waiting
rows could fill the window and starve the source in use: its songs stayed
queued with no engine id, for good, through every tick. It now scopes to the
active source, which is also the only one that can resolve a URL here.
- An artist with some songs downloaded offered only Download, because the more
menu rendered `action()` rather than the actions the state offers. It renders
the whole list now, so a partial artist gets Download and Delete.
- The hero's long press is only advertised when there is something to delete,
instead of announcing a Delete it would ignore, and it declares a button role
again after losing `IconButton`.
- The delete dialog uses the platform size formatter rather than a hand-rolled
one, which also fixes 999_999 reading as "1000.0 kB".
- The poll warms artwork through the same helper reconcile uses.
- Cancelling a big playlist did per-row work: a binder cancel, two queries and a
delete each, thousands of times, under the lock. The platform takes a list of
ids in one call, so the cancel is now one binder call, one delete per chunk of
rows and one sweep, which is both the several seconds and the stale button.
- Partial files are covered by the same sweep, which already deletes any file in
a source directory that no row names; a test now proves a stray .part goes with
the cancel.
- The hero's control was a plain clickable Box after the long-press work, so its
splash was square; it clips to a circle the way an IconButton does.
The hero's control now carries the whole rule: a tap does what the state offers
(Download, Cancel, Delete) and a long press asks to delete when there is anything
downloaded to delete, so a partially downloaded album can be emptied without
starting the rest first. Nothing downloaded means the long press does nothing.
The rule itself returns the actions rather than one of them, so a partially
downloaded list offers both Download and Delete; the tap takes the first. That
list is what the library tabs' context menus will read.
Only a single song deletes without asking; the artist's delete now opens the same
dialog as the album and playlist, naming the artist and the space it frees. The
artist's download action stays in its more-menu rather than getting a visible
control, deliberately, so bulk downloading a whole artist is a considered
action.
Two things a 3k-song playlist turned up, plus the hero's downloaded state.
- The platform runs about eight downloads at a time, but a bulk action handed it
every song at once. Three thousand pending requests meant a notification flood,
and a poll tick that rewrote every row's bytes/total, so the whole list churned
ten times a second. Now a bulk action only records the songs and hands over at
most IN_FLIGHT_LIMIT, oldest first, promoting the next as each finishes, so our
row state mirrors what is actually running and a cancel only has to reach what
is in flight.
- Cancelling was therefore unusable at that size. It now touches at most the
in-flight requests instead of thousands, and the rows wait behind it.
- A queued row with no engine id is waiting its turn, not lost, so reconcile no
longer re-enqueues the whole list on every pass; a row whose engine entry
vanished goes back to waiting and the next promote picks it up. A song that
cannot be handed over fails instead of being retried every tick.
- Requests follow the order the screen shows: artist songs resolve through the
same albums ordering the artist page uses, so they were the one list that did
not.
- The hero's fully-downloaded state shows the downloaded glyph rather than a bin,
and tapping it asks before deleting, naming the album or playlist and the space
it frees.
Second review round on the bulk downloads:
- The cancel-during-bulk fix had no test, so the invariant it exists for could
regress silently. The fake engine can now delay an enqueue, which holds the
race window open: the queue loop is kept busy, a cancel is fired mid-queue,
and the test asserts the whole list ends up cancelled. Reverting to per-song
locking fails it, which I checked.
- The per-song catch around the queue loop swallowed CancellationException, so
cancelling the caller did not stop the loop. It rethrows now.
- The artwork pass skips songs whose row a cancel has already taken.
- The lock-hold ceiling is recorded where the lock is taken: cancel latency and
the poll tick both scale with the list length, and a per-list cancel
generation would lift that if it ever bites.
- Tests: an empty list says so, one song failing to enqueue leaves the rest of
the list alone, the applyAction test covers Delete as well as Download and
Cancel, and the two playlist fixtures share a helper.
- Cancelling a list while its download loop was still enqueueing missed every
song queued after the cancel looked. The loop now holds the lock for the whole
queue, so a cancel waits for it to finish and then removes all of it. That
needed the enqueue helper split into a locked entry point and a body the loop
can call while already holding the lock — calling the locked one from inside
the lock deadlocked, which is what hung the tests.
- An empty resolved list now says so instead of doing nothing.
- One song failing to enqueue no longer aborts the rest of the batch.
- The artist actions resolve through the albums the artist page lists, matching
what is on screen, rather than through songs.artistId, which for a guest on a
compilation enqueued nothing.
- Bulk actions are one `applyAction` on the repository, so the three view models
and both screens stop repeating the same dispatch.
- Tests: the playlist and artist resolve paths, a Running row counted as
downloading, the action dispatch, and the action rule moved to a plain unit
test.
Third block of #65: bulk downloads and the list-level actions.
- The hero's download button on albums and playlists is real now. It starts the
songs the list is missing, cancels what is in flight, or deletes what is
already downloaded, chosen from the list's own status. The artist has no hero
button, so its bulk actions live in the context menu, which now carries the
same status.
- No new storage: a bulk job is the download rows for the list, and the status is
counted from song_downloads joined to the list's songs, so cancel and delete
stay well defined without anything to keep in sync.
- downloadAll fetches artwork for the whole list under one shared budget, once
per unique cover, and skips songs that are already downloaded so re-tapping
Download resumes rather than restarting.
- 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