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.
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.
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.
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`.
- 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.
- 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.
- 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.
- 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.
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.
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.
- 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).
- 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).
- 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.
Re-anchor the block whenever playback lands on a different context track
(skip, previous, jump, shuffle toggle, restore), so "Up next" always
plays next instead of stranding the block behind a track the listener
has moved past. reanchorFor reports whether the anchor actually moved,
since the current track's combined position can be unchanged even when
the block shifts.
Split playback into a shuffled context and a manual up-next list.
`up_next_entries` holds the same reference-plus-range rows as the
context but is never shuffled; `playback_cursor.upNextAnchor` pins the
block between a context track and its successor. `QueueRepository`
resolves a combined position by mapping through the context order and
splicing the block after the anchor, so the controller's window and
next/previous keep working.
PlaybackController gains addToQueue/playNext, consumes a queued track
when playback advances out of it, resumes the context when the block
empties, and clears the block on a new context. The queue view gets an
editable "Up next" section and a read-only context section, and the
context menu gains Play next / Add to queue for songs, albums and
playlists.
It made no perceivable difference to the first-play hitch, so it only cost buffering
audio at launch that the user may never play. loadWindow prepares when it plays again,
and the two tests go back to their original assertions.
The tab bar was laid out before playback was known, so the restored mini player arrived
later and re-laid the whole bottom area: a couple of frames of bump, and the mini player
"loading in" after the library. SubtracksRoot now waits for PlaybackController to finish
connecting and restoring (a `ready` flag, set after restore or on disconnect) before it
composes the library, so the first library frame already includes the mini player and
nothing moves.
With playback resolved up front, the `restored` flag and its special enter/inset specs
are pointless, so they are reverted; playback the user starts animates as before.
The mini player's enter spec was decided from `firstReveal`, which was false at the first
composition (the player is not visible yet) and flipped back to `expandVertically` in the
same frame the visibility changed, so the expand animation always ran. PlaybackState now
carries `restored`, set by restore() and cleared on any user playback, and the mini
player uses it to appear in place for the whole restored session.
The first play after a restart could still pay for a fresh buffer because a stream-quality
reload rebuilds the window, and loadWindow only prepared when autoplay was set. It now
always prepares, so a reload (or the restore) leaves the current track buffered.
Restore set the window with autoplay = false and never prepared, so the first tap paid
for the connection, the first range request and the startup buffer. Prepare once the
window is set; toggling play then only starts playback.
The mini player and tab bar no longer expand or animate their inset when playback is
restored at launch, and the first artwork colour lands without a 600ms fade from the
base scheme. The last active artwork seed is persisted and read back synchronously so
the mini player can paint with it on its first frame.
beyondViewportPageCount = 1 keeps the next tab's paging flow subscribed, so Artists
replays cached data instead of showing a spinner the first time it is opened.
Flag the field that is actually blank instead of one combined "Name and address are
required", and focus it so it scrolls clear of the keyboard. The connection/save status
moves above the buttons in the ime-padded bottom bar, where the keyboard cannot hide it.
- treat flac/wav/alac as lossless and ignore the bitrate for them, so the declared length never drops below the real encoded size and truncates a lossless transcode
- read the effective stream quality once per media item and use it for both the URL and the clip, closing the window where they disagreed
- honour a bounded DataSpec length in KnownLengthDataSource and reject non-positive length fragments
- drop MetadataDurationPlayer: clipping already gives the timeline the real duration, and the forwarder could publish a wrong metadata duration for direct play
- drop the dead PlaybackState.error/lastError now that errors are toast-only, and tighten the floor test to assert the exact value
Media3 treats a progressive stream as live, and ignores seeks, when neither the HTTP length nor the extractor duration is known. A transcoded response is chunked with no length and ffmpeg writes no MP3 duration header when streaming, so seeks stopped working once estimateContentLength was dropped. The server estimate is not a fix either: it under-counts (23% at mp3/24) and truncates the track.
Transcoded URLs now carry a declared upper-bound length in the fragment (never sent to the server). KnownLengthDataSource hands that length to Media3, keeping the stream seekable while never bounding reads below the real size. MetadataDurationPlayer reports the library duration to the session so the notification and UI show the true length rather than the upper bound.
The server's byte estimate can be shorter than the transcoded stream, so the player stops at Content-Length and some tracks end early. Let the server stream the true length instead.
- label the rows 'Stream quality: Wi-Fi' / 'Stream quality: Mobile'
- default mobile to 192kbps/mp3 while Wi-Fi stays unlimited/server default
- apply the selection only when Done is pressed, cancelling discards the draft
- fade the bottom of the dialog list so the remaining options are visible as scrollable
- fall back to the legacy max_bitrate/stream_format keys so existing settings survive the upgrade
- keep the last network mode when there is no active network instead of reporting mobile, so a connectivity gap does not flip modes and cause spurious reloads
- skip the quality reload when the player has ended or advanced mid-read
- keep the reload collector alive across transient failures
- edit stream quality from a local dialog draft so a fast second tap is not lost
- cover the mode mapping, migration, paused reload and equal-quality no-op with tests
Combine max bitrate and transcode format into one StreamQuality and store a separate value per network mode (Wi-Fi vs mobile data). NetworkMonitor reports metered vs unmetered via ConnectivityManager, SourceRepository selects and exposes the active quality, and PlaybackController reloads the window at the current position when it changes, so a network switch re-issues stream URLs with the new settings. Reset Coil's singleton in SubtracksApp.onCreate alongside Koin, since ArtworkSeedCache's background scope can call get() across Robolectric tests and poison the next Application.
The index to pixel arithmetic was inline and fiddly (it was missing the row gap), so pull it
out as estimateScrollPx and pin the 2-column cases. ROW_GAP is shared with the grid spacing.
- 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
- 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
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.
- 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
An existing token-auth source with an already-synced library would fail playback before
any API call ran: stream and cover-art URLs are built by uri() and fetched by
ExoPlayer/Coil, outside stream(). Probe the active source once when it uses token auth so
the 41 flips and persists before any URL is built.
Also base the retry on the flag the request actually used (concurrent requests no longer
lose their retry), report the fallback from ping() so Add Source flips the toggle instead
of reporting success and saving token auth on, and make the callback non-null.
Navidrome refuses the Subsonic token scheme for LDAP users with error code 41. Catch it
once in SubsonicClient: flip that client to the password scheme, tell SourceRepository to
persist the change for the source and show a toast, then retry the request. Only 41 is
retried, the flip is idempotent, and a client never retries more than once.