Exercise context and block drags against the queue authority: all context positions across the up-next block, a far move that crosses the window, a context move while an up-next track plays, and moving the playing up-next track.
A context drag that crosses the up-next block moves the block's anchor in the repository but the old player.move used the combined drop position, leaving the two in different orders. Rebuild the window for context moves that happen while a block exists, in both the in-window and window-offset paths.
Also drop the redundant rebuildWindow snapshot parameter, the unused media-item context read-back, and add coverage for shuffled album context, ranged album moves and ranged up-next moves.
Use album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues. Carry the resolved context on each media item so the audio thread does not need to inspect the queue.
Keep a track moved within an album as an album range so it stays in album context; moves out of playlists, artists, genres and other mixed contexts remain pinned song entries.
Use symmetric page margins for the artist bio, let lines below the action buttons use the full width, and stop the expanding bio from shifting the hero gradient.
Keep source line breaks, treat the triple-space paragraph separator some servers return as a blank line, justify wrapped bio lines, and append a bold Less control to the expanded bio.
Retry a failed download bio fetch instead of blacklisting the artist for
the rest of the process, and give a failed or offline refresh its own
message so it is not mistaken for a server with no biography.
Wait for the stored row before choosing the bio text, so a cached bio no
longer flashes the loading state on entry, and bound the page refresh so
a stalled response cannot leave the loading state up indefinitely.
Render an oversized first token instead of a blank collapsed line, and
check whether the whole remainder fits in one measurement.
Load the biography when the artist page is entered. A stored biography is
rendered immediately while it refreshes in the background; otherwise the
page starts with the loading message and falls back to the empty state.
Collapse a biography to two lines and append a bold More only when the
text does not fit. Tapping that line expands the biography, after which
the first lines keep clear of the star and overflow buttons and the rest
use the full width.
Artist biographies come from getArtistInfo2, which the library sync does
not call. Store them in their own table so a page open can use the last
value immediately and downloads can keep it for offline use, without
pulling a large text column into every artist list query.
A missing row means the artist has never been fetched; a row with a null
biography means the server had none. That lets the page show the loading
state only until the first fetch completes.
The fallback art reads albums and credits, but a successful sync invalidates through the sources row, so observing those tables only adds refreshes for play, star, and sync writes that do not change artist art.
Avoid re-requesting search focus when the screen is restored, keep the cheaper album-credit-first art lookup, and test restoration plus association-table invalidation.
Keep the search field from stealing focus again when a result is dismissed, observe album and credit tables in artist paging, and simplify the cover fallback to one ranked union. Cover the association-table and invalidation paths in tests.
Fall back to the first album credit cover (then the first track credit) for artist art, keep list searches when opening a result, resume search at the end of the query, and navigate immediately from the artist chooser.
Use 'album' and 'track' instead of a 1/0 flag so the credit kind is readable
without a comment. This column is a constant in the SQL, not a stored value,
and only the per-artist credit query compares it, so the text costs nothing
meaningful.
Default the second row to a greyed "No artist bio available" placeholder.
Keep the collapsed two-line layout with a bold More and the expanded flow
that wraps around the action buttons for the next PR, which will supply the
server biography.
Keep the type and count on the first row with space reserved for the hanging
shuffle, and put the star and more buttons on the blank second row so they sit
at their old height.
Keep the type and count on the same row as the star and more buttons, and move
that row down to where it sat before the split. Give the Albums and Appears on
headers the album and microphone icons alongside the disc-title styling.
Move the star and more buttons from the meta line onto the first section
header so they sit below the hanging shuffle button again, and remove the
per-section counts now that the meta line carries the total.
Distinguish the album-credit rule (any downloaded song on the album) from the
track-credit rule (the credited track itself must be downloaded) in the
repository test, so a swap of those clauses fails.
Assert the combined meta line while loading, cover the downloaded partition
and the plural album/song credit branches, share the album-credit id query
with its plan test, and put the meta line through a translatable string.
Put the artist type and total album count on the single meta row beside the
star and more buttons, then list albums where the artist is an album artist
separately from albums where they only have a track credit. The hero shuffle
button and page-level actions stay where they were.
The split comes from an albumCredit flag on the shared credit rows, so the
online and downloaded album lists are partitioned by the same rule and the
two sections still sum to the stored album count.
Put the download-visibility comment on the constant it describes, keep the
no-scan guards beside the seek assertions, and give the identical-sync test a
positive control for both the artist aggregate and the album play recompute.
Share the artist page SQL with its plan test and assert the artist seeks, so
the test fails if the predicate push-down regresses. Add a query-log test that
an identical sync skips the aggregate, stop the downloaded album badge from
counting a song whose album row is missing, and correct comments that
overclaimed the remaining hand-written credit paths.
Replace the per-credit invalidation with one signal from the sync's write
transactions: if artists, albums or songs changed, the source is marked stale
and a single aggregate pass refreshes counts, plays and the credited flag in
one query. Identical syncs no longer run any of those scans, and a new credit
path cannot forget to invalidate the cache.
A changed sync still reads the credit unions once instead of separately for
counts and plays, and migration 33->34 remains a frozen snapshot rather than
sharing the runtime query text.
The artist page, shuffle, download attribution and artist badge each spelled
out the four credit paths. Reuse the shared credited song and album rows, and
keep only the download-aware album queries hand-written, where the per-credit
download visibility genuinely differs.
The shared song and album rows use UNION ALL so the artist list filter can push
its artist predicate into every branch instead of materialising a deduplicated
union; callers that need a set use DISTINCT or COUNT(DISTINCT).
The credit recompute was keyed on song-to-album moves and relation-table
writes, so a server that changed songs.artistId or albums.artistId while the
plural refs stayed the same left the counts attributed to the old lead. Fold
the lead ids into the change check for both songs and albums.
The artist badge now counts albums from album lead ids and album_artists even
when an album has no songs, and only counts song-credited albums that still
exist, matching what albumsForArtist lists. Preserve the derived albumCount
when the server re-upserts an artist row so an identical sync does not rewrite
it twice, relax the migration guard to either relation table, and share one
album-count order between the plain and downloaded artist lists.
Artist badges and play rankings only counted album credits, while the artist
page, shuffle queue and download attribution already included track credits.
Derive albumCount and the artist plays from the same four-way credit union,
count lead ids that the server omits from the plural refs, and bump the song's
credited artists on a local scrobble even when the track has no album.
Sort the downloaded artist list by the downloaded album count it displays
instead of the synced total, and backfill the artist totals on upgrade so the
fix applies before the next sync.
The preamp prompt promised extra level unconditionally, but a track whose gain has no peak is capped at unity. Say so in the supporting text so the setting does not overpromise for Opus/R128 libraries.
A peak is the only headroom check the gain code has. When the server sends a gain without one (Navidrome does this for Opus R128 gains, which carry no peak tags by design), a positive gain was applied unchecked and the 16-bit sink saturated.
Cap a peakless gain at unity instead: attenuation still applies, but a boost needs a peak to prove it fits. The cap is unchanged when a peak is present.
Update the tests that expected a peakless boost, and cover the positive preamp that can no longer lift a peakless track and the attenuation that still applies.
The adjacent artwork effect keys on the item and queue layout, but the predictions now also depend on the repeat mode. Toggling repeat left the cached neighbours stale, so the edge swipe still resisted until the track changed. Key the effect on the repeat mode too, with a route test that toggles repeat and expects the wrapped previous art.
Lift the wrap arithmetic out of next(), previous() and previousTrack() into nextPosition()/previousPosition() so upcomingItem()/previousItem() cannot drift from the actions again. A one-track queue no longer predicts itself as its own neighbour, online or offline: the wrap would land on the current position, and the actions are a no-op there, so the strip offered a dead swipe.
The now playing strip took its neighbours from previousItem() and upcomingItem(), which returned null at the queue edges online even when repeat would wrap. Swiping there resisted and only a hard fling advanced the track, leaving no artwork to slide in. Mirror next() and previousTrack() so the online path wraps with repeat All, and with repeat One for the next slot.
Use right/down chevrons in the download manager and order downloaded
tracks by disc and track number, matching the album screen. Move sync
concurrency to the end of the network settings, let the artist hero name
wrap to six lines, and leave the album hero title unbounded.
Restore the both-tables-empty fallback in the credit recompute, skip
redundant marker writes, append albumless tracks after album tracks, and make
the paged artist query observe sources. Add tests for an interrupted sync and
the partial-relation migration guard.
Left-join albums in the artist queue queries so albumless credited songs can
be played, downloaded and deleted. Add sortName to the starred index, persist
a per-source stale-credit marker so an interrupted sync cannot strand artist
counts or hide artists, and recompute artist play data in one grouped pass.
Add artists.credited, updated at the end of a sync that changed artists or
credits and backfilled on upgrade when relation data exists. Filter the
artist lists and the downloaded artist list on it, which keeps partially
synced libraries visible and hides display-only rows once credits are known.
Filter the paged, search and home artist lists to artists referenced by the
plural credits, falling back to the full list while the relation tables are
still empty. Display names keep using the singular tag and navigation keeps
using the plural refs.
Collect every credited artist id when resolving album and song sort keys so
the singular id and plural refs can both be looked up, and add regression
tests for the compilation and stale-sort-name cases.
Drop the FTS tables before the backfill so the old triggers do not index rows
that are about to be discarded, and rely on createSearchIndex's rebuild
instead of rebuilding a second time. Treat a whitespace-only artist sortName
as absent, and use the sort keys in the downloads tree too.
Parse the OpenSubsonic sortName attributes, store sort keys on artists,
albums and songs, and sort on them instead of the display names. Resolve
album and track artist sort names from the artists table, and include the
keys in the FTS content so romaji sort names are searchable as well.
Schema 31 rebuilds the sort indexes and search tables, and backfills
existing rows from their display names.
Use songCount as the total for both artist status queries and stop excluding
albumless tracks from download attribution, so the Artists badge and the
artist page agree. Backfill song counts in the 29->30 migration for offline
upgrades, and recompute only when a song moves albums or its credits change
rather than on every play-metadata sync.
Add artists.songCount, recomputed after syncs that change albums or songs,
and derive the Artists tab status map from the download rows plus that count
instead of walking every song. The badge query is now proportional to
downloads rather than library size.
Rewrite artist queue membership as a UNION of indexed song-id branches
instead of an OR that scanned every song, and recompute artist play data from
an explicit credited-album set instead of scanning all albums per artist.
Run PRAGMA optimize after syncing songs and before play aggregation, and
derive downloaded artist lists and filters from downloads rather than all
songs. Count downloaded albums per artist with artist-first branches.
Extend the query timing harness with the artist playback, download and status
queries plus a partial-download fixture.
Keep the server's combined legacy artist/albumArtist string when it is
present, prefer the OpenSubsonic displayArtist when it is not, and otherwise
join the plural artist refs with Navidrome's separator. The normalized value
is stored in the display columns, so song rows, album cards, now playing,
menu subtitles, downloads, search and artist ordering all get it.
Rewrite the artist album lookup as indexed UNION branches instead of ORs
that scanned the song table, and let artistName take the first indexed row.
Keep the type label and album count blank until the album flow has emitted,
so the page no longer flashes "Artist" and "0 albums" before showing the
real values.
A multi-artist album or song now adds one row per artist, so the sheet can
exceed the screen and clip the remaining actions. Cap it at 560dp and scroll
the content inside.
Credit all album artists in play aggregation and recorded plays, include the
relation rows in the downloaded artist list, and fall back to the album artist
display name in download groups.
Also keep the chooser scrollable, close it when the track changes, keep the
singular id in the album-artist label, stop a nested artists container from
swallowing later artist elements, and make the refs encoding length-prefixed
so tag values cannot corrupt it.
OpenSubsonic servers send plural artists/albumArtists refs and can omit the
singular artistId when an album or song has more than one artist. Parse and
store the ordered refs on the item and in indexed album_artists/song_artists
tables, so artist pages, shuffles, downloads and the artist filters include
every credited artist.
Album and now playing artist taps open a chooser when several artists are
credited; context menus get one artist row per artist.
The toggle's behaviour was untested: deleting the Modifier.keepScreenOn(),
hard-coding the row's request or emptying the Switch handler all left the
suite green, so none of the three new code paths were actually pinned.
- NowPlayingKeepScreenOnTest renders Now Playing and asserts a view in the
activity tree holds the screen on while the flag is set and releases it
once the flag clears.
- SettingsScreenTest now covers off->on and on->off on the row, and the
Switch itself, instead of only the off->on row click.
- Remember the keep-screen-on flow so NowPlayingRoute stops tearing down
and rebuilding its DataStore collector on every position tick.
A screen timeout mid-album is a common complaint for a music player: the
phone lies on a table while art or lyrics are on screen and the display
sleeps. Add a Settings toggle under Playback, off by default, that holds
the screen awake while now playing is open. The flag rides on the now
playing overlay through Compose's keepScreenOn modifier, and since the
overlay is only composed while expanded, collapsing it releases the flag
without any window-flag lifecycle handling.
Closesaustinried/subtracks#201
Review follow-ups.
- The snap clamped nothing, so an out-of-range stored value would show a
decibel label the slider cannot reach: clamps now live in the snap
rather than duplicated in the step.
- The screenshot test only rendered the dialog without asserting it
opened, so deleting the row's click would have left it passing while
the capture showed the plain list. It now requires the dialog's Reset.
- "Adjust level on top of the ReplayGain" was not grammatical, and the
mode prompt claimed the server normalises when it only supplies the
tags.
- Drop a test assertion that restated the production formula.
The in-repo comment rule allows a comment only when it names an external
system, version or issue, so both of these now name Compose Material 3,
which is what they are actually about.
Fifty-nine steps across a phone-width track is a tick every few dps,
which reads as a smudge on the bar. The steps are what make the slider
snap in 0.5 dB increments, so they stay; only the tick drawing is
suppressed, through a SliderDefaults.Track that skips drawTick.
- Call it ReplayGain, which is the name of the standard, in the row, the
prompt and the preamp prompt.
- Give the slider room under the prompt instead of sitting against it.
- Put Reset in the button row, where it lines up with Cancel and Done,
rather than above them, and centre the decibel value that is left in
the content.
- Add a screenshot test that scrolls to the section and opens the dialog,
which also composes the new rows; the existing screenshot only captures
the top of the list, so it never rendered them.
The slider alone is awkward for the two things people actually do with a
preamp: zero it, and nudge it by a known amount.
- Minus and plus buttons flank the slider, stepping one grid notch (0.5
dB) and disabling at the ends.
- A Reset button sits under it, beside the current value, disabled when
the value is already zero.
- Both go through the same snapping the slider does, so a button cannot
land off the grid.
Review follow-ups.
- Remove the second "Playback" section header the rows were added
under; the section already had one, so the screen rendered it twice.
- Snap the preamp to the 0.5 dB grid in the dialog. Material's slider
snaps in pixel space and converts back, so the value it reports is near
the configured grid but not on it: -1.0 dB arrives as -0.99999905, was
displayed verbatim and was stored that way until the next drag. The
step count is now derived from the range rather than hardcoded.
- Format the decibel value with the resource locale so translated
layouts get their own decimal separator.
- The controls move to `ReplayGainControls.kt`, since the file now holds
the grid as well as the labels, and the snap is covered by a test that
reproduces the off-grid value without it.
Final slice: the screen that drives the mode and preamp preferences the
playback side already reads. Until now `setReplayGainMode` and
`setReplayGainPreampDb` had no production caller, so Off and the preamp
were unreachable.
- `SettingsViewModel` exposes both preferences and writes them back.
- The Playback section gains a "Replay gain" row opening an Off / Track /
Album choice, and a "Preamp" row opening a slider across the +-15 dB
range in 0.5 dB steps.
- `ReplayGainMode` is ordered Off, Track, Album so the choice list reads
that way; the stored value is the name, so the order is not persisted
and the default is still Track.
- The labels live in their own file so the signed decibel formatting can
be tested, as `formatTrackTime` does.
Float output is the one way the audio processor chain is skipped on this
build, and it is a flag the renderers factory is handed. Nothing sets it,
so nothing happens today; if it is ever turned on, this says so once at
sink construction rather than leaving replay gain silently inapplicable
to high-resolution tracks.
Offload has no equivalent check: it is not a flag this code sets, and
AudioSink.setOffloadMode is only reachable by building the audio renderer
by hand. The factory comment names it instead.
Review follow-ups for the apply slice.
- A negative-infinite gain produced 0 dB, not unity: 10^(inf/20) is 0.0,
which is finite, so the result guard never fired and the track played
silent — the opposite of what the guard is for. A large-enough finite
negative gain underflows the same way, and an infinite gain paired with
a peak survived as the cap. The gain is now rejected before the pow and
an underflowed result falls back to unity.
- A peak carrying no data shadowed a usable peak in the other slot, so
trackPeak=0 with albumPeak=0.5 skipped the cap entirely — the gonic
shape on the default mode. Both peaks are filtered before one is chosen.
- Drop the claim that a period-less flush comes from a sink reset; it does
not, and the guard stands on its own.
- Trim two comments back to naming the Media3 types.
- Revert the observeStar rename, which was for a path that no longer
applies the gain.
The in-repo comment rule now allows a comment only when it names an
external system, version or issue, so the ones carrying rationale move
to this message.
- why the gain rides on the media item: PlaybackService can then apply it
the moment the item becomes current instead of after a library round
trip
- that ReplayGainController is written from the playback thread by
onFlush and from the app thread by the preference collector
- that an empty passthrough input is the pipeline's own shared buffer,
which cannot be put into itself
The Playback section already documents the star button's dependence on
unstable Media3 APIs and what to re-check when bumping the library. The
gain path needs the same treatment: it relies on flush carrying the
timeline and period, on the sink configuring per media item, and on the
processor chain being reached at all.
Review follow-ups for the apply slice.
- Drop the media item transition application. It never supplied a gain
the flush had not already supplied, and as a second writer it raced the
playback thread: it applied the incoming item's gain while the outgoing
item's buffers were still queued, scaling the previous track's tail by
the next track's gain (reproduced at +12 dB). The flush is the only
writer now.
This reverses the earlier decision to keep it as a fallback. That was
made on the premise that it was insurance against a flush that could
not be resolved. The flush covers every transition, and a period-less
flush only happens while the sink is being reset, so the premise does
not hold.
- Ignore a flush that carries no period rather than reading it as "this
item has no gain". Resetting to unity there would have been wrong if a
bare flush ever arrived mid-playback; the test that pinned that
behaviour now pins the opposite.
- Guard the pass-through branch against an empty input, which is the
pipeline's own shared buffer and cannot be put into itself.
- The controller is written from the playback thread now, so its state
updates and the recompute that follows them are synchronized rather
than two independent volatile writes.
- Add ReplayGainSinkTest, which drives the real DefaultAudioSink and
asserts it hands the processor the item being configured. That is the
media3 behaviour this whole feature rests on, and it was previously
asserted only by comments.
The app-thread transition callback was only ever a stopgap: media3 posts
it after the playback thread has already rendered, so the first buffers
of a new item could be scaled by the previous item's gain.
media3 forwards the playback timeline and the current period to
AudioProcessor.flush, and DefaultAudioSink flushes the processors on
every sink configuration, which happens at each media item transition
even when the format is unchanged. That is early enough to set the gain
before any of the new item's buffers are processed, on the playback
thread. See androidx/media#418.
The processor resolves the period back to its MediaItem and reports it,
so the gain maths and the preferences stay in ReplayGainController. That
controller is now written from two threads, so its state is volatile.
The transition callback stays as the fallback for a flush that carries no
period: without it a stale gain would persist for a whole track rather
than for a few milliseconds.
Review follow-ups.
- `ReplayGainController.applyItem` owns reading the gain off a
`MediaItem`, so the transition, the controller and the extras read are
exercised together rather than only in isolation.
- drop the leftover `setTrack(null)` on a missing active source: now that
the transition sets the gain from the item, that line only raced it and
could reset a playing item to unity.
- tests for a zero gain surviving the extras round trip (zero is a real
gain, not an absent one), for the latest item's gain being the one left
applied, and for a missing item applying unity.
The gain was resolved by querying the library from the media item
transition callback, so the buffers the player had already begun
queueing for the new track could be scaled by the previous track's gain.
A quiet track followed by a hot one could clip at the boundary for as
long as that query took.
Carry the gain on `QueueItem` so `queueMediaItem` puts it on the
`MediaItem` extras, and apply it from the item that just became current,
with no library round trip.
That narrows the window rather than closing it: media3 delivers
`onMediaItemTransition` on the app thread after the playback thread has
already rendered for that iteration, so the very first buffers after a
transition can still carry the previous item's gain. The gap is now one
handler hop instead of a database query.
The trade-off is staleness: if a sync changes a track's gain while that
track is queued, the item keeps the old value until it is rebuilt.
Applying a new gain mid-track is of marginal value anyway.
Review follow-ups for the apply slice.
The parse guard in the base branch keeps non-finite gains and peaks out
of the library, but nothing here should depend on that: a value already
stored, or written by a future caller, must not be able to silence a
track or blast it at full scale.
- the gain maths ignores a non-finite peak and falls back to unity when
the result is not finite.
- the processor falls back to unity for a non-finite gain, so
`roundToInt` can never be handed a NaN.
- the preamp is sanitised on both read and write, since `coerceIn` passes
NaN straight through.
- the renderers factory is pulled out of `PlaybackService` into
`ReplayGainRenderersFactory` so a test can assert the sink really is
built with the processor in its chain; the wiring was otherwise
untested.
- a null active source clears the gain rather than leaving the previous
track's applied.
Second slice: read the gain synced in the first one and scale the decoded
PCM by it.
- `ReplayGainProcessor` is a `BaseAudioProcessor` that scales 16-bit and
float samples, saturating on overflow rather than wrapping. It is
installed through `DefaultAudioSink`'s audio processor chain, so it runs
ahead of the default silence-skip and speed processors.
- `replayGainLinearGain` picks the track or album gain according to the
mode, falls back to the other gain when one is missing, adds the preamp,
and caps the result at the peak's headroom so a boost cannot clip. A
non-positive peak means the server has no peak data and is ignored.
- `ReplayGainMode` and the preamp live in `UserPreferences`, defaulting to
track mode and 0 dB. The settings UI is still to come.
- `PlaybackService` resolves the current song's gain alongside its star
state and applies it on every media item transition.
The processor only runs while the audio processor chain does, which
`DefaultAudioSink` skips when float output is enabled. That stays off, as
it is by default.
A second <replayGain> element for the same song cleared whatever the
first one set, because each field was assigned unconditionally and an
absent attribute parses to null. No server is known to split the object
across elements, but the merge costs nothing and the alternative loses
fields silently.
Also covers peaks arriving without gains, which the existing cases did
not.
`toFloatOrNull` accepts `"Infinity"`, `"NaN"` and overflowing literals
like `"1e40"`, so a server (or a corrupt tag it read) could put a
non-finite gain or peak into the library. An infinite peak later caps the
applied gain at negative infinity, i.e. silence; an infinite gain clips
every sample to full scale.
Nothing consumes these columns yet, so this is only about not storing
values that cannot mean anything.
Review follow-ups for the replay gain slice.
`upsertChanged` never checked that its hand-maintained column list and
the row values line up, and SQLite binds a missing trailing value as NULL
without error: adding a column to `songColumns` but not to `Song.values()`
would have written NULLs silently. It now asserts the sizes match before
preparing, so that mistake fails the sync instead.
The replay gain sync test also now re-syncs identical values and asserts
no rows are rewritten, pinning the REAL change-detection clause; an
update there rewrites the `song_search` FTS row for no reason.
Store the per-track replay gain a server reports on OpenSubsonic's
`Child.replayGain` so playback can normalise loudness later.
- add trackGain/albumGain/trackPeak/albumPeak (REAL) to the songs table
and the 27 -> 28 migration
- parse the nested `<replayGain>` element, folding the child-element
dispatch into a single `songChild` shared by the search and album paths
- teach the sync upsert to bind Float, which it previously rejected
baseGain and fallbackGain are omitted: the servers that send the object
do not populate them, so they would only ever be null columns.
Nothing applies the values yet; this slice only mirrors them.
The `search3`-less fallback emitted one batch per album, and
`SyncService.writeSongs` opens a transaction per batch, so syncing a large
library that way ran one transaction per album — thousands of fsyncs, and an
invalidation round for every list observing `songs` with each.
Accumulate what each `getAlbum` returns and flush at `PAGE_SIZE`, so the
fallback writes the same ~500-row batches every other fetch already does.
Albums are fetched concurrently and complete out of order, so the accumulator
sits behind a mutex and the send happens outside it.
Batches are in-flight memory, so the flush is per rows reached rather than
per album page. Measured per `Song` (strings, boxed longs and genre list
included): ~0.7 KiB, so a 500-row batch is ~334 KiB plus ~51 KiB of the
`names`/`genres` structures `writeSongs` derives, where a whole album page
(~5,500 rows) would be ~3.8 MiB. `albumSongs()` is also a bare `channelFlow`,
whose channel holds 64 elements whatever their size, so 500-row batches would
have made its worst case ~21 MiB; it now carries the same `.buffer(1)` that
`entityBatches()` already uses on itself. The rest of the in-flight rows
scale with `syncConcurrency`, so that leaves the default's batch in hand and
a few MiB at the highest setting.
`SubsonicSourceTest.albumSongsAreEmittedInBoundedBatches` asserts the emitted
sizes, [500, 100] for 600 songs spread over 200 three-song albums, which pins
the carried remainder as well as the flush: verified it fails on the
pre-change code (200 batches of 3) and against a mutant that drops the
remainder ([500, 99]). `docs/architecture.md`'s page cap was 20x out of date,
so it is corrected alongside. `ktlintCheck`, `testDebugUnitTest` and
`lintDebug` pass.
The GitHub job's store is ~7.3 GiB but gc-max-store-size-linux is 6G, and
nothing roots the devshells, so cache-nix-action's pre-save `nix store gc`
deletes whichever paths cross the cap before every save. The snapshots land
missing the JDK and the Android SDK components, so both `nix develop` steps
re-download or rebuild them on every run.
Raise the cap to 10G, which clears the store, and bump the key to nix-v3-. The
first run after that downloads the full closure once and saves a complete
snapshot; the gutted nix-v2- snapshots are not restored, so there is one cold
run per ref and v3 is warm from then on.
Compressed at ~0.47x its store the snapshot is ~4.7 GiB at worst, inside the
10 GB per-repo budget. What pushes the repo over that budget today is the four
stale gradle- (v1) entries, which no restore-key can reach and which age out by
LRU.
A scrobble writes albums.playCount/played, which invalidates the album
list's PagingSource. Room then refreshes from anchor - initialLoadSize/2,
and with placeholders off that page is the presented list: LazyPagingItems
index zero becomes the page's first album, so the grid re-groups its rows
and every cover moves by (oldStart - newStart) % 3 columns, while the last
row's width becomes the loaded count % 3 instead of the library size % 3.
Enable placeholders for the album pager so an album's index is its
absolute position, and draw a placeholder cell for an unloaded slot. The
other lists are single column, so a reload re-anchors them by key and they
never shifted.
This only keeps the grid still; the reloads themselves are still
unnecessary and are tracked separately.
Media3 marks a progressive stream live when the data source reports no
length and the extractor no duration, and a live item makes the media
session publish PLAYBACK_POSITION_UNKNOWN, so the notification progress
never advances even though the item carries the library duration.
The app only declares a stream length for a transcode of a track that has
one in a length-estimable format (`declaredStreamLength`), so a chunked
response stays live for an Opus/Ogg/Vorbis transcode (a length estimate
breaks the end-seek they rely on), for a transcode of a track whose
library duration is missing, and for a direct play the server does not
give a Content-Length. The now playing screen shows progress in all of
those because it falls back to the metadata duration; the notification
had nothing to advance.
Wrap the ExoPlayer in a ForwardingPlayer that reports an item with a
declared duration as finite. The session then publishes the real position
and the metadata duration, so the notification shows progress for every
unknown-length stream, not just Opus. The stream is still not seekable;
only the reported liveness changes.
The cover strip clipped to the 24dp content inset. A landscape cover fills
that inset, so its drop shadow was cut off on the left and right and only
the top and bottom survived, which reads as a stray band hugging the
artwork. A tile that fills the strip's height had the same problem
vertically.
Drop the clip and space the tiles by TILE_GAP instead. The shadow then
renders whole on all four sides while the swipe target and the artwork
keep the size and the content margins they had, and the spacing leaves a
gap between the tiles while dragging between tracks. It is also what keeps
the neighbours off-screen now that the strip is unclipped.
Refs: #173
org.gradle.caching=true plus CI restoring ~/.gradle/caches (which holds
the local build cache) let GitHub CI restore :app:testDebugUnitTest
FROM-CACHE: a re-run reported green without executing a single test. A
cached PASS is also unsound while any test is flaky. Mark test tasks
uncacheable so every invocation actually runs the tests; up-to-date and
incremental behaviour for local dev is unchanged.
The playback/queue wall-clock awaits have flaked on the 4-vCPU GitHub
runner at exactly the 30s limit, a different test each time. That is a
stall, not slowness: the suite finishes ~80s for 795 tests, so no test is
anywhere near 30s normally. The JUnit XML holds the stacks but is not
uploaded, leaving the failures undiagnosable from the CI log. Put every
thread's stack in the failure message, which Gradle prints.
The git-runner VMs' nix-docker/bookworm labels are gone, replaced by the
ci-manager's ephemeral pools, so point every Forgejo workflow at them.
The ci job needs the Nix devshell, so it runs on the nix-xlarge pool (a
4 CPU / 8 GiB NixOS VM that already has Nix) with Gradle at -Xmx2g; the
mirror and PR-import jobs don't need Nix and use the ubuntu pool. The
mirror job pins git-filter-repo 2.47.0 itself, since the image's version
may differ and another one would move every commit on GitHub.
While the app is in the foreground a timer checks the auto-sync setting and
syncs when due (default every 24h; the interval is selectable from 15 minutes
to 24 hours). It stops when the app is backgrounded. "Sync automatically"
is one setting: Off, Wi-Fi only (default) or Always.
A running sync is shown by a silent, dismissible notification and a small
spinner on the library tab bar's settings button. A manual pull-to-refresh
still holds the pull spinner; only an automatic sync does not. The library
and Home sync actions no-op while a sync is running, and SyncManager
coalesces a request that arrives mid-run into a single follow-up, so repeated
pull-to-refresh cannot pile up runs while adding a server still syncs it.
The time of the last sync attempt is recorded, so a failing server is retried
once per interval rather than every poll. The sync consumer and the auto-sync
loop both survive an unexpected failure instead of silently disabling syncing
for the rest of the process.
The poll sleeps for the interval (capped at 15 minutes) instead of a fixed
minute, the network mode is collected once rather than re-registering a
connectivity callback each poll, and the decision is logged at DEBUG (visible
with Verbose logging) rather than writing to the log file every poll.
A drop captured the dragged row's queue position once, at drag start, and
committed on a fire-and-forget coroutine. If the queue renumbered while the
drag was in flight (up-next consumed, a reconcile) or before a prior move had
reconciled, the drop resolved its target against stale coordinates and moved
the wrong entry - the "two middle rows swap on release" symptom.
Resolve the move from the current rows at drop time, and have move() report
whether it was accepted so a refused reorder is not announced as done; a
further move is refused while one is still committing.
A short, faded commit hash next to the version in Settings > About, so a bug
report can name the exact build. Baked into BuildConfig at build time from
`git rev-parse HEAD` (fixed 7 chars, so the same commit always yields the
same value and the APK stays reproducible); empty, and hidden, on a build
with no git metadata such as a source tarball.
The React Native 1.x app shipped as com.subtracks and left an AsyncStorage
database (RKStorage), an older `servers` dump and a downloaded/cached
artwork+audio tree in app-specific storage. v3 reads none of those paths, so
an update would otherwise leave the data behind, reachable only by wiping the
whole app.
Remove them once at startup, gated on one of those 1.x paths existing, so a
fresh install does nothing and an upgrade is cleaned exactly once. The cached
trees can be large, so the delete runs off the main thread. Kept in a single
file so it can be deleted once no installs can come from 1.x.
The recipe is maintained by hand now, so the two Forgejo workflows that
pulled fdroiddata into tools/fdroid, tracked a working-copy hash and
opened the GitLab merge request are more machinery than the channel
needs. Their support files and the GL_TOKEN note go with them; the
F-Droid references in the README, signing docs and architecture notes
describe the channel itself and stay.
A streamed MP3 with no Xing/Info/VBRI header gets an estimated duration
from Mp3Extractor. At end of stream the extractor rewrites it, which
refreshes the timeline; MediaPeriodQueue.updateQueuedPeriods then sees
the playing period's duration change, drops every period after it and
re-enqueues the next one, so the next track is opened from byte 0 again.
After a seek near the end that repeats a few times.
Route streamed bytes through a bounded SimpleCache (DefaultDataSource ->
CacheDataSource -> KnownLengthDataSource -> OkHttp). A re-opened next
period is served from the bytes the prefetch already committed, so the
redundant network fetches stop without changing playback length or
gapless padding. DefaultDataSource still handles file:/content:, so
downloads bypass the cache.
The cache is a process-scoped 64 MiB LRU in cacheDir/stream-cache; a
construction failure falls back to no cache so playback still starts.
AGP's dependency-metadata signing block (0x504b4453) is encrypted per
build, so two otherwise byte-identical release APKs differed in that
block. Disable it in the APK with dependenciesInfo.includeInApk = false;
the bundle keeps its copy for Play.
The now-playing suppression keyed off the item id alone, so a track loaded while paused (which sets the tracked id without announcing) never sent its first now-playing when playback then started from the beginning. Gate the suppression on nowPlayingSent as well, and cover that case plus the transition at the combine boundary.
A track transition resets the player position, and the scrobble policy's restart heuristic (same song, position drops to ~0) then re-announced the track we just left and, when the new track's position jittered back to zero, announced it twice. Suppress the now-playing when the restart is the same song; the submission logic still resets so a genuine replay is scrobbled again.
The flock/nice/worker-cap wrapper serialised local builds to protect the
shared box. That is now handled at the machine level (a GRADLE_OPTS cap
in nix-home), so the devshell goes back to the plain nix Gradle.
Drop the non-distinct song comparison (sum of album song counts double-counts compilations), skip redaction for dropped lines via a level check, defer the startup prune off the main thread and cap the writer queue, and only warn about a stall against the still-current player. Reward the scrobble info line and cover the new preferences/level toggles with tests.
sum(artist.albumCount) double-counts compilation albums, so it read higher than the albums actually synced; keep only the song total, which matches exactly.
Warn when playback stays buffering for 15s while play-wanted, and log what the server reports for album/song totals next to what was synced and pruned.
Playback now logs each track transition and end at info (title/artist/album/source/reason), sync logs per-entity counts and pruned rows, and the Subsonic client logs server error codes and the token-auth fallback. Add a Verbose logging setting that drops the file level to debug at runtime (persisted) so a support session can capture full detail without flooding normal release logs.
Log sync start/finish/failure, star/unstar, scrobble now-playing/submission (and failures), download queue/start/complete/fail/remove, offline-mode toggles, network-mode and active-source changes, and pending-action queue/replay/drop. Enrich playback logs with title/artist/album, file-vs-http source and the transition reason, and render C.TIME_UNSET durations as 'unset'. Make console logging non-fatal so a logcat write never breaks the caller, and never prune the file being written.
Room's @Upsert insert-then-update fires a uniqueness conflict on every existing-row write (e.g. each playback-cursor save), which is normal control flow and flooded the debug file at debug level. Move it below the file threshold so the shared log stays quiet.
Only downgrade a uniqueness conflict on an INSERT, so genuine duplicate-key failures still surface. Clear old zips from the dump directory before exporting, roll the log file over at midnight, prune on roll/day-change instead of on every line, and drop the unused files-path from the FileProvider config.
Room's statement cache resets a statement when returning it to the cache, so starting the timer at reset counted idle time in the cache and reported normal queries as tens of seconds. Start timing on the first step instead, and finish on the completing step. Also downgrade Room @Upsert uniqueness-conflict errors (its insert-then-catch-then-update control flow) from ERROR to DEBUG, and log successful HTTP requests at VERBOSE so debug logs aren't flooded by cover-art requests.
Align the Settings label with the old Flutter wording and port the 19 existing settingsAboutShareLogs translations. The subtitle was new text with no prior translation, so drop it rather than ship an untranslated string.
Never let a log-write or export failure reach the uncaught handler: wrap the async writer and crash path, synchronize export with rotation, and tolerate vanished files. Redact the Logcat stack-trace copy, stop leaking rows=0 on writes, shut down the previous writer on re-init, and drop unused API. Strengthen tests for the batched write path, rotation count and throwable redaction.
Capture logs to rotating app-private files with Subsonic credential and URL basic-auth redaction, mirror to Logcat, and capture uncaught crashes. Log slow SQL (>100ms), SQL errors, migrations and DB lifecycle via a decorating SQLiteDriver. Add a Send logs row in Settings that zips the logs and opens the share sheet through a FileProvider.
- cap workers via GRADLE_OPTS instead of a CLI flag, so a caller passing
--max-workers no longer collides and fails the build
- keep the lock under $XDG_RUNTIME_DIR (falling back to ~/.cache) rather
than a world-writable /tmp path
- let --stop/--status/--version bypass the lock, and announce when a build
is waiting for it
- drop the redundant GITHUB_ACTIONS check; the CI runners all set CI
The devshell `gradle` now serialises local builds with flock and runs
them under `nice` with capped workers, so several agents building at
once queue instead of piling up daemons and compile workers and taking
the machine down. With CI/GITHUB_ACTIONS set it execs Gradle directly,
leaving CI untouched. Also shorten the Gradle daemon idle timeout so
idle daemons are reaped sooner.
The fix keyed the request remembers in all three CoverArt branches; assert
each of them survives a re-salted URL for the same cacheKey, not just the
square grid path.
CoverArt keyed its remember blocks on the whole CoverArtRef, whose url is
freshly salted on every call. A recomposition therefore built a new
ImageRequest, Coil restarted the load and dropped the current drawable, so
the image flashed back to the letter placeholder before resolving from
cache. Key the request and the load-state remembers on the ref's stable
cacheKey instead.
Build the shared OkHttpClient through UserAgent.httpClient() so the unit test exercises the same client the app uses, assert the exact 'Subtracks (Android; <version>)' value rather than the property it writes, and run the test under the Android runner.
Only SubsonicClient tagged requests with 'subtracks/android'; playback (Media3 OkHttpDataSource), downloads (DownloadManager) and artwork (Coil/OkHttpArtworkFetcher) fell back to the OkHttp or platform default. Add a single UserAgent with the app version, apply it to the shared OkHttp client, the streaming data source factory and the DownloadManager request, and drop SubsonicClient's now-redundant header.
Set the nightly launcher background to a dark tint of the amber mark
(#1A1200) instead of the magenta-tinted near-black, mirroring how the
standard icon's background is a dark tint of its magenta fill.
The 17-character 'subtracks nightly' label just truncates in the launcher,
so revert the app_name override (debug and nightly show 'subtracks' like
the release) and distinguish the nightly with a recoloured launcher icon
(amber mark) instead. The debug and nightly variants share src/nightly/res,
so installDebug and installNightly look the same.
- write the Play service account in its own step so fastlane never has
the secret in its environment (only the 0600 file path)
- fail the build when the tag does not match versionName
- serialize releases with a concurrency group
- attach the R8 mapping.txt to the GitHub release
- write SHA256SUMS into the subtracks-nightly artifact
- assert at least one changelog exists in the metadata test
- drop the unused assembleDebug from the Forgejo CI
- only claim the AAB was uploaded to Play for stable releases
- clean up the export-certificate keystore with a trap
Drop the separate build-nightly workflow: on a push to main the CI job
uploads the debug and nightly APKs as the subtracks-nightly artifact, so
the nightly variant is built once instead of twice (and there is no
second Nix/cache setup).
Add :app:assembleNightly to the CI Gradle invocation so the R8 minify,
resource shrinking and packaging path is exercised on every PR (signed
with the committed debug key, so no secrets are needed).
- fail the release before uploading to Play when the versionCode has no
changelog
- rely on supply's defaults for release_status and metadata_path
- enforce 8-bit PNGs and reject tRNS chunks in MetadataConstraintsTest
- name the nightly artifact's APKs distinctly
- fix doc drift and the stale libraries uniqueId
Google re-signs Play-delivered apps, so the release key is only the
upload key there and signs the AAB we send; the installed app verifies
against Google's app signing certificate. The release key also signs the
GitHub-release APK.
Print the artifact SHA-256 and the APK signer certificate in the release
log, attach SHA256SUMS and the public release certificate to the GitHub
release, list the APK and certificate SHA-256 in the release notes, print
the next build checksums, and commit the next build's public certificate
with verification steps in signing/README.md.
Gate the Play upload on a full vX.Y.Z tag and mark other tags as
prereleases. Fold the changelog length limit into MetadataConstraintsTest
and make its image checks reject anything but PNG or JPEG. Keep the
decoded keystore at 0600 and note in .forgejo/README.md that the GitHub
CI now factors the build into composite actions.
The .gitignore build/ pattern also matched .github/actions/build, so the
action was never committed even though the workflows referenced it.
Negate the rule for that path.
Replace the reusable workflow with .github/actions/build plus a nested
nix setup action, invoked as a step. CI and the next build stay a single
job (no extra runner or Nix restore); the release keeps its build/publish
split for credential isolation.
Extract checkout, nix, signing and the Gradle run into a reusable build
workflow used by CI, the next build and the release build job. The build
job holds only the signing secrets; the publish job is the only one with
a write token and the Play credential. The Play service account is now
written to a 0600 file and passed via --json_key instead of on argv.
Set persist-credentials: false so the contents:write token is not left
in .git/config where every later step (including the Gradle build) could
read it, and delete the decoded keystore once signing is done.
Add metadata checks (title, short/full description, icon, feature
graphic, phone screenshots) at the stricter of the Play and F-Droid
limits, and drop the trailing period from the short description.
Re-encode each capture to opaque RGB so the store screenshots copied
into metadata/ already satisfy Play's rule, instead of depending on a
manual palette-free re-encode.
Drop the fastlane --skip_upload_* flags so the title, descriptions,
icon, feature graphic and screenshots upload alongside the AAB, and
re-encode the phone screenshots as 24-bit no-alpha PNGs, which is what
Play requires.
Replace the unpinned third-party upload-google-play action with fastlane
supply from a small nix release devshell, and pin the remaining
third-party GitHub release action to a commit SHA.
Drop setup-java, setup-android and the Gradle wrapper in favour of the
flake's pinned JDK, Gradle and Android SDK, restoring the Nix store with
cache-nix-action, so the release and next builds share the dev toolchain.
Commit a public debug keystore and point the debug signing config at it,
and add a next build type (release-like, same key). Both carry the .next
application id, so next builds install alongside the release and update
each other without exposing the release key. A GitHub workflow builds
both APKs on every push to main and uploads them as artifacts.
Set applicationId to com.subtracks and add a GitHub Actions workflow
that, on a v* tag, builds and signs the release APK and AAB, creates
the GitHub release with the APK, and uploads the AAB to Google Play.
Release notes come from the matching metadata changelog.
Signing reads the keystore from the environment
(RELEASE_KEYSTORE, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS,
RELEASE_KEY_PASSWORD) and falls back to the debug key locally.
Give each feature a bolded name with a short description, add large-library
and offline-mode points, and bring the store description's order and content
in line with the README.
A clean build now emits no warnings of its own, and Kotlin
`allWarningsAsErrors` plus lint `warningsAsErrors` stop new ones from
sneaking back in.
- Kotlin: give SearchFilter's @StringRes parameter an explicit @param:
target; use safe calls for File.parentFile in StringResourcesTest.
- Packaging: keep debug symbols for the prebuilt .so files that have no
symbol table, which is what the "unable to strip" warning was about.
- Lint: move `modifier` to the first optional parameter across the Compose
screens, use mutableIntStateOf for int state, read Resources via
LocalResources, use the KTX Bitmap.get/String.toUri, drop the unused
ic_action_name and ic_stat_name bitmaps and the dead -v24 qualifier on
the notification vector, fix the pt-BR "album" accents and the unused
Vietnamese/Thai plural quantities, and suppress the intentional
cleartext/user-certificate, backup, exported-service and
plural-candidate findings. Disable the version-update nags, which the
dependency-update process owns.
The AboutLibraries plugin still resolves the variant classpaths during
configuration, so AGP keeps warning about that one; upstream in
mikepenz/AboutLibraries#1369.
The v4 pair targets Node 20, which GitHub now forces onto Node 24 (Node 20 is
deprecated). The Forgejo runner is v13, which runs Node 24, so both copies move
together. Pin the GitHub runner to ubuntu-26.04: ubuntu-latest migrates to it
over Oct 19 - Nov 19, 2026, and Nix supplies the toolchain either way.
Add .github/workflows/ci.yml, a copy of the Forgejo CI workflow adapted to
GitHub-hosted runners (ubuntu-latest + cachix/install-nix-action), so pull
requests on the GitHub mirror get the lint, unit-test and integration checks.
The GitHub copy uses a 90 minute timeout so a cold run can finish and warm the
caches, caps the Nix cache so it cannot evict the Gradle/music caches from
GitHub's 10 GB per-repo budget, and pins the job token to contents:read.
Add the F-Droid version badge back ahead of the GitHub release badge, match its colour to the release badge's blue, and darken the GPL-3.0 license badge.
The artist page now shows the artist name, an Artist/Album artist label
and keeps the count on the button row. Feed those fields in the demo
screenshot and re-render 05_artist.png from the Navidrome demo library.
The album count sits in the star/more button row, so the row is 48dp tall
only when those buttons are present, shifting the count for album artists
versus credited track artists. Pin the row to that height either way.
Lift the type label to the artist name's inset and centre the album count
on the star/more row. A credited track artist has no artists row, so its
star is disabled and more does nothing; hide both there.
A track artist without an artists row had no name on the artist page, so
the header and the placeholder letter were blank. Derive the name from the
songs that credit them, and label the page 'Album artist' when they lead an
album or 'Artist' when they are only credited on tracks. The hero art keeps
the letter placeholder when there is no artist image.
A song's artist can differ from its album artist (compilations, guest
tracks). The artist page was built only from albums where the artist is
the album artist, so opening such an artist showed a blank page. Attribute
a song to both its album artist and its credited track artist across the
artist album list, shuffle queue, download status and the artists list
filter/badges, and show the listed album count on the detail page.
A notification tap can fire the dismissal before the route's collector
subscribes (activity recreated with a restored options sheet), and a
replay-zero broadcast drops it. Replaying one is a no-op on default state
and the new ContextMenuHost test guards it.
An Int counter was already rejected for the now-playing launch signal; use
a broadcast Flow on the shared ContextMenuHost and collect it, so the
dismissal is an event rather than an ever-increasing number.
Tapping the media notification opened now playing over whatever was on
screen, leaving the item context menu, the library options sheet, the
keyboard and any open dialog above it. On a now-playing request, clear
focus, dismiss the shared menus and close each route's dialog state.
conflate rather than distinctUntilChanged: switching between two
available networks (WiFi LAN server -> cellular -> WiFi) kept the signal
true, so the queue never drained until another trigger. Emit on each
callback instead, and drop the sink combine's distinctUntilChanged so a
repeated ready edge still flushes. Simplify flush to reuse the safe drain.
Add a regression test that replays on two true emissions with no false
between.
A raw network drop and recovery never flipped the offline flag, so the
queue only drained on the next sink op, an offline-mode toggle or a
restart. Observe the default network's availability and use it, together
with the persisted offline preference, to trigger a replay. This signal
only drains pending actions; it never changes offline mode.
- Order direct stars against the queue so a successful online unstar is no
longer overwritten by an older queued star, and serialize all queue
mutations under one lock.
- Treat a SubsonicException with a negative code as a transport failure:
queue it in setStar and keep it on flush instead of dropping it.
- Make coalescing atomic, keep the offline collector alive across errors,
and drain from the persisted offline flag so a queued action flushes on
a cold start that is already online.
- Give ServerActionSink.flush a default body and drop the no-op overrides.
Actions that cannot be delivered are persisted in a pending_actions
table and replayed once the server is reachable again. Stars coalesce so
only the latest state per item is sent, and replay happens on the next
successful action, when offline mode is turned off, or before a sync.
Query the target project for an existing MR (fork->upstream MRs live upstream), pin checkout to main, use a sha256 marker instead of a duplicated upstream copy, pass GL_TOKEN via GIT_ASKPASS instead of the push URL, use sed -E and check the upstream id parsed, and abort if upstream moves between the guard and the clone.
Sync pulls fdroiddata's current recipe into tools/fdroid/ (refusing to clobber unsubmitted moves). Submit refuses when upstream has moved since the last sync or when there is nothing to submit, then pushes this repo's copy to the GitLab fork on a branch based on current upstream and opens the MR. Drops the API-overwrite script.
Use PUT for the repository file (it already exists on the fork), create the MR on the fork with target_project_id (the create endpoint ignores source_project_id), and reuse an open MR instead of deleting and recreating the branch. Drop the unused --branch/--title flags and the redundant workflow concurrency/defaults.
The fdroiddata recipe is now kept in tools/fdroid/ as the source of truth. tools/fdroid-submit.nu pushes it to the fork and opens the merge request via the GitLab API, so no fdroiddata clone or manual web editing is needed. Also runnable from a Forgejo Actions workflow_dispatch.
Only cache a cover after a successful response and drop the file if decoding
fails, so a transient error cannot poison the cache permanently. Fail with a
clear message instead of a bare NoSuchElementException when a demo album has
no songs or the artist list is empty, and normalize line endings before the
changelog length check.
Drop the "API" from the server compatibility wording, and lowercase the brand
in the title, description, old changelog and the app name to match the logo.
Both F-Droid and Google Play cap release notes at 500 characters. Check every
metadata changelog in the unit test suite (which CI already runs), and trim the
two old Flutter changelogs that were over the limit.
Neither F-Droid nor Google Play renders Markdown in release notes, and both
cap them at 500 characters. Drop the Markdown header and bullets and trim the
copy to fit.
The demo library has plenty of entries with no artwork, which render as
placeholders in the store screenshots. Filter the album and artist lists (and
the home rows) to entries the sync marked with a coverArt id, so no extra
image loads are needed to decide.
The library accent follows the playback artwork (LibraryRoute derives it from
the current item), so pass the playing album instead of the first list entry,
which made the tab bar disagree with the miniplayer. Inset the miniplayer
progress bar when the tab bar is present, matching AppRoot.
Render the miniplayer in a column below the screen instead of overlaying it, so
the library tab bar and the list-options FAB are no longer covered. Use Ugress
for the artist screen, drop Various Artists from the home artist rows, and have
the album screen play its first track so the row indicator and the miniplayer
colours match the album.
Use a different album for the now-playing and album screens (Chillhop Winter,
My latin way) so the store shots show distinct colour schemes, tint the home
screen with the playing album so the logo pops, and render the miniplayer on
the albums, album, artists and search screens. Search now runs "chill".
The generator stages the sync into an on-disk database and caches cover bytes
under the temp dir, and only fetches full-resolution art for the covers shown
large; grid art uses the thumbnail endpoint.
Tint the home screen with the artwork theme the app derives from the playing
album, so the subtracks logo and accents pick up a vivid colour (The Knife -
Shaking The Habitual) instead of the default scheme. Switch the now-playing and
album screens to The Polish Ambassador - Future, Sex, Computers, and search for
"chill" so the result grid shows the Chillhop Essentials covers.
Replace the placeholder-art screenshots with renders of a real library, so the
store listing shows real album and artist art and names.
The generator (DemoScreenshotTest, run with `gradle :app:demoScreenshots`) syncs
the public Navidrome demo (demo.navidrome.org) into an in-memory database using
the normal SyncService pipeline, derives the theme colours from the fetched
cover art, and renders each screen. The synced data and cover art stay in memory
and are never written to the repository; only the resulting PNGs are committed.
The test is excluded from the unit-test tasks and is not part of CI.
Also reverts the screenshot fixture palette tweak from the previous commit,
which the real art makes unnecessary.
Bump versionCode to 13 and versionName to 3.0.0. The Flutter app ended at
2.0.0-alpha.3+12 (versionCode 12), so 13 keeps the eventual com.subtracks
release an in-place upgrade.
Metadata was still inherited from the Flutter app:
- add the changelog for versionCode 13
- rewrite the descriptions for the native app
- replace the Flutter phone screenshots with native renders, and mute the
screenshot fixture palette so the placeholder artwork reads as covers
Order the result sections smallest-first and apply the review fixes: pad
the filter bar for the navigation bar, keep at least one filter enabled,
hide the clear button on an empty field, drop the unused autoFocus and
bar-modifier parameters, and test the filter mask and section order.
Add a filter chip bar above the keyboard to toggle songs, albums, artists
and playlists independently, all on by default and scrolling when it
overflows. Extract one compact 48dp search field and use it in both the
cross-library screen and the library tabs.
Prefix result keys per type so ids shared across songs, albums, artists
and playlists cannot collide and crash the list; add a loading state so
a query in flight no longer reads as no results; gate on code points to
match SQLite's length(); bind the three-character floor to one constant;
label the search field for TalkBack; and drop the vacuous trigram-plan
test. Cancel the ViewModel scope in its test teardown so it cannot leak
into later Robolectric tests.
Drop the library-wide instr fallback: below three characters the
cross-type search now returns nothing instead of scanning every table,
and the screen asks for more input. Lock in that a song result plays its
album context rather than a search queue.
Add FTS5-backed search across songs, albums, artists and playlists,
reachable from a search FAB on the Home tab. Terms of three characters
or more run through the trigram indices; shorter terms fall back to the
instr scan, matching the per-tab filters. Results are grouped by type
and route into detail screens or song playback.
Albums/Artists/Playlists were new keys because the old Flutter strings
were plurals, so their translations went unused. Take the `other` form of
resourcesAlbumName/resourcesArtistName/resourcesPlaylistName into the three
plain-string resources for the 26-27 locales that translated them. Arabic
`resources_album_name` and Hungarian `resources_playlist_name` stay English
because their upstream `other` form is empty.
- AddSourceViewModel resolves SubsonicException.uiMessage too, so malformed/
HTTP/truncated responses show localized text instead of the raw parser
message; add a regression test.
- StringResourcesTest now requires an 'other' quantity in every plural and
asserts exact placeholder (index and conversion) equality, both of which
could previously pass while crashing getQuantityString at runtime.
- Map the pre-enum song_downloads.error sentences back to their DownloadError
so existing failed rows keep their specific message.
- Localize the media-session star/unstar button label.
- Remove 12 unreferenced resources (queue name, filter year, sync_failed, and
the unused codec_* strings; codec labels stay in AudioEncoding).
- Make buildTree's fallback arguments required; only tests used the defaults.
Route user-facing messages produced below the UI through a typed UiMessage
(string-resource id plus format args) instead of pre-built English strings:
showMessage, SyncStatus.Failed, playback errors and download toasts now
resolve in the current locale at the UI edge. Subsonic and page-cap
exceptions carry a UiMessage alongside their developer message, and the
download progress notification is formatted from resources.
Internal invariants and log tags stay plain strings.
Move every exposed UI string into res/values/strings.xml (+ plurals) and
read it via stringResource/pluralStringResource or getString, with 27
locale files carried over from the Flutter app's Weblate translations.
Only strings whose English is unchanged reuse an old translation; reworded
ones get fresh keys.
Persisted download failures become a DownloadError enum mapped to string
resources (stored by name, unknown legacy values degrade to Failed), the
per-app locale config is generated by AGP, and StringResourcesTest guards
locale keys and placeholders.
Resolve previousItem/upcomingItem through the same offline download scan the transport uses, so the peeking art is the track the swipe will actually play. Clear a frozen strip when a fallback swipe fires, prefer the dragged direction over an opposite fling, and simplify the hand-back and ratio prefetch.
Swipe-back now goes to the previous track (previousTrack) instead of restarting, so the previous peek stays visible past the restart threshold. The frozen strip only hands back once the live neighbour props match it, so a stale neighbour can no longer flash while the slide settles.
Resolve the previous/next queue items and render a three-slot cover strip that follows the drag, so the neighbouring cover slides in from the side on release. The strip is rotated optimistically at commit and hands back to live state once the controller reports the new track (or after a grace period), so a prop update mid-animation cannot desync it. Ratio prefetch and layered cover loading keep the incoming art from snapping as it loads.
Left/right drags on the cover art skip to the next/previous track, mirroring the transport buttons; the cover follows the finger and settles back on release. Also drops the ripple on the title/artist block.
Name the cancel and delete buttons after the row so screen readers can
tell them apart, show an indeterminate bar when a running track has no
known total, and drop the no-op media refresh on cancel. Trim the dead
size alias and a redundant guard, and cover a queued row with no engine
id plus a failed track in the progress denominator.
The aggregate summed bytes over only the currently active songs, so it
reset each time a track finished and the next one started. Count completed
tracks (plus the fraction of each running track) over the node's full track
count instead, so it climbs one track at a time.
A node with any active download now shows the aggregate progress bar and
cancels all its active downloads instead of offering delete; a node with
only finished downloads keeps the trash.
In-progress rows on Manage downloads now render a determinate progress bar
and a cancel button; completed and failed rows keep the delete button.
Cancelling removes only the active rows, so any completed downloads stay
and can still be deleted.
RevealNewFrontItem compared the post-anchor firstVisibleItemIndex against the
front threshold, so it only worked when the row was at index 0 and exactly one
item was prepended. Recover the pre-change position by subtracting the number
of items now before the old front, and cover the one-in and multi-prepend cases
with integration tests.
Adopt the canonical TagLib/mutagen names for two extended indices: 133 is
Worldbeat (was Negerpunk) and 148 is Abstract (was Christmas). Drop the unused
default on SongInfoDialog's genres argument.
Split genre tags on Navidrome's separators (semicolon, slash, comma) and
resolve ID3v1 numeric indices to their names, so a song's genres are one
deduped list. songs.genre is now derived data with no reader: drop the
column and its index, and read the song info dialog's genres from
song_genres.
Scroll a recently played row back to the front when a new first item
appears while it is already at/near the start; the lazy layout otherwise
anchors the old first item and hides the new one. Balance the three
genre rows by estimated width and tighten their gap.
The album/artist/playlist paging SQL named song_downloads, so Room
invalidated the pages on every download progress tick, janking the list
and resetting scroll when a download started. Route the default (filter
off) path through a raw query that only observes its own table; the
downloaded/offline path keeps the download-aware queries.
Port the Flutter app's network security config: add a base-config that trusts system and user certificate anchors and permits cleartext, and reference it from the application manifest. android:usesCleartextTraffic is now redundant and removed.
Use cancelAndJoinBlocking in the repo tests and AppModuleTest instead of hand-rolled runBlocking joins, put AppModuleTest's cleanup in a finally so a failed join cannot leave Main overridden and Koin started, and collapse the five polling await copies into one awaitUntil. upcomingItem now reads the queue snapshot under the lock but does the Room read outside it.
The repositories and the playback controller each created a hidden SupervisorJob scope and exposed close() only so tests could join it before closing the DB; production never called close(). Inject the scope instead and have each test cancel and join the scope it passes, so no test-only teardown lives in the app. Koin now provides the app/io/playback scopes.
Also read upcomingItem under the controller lock, and give the wall-clock test waits one shared 30s budget instead of 5s per file: a loaded runner could starve the controller's single thread past the old deadline.
OpenSubsonic servers return a per-song <genres> array that was dropped,
leaving every song with only its first (or joined) legacy genre. Parse it
and store the ordered list in a song_genres table, keeping the single
genre column as the fallback for base Subsonic servers.
Album genres are derived by unioning the album's song genres, falling
back to the single song genre, so album-level genre never trusts the
per-server album genres[] behaviour.
Schema 21 -> 22, with parser, sync, unit and integration coverage.
A submitted scrobble now counts locally regardless of the HTTP result
(offline already did), so a transient server error no longer drops the
play from the sorts. Last-played uses MAX(existing, at) so a backwards
clock step cannot move an album or artist back down the list.
It compared a possibly stale listQuery (the previous tab's value during a
tab switch) against the new tab's options and rewrote the new tab's query
to its default, so sorts appeared to reset. Not worth the fragility for a
hidden-option edge case.
A submitted scrobble now bumps the song's playCount/played and its album
and artist ranks, so the play sorts move without waiting for a sync. The
sync then overwrites them with the server's own numbers, so the
increment cannot double-count.
Song playCount/played are parsed and synced, then aggregated per album
(SUM/MAX) and per artist (through the album's artistId) after the song
sync. Albums and artists gain Frequent/Recent sorts with their own
indices; the options show only when the active source returned the
fields, and the aggregate only writes rows whose value changed.
The artist subqueries pin index_albums_sourceId_artistId: without it the
planner answers MAX(played) from index_albums_recent (sourceId, played),
which cannot apply the artistId equality, and scans every album once per
artist. The availability flows read the indexed album ranks rather than
scanning songs, which would re-run on every song-sync batch.
PlaybackController.now exposes awaitStopped() so the tests that own its DB can join its scope before db.close(); close() stays cancel-only so AppModuleTest (Main dispatcher) cannot deadlock. Restores db.close() in the tests that have no leaking scope, closes the SyncManager AddSourceViewModelTest was leaking, replaces the duplicated poll with the existing awaitCoverArt helper, and trims the testLogging block to the one line that does anything.
The ThemeTest failure was cross-test contamination, not a theme bug. A Room invalidation-tracker query keeps running on a cancelled flow collector after the in-memory DB is closed or finalised, throws "no such table: sources", and kotlinx-coroutines-test records it and fails the next Compose test with UncaughtExceptionsBeforeTest. Which test it lands on is just ordering/load luck.
Close repositories the way DownloadRepository already did: cancel and join the scope, so Room work cannot outlive close. Stop closing the in-memory test DBs; the DB is dropped and collected once the test releases it, instead of being torn down under a pending query. Wait for the stored ref in offlineModeStillServesDownloadedArtwork instead of asserting once, since it was waiting on a different flow than the state coverArt reads. Print full stack traces for failed tests in CI.
Trim the explanatory comments to short quirk notes, drop the module headers, and fail cleanly: navidrome/gonic now kill their process if they never become ready and the entrypoint always tears down. The prune test waits for the removal to appear in the album list rather than for the scan flag. Correct the AGENTS token-auth note (lms answers 42, Nextcloud 41) and give CI more headroom.
tools/integration-test.nu is now just the entrypoint. The server lifecycle and fixture preparation live in tools/integration/servers.nu, the Subsonic request helpers in subsonic.nu, and shared waiting/port helpers in util.nu. No behaviour change; the assertions still live in the Kotlin tests.
Run the integration harness from its own devshell so the server packages stay out of the everyday shell. Add lms and a Nextcloud Music instance to the harness and extend the suite with auth-fallback, media, error, write round-trip, multi-source and prune tests.
The auth test caught that lms answers OpenSubsonic error 42 (not 41) when it refuses token auth, so the client now falls back on both codes.
The app itself and the MPL-2.0 public-suffix data OkHttp embeds fall
outside the automatically collected Gradle dependency list, so add both
via the manual config. The Subtracks entry links to its source.
desugar_jdk_libs is bundled by core library desugaring rather than the
runtime classpath, so it never reached the generated licence list. Add it
plus its GPL-2.0-with-Classpath-Exception text via the plugin's manual
config directory.
Add AboutLibraries, whose Android Gradle plugin collects the licence and
attribution of every dependency into a generated resource at build time,
and surface it under a new About section in Settings.
1.15.0 is the highest minor Airsonic and Airsonic-Advanced accept; they
reject a newer client with error 30. The OpenSubsonic response fields do
not depend on the declared version.
The active source becomes one immutable id+source pair, so cover art and
stream/download URLs can no longer read a torn state mid-switch. The root
navigation watches the active source id and, on a real switch, closes the
overlays and pops an open album/artist/playlist detail back to the library.
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.
The download and more blocks were different widths, so the play/shuffle pill
sat off centre and the download hugged its edge closer than the more button.
Both now take the same inset.
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.
The ring reads on the theme background of the artist and playlist rows but got
muddled over cover art, so the album tiles and the artist page's album cells put
it back on a translucent surface circle; the rows stay bare.
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 album and playlist menus lose Play and Shuffle and the song menu loses Play;
tapping the row or the detail hero buttons already covers those, so the
ItemActions play/shuffle callbacks and the artist view-model methods behind them
go too.
The badge was a filled circle that stood out over the art. It is now a white
glyph with a soft shadow in the icon's own shape, with no background behind it.
The library screen held all three grouped status flows at once, so the two tabs
off screen ran queries for badges nobody could see. It now collects only the
visible tab's map.
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.
The 'Artist biography coming soon.' line is temporary and the bio work is shelved, so remove it. With only the album count left, centre it against the star and more buttons under the image (the buttons no longer need to sit below the hanging shuffle).
`git add -A` while rebasing and committing swept up another agent's edits from
this checkout: a THUMBNAIL_SIZE bump in SubsonicSource and a refreshArtistInfo
call appended to the artwork pass in DownloadRepository. Both are out of scope
here and are reverted to main's version, so they can land through their author's
own change or not at all.
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.
The conflict resolution took my side of ItemActions.setStar, which still
suspended and returned Result; main's rework made it a plain action the menu
calls without a coroutine, with the star filled optimistically. Compiles again,
and ItemContextMenuStarTest passes.
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.
- draw the play halo as an offset copy below API 28, where path
shadow layers are ignored, and skip the inner shadow there too
- add a repository toggleStar that reads and writes under the star lock,
so fast double taps on the notification cannot desync
- reset the star button when a track starts, so a failed source lookup
cannot leave the previous song's state showing
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.
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.
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.
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.
- 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
- 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
- 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
- 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
- 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
- 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.
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
- 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).
- 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.
`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`.
- `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.
- 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.
#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.
- 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.
- 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.
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.
`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.
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.
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.
`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.
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.
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.
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.
`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.
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).
`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.
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.
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.
The previous commit assumed a removed/moved track only shifts play
positions by one, but a shuffled context removal re-derives the
permutation, so the current track's play position is a remap of its flat
index. `removeAt` now maps the current's flat through the updated order
(shuffled) or shifts it (unshuffled), and re-anchors the up-next block to
the track the cursor lands on. This fixes removing any track while
shuffled picking a random track and the anchor removal not advancing to
the block.
Restore "shuffle starts at the top": pick the seed so the requested start
track maps to play position 0 (`pinnedSeed`), instead of leaving the
current at its derived position. Applies to shuffle-play, tapping a track
while shuffled, toggling shuffle on, and restore.
The queue view's playing highlight now requires both the combined position
and the song id, so a transient position mismatch no longer flashes the
row above as playing.
The queue can hold the same song more than once (a song in both the context
and the up-next block, a duplicate album track, ...), so resolving the
playing entry by song id could match the wrong copy.
- `PlaybackController.move` and `removeAt` now derive the current track's
new position from the edit (block index / context play, read back through
the updated snapshot so a moved anchor is accounted for) instead of
`combinedIndexOf`/`combinedContextIndexOf`.
- the queue view's playing highlight compares the row position to the
playback position instead of the song id, so duplicates no longer both
light up.
Regression tests cover a move and a remove with a duplicate song in the
context; both fail without the change.
Still id-based, deliberately: `undo`'s canMirror guard (already position-
guarded), the shuffle-state-changed branch of `shiftWindowLocked` (rare,
needs the id to find the track after an arbitrary reorder) and the view's
LazyColumn keys (cosmetic only).
Removing the currently playing track resolved its id against the updated
queue afterwards; if the same song also exists in the context, the lookup
matched that copy and jumped the cursor to it instead of advancing. When the
removed position was the playing one, use the fallback (next in the block /
slot fill) rather than a by-id lookup.
Pre-existing on main, exposed while testing the shuffle rework. Regression
test fails without the fix.
- `QueueRepository.setShuffle(seed: Long?)` derives `shuffleEnabled` from the
seed, so disabled callers drop the meaningless sentinel `0L`.
- `playContext` returns `Long?` for symmetry with `flatContext`; `anchorPlay`
treats an out-of-range anchor as -1 instead of silently clamping to the last
track, and `combinedContextIndexOf` returns null.
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.
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.
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.
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.
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.
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.
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.
- 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.
- 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.
- 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.
- 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
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.
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.
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.
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.
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.
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).
- 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
- 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
- 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
- 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
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.
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.
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.
- 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
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.
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.
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.
- 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
- 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
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.
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.
- 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)
- 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.
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.
- 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.
- 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.
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.
- 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
On the library no navigation happens, so ours was registered before the NavHost's and
lost; keying the DisposableEffect on the overlay flags re-adds it last as soon as the
overlay opens.
The NavHost registers its own back callback after ours, so on a pushed route it won
the back and popped the route under the now playing overlay. Re-register our callback
on every navigation (DisposableEffect on the current route) so it is always the most
recent, and toggle its enabled state with the overlay/queue.
Keeps the NavHost composed (the dropped-NavHost version left the still-transparent
overlay over black) while stopping the controller from winning the back over the
overlay's BackHandler. Back now: queue -> now playing -> pop the route.
- restore the now playing overlay (the destination had too much moving during the
transition); while the overlay is open, omit the NavHost so it has no back callback
to steal the back from the overlay's BackHandler
- first disc header gets a little top padding (16dp), the rest keep 40dp
- keep the fab glyph container 28dp so the filter dot aligns as before
The overlay fought the NavHost over the back: OnBackPressedDispatcher hands the back
to the last-registered enabled callback, and the NavHost registers its own as part of
its composition, so it intermittently popped the route underneath. A destination lets
the back stack own it.
- add Routes.NOW_PLAYING with a vertical slide, opened by tapping the mini player or
flicking it up; back or a downward fling pops it
- hide the mini player while on the now-playing route so it fills the screen
- the static scrim now follows the route instead of a drag progress
- drop the nowPlayingOpen/progress overlay state and its test
- register the now playing and queue back callback from a LaunchedEffect instead of BackHandler: the NavHost registers its own callback from a LaunchedEffect, so a BackHandler composed before it loses the back and pops the route underneath
- NowPlayingBackTest registers the overlay handler before the NavHost (the losing order) and asserts back closes the overlay without popping the route
- more top padding on disc headers, except the first which sits flush
- LIST_OPTIONS_WEIGHT 0.15 -> 0.11
- Now Playing: the type line reads "songs" for the library songs list, source line stays "Library"
- Album detail: align the disc icon over the track numbers and the disc label with the track titles (16dp start + 18dp icon + 16dp gap, matching the ListItem columns)
- Add NowPlayingBackTest covering back-from-overlay not popping the destination
The bundled Material icon set has no weight axis, so the FAB now draws the
three-bar glyph with a configurable stroke thickness instead of using Segment.
- FAB: use the Rounded Segment icon so the glyph reads thick
- Now Playing: two-line header, an uppercase "Now playing: <type>" label (disc-title style) above the source, both left aligned
- CoverArt: remember the aspect ratio every image loads at and seed the non-square placeholder from it, so the detail hero does not start square and snap
- derive the now playing fallback title from the current item for every kind except the songs list, so a track reordered into the album queue (now a Song entry) still shows its album
- reset the resolved title to the fallback when the queue context changes, so crossing albums does not briefly show the previous name
- start the now playing scrim fade later (0.85) so it darkens the sheet, not the library, while the sheet is still sliding up
- reuse the thumbnail's onSuccess painter ratio instead of loading a second thumbnail painter for the aspect ratio
- drop the now-unneeded ExperimentalLayoutApi opt-in on AddSourceScreen (imePadding is stable)
- CoverArt: use the thumbnail's intrinsic ratio until the full-res painter loads, so non-square art no longer snaps
- Now Playing: move the top scrim out of the sliding screen into MainNavigation, static under the status bar, fading in as the screen settles at the top
- Now Playing: titlebar shows the playback source (album/playlist name, or Library) instead of "Now playing"
- Add server: keep the test/save bar above the keyboard on first launch
- Titlebars: use the theme default title style instead of headlineMedium
- Library: clicking an item in a searched tab dismisses the search
- Library FAB: use the AutoMirrored Sort icon at 28dp so the glyph reads a little thicker
The header was the same size as the track title, so render it uppercase
labelLarge with letter spacing to separate it from the tracks. Use the
outlined FiberSmartRecord icon.
DiscFull reads as an error icon, so use FiberSmartRecord, render it on
the "Disc N" fallback too, sit it 8dp from the edge with 8dp before the
text, and size it 20dp.
Use Icons.Rounded.DiscFull, which is distinct from the album tab's
Icons.Rounded.Album, and only beside a real title rather than the
"Disc N" fallback.
When two discs of an album share a title, showing "Live" alone is
ambiguous, so render "Live: Disc 1"/"Live: Disc 2". Unique titles stay
as-is. Also drop the header from titleLarge to a semibold titleMedium so
longer titles fit on one line.
Navidrome and gonic expose OpenSubsonic discTitles on the album response.
Parse them into a transient Album.discTitles map and persist each album's
titles in a child discs table (sourceId, albumId, disc, title) cascading
from albums. Songs keep only their disc number, and the album screen joins
by disc to render a title when one exists, falling back to "Disc N".
Titles are read opportunistically: a server that doesn't send discTitles
just leaves the table empty, so no API version negotiation is needed.
Titles apply to single- and multi-disc albums, are pruned when removed,
and are stored per album.
Schema 10 -> 11.
Drives the pager across the Artists to Playlists transition and asserts the
Songs icon only moves one way. Fails on the min-width clamp version (738-727-745)
and passes on the natural-48dp version. LibraryTabs is internal so the test can
drive its PagerState.
A min-width clamp kept an incoming tab at 48dp until its label passed ~4dp while the outgoing label shrank immediately, so the tab row dipped left and then grew right. With the tab naturally 48dp wide (8dp/16dp padding around the 24dp icon) the width tracks the label continuously and the row moves one way.
Replace minimumInteractiveComponentSize (which centred the whole tab content and drifted as the active label expanded) with an explicit 48dp touch target whose content is aligned to the start. The icon position no longer depends on the label width.
Tracking the content position per frame made the inactive-tab colour split chase the layout instead of only following the indicator, so the icons jittered on a swipe. Go back to the stable tab-relative offset.
- offer a Sync button on a truly empty library tab so an empty library is not stranded (pull-to-refresh needs scrollable content)
- do not close the search field when it auto-syncs from a tab with an empty term
- make rememberArtworkColors nullable so the no-track fallbacks are live and the idle theme is the monochrome background
- measure the tab overlay content offset so the colour clip accounts for the touch-target centering
- put the pull indicator below the status-bar scrim, and seed the tab bar height estimate to avoid a first-frame inset jump
- collapse the two equal clearance constants, drop the redundant search imePadding, reuse the hoisted artwork for the mini player, and de-duplicate the mini player screenshots
- the indicator now uses the artwork primary and player surface instead of the default onSurfaceVariant
- keep the tab bar height in the list inset while searching so the content does not expand for a frame before the keyboard lands
- replace the library sync button with pull-to-refresh on every tab
- hide the tab bar while searching so only the search field sits above the keyboard
- animate the mini player progress bar between full bleed and 8dp inset rounded ends as the tab bar comes and goes
- make the warm-hue saturation damping continuous across the 0/360 hue wrap
- restore the library sync control removed when the tab bar was reworked, with a test that clicks it
- lift the search field above the tab bar instead of overlapping it
- give the tabs and settings/sync buttons 48dp touch targets
- drop the duplicated label semantics on tabs
- animate the library bottom inset so the bar does not jump when the mini player enters/exits
- remove imports and a parameter left dead by the header rework
adds docs/architecture.md (stack, layers, data model, sync, search, networking, testing, CI), docs/implementation-plan.md (phases and status), and AGENTS.md for agents and contributors
SubsonicSourceIntegrationTest runs against locally started navidrome and gonic; tools/integration-test.nu downloads the test music once, starts both servers, seeds data, runs :app:integrationTest, and tears everything down.
MockWebServer client tests, XML mapping (including timestamp parsing), source fallback, and sync/search against an in-memory Room DB using the bundled SQLite JVM driver.
AGP 9.4.1 / Gradle 9.7.1 / Kotlin 2.3.10 (AGP built-in Kotlin), compileSdk and targetSdk 37, Room 3 with KSP schema export, and nushell/navidrome/gonic in the devshell plus a direnv .envrc. Adds a dedicated :app:integrationTest task and excludes the integration class from the unit test tasks.
- flake: pin build-tools and platform-tools to 37.0.0 instead of "latest", matching build.gradle.kts
- robolectric: run at sdk 35 (targetSdk) and re-record the golden
- manifest: allowBackup off, drop not-yet-used permissions
- theme: drop no-op status/nav bar colors (edge-to-edge)
- deps: kotlin 2.1.20, drop unused deps and test runner
- wrapper: regenerate jar from 8.11.1, add distributionSha256Sum
- remove stale README
replace the flutter project with a gradle/compose android app:
- nix flake dev shell (jdk21, android sdk 35, build-tools 37, gradle)
- material3 app with a placeholder screen
- roborazzi/robolectric screenshot tests with one golden
- carry over the existing launcher/notification assets
Currently translated at 100.0% (94 of 94 strings)
Translated using Weblate (Tamil)
Currently translated at 100.0% (94 of 94 strings)
Translated using Weblate (Tamil)
Currently translated at 100.0% (94 of 94 strings)
Added translation using Weblate (Tamil)
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ta/
Translation: Subtracks/subtracks
Currently translated at 82.0% (55 of 67 strings)
Translated using Weblate (Czech)
Currently translated at 1.4% (1 of 67 strings)
Added translation using Weblate (Czech)
Co-authored-by: Roman <peposik.r@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/cs/
Translation: Subtracks/subtracks
Currently translated at 100.0% (67 of 67 strings)
Translated using Weblate (Portuguese)
Currently translated at 11.9% (8 of 67 strings)
Added translation using Weblate (Portuguese)
Co-authored-by: Joel Calado <joelcalado@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/pt/
Translation: Subtracks/subtracks
* get all song coverArt as they are rendered
doing it all up front was too heavy
temporarily disabled mapping artwork in setQueue, need to fix this
* use cache data for track artwork when available
* fix round art in context menu for songs
* set only the first artwork at play time
then set the rest in the playback service
* handle both cached images and fetching images
* remove commented code
* fix shuffle
fix first thumbnail not being updated on shuffle for now playing background
* basic i18n poc
* translate home, filters, tabs
support dot notation in backend for namespaces
* i18n context menu, artist filters, list controls
also nothings here
fix backend not caching fallback
* i18n queue, artist view, search/results
* i18n settings and server view
* Added translation using Weblate (Norwegian Bokmål)
* Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (6 of 6 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/nb_NO/
* Update translation files
Updated by "Cleanup translation files" hook in Weblate.
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/
* Update translation files
Updated by "Cleanup translation files" hook in Weblate.
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/
* Update translation files
Updated by "Cleanup translation files" hook in Weblate.
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/
* fix url escaping
* added some mostly naive text overflow fixes
rewrote filter context menu as a slide in because the old one apparently can't handle dynamic width
* Added translation using Weblate (French)
* Translated using Weblate (French)
Currently translated at 17.4% (11 of 63 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
* Translated using Weblate (French)
Currently translated at 19.0% (12 of 63 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
* Translated using Weblate (French)
Currently translated at 40.0% (26 of 65 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
* add weblate and some pretty badges to readme
* fix link
* Translated using Weblate (French)
Currently translated at 50.7% (33 of 65 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
* Translated using Weblate (English)
Currently translated at 100.0% (65 of 65 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/en/
* Translated using Weblate (French)
Currently translated at 90.7% (59 of 65 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
* i18n now playing context type
fix overscroll on new filter menu
fix getting default namespace from the i18n backend
* Translated using Weblate (French)
Currently translated at 96.9% (63 of 65 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
* Translated using Weblate (French)
Currently translated at 100.0% (66 of 66 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
* Translated using Weblate (Japanese) (#98)
Currently translated at 7.5% (5 of 66 strings)
Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ja/
Co-authored-by: Austin Riedhammer <4966622+austinried@users.noreply.github.com>
* little note to remind me why that's there
* update licenses
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Clyhtsuriva <aimeric@adjutor.xyz>
2022-04-15 12:11:00 +09:00
629 changed files with 102050 additions and 59636 deletions
Guidance for agents (and humans) working in this repository.
## What this is
A native Android rewrite of Subtracks, a client for Subsonic-compatible servers (Navidrome, gonic, Airsonic, ...), in Kotlin and Jetpack Compose. The `main` history still contains the earlier Flutter app; the native app is being built fresh under `app/` and ships as `com.subtracks`. The aim is a clean, F-Droid-friendly client: free/libre dependencies only, no telemetry.
## Environment
Everything comes from the Nix flake devshell; do not install toolchains by hand.
- Enter it with `nix develop`, or let direnv load it into your shell (`.envrc` runs `use flake`).
- The default devshell provides JDK 21, Gradle 9, the Android SDK (platform 37, build-tools 37.0.0, platform-tools 37.0.1) and nushell.
- The integration harness needs real servers, so it lives in a separate `nix develop .#integration` shell (navidrome, gonic, lms, php, Nextcloud with the Music app, sqlite). Keeping them out of the default shell avoids pulling that closure into everyday builds.
- Build with the devshell `gradle`, not `./gradlew`. CI uses the nix Gradle pinned by `flake.lock`; the wrapper is kept only for people without Nix.
## Commands
- Build: `gradle :app:assembleDebug`
- Release build: `gradle :app:assembleRelease` only when build config, R8/ProGuard or dependencies change, and once before a merge; not part of routine verification
- Unit tests: `gradle :app:testDebugUnitTest`
- Lint: `gradle :app:ktlintCheck :app:lintDebug`
- Format: `gradle :app:ktlintFormat`
- Integration tests: `nix develop .#integration --command nu tools/integration-test.nu` (starts navidrome, gonic, lms and a Nextcloud Music instance, then runs `:app:integrationTest`)
- Screenshots: `gradle :app:recordRoborazziDebug` renders the `*Screen` composables to `app/src/test/screenshots/` (gitignored) plus an HTML report in `app/build/reports/roborazzi/`, for local review. No golden images are committed and `verifyRoborazziDebug` is not part of CI.
- Demo screenshots: `gradle :app:demoScreenshots` does a real sync of the public Navidrome demo (`https://demo.navidrome.org`, `demo`/`demo`) and renders the store screenshots with that library's art and names into `app/src/test/screenshots/demo/` (gitignored). Needs network; not part of CI or the integration suite.
## Testing layout
-`app/src/test` - JVM unit tests (JUnit4, Robolectric, Roborazzi, MockWebServer). Run by `:app:testDebugUnitTest`.
-`app/src/integrationTest` - tests that need real servers. Compiled with the unit test sources but run by the dedicated `:app:integrationTest` task, and excluded from every `*UnitTest` task. The nushell harness starts the servers for you.
- A test earns its place only if it fails when the behaviour it covers breaks, and passes when the behaviour works. A test that passes either way is worse than no test: it looks like coverage while hiding the gap. So when you add one, break the behaviour (or run the test against the pre-fix code), watch it fail, then restore the fix and watch it pass.
## CI
- The CI workflows live under `.forgejo/` and `.github/`, and the GitHub mirror and the GitHub PR import under `.forgejo/`; see `.forgejo/README.md`.
## Conventions
- Prefer self-explanatory code; do not add comments unless the reason cannot be inferred from the code itself (docstrings for functions and exports are fine).
- ktlint (`org.jlleitschuh.gradle.ktlint`) enforces style and flags unused imports; run `gradle :app:ktlintFormat` before committing. `.editorconfig` exempts `@Composable` functions from the lowerCamelCase function-naming rule.
- Room 3 (`androidx.room3`) on AndroidX `sqlite-bundled` (`BundledSQLiteDriver`, SQLite 3.50+). Do not reintroduce SQLDelight or the platform SQLite.
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running.
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
- Verify current dependency versions and their compatibility before adding or pinning anything.
## Gotchas
- Robolectric runs at SDK 35 while `targetSdk` is 37; raising it changes the on-demand Roborazzi renders (there are no committed goldens to re-record).
- Room 3 does not map `PagingSource` automatically: a DAO that returns it needs `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`.
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
-`nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
- The store screenshots in `metadata/en-US/images/phoneScreenshots/` must be 24-bit no-alpha PNGs (Play rejects palette and alpha PNGs for screenshots). `:app:demoScreenshots` writes its captures in that form, so copy them across as-is and don't run a palette optimizer over them.
Instructions are provided here for Linux, specifically Ubuntu 20.04, but other operating systems should be fine as long as the requirements are met.
## Requirements
### Base
- git
- wget
- unzip
- node.js (14.x)
- yarn (1.x)
### React Native
Subtracks is a React Native application, which means you need to make sure you are set up for that. Follow the guide here to set up your development environment (React Native CLI, not Expo):
https://reactnative.dev/docs/environment-setup
When setting up your environment, make sure to install the following dependency versions:
- Node 14
- Java JDK 11 (tested with openjdk-11-jdk)
### ExoPlayer
We also need the requirements to build ExoPlayer's ffmpeg extension for Android, which should be covered by:
- build-essential (make, etc.)
More information on building ffmpeg can be found here (this is what the `prepare-build` script does):
Install the yarn packages and prepare the ExoPlayer ffmpeg extension by running the yarn script:
```sh
yarn install
yarn prepare-build
```
### Debug
In a separate terminal start Metro, which will be an ongoing process that serves javascript bundles to the build:
```sh
yarn start
```
Run the android debug build script, which will start the build and also start your default emulator and push the build to it (or push the build to a running emulator/already connected device):
```sh
yarn android
```
### Release
Similar to the debug build, except we don't need to run Metro first:
```sh
yarn android:release
```
The generated APK will be written to `android/app/build/outputs/apk/release/app-release.apk`
Subtracks does not contain advertisements, and does not collect personal data. I am not interested in advertisements or your personal data at all. There is no Subtracks server for any data to be sent to, so no information is ever transmitted to me or stored by me outside of your device in any way.
The only data that is stored on your device is information about the music on the servers that you choose to connect to. Data that is transmitted to those servers is subject to that server's privacy policy, and I do not collect or store it.
## Contact
If you have any questions about this policy, you may contact me at: subtracks(@)functionkey(.)xyz
Subtracks is an Android open source music streaming app for [Subsonic-API-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Subsonic](http://www.subsonic.org/pages/index.jsp), [Navidrome](https://www.navidrome.org/), [Airsonic](https://airsonic.github.io/), and more). It's designed to give you clean and convenient access to your music in the style of modern media players.
subtracks is an open source Android client for [Subsonic-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Navidrome](https://www.navidrome.org/), [gonic](https://github.com/sentriz/gonic), [LMS](https://github.com/epoupon/lms), [Nextcloud Music](https://apps.nextcloud.com/apps/music) and more). It is written natively in Kotlin and Jetpack Compose and gives you clean, convenient access to your music in the style of a modern media player.
> :warning: Note: each download source above is signed with a different key, so you cannot switch between them without first uninstalling and then re-installing the app.
See [signing/README.md](signing/README.md) for the fingerprints and the commands to verify a download.
## Features
- **Large Libraries:** Built for very large libraries, from syncing the whole collection to playing playlists with thousands of tracks.
- **Downloads:** Song and bulk downloads, with download quality (encoding, bitrate) and metered-connection settings.
- **Offline Mode:** Stars, scrobbles and play counts queue up while you're offline and sync back to the server when you're online again.
- **Artwork-first:** Album and artist art everywhere, shown full-resolution in the detail and now playing views.
- **Adaptive Theming:** Colours drawn from the current song's artwork.
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
- **Up Next:** Add items into the currently playing queue without rewriting it.
- **Multiple Servers:** Connect several servers and switch between them quickly.
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
- **System Controls:** Media notification and headset/Bluetooth controls.
## Building
The project's entire toolchain is provided by a Nix flake. The default devshell provides JDK, Gradle and the Android SDK, so there is nothing to install by hand.
Therefore, to get started, you'll need to install Nix. I prefer [Lix](https://lix.systems/install/).
```sh
nix develop
gradle :app:assembleDebug
```
Test, lint and screenshot commands, plus the integration harness, are documented in [AGENTS.md](AGENTS.md). The architecture is described in [docs/architecture.md](docs/architecture.md).
## Contributing
The GitHub repository [austinried/subtracks](https://github.com/austinried/subtracks) is a mirror of the repo hosted on my personal (private) code forge that syncs whenever I commit to it.
Bug reports and feature requests are accepted on GitHub, as well as pull requests, but:
* A well-crafted issue with clear reproduction and logs for bugs or mock-ups and use case details for features is preferred over a pull request
* Pull requests will be pulled in as patches to my code forge and then synced back (you'll still be the author, I'll be shown as the committer)
## Translations
Want to see subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help!
This project contains code and documentation generated by LLM, which is created via human prompting, iteration and review. The current native rewrite is based on two (and a half) other iterations of this project that I coded by hand, so the architecture and other decisions behind that are all still coming from me.
## License
subtracks is free software, licensed under the [GNU General Public License v3.0](LICENSE). It has no ads and no telemetry, and uses only free/libre dependencies.
This software uses code of <a href="http://ffmpeg.org">FFmpeg</a> licensed under the <a href="http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html">LGPLv2.1</a> and its source can be downloaded <a href="http://ffmpeg.org/download.html#get-sources">here</a>.
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
// project(modulePrefix + 'extension-flac').projectDir = new File(rootDir, 'extensions/flac')
// project(modulePrefix + 'extension-gvr').projectDir = new File(rootDir, 'extensions/gvr')
// project(modulePrefix + 'extension-ima').projectDir = new File(rootDir, 'extensions/ima')
// project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast')
// project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet')
// project(modulePrefix + 'extension-mediasession').projectDir = new File(rootDir, 'extensions/mediasession')
// project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'extensions/okhttp')
// project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensions/opus')
// project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9')
// project(modulePrefix + 'extension-rtmp').projectDir = new File(rootDir, 'extensions/rtmp')
// project(modulePrefix + 'extension-leanback').projectDir = new File(rootDir, 'extensions/leanback')
// project(modulePrefix + 'extension-jobdispatcher').projectDir = new File(rootDir, 'extensions/jobdispatcher')
// project(modulePrefix + 'extension-workmanager').projectDir = new File(rootDir, 'extensions/workmanager')
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.