Commit Graph
52 Commits
Author SHA1 Message Date
austinried e8fb0ac564 release: split the GitHub notes from the store changelog
The GitHub release body was the 500-char store changelog. Give the
GitHub release its own detailed notes under docs/releases/<tag>.md and
point release.yml at it, leaving the store changelog to be the trimmed
version plus a link back to the GitHub release.

The notes cover everything since 3.0.0, including ReplayGain, the artist
page work, the sort name preference and the fixes.

The store changelog still must exist; the workflow now checks both
files.
2026-10-24 02:16:45 +00:00
austinried d81efe168e playback: make Auto the default ReplayGain mode
Use Auto for the preference fallback, the settings initial state and the controller's pre-preference default. Existing stored modes are unchanged.
2026-10-19 14:13:22 +00:00
austinried 0bd8f1bda8 playback: call the context-aware ReplayGain mode Auto
Rename the ReplayGainMode value, string resource and user-facing label from Dynamic to Auto. No behaviour change.
2026-10-19 14:13:22 +00:00
austinried 3f1d9b0d8d playback: add dynamic ReplayGain mode
Use album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues. Carry the resolved context on each media item so the audio thread does not need to inspect the queue.

Keep a track moved within an album as an album range so it stays in album context; moves out of playlists, artists, genres and other mixed contexts remain pinned song entries.
2026-10-19 14:13:22 +00:00
austinried 87dbabb496 playback: only attenuate a gain with no peak
A peak is the only headroom check the gain code has. When the server sends a gain without one (Navidrome does this for Opus R128 gains, which carry no peak tags by design), a positive gain was applied unchecked and the 16-bit sink saturated.

Cap a peakless gain at unity instead: attenuation still applies, but a boost needs a peak to prove it fits. The cap is unchanged when a peak is present.

Update the tests that expected a peakless boost, and cover the positive preamp that can no longer lift a peakless track and the attenuation that still applies.
2026-10-18 13:35:27 +00:00
austinried 62409898c1 docs: record how replay gain reaches the audio sink
The Playback section already documents the star button's dependence on
unstable Media3 APIs and what to re-check when bumping the library. The
gain path needs the same treatment: it relies on flush carrying the
timeline and period, on the sink configuring per media item, and on the
processor chain being reached at all.
2026-10-16 13:07:07 +00:00
austinried 7b17e33545 sync: batch the fallback getAlbum song fetches
The `search3`-less fallback emitted one batch per album, and
`SyncService.writeSongs` opens a transaction per batch, so syncing a large
library that way ran one transaction per album — thousands of fsyncs, and an
invalidation round for every list observing `songs` with each.

Accumulate what each `getAlbum` returns and flush at `PAGE_SIZE`, so the
fallback writes the same ~500-row batches every other fetch already does.
Albums are fetched concurrently and complete out of order, so the accumulator
sits behind a mutex and the send happens outside it.

Batches are in-flight memory, so the flush is per rows reached rather than
per album page. Measured per `Song` (strings, boxed longs and genre list
included): ~0.7 KiB, so a 500-row batch is ~334 KiB plus ~51 KiB of the
`names`/`genres` structures `writeSongs` derives, where a whole album page
(~5,500 rows) would be ~3.8 MiB. `albumSongs()` is also a bare `channelFlow`,
whose channel holds 64 elements whatever their size, so 500-row batches would
have made its worst case ~21 MiB; it now carries the same `.buffer(1)` that
`entityBatches()` already uses on itself. The rest of the in-flight rows
scale with `syncConcurrency`, so that leaves the default's batch in hand and
a few MiB at the highest setting.

`SubsonicSourceTest.albumSongsAreEmittedInBoundedBatches` asserts the emitted
sizes, [500, 100] for 600 songs spread over 200 three-song albums, which pins
the carried remainder as well as the flush: verified it fails on the
pre-change code (200 batches of 3) and against a mutant that drops the
remainder ([500, 99]). `docs/architecture.md`'s page cap was 20x out of date,
so it is corrected alongside. `ktlintCheck`, `testDebugUnitTest` and
`lintDebug` pass.
2026-10-16 12:39:42 +00:00
austinried 25dafb0701 playback: show notification progress for unknown-length streams
Media3 marks a progressive stream live when the data source reports no
length and the extractor no duration, and a live item makes the media
session publish PLAYBACK_POSITION_UNKNOWN, so the notification progress
never advances even though the item carries the library duration.

The app only declares a stream length for a transcode of a track that has
one in a length-estimable format (`declaredStreamLength`), so a chunked
response stays live for an Opus/Ogg/Vorbis transcode (a length estimate
breaks the end-seek they rely on), for a transcode of a track whose
library duration is missing, and for a direct play the server does not
give a Content-Length. The now playing screen shows progress in all of
those because it falls back to the metadata duration; the notification
had nothing to advance.

Wrap the ExoPlayer in a ForwardingPlayer that reports an item with a
declared duration as finite. The session then publishes the real position
and the metadata duration, so the notification shows progress for every
unknown-length stream, not just Opus. The stream is still not seekable;
only the reported liveness changes.
2026-10-15 13:44:52 +00:00
austinried 3c184a71a6 ci: run the Forgejo workflows on the ephemeral runners
The git-runner VMs' nix-docker/bookworm labels are gone, replaced by the
ci-manager's ephemeral pools, so point every Forgejo workflow at them.

The ci job needs the Nix devshell, so it runs on the nix-xlarge pool (a
4 CPU / 8 GiB NixOS VM that already has Nix) with Gradle at -Xmx2g; the
mirror and PR-import jobs don't need Nix and use the ubuntu pool. The
mirror job pins git-filter-repo 2.47.0 itself, since the image's version
may differ and another one would move every commit on GitHub.
2026-10-11 03:22:32 +00:00
austinried e396671ac7 feat: auto-sync the library on a timer while the app is open
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.
2026-10-10 03:36:16 +00:00
austinried e806eaef89 playback: cache streamed audio to stop redundant refetches
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.
2026-10-09 13:36:55 +00:00
austinried cc65f238ae docs: correct the architecture notes and drop the implementation plan 2026-10-08 14:09:52 +00:00
austinried 98a3d336a8 port the library tab labels from the old plural names
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.
2026-10-04 00:30:07 +00:00
austinried 699a6e86f5 address review: localize connection errors, tighten i18n guard, drop dead resources
- 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.
2026-10-04 00:30:07 +00:00
austinried 3162ac07d5 localize transient messages and the download notification
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.
2026-10-04 00:30:07 +00:00
austinried 24757bda3f localize user-facing text into string resources
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.
2026-10-04 00:30:07 +00:00
austinried 227379d1d7 docs: mark phases 3 and 4 done and refresh the plan 2026-10-04 00:30:05 +00:00
austinried 4d4eb7b758 address review: count a play even when its scrobble fails
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.
2026-10-04 00:30:05 +00:00
austinried d3aaeca405 count a play locally when it is scrobbled
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.
2026-10-04 00:30:05 +00:00
austinried 06ca2da48d add frequently and recently played album and artist sorts
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.
2026-10-04 00:30:05 +00:00
austinried dff2fe58e0 default to the Material You dark scheme instead of black and white 2026-10-04 00:30:05 +00:00
austinried 153122a552 fix scrobble double-count and missed plays found in review
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.
2026-10-04 00:30:04 +00:00
austinried 0379102e24 gate scrobbling behind a setting and funnel server writes through a sink
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.
2026-10-04 00:30:04 +00:00
austinried 29e5029b56 scrobble playback back to the server
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.
2026-10-04 00:30:04 +00:00
austinried f173f394fb mark the scale hardening backlog done in the plan 2026-10-04 00:30:03 +00:00
austinried 954d0eddd2 search the library through FTS5 trigram tables 2026-10-04 00:30:03 +00:00
austinried 83d0bafeb9 note the notification star's Media3 unstable API dependency 2026-10-04 00:30:02 +00:00
austinried 93742e8584 rework: derive the shuffle order from a seed on top of the up-next queue
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.
2026-10-04 00:30:01 +00:00
austinried 9832f7c217 float the up-next block behind the current track
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.
2026-10-04 00:30:00 +00:00
austinried d5a9a11cef add a separate, editable up-next queue (#61)
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.
2026-10-04 00:30:00 +00:00
austinried a0647deaae address playback review findings
- 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
2026-10-04 00:30:00 +00:00
austinried 22feb2372d keep transcoded streams seekable without cutting them short
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.
2026-10-04 00:30:00 +00:00
austinried 117e8a69fb add network-dependent stream quality
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.
2026-10-04 00:29:59 +00:00
austinried d703b8ec5b index the album, artist and playlist list sorts 2026-10-04 00:29:59 +00:00
austinried e3af5ed0ac drop the songs tab and the whole-library song queue 2026-10-04 00:29:59 +00:00
austinried d11bd45fe7 document that the library is mirrored offline in full
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.
2026-10-04 00:29:58 +00:00
austinried a356226e48 address the review: keep the stored seed on a failed decode, one cache, static bar colour
- 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
2026-10-04 00:29:58 +00:00
austinried 8b43de25f7 address final review: coalesce sync requests, fix page-cap edge
- 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.
2026-10-04 00:29:58 +00:00
austinried 7b06b23860 confirm the end of a full last page before failing the page cap
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.
2026-10-04 00:29:58 +00:00
austinried c48ec670d8 address arch review: guard the page cap, coalesce syncs, tighten buffers
- 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.
2026-10-04 00:29:58 +00:00
austinried 3c941ef0f2 document the streaming sync in the architecture doc
The Sync section still described fetching the whole library and writing
it in one transaction, and the stack table still said DOM parsing.
2026-10-04 00:29:58 +00:00
austinried bd23f4369f update the architecture docs for the removed search index 2026-10-04 00:29:56 +00:00
austinried 53875b3640 open an artist's albums from the artists tab 2026-10-04 00:29:52 +00:00
austinried a682cfc1cb reorder the queue with the reorderable library 2026-10-04 00:29:52 +00:00
austinried 7fc142ce6f add a one-step undo and harden queue edits 2026-10-04 00:29:51 +00:00
austinried e87d176155 add a queue view with jump, remove and reorder 2026-10-04 00:29:51 +00:00
austinried 853f37f14d stop committing the roborazzi screenshots 2026-10-04 00:29:51 +00:00
austinried ebe76cd5bb play the songs list from the songs tab 2026-10-04 00:29:51 +00:00
austinried 659df47c14 choose the stream bitrate and format from settings 2026-10-04 00:29:51 +00:00
austinried e7cfe7a840 document the playback queue model and mark generated files 2026-10-04 00:29:51 +00:00