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>
async-storage 1.17.3: required updating sdk to 31, kotlin to 1.6.10
react-native-track-player 2.1.3: added new "android:exported" prop required by API 31
react-native-fs: 2.19.0
* initial react-query experiments
* use queries for item screens
send the data we do have over routing to prepopulate (album/playlist)
use number for starred because sending Date freaks out react-navigation
* add in equiv. song cover art fix
* reorg, switch artistview over
start mapping song cover art when any are available
* refactor useStar to queries
fix caching for starred items and album cover art
* add hook to reset queries on server change
* refactor search to use query
* fix song cover art setting
* use query for artistInfo
* remove last bits of library state
* cleanup
* use query key factory
already fixed one wrong key...
* require coverart size
* let's try no promise queues on these for now
* image cache uses query
* perf fix for playlist parsing
also use placeholder data so we don't have to deal with staleness
* drill that disabled
also list controls doesn't need its own songs hook/copy
* switch to react-native-blob-util for downloads
slightly slower but allows us to use DownloadManager, which backgrounds downloads so they are no longer corrupted when the app suspends
* add a fake "top songs" based on artist search
then sorted by play count/ratings
artistview should load now even if topSongs fails
* try not to swap between topSongs/search on refetch
set queueContext by song list so the index isn't off if the list changes
* add content type validation for file fetching
also try to speed up existing file return by limiting fs ops
* if the HEAD fails, don't queue the download
* clean up params
* reimpl clear image cache
* precompute contextId
prevents wrong "is playing" when any mismatch between queue and list
* clear images from all servers
use external files dir instead of cache
* fix pressable disabled flicker
don't retry topsongs on failure
try to optimize setqueue and fixcoverart a bit
* wait for queries during clear
* break out fetchExistingFile from fetchFile
allows to tell if file is coming from disk or not
only show placeholder/loading spinner if actually fetching image
* forgot these wouldn't do anything with objects
* remove query cache when switching servers
* add content-disposition extension gathering
add support for progress hook (needs native support still)
* added custom RNBU pkg with progress changes
* fully unmount tabs when server changes
prevents unwanted requests, gives fresh start on switch
fix fixCoverArt not re-rendering in certain cases on search
* use serverId from fetch deps
* fix lint
* update licenses
* just use the whole lodash package
* make using cache buster optional
* start of music store refactor
moving stuff into a state cache
better separate it from view logic
* added paginated list/album list
* reworked fetchAlbumList to remove ui state
refactored home screen to use new method
i broke playing songs somehow, JS thread goes into a loop
* don't reset parts manually, do it all at once
* fixed perf issue related to too many rerenders
rerenders were caused by strict equality check on object/array picks
switched artistInfo to new store
updated zustand and fixed deprecation warnings
* update typescript
and use workspace tsc version for vscode
* remove old artistInfo
* switched to new playlist w/songs
removed more unused stuff
* remove unused + (slightly) rework search
* refactor star
* use only original/large imges for covers/artist
fix view artist from context menu
add loading indicators to song list and artist views (show info we have right away)
* set starred/unstar assuming it works
and correct state on error
* reorg, remove old music slice files
* added back fix for song cover art
* sort artists by localCompare name
* update licenses
* fix now playing background grey bar
* update react-native-gesture-handler
for node-fetch security alert
* fix another gradient height grey bar issue
* update licenses again
* remove thumbnail cache
* rename to remove "Library" from methods
* Revert "remove thumbnail cache"
This reverts commit e0db4931f1.
* use ids for lists, pull state later
* Revert "use only original/large imges for covers/artist"
This reverts commit c9aea9065c.
* deep equal ListItem props for now
this needs a bigger refactor
* use immer as middleware
* refactor api client to use string method
hoping to use this for requestKey/deduping next
* use thumbnails in list items
* Revert "refactor api client to use string method"
This reverts commit 234326135b.
* rename/cleanup
* store servers by id
* get rid of settings selectors
* renames for clarity
remove unused estimateContentLength setting
* remove trackplayer selectors
* fix migration for library filter settings
* fixed shuffle order reporting wrong track/queue
* removed the other selectors
* don't actually need es6/react for our state
* fix slow artist sort on star
localeCompare is too slow for large lists
* upgrade RN to 0.67-rc.6
upgrade react-native-reanimated to 2.3.1
debug and release building/working
* update to RN stable 0.67
* yarn upgrade
also use xmldom builtin types
* ignore ndk dir for linting
* upgrade types (RN 0.67 not out yet)
* remove deprecated rule
* actually i like that one, keep for now
* update licenses
* upgrade RN types and use builtin dep
* upgrade RN 0.67.1
* FEATURE: add plain text password toggle to settings
* clean up state types, lint, and add migrate
Co-authored-by: austinried <4966622+austinried@users.noreply.github.com>
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
- Forgejo Actions, `.forgejo/workflows/ci.yml`, a single `ci` job that runs lint, the unit tests, the debug and nightly assembles and the integration suite (one cache restore/save and Gradle warm-up instead of several); the GitHub copy uploads the debug and nightly APKs and the checksums as separate artifacts on a push to `main`.
- Runs on the `nix-docker` runner (the `localhost/nix-ci` image built by the separate `nix-home` repo) inside the devshell, restoring the Nix store via `cache-nix-action` and caching Gradle and the integration test music.
- The `nix-docker` and `bookworm` runner labels, their images and the runner's networking are defined in the separate `nix-home` repo (`hosts-incus/git-runner.nix`).
- Pull requests from a fork need approval before the workflow runs.
- The GitHub mirror and the GitHub PR import live 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`.
- The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell.
- 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.
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.
See [signing/README.md](signing/README.md) for the fingerprints and the commands to verify a download.
- Performance & animation improvements
## 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.
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.