32 Commits
Author SHA1 Message Date
austinried b2c66bf03e ci: title the release run from its tag 2026-10-16 14:06:14 +00:00
austinried 8bcca2767d settings: clamp the preamp and make the dialog test assert
Review follow-ups.

- The snap clamped nothing, so an out-of-range stored value would show a
  decibel label the slider cannot reach: clamps now live in the snap
  rather than duplicated in the step.
- The screenshot test only rendered the dialog without asserting it
  opened, so deleting the row's click would have left it passing while
  the capture showed the plain list. It now requires the dialog's Reset.
- "Adjust level on top of the ReplayGain" was not grammatical, and the
  mode prompt claimed the server normalises when it only supplies the
  tags.
- Drop a test assertion that restated the production formula.
2026-10-16 13:07:12 +00:00
austinried 5b6c0ef45c settings: name the toolkit in the slider comments
The in-repo comment rule allows a comment only when it names an external
system, version or issue, so both of these now name Compose Material 3,
which is what they are actually about.
2026-10-16 13:07:12 +00:00
austinried d245da20a4 settings: stop the preamp slider drawing a tick per step
Fifty-nine steps across a phone-width track is a tick every few dps,
which reads as a smudge on the bar. The steps are what make the slider
snap in 0.5 dB increments, so they stay; only the tick drawing is
suppressed, through a SliderDefaults.Track that skips drawTick.
2026-10-16 13:07:11 +00:00
austinried 730ea11235 settings: name the setting ReplayGain and rework the preamp dialog
- Call it ReplayGain, which is the name of the standard, in the row, the
  prompt and the preamp prompt.
- Give the slider room under the prompt instead of sitting against it.
- Put Reset in the button row, where it lines up with Cancel and Done,
  rather than above them, and centre the decibel value that is left in
  the content.
- Add a screenshot test that scrolls to the section and opens the dialog,
  which also composes the new rows; the existing screenshot only captures
  the top of the list, so it never rendered them.
2026-10-16 13:07:11 +00:00
austinried 31859b66b6 settings: make the preamp easier to set exactly
The slider alone is awkward for the two things people actually do with a
preamp: zero it, and nudge it by a known amount.

- Minus and plus buttons flank the slider, stepping one grid notch (0.5
  dB) and disabling at the ends.
- A Reset button sits under it, beside the current value, disabled when
  the value is already zero.
- Both go through the same snapping the slider does, so a button cannot
  land off the grid.
2026-10-16 13:07:11 +00:00
austinried 0b4e1d18bc settings: snap the preamp to its grid and drop a duplicate header
Review follow-ups.

- Remove the second "Playback" section header the rows were added
  under; the section already had one, so the screen rendered it twice.
- Snap the preamp to the 0.5 dB grid in the dialog. Material's slider
  snaps in pixel space and converts back, so the value it reports is near
  the configured grid but not on it: -1.0 dB arrives as -0.99999905, was
  displayed verbatim and was stored that way until the next drag. The
  step count is now derived from the range rather than hardcoded.
- Format the decibel value with the resource locale so translated
  layouts get their own decimal separator.
- The controls move to `ReplayGainControls.kt`, since the file now holds
  the grid as well as the labels, and the snap is covered by a test that
  reproduces the off-grid value without it.
2026-10-16 13:07:11 +00:00
austinried 229d6de4c6 settings: add the replay gain controls
Final slice: the screen that drives the mode and preamp preferences the
playback side already reads. Until now `setReplayGainMode` and
`setReplayGainPreampDb` had no production caller, so Off and the preamp
were unreachable.

- `SettingsViewModel` exposes both preferences and writes them back.
- The Playback section gains a "Replay gain" row opening an Off / Track /
  Album choice, and a "Preamp" row opening a slider across the +-15 dB
  range in 0.5 dB steps.
- `ReplayGainMode` is ordered Off, Track, Album so the choice list reads
  that way; the stored value is the name, so the order is not persisted
  and the default is still Track.
- The labels live in their own file so the signed decibel formatting can
  be tested, as `formatTrackTime` does.
2026-10-16 13:07:11 +00:00
austinried 72784ee914 playback: warn once when float output would skip the processor
Float output is the one way the audio processor chain is skipped on this
build, and it is a flag the renderers factory is handed. Nothing sets it,
so nothing happens today; if it is ever turned on, this says so once at
sink construction rather than leaving replay gain silently inapplicable
to high-resolution tracks.

Offload has no equivalent check: it is not a flag this code sets, and
AudioSink.setOffloadMode is only reachable by building the audio renderer
by hand. The factory comment names it instead.
2026-10-16 13:07:08 +00:00
austinried 8f34c958fb playback: reject non-finite gains before the pow, and filter peaks first
Review follow-ups for the apply slice.

- A negative-infinite gain produced 0 dB, not unity: 10^(inf/20) is 0.0,
  which is finite, so the result guard never fired and the track played
  silent — the opposite of what the guard is for. A large-enough finite
  negative gain underflows the same way, and an infinite gain paired with
  a peak survived as the cap. The gain is now rejected before the pow and
  an underflowed result falls back to unity.
- A peak carrying no data shadowed a usable peak in the other slot, so
  trackPeak=0 with albumPeak=0.5 skipped the cap entirely — the gonic
  shape on the default mode. Both peaks are filtered before one is chosen.
- Drop the claim that a period-less flush comes from a sink reset; it does
  not, and the guard stands on its own.
- Trim two comments back to naming the Media3 types.
- Revert the observeStar rename, which was for a path that no longer
  applies the gain.
2026-10-16 13:07:08 +00:00
austinried aee631c08d playback: drop comments that explain rather than name
The in-repo comment rule now allows a comment only when it names an
external system, version or issue, so the ones carrying rationale move
to this message.

- why the gain rides on the media item: PlaybackService can then apply it
  the moment the item becomes current instead of after a library round
  trip
- that ReplayGainController is written from the playback thread by
  onFlush and from the app thread by the preference collector
- that an empty passthrough input is the pipeline's own shared buffer,
  which cannot be put into itself
2026-10-16 13:07:08 +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 cf272c4edc playback: apply the gain only from the item flush
Review follow-ups for the apply slice.

- Drop the media item transition application. It never supplied a gain
  the flush had not already supplied, and as a second writer it raced the
  playback thread: it applied the incoming item's gain while the outgoing
  item's buffers were still queued, scaling the previous track's tail by
  the next track's gain (reproduced at +12 dB). The flush is the only
  writer now.

  This reverses the earlier decision to keep it as a fallback. That was
  made on the premise that it was insurance against a flush that could
  not be resolved. The flush covers every transition, and a period-less
  flush only happens while the sink is being reset, so the premise does
  not hold.

- Ignore a flush that carries no period rather than reading it as "this
  item has no gain". Resetting to unity there would have been wrong if a
  bare flush ever arrived mid-playback; the test that pinned that
  behaviour now pins the opposite.

- Guard the pass-through branch against an empty input, which is the
  pipeline's own shared buffer and cannot be put into itself.

- The controller is written from the playback thread now, so its state
  updates and the recompute that follows them are synchronized rather
  than two independent volatile writes.

- Add ReplayGainSinkTest, which drives the real DefaultAudioSink and
  asserts it hands the processor the item being configured. That is the
  media3 behaviour this whole feature rests on, and it was previously
  asserted only by comments.
2026-10-16 13:07:07 +00:00
austinried c92d28c9dd playback: apply the gain at the media item flush
The app-thread transition callback was only ever a stopgap: media3 posts
it after the playback thread has already rendered, so the first buffers
of a new item could be scaled by the previous item's gain.

media3 forwards the playback timeline and the current period to
AudioProcessor.flush, and DefaultAudioSink flushes the processors on
every sink configuration, which happens at each media item transition
even when the format is unchanged. That is early enough to set the gain
before any of the new item's buffers are processed, on the playback
thread. See androidx/media#418.

The processor resolves the period back to its MediaItem and reports it,
so the gain maths and the preferences stay in ReplayGainController. That
controller is now written from two threads, so its state is volatile.

The transition callback stays as the fallback for a flush that carries no
period: without it a stale gain would persist for a whole track rather
than for a few milliseconds.
2026-10-16 13:07:07 +00:00
austinried 7ce754f95a playback: apply the item's gain in one tested step
Review follow-ups.

- `ReplayGainController.applyItem` owns reading the gain off a
  `MediaItem`, so the transition, the controller and the extras read are
  exercised together rather than only in isolation.
- drop the leftover `setTrack(null)` on a missing active source: now that
  the transition sets the gain from the item, that line only raced it and
  could reset a playing item to unity.
- tests for a zero gain surviving the extras round trip (zero is a real
  gain, not an absent one), for the latest item's gain being the one left
  applied, and for a missing item applying unity.
2026-10-16 13:07:07 +00:00
austinried 5f5ad85dff playback: apply replay gain from the media item
The gain was resolved by querying the library from the media item
transition callback, so the buffers the player had already begun
queueing for the new track could be scaled by the previous track's gain.
A quiet track followed by a hot one could clip at the boundary for as
long as that query took.

Carry the gain on `QueueItem` so `queueMediaItem` puts it on the
`MediaItem` extras, and apply it from the item that just became current,
with no library round trip.

That narrows the window rather than closing it: media3 delivers
`onMediaItemTransition` on the app thread after the playback thread has
already rendered for that iteration, so the very first buffers after a
transition can still carry the previous item's gain. The gap is now one
handler hop instead of a database query.

The trade-off is staleness: if a sync changes a track's gain while that
track is queued, the item keeps the old value until it is rebuilt.
Applying a new gain mid-track is of marginal value anyway.
2026-10-16 13:07:07 +00:00
austinried 3c0372cceb playback: reject non-finite replay gain
Review follow-ups for the apply slice.

The parse guard in the base branch keeps non-finite gains and peaks out
of the library, but nothing here should depend on that: a value already
stored, or written by a future caller, must not be able to silence a
track or blast it at full scale.

- the gain maths ignores a non-finite peak and falls back to unity when
  the result is not finite.
- the processor falls back to unity for a non-finite gain, so
  `roundToInt` can never be handed a NaN.
- the preamp is sanitised on both read and write, since `coerceIn` passes
  NaN straight through.
- the renderers factory is pulled out of `PlaybackService` into
  `ReplayGainRenderersFactory` so a test can assert the sink really is
  built with the processor in its chain; the wiring was otherwise
  untested.
- a null active source clears the gain rather than leaving the previous
  track's applied.
2026-10-16 13:07:07 +00:00
austinried e33784ddd3 feat: apply replay gain during playback
Second slice: read the gain synced in the first one and scale the decoded
PCM by it.

- `ReplayGainProcessor` is a `BaseAudioProcessor` that scales 16-bit and
  float samples, saturating on overflow rather than wrapping. It is
  installed through `DefaultAudioSink`'s audio processor chain, so it runs
  ahead of the default silence-skip and speed processors.
- `replayGainLinearGain` picks the track or album gain according to the
  mode, falls back to the other gain when one is missing, adds the preamp,
  and caps the result at the peak's headroom so a boost cannot clip. A
  non-positive peak means the server has no peak data and is ignored.
- `ReplayGainMode` and the preamp live in `UserPreferences`, defaulting to
  track mode and 0 dB. The settings UI is still to come.
- `PlaybackService` resolves the current song's gain alongside its star
  state and applies it on every media item transition.

The processor only runs while the audio processor chain does, which
`DefaultAudioSink` skips when float output is enabled. That stays off, as
it is by default.
2026-10-16 13:07:07 +00:00
austinried 81e6189ebe sync: keep every replayGain element's fields
A second <replayGain> element for the same song cleared whatever the
first one set, because each field was assigned unconditionally and an
absent attribute parses to null. No server is known to split the object
across elements, but the merge costs nothing and the alternative loses
fields silently.

Also covers peaks arriving without gains, which the existing cases did
not.
2026-10-16 13:06:20 +00:00
austinried 069a0f7f19 sync: drop non-finite replay gain values at the parse boundary
`toFloatOrNull` accepts `"Infinity"`, `"NaN"` and overflowing literals
like `"1e40"`, so a server (or a corrupt tag it read) could put a
non-finite gain or peak into the library. An infinite peak later caps the
applied gain at negative infinity, i.e. silence; an infinite gain clips
every sample to full scale.

Nothing consumes these columns yet, so this is only about not storing
values that cannot mean anything.
2026-10-16 13:06:20 +00:00
austinried 86d0d06e76 sync: fail loudly on upsert column/value drift
Review follow-ups for the replay gain slice.

`upsertChanged` never checked that its hand-maintained column list and
the row values line up, and SQLite binds a missing trailing value as NULL
without error: adding a column to `songColumns` but not to `Song.values()`
would have written NULLs silently. It now asserts the sizes match before
preparing, so that mistake fails the sync instead.

The replay gain sync test also now re-syncs identical values and asserts
no rows are rewritten, pinning the REAL change-detection clause; an
update there rewrites the `song_search` FTS row for no reason.
2026-10-16 13:06:20 +00:00
austinried 526373c10a feat: sync OpenSubsonic replay gain
Store the per-track replay gain a server reports on OpenSubsonic's
`Child.replayGain` so playback can normalise loudness later.

- add trackGain/albumGain/trackPeak/albumPeak (REAL) to the songs table
  and the 27 -> 28 migration
- parse the nested `<replayGain>` element, folding the child-element
  dispatch into a single `songChild` shared by the search and album paths
- teach the sync upsert to bind Float, which it previously rejected

baseGain and fallbackGain are omitted: the servers that send the object
do not populate them, so they would only ever be null columns.

Nothing applies the values yet; this slice only mirrors them.
2026-10-16 13:06:20 +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 6cfc1d29f1 ci: fit the whole Nix store in the GitHub cache
The GitHub job's store is ~7.3 GiB but gc-max-store-size-linux is 6G, and
nothing roots the devshells, so cache-nix-action's pre-save `nix store gc`
deletes whichever paths cross the cap before every save. The snapshots land
missing the JDK and the Android SDK components, so both `nix develop` steps
re-download or rebuild them on every run.

Raise the cap to 10G, which clears the store, and bump the key to nix-v3-. The
first run after that downloads the full closure once and saves a complete
snapshot; the gutted nix-v2- snapshots are not restored, so there is one cold
run per ref and v3 is warm from then on.

Compressed at ~0.47x its store the snapshot is ~4.7 GiB at worst, inside the
10 GB per-repo budget. What pushes the repo over that budget today is the four
stale gradle- (v1) entries, which no restore-key can reach and which age out by
LRU.
2026-10-16 09:02:57 +00:00
austinried 9cfa4b62e2 library: keep the album grid aligned across a paging reload
A scrobble writes albums.playCount/played, which invalidates the album
list's PagingSource. Room then refreshes from anchor - initialLoadSize/2,
and with placeholders off that page is the presented list: LazyPagingItems
index zero becomes the page's first album, so the grid re-groups its rows
and every cover moves by (oldStart - newStart) % 3 columns, while the last
row's width becomes the loaded count % 3 instead of the library size % 3.

Enable placeholders for the album pager so an album's index is its
absolute position, and draw a placeholder cell for an unloaded slot. The
other lists are single column, so a reload re-anchors them by key and they
never shifted.

This only keeps the grid still; the reloads themselves are still
unnecessary and are tracked separately.
2026-10-15 13:45:12 +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 b019efbef9 now playing: stop clipping the cover shadow
The cover strip clipped to the 24dp content inset. A landscape cover fills
that inset, so its drop shadow was cut off on the left and right and only
the top and bottom survived, which reads as a stray band hugging the
artwork. A tile that fills the strip's height had the same problem
vertically.

Drop the clip and space the tiles by TILE_GAP instead. The shadow then
renders whole on all four sides while the swipe target and the artwork
keep the size and the content margins they had, and the spacing leaves a
gap between the tiles while dragging between tracks. It is also what keeps
the neighbours off-screen now that the strip is unclipped.

Refs: #173
2026-10-15 13:37:57 +00:00
austinried d171825b5e build: stop test results being restored from the build cache
org.gradle.caching=true plus CI restoring ~/.gradle/caches (which holds
the local build cache) let GitHub CI restore :app:testDebugUnitTest
FROM-CACHE: a re-run reported green without executing a single test. A
cached PASS is also unsound while any test is flaky. Mark test tasks
uncacheable so every invocation actually runs the tests; up-to-date and
incremental behaviour for local dev is unchanged.
2026-10-11 07:06:05 +00:00
austinried 6f997dceb9 test: dump thread stacks when an await times out
The playback/queue wall-clock awaits have flaked on the 4-vCPU GitHub
runner at exactly the 30s limit, a different test each time. That is a
stall, not slowness: the suite finishes ~80s for 795 tests, so no test is
anywhere near 30s normally. The JUnit XML holds the stacks but is not
uploaded, leaving the failures undiagnosable from the CI log. Put every
thread's stack in the failure message, which Gradle prints.
2026-10-11 06:27:42 +00:00
austinried 43ee2d9f27 ci: bump the Gradle cache key
Actions caches are immutable, so the key derived from the Gradle files is
written once and never refreshed; v2 forces a fresh save.
2026-10-11 03:22:40 +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 8396f98a85 ci: drop checksum/signature footer from release notes 2026-10-10 14:49:47 +00:00
48 changed files with 3699 additions and 131 deletions
+2 -2
View File
@@ -38,8 +38,8 @@ runs:
- uses: actions/cache@v6
with:
path: ~/.gradle/caches
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-v2-
- id: signing
if: inputs.sign == 'true'
shell: bash
+3 -3
View File
@@ -9,6 +9,6 @@ runs:
extra_nix_config: experimental-features = nix-command flakes
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v2-
gc-max-store-size-linux: 6G
primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v3-
gc-max-store-size-linux: 10G
+3 -21
View File
@@ -1,5 +1,7 @@
name: Release
run-name: Release ${{ github.ref_name }}
on:
push:
tags:
@@ -117,31 +119,11 @@ jobs:
printf '%s\n' "$certs"
fingerprint=$(printf '%s\n' "$certs" | sed -n 's/.*certificate SHA-256 digest: //p' | head -1 | tr '[:lower:]' '[:upper:]' | sed 's/\(..\)/\1:/g; s/:$//')
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
- name: Compose release notes
run: |
{
cat "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
echo
echo '---'
echo
echo "APK SHA-256: \`$(grep 'subtracks\.apk$' SHA256SUMS | cut -d ' ' -f1)\`"
echo
echo "AAB SHA-256: \`$(grep 'subtracks\.aab$' SHA256SUMS | cut -d ' ' -f1)\`"
echo
echo "Signing certificate SHA-256: \`${{ steps.checksums.outputs.fingerprint }}\`"
echo
echo 'The signing certificate is attached as `release-certificate.pem`.'
if [ "${{ steps.stable.outputs.value }}" = "true" ]; then
echo 'The `.aab` is the exact bundle uploaded to Google Play; the `.apk` is the universal APK for sideloading.'
else
echo 'The `.aab` is the built app bundle (this pre-release was not uploaded to Google Play); the `.apk` is the universal APK for sideloading.'
fi
} > release-notes.md
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md)
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt")
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
args+=(--prerelease)
fi
+1 -6
View File
@@ -34,11 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
## 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`.
- The CI workflows live under `.forgejo/` and `.github/`, and the GitHub mirror and the GitHub PR import under `.forgejo/`; see `.forgejo/README.md`.
## Conventions
@@ -60,7 +56,6 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
- 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.
+1
View File
@@ -189,6 +189,7 @@ tasks.withType<Test>().configureEach {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
}
outputs.cacheIf { false }
}
tasks.register<Test>("integrationTest") {
File diff suppressed because it is too large Load Diff
@@ -384,6 +384,16 @@ val MIGRATION_26_27 =
}
}
val MIGRATION_27_28 =
object : Migration(27, 28) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -444,4 +454,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_24_25,
MIGRATION_25_26,
MIGRATION_26_27,
MIGRATION_27_28,
)
@@ -49,7 +49,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 27,
version = 28,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -264,6 +264,10 @@ data class Song(
@ColumnInfo(defaultValue = "0") val created: Long = 0,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
val trackGain: Float? = null,
val albumGain: Float? = null,
val trackPeak: Float? = null,
val albumPeak: Float? = null,
@Ignore val genres: List<String> = emptyList(),
)
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -51,8 +52,13 @@ private val Context.preferences: DataStore<Preferences> by preferencesDataStore(
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
enum class SyncMode { Off, WifiOnly, Always }
enum class ReplayGainMode { Off, Track, Album }
data class ArtworkSeedValue(
val cacheKey: String,
val primary: Int,
@@ -146,6 +152,28 @@ class UserPreferences(
private val scrobblingKey = booleanPreferencesKey("scrobbling")
fun replayGainMode(): Flow<ReplayGainMode> =
store.data.map { prefs ->
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
}
suspend fun setReplayGainMode(mode: ReplayGainMode) {
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
}
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
fun replayGainPreampDb(): Flow<Float> = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() }
suspend fun setReplayGainPreampDb(value: Float) {
store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() }
}
private fun Float?.sanitizedPreampDb(): Float =
this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
suspend fun setOfflineMode(enabled: Boolean) {
@@ -174,7 +174,7 @@ class LibraryRepository(
search: String,
downloaded: Boolean,
): Flow<PagingData<Album>> =
pager(40) {
pager(40, enablePlaceholders = true) {
val dao = db.libraryDao()
if (!downloaded) {
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
@@ -507,10 +507,11 @@ class LibraryRepository(
private fun <T : Any> pager(
pageSize: Int,
enablePlaceholders: Boolean = false,
source: () -> PagingSource<Int, T>,
): Flow<PagingData<T>> =
Pager(
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
pagingSourceFactory = source,
).flow
}
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.xml.sax.Attributes
@@ -205,7 +207,7 @@ class SubsonicSource(
),
tag = "song",
create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
onChild = SubsonicXml::songChild,
).collect { batch ->
raw += batch.size
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
@@ -224,6 +226,23 @@ class SubsonicSource(
private fun albumSongs(): Flow<List<Song>> =
channelFlow {
val permits = Semaphore(maxConcurrentFetches)
val lock = Mutex()
val pending = ArrayList<Song>(PAGE_SIZE)
suspend fun accept(songs: List<Song>) {
lock
.withLock {
pending.addAll(songs)
if (pending.size < PAGE_SIZE) {
null
} else {
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
pending.subList(0, PAGE_SIZE).clear()
batch
}
}?.let { send(it) }
}
var offset = 0
var pages = 0
while (true) {
@@ -248,10 +267,10 @@ class SubsonicSource(
params = mapOf("id" to albumId),
tag = "song",
create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
onChild = SubsonicXml::songChild,
).collect { batch ->
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
if (accepted.isNotEmpty()) send(accepted)
accept(accepted)
}
}
}
@@ -265,7 +284,14 @@ class SubsonicSource(
if (pages > maxPages) throw pageCapExceeded()
offset += PAGE_SIZE
}
}
val tail =
lock.withLock {
val held = pending.toList()
pending.clear()
held
}
if (tail.isNotEmpty()) send(tail)
}.buffer(1)
private fun supportsEmptyQuerySearch(): Boolean {
emptyQuerySearchSupported?.let { return it }
@@ -78,6 +78,17 @@ object SubsonicXml {
played = attrs.dateAttr("played"),
)
internal fun songChild(
entity: SongDraft,
name: String,
attrs: Attributes,
) {
when (name) {
"genres" -> entity.addGenre(attrs)
"replayGain" -> entity.addReplayGain(attrs)
}
}
internal fun playlist(
sourceId: Long,
attrs: Attributes,
@@ -184,6 +195,10 @@ internal class SongDraft(
private val created: Long = 0L,
private val playCount: Long = 0L,
private val played: Long? = null,
private var trackGain: Float? = null,
private var albumGain: Float? = null,
private var trackPeak: Float? = null,
private var albumPeak: Float? = null,
private val genres: MutableList<String> = mutableListOf(),
) {
fun addGenre(attrs: Attributes) {
@@ -194,6 +209,13 @@ internal class SongDraft(
?.let { genres += it }
}
fun addReplayGain(attrs: Attributes) {
attrs.floatAttr("trackGain")?.let { trackGain = it }
attrs.floatAttr("albumGain")?.let { albumGain = it }
attrs.floatAttr("trackPeak")?.let { trackPeak = it }
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
}
fun toSong(): Song =
Song(
sourceId = sourceId,
@@ -211,6 +233,10 @@ internal class SongDraft(
created = created,
playCount = playCount,
played = played,
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
genres = genres.toList(),
)
}
@@ -219,6 +245,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
private const val ROOT_TAG = "subsonic-response"
@@ -314,6 +314,10 @@ private val songColumns =
"created",
"playCount",
"played",
"trackGain",
"albumGain",
"trackPeak",
"albumPeak",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
@@ -358,6 +362,10 @@ private fun Song.values() =
created,
playCount,
played,
trackGain,
albumGain,
trackPeak,
albumPeak,
)
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
@@ -374,6 +382,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
values: (T) -> List<Any?>,
) {
if (rows.isEmpty()) return
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
val updates = columns.filterNot { it in key }
val sql =
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
@@ -401,6 +410,7 @@ private fun SQLiteStatement.bind(
is Int -> bindLong(index, value.toLong())
is Boolean -> bindLong(index, if (value) 1L else 0L)
is Double -> bindDouble(index, value)
is Float -> bindDouble(index, value.toDouble())
is String -> bindText(index, value)
else -> error("Cannot bind ${value::class}")
}
@@ -0,0 +1,30 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
/**
* Reports a track whose duration is known from its metadata as finite rather than live.
*
* Media3 marks a progressive stream live when the data source reports no length and the extractor
* no duration. The app declares a length only for a transcode of a track that has one in a
* length-estimable format, so a chunked response stays live for an Opus/Ogg/Vorbis transcode, for
* a transcode of a track whose library duration is missing, and for a direct play the server does
* not give a Content-Length. A live item makes the media session publish no playback position, so
* the notification loses its progress even though the item carries a real duration. The stream is
* still not seekable; this only restores the position the session reports.
*/
@OptIn(UnstableApi::class)
internal class KnownDurationPlayer(
player: Player,
) : ForwardingPlayer(player) {
override fun isCurrentMediaItemLive(): Boolean = if (declaredDurationMs() != null) false else super.isCurrentMediaItemLive()
private fun declaredDurationMs(): Long? =
currentMediaItem
?.mediaMetadata
?.durationMs
?.takeIf { it > 0 }
}
@@ -234,11 +234,35 @@ class Media3PlayerHandle(
mediaItem.mediaMetadata.extras
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS),
replayGain = mediaItem.replayGain(),
)
}
private const val EXTRA_COVER_ART_ID = "coverArtId"
private const val EXTRA_DURATION_MS = "durationMs"
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
internal fun MediaItem?.replayGain(): ReplayGain {
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
return ReplayGain(
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
)
}
private fun Bundle.putGain(
key: String,
value: Float?,
) {
if (value != null) putFloat(key, value)
}
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
internal fun queueMediaItem(
item: QueueItem,
@@ -260,6 +284,10 @@ internal fun queueMediaItem(
Bundle().apply {
putString(EXTRA_COVER_ART_ID, item.coverArtId)
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
},
).build()
val builder =
@@ -34,6 +34,7 @@ data class QueueItem(
val album: String?,
val coverArtId: String?,
val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(),
)
data class QueueContext(
@@ -34,6 +34,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.subtracks.MainActivity
import com.subtracks.R
import com.subtracks.UserAgent
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
@@ -43,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext
@@ -60,6 +62,9 @@ class PlaybackService : MediaSessionService() {
private var starred = false
private var starJob: Job? = null
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
private val starCallback =
object : MediaSession.Callback {
override fun onConnect(
@@ -97,10 +102,18 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
val preferences = GlobalContext.get().get<UserPreferences>()
val cache = StreamCache.open(this)
scope.launch {
preferences
.replayGainMode()
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
.collect(replayGain::setSettings)
}
val exoPlayer =
ExoPlayer
.Builder(this)
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
.setAudioAttributes(
AudioAttributes
.Builder()
@@ -130,6 +143,7 @@ class PlaybackService : MediaSessionService() {
}
},
)
val player = KnownDurationPlayer(exoPlayer)
val loader =
CoverArtBitmapLoader(
applicationContext,
@@ -139,7 +153,7 @@ class PlaybackService : MediaSessionService() {
artworkLoader = loader
session =
MediaSession
.Builder(this, exoPlayer)
.Builder(this, player)
.setSessionActivity(
PendingIntent.getActivity(
this,
@@ -10,4 +10,5 @@ fun SongItem.toQueueItem() =
album = song.album,
coverArtId = coverArt,
durationMs = song.duration?.let { it * 1000 },
replayGain = song.replayGain(),
)
@@ -0,0 +1,49 @@
package com.subtracks.playback
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.ReplayGainMode
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.pow
data class ReplayGain(
val trackGain: Float? = null,
val albumGain: Float? = null,
val trackPeak: Float? = null,
val albumPeak: Float? = null,
)
internal data class ReplayGainSettings(
val mode: ReplayGainMode,
val preampDb: Float,
)
internal fun Song.replayGain(): ReplayGain =
ReplayGain(
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
)
internal fun replayGainLinearGain(
gain: ReplayGain,
settings: ReplayGainSettings,
): Float {
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
// "no peak data" and is ignored rather than treated as silence.
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
val (requestedDb, peak) =
when (settings.mode) {
ReplayGainMode.Off -> return 1f
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
}
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
val requested = gainDb + settings.preampDb
val applied = peak?.let { min(requested, maxSafeGainDb(it)) } ?: requested
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
}
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
@@ -0,0 +1,27 @@
package com.subtracks.playback
import androidx.media3.common.MediaItem
import com.subtracks.data.prefs.ReplayGainMode
internal class ReplayGainController(
private val apply: (Float) -> Unit,
) {
private var track = ReplayGain()
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
@Synchronized
fun applyItem(item: MediaItem) {
track = item.replayGain()
applyGain()
}
@Synchronized
fun setSettings(value: ReplayGainSettings) {
settings = value
applyGain()
}
private fun applyGain() {
apply(replayGainLinearGain(track, settings))
}
}
@@ -0,0 +1,71 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Timeline
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
import androidx.media3.common.audio.BaseAudioProcessor
import androidx.media3.common.util.UnstableApi
import java.nio.ByteBuffer
import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
internal class ReplayGainProcessor(
private val onItem: (MediaItem) -> Unit = {},
) : BaseAudioProcessor() {
@Volatile
private var linearGain = 1f
fun setLinearGain(value: Float) {
linearGain = value
}
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
// DefaultAudioSink flushes the processors on every configuration, which happens at each media
// item transition even when the format is unchanged, and hands over the timeline and the period
// being flushed (androidx/media#418). That is early enough to set the new item's gain before any
// of its buffers are processed, unlike Player.Listener.onMediaItemTransition, which the app
// thread receives afterwards.
override fun onFlush(streamMetadata: StreamMetadata) {
streamMetadata.currentItem()?.let(onItem)
}
override fun queueInput(inputBuffer: ByteBuffer) {
val output = replaceOutputBuffer(inputBuffer.remaining())
val gain = linearGain.takeIf { it.isFinite() } ?: 1f
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_16BIT -> {
while (inputBuffer.remaining() >= 2) {
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
}
}
C.ENCODING_PCM_FLOAT -> {
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
}
else -> {
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
}
}
inputBuffer.position(inputBuffer.limit())
output.flip()
}
private companion object {
const val MIN_SAMPLE = -32768
const val MAX_SAMPLE = 32767
}
}
@OptIn(UnstableApi::class)
internal fun StreamMetadata.currentItem(): MediaItem? {
val uid = periodUid ?: return null
val index = timeline.getIndexOfPeriod(uid)
if (index == C.INDEX_UNSET) return null
val period = timeline.getPeriod(index, Timeline.Period())
return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem
}
@@ -0,0 +1,38 @@
package com.subtracks.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import com.subtracks.log.Log
@OptIn(UnstableApi::class)
internal class ReplayGainRenderersFactory(
context: Context,
private val processor: ReplayGainProcessor,
) : DefaultRenderersFactory(context) {
// Mirrors the stock DefaultRenderersFactory.buildAudioSink. setAudioProcessors keeps the default
// chain and prepends ours; DefaultAudioSink omits the whole chain when float output is enabled,
// and AudioSink.setOffloadMode bypasses it entirely.
public override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParameters: Boolean,
): AudioSink {
if (enableFloatOutput) {
Log.w(FLOAT_OUTPUT_TAG, "float output is on, so replay gain is not applied to high-resolution tracks")
}
return DefaultAudioSink
.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
.setAudioProcessors(arrayOf(processor))
.build()
}
private companion object {
const val FLOAT_OUTPUT_TAG = "replaygain"
}
}
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -43,6 +45,8 @@ import com.subtracks.ui.theme.PrefetchArtworkSeeds
const val ALBUM_COVER_TAG = "album-cover"
const val ALBUM_PLACEHOLDER_TAG = "album-placeholder"
@Composable
fun AlbumsContent(
items: LazyPagingItems<Album>,
@@ -117,6 +121,16 @@ fun AlbumsContent(
scrim = true,
)
}
} else {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag(ALBUM_PLACEHOLDER_TAG),
)
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
@@ -58,7 +58,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
@@ -111,6 +110,10 @@ private const val SWIPE_SETTLE_MS = 180
private const val SWIPE_EDGE_RESISTANCE = 0.3f
private const val HAND_BACK_TIMEOUT_MS = 750L
// The strip is unclipped, so this spacing is what keeps the neighbours off-screen, and it has to
// clear the content inset plus a tile's shadow. It doubles as the gap between tiles while dragging.
private val TILE_GAP = 40.dp
data class NowPlayingArt(
val id: String?,
val ref: CoverArtRef?,
@@ -329,6 +332,7 @@ fun NowPlayingScreen(
val strip = frozenStrip ?: liveStrip
val canGoNext = strip.getOrNull(2) != null
val canGoPrevious = strip.getOrNull(0) != null
val stridePx = coverWidthPx + with(LocalDensity.current) { TILE_GAP.toPx() }
fun animateSwipe(target: Float) {
settleJob?.cancel()
@@ -340,7 +344,7 @@ fun NowPlayingScreen(
fun commitSwipe(direction: Int) {
frozenStrip = rotateStrip(strip, direction)
swipeOffsetX += direction * coverWidthPx
swipeOffsetX += direction * stridePx
if (direction > 0) onNext() else onSkipPrevious()
animateSwipe(0f)
}
@@ -410,13 +414,14 @@ fun NowPlayingScreen(
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Deliberately unclipped: a tile that fills the width would lose its drop
// shadow. TILE_GAP keeps the neighbours off-screen instead.
Box(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = 2.dp)
.clipToBounds()
.onSizeChanged { coverWidthPx = it.width.toFloat() }
.draggable(
orientation = Orientation.Horizontal,
@@ -428,7 +433,7 @@ fun NowPlayingScreen(
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
else -> delta
}
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-coverWidthPx, coverWidthPx)
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-stridePx, stridePx)
},
onDragStarted = {
dragging = true
@@ -484,7 +489,7 @@ fun NowPlayingScreen(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX = (index - 1) * coverWidthPx + swipeOffsetX
translationX = (index - 1) * stridePx + swipeOffsetX
}.then(
when (index) {
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
@@ -0,0 +1,44 @@
package com.subtracks.ui.settings
import android.content.res.Resources
import com.subtracks.R
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import kotlin.math.roundToInt
internal const val REPLAY_GAIN_PREAMP_STEP_DB = 0.5f
internal val REPLAY_GAIN_PREAMP_STEPS =
((MAX_REPLAY_GAIN_PREAMP_DB - MIN_REPLAY_GAIN_PREAMP_DB) / REPLAY_GAIN_PREAMP_STEP_DB).toInt() - 1
// Compose Material 3's Slider snaps in pixel space and converts back, so the value it reports is
// near, but not on, the grid it was configured with (for example -0.99999905 for -1.0). Snap before
// showing or storing it.
internal fun snapReplayGainPreampDb(value: Float): Float =
((value / REPLAY_GAIN_PREAMP_STEP_DB).roundToInt() * REPLAY_GAIN_PREAMP_STEP_DB)
.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB)
internal fun stepReplayGainPreampDb(
value: Float,
steps: Int,
): Float = snapReplayGainPreampDb(value + steps * REPLAY_GAIN_PREAMP_STEP_DB)
internal fun replayGainModeLabel(
resources: Resources,
mode: ReplayGainMode,
): String =
when (mode) {
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
}
internal fun replayGainPreampLabel(
resources: Resources,
decibels: Float,
): String {
val sign = if (decibels > 0f) "+" else ""
val magnitude = String.format(resources.configuration.locales[0], "%.1f", decibels)
return resources.getString(R.string.settings_replay_gain_preamp_value, "$sign$magnitude")
}
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -39,6 +40,8 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
@@ -46,6 +49,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -66,6 +71,9 @@ import com.subtracks.BuildConfig
import com.subtracks.R
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.log.Log
@@ -84,7 +92,16 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
private enum class SettingsDialog {
WifiQuality,
MobileQuality,
DownloadQuality,
SyncConcurrency,
SyncInterval,
SyncMode,
ReplayGain,
ReplayGainPreamp,
}
@Composable
fun SettingsRoute(
@@ -108,6 +125,8 @@ fun SettingsRoute(
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
SettingsScreen(
@@ -121,6 +140,8 @@ fun SettingsRoute(
downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered,
scrobbling = scrobbling,
replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp,
offline = offline,
verboseLogging = verboseLogging,
onSelectSource = viewModel::selectSource,
@@ -133,6 +154,8 @@ fun SettingsRoute(
onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging,
onAddServer = onAddServer,
@@ -176,6 +199,8 @@ fun SettingsScreen(
verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
replayGainPreamp: Float = 0f,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
@@ -186,6 +211,8 @@ fun SettingsScreen(
onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit,
onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit,
@@ -337,6 +364,22 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_replay_gain_title)) },
supportingContent = { Text(replayGainModeLabel(LocalResources.current, replayGainMode)) },
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGain },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
supportingContent = { Text(replayGainPreampLabel(LocalResources.current, replayGainPreamp)) },
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGainPreamp },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.downloads_title)) }
item {
ListItem(
@@ -519,6 +562,22 @@ fun SettingsScreen(
)
}
SettingsDialog.ReplayGain -> {
ReplayGainModeDialog(
selected = replayGainMode,
onSelect = onReplayGainModeChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.ReplayGainPreamp -> {
ReplayGainPreampDialog(
selected = replayGainPreamp,
onSelect = onReplayGainPreampChange,
onDismiss = { dialog = null },
)
}
null -> {
Unit
}
@@ -672,6 +731,118 @@ private fun SyncModeDialog(
)
}
@Composable
private fun ReplayGainModeDialog(
selected: ReplayGainMode,
onSelect: (ReplayGainMode) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_replay_gain_title)) },
text = {
ChoiceGroup(
header = stringResource(R.string.settings_replay_gain_prompt),
options = ReplayGainMode.entries.map { it to replayGainModeLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
},
confirmButton = {
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReplayGainPreampDialog(
selected: Float,
onSelect: (Float) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableFloatStateOf(snapReplayGainPreampDb(selected)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
text = {
Column {
Text(
stringResource(R.string.settings_replay_gain_preamp_prompt),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, -1) },
enabled = draft > MIN_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Remove,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_decrease),
)
}
Slider(
value = draft,
onValueChange = { draft = snapReplayGainPreampDb(it) },
valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB,
steps = REPLAY_GAIN_PREAMP_STEPS,
// Compose Material 3 draws one track tick per step, and at 59 steps they
// smear together, so the ticks are suppressed and the snapping kept.
track = { state -> SliderDefaults.Track(sliderState = state, drawTick = { _, _ -> }) },
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, 1) },
enabled = draft < MAX_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Add,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_increase),
)
}
}
Text(
replayGainPreampLabel(resources, draft),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
onClick = { draft = 0f },
enabled = draft != 0f,
) { Text(stringResource(R.string.settings_replay_gain_preamp_reset)) }
Spacer(Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
}
},
)
}
@Composable
private fun QualityDialog(
title: String,
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.ReplayGainMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences
@@ -65,6 +66,16 @@ class SettingsViewModel(
.scrobbling()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
val replayGainMode: StateFlow<ReplayGainMode> =
userPreferences
.replayGainMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
val replayGainPreamp: StateFlow<Float> =
userPreferences
.replayGainPreampDb()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
val offline: StateFlow<Boolean> = sourceRepository.offline
val verboseLogging: StateFlow<Boolean> =
@@ -115,4 +126,12 @@ class SettingsViewModel(
fun setScrobbling(enabled: Boolean) {
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
}
fun setReplayGainMode(mode: ReplayGainMode) {
viewModelScope.launch { userPreferences.setReplayGainMode(mode) }
}
fun setReplayGainPreamp(value: Float) {
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
}
}
+11
View File
@@ -74,6 +74,17 @@
<string name="settings_music_name">Playback</string>
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
<string name="settings_replay_gain_mode_album">Album gain</string>
<string name="settings_replay_gain_mode_off">Off</string>
<string name="settings_replay_gain_mode_track">Track gain</string>
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
<string name="settings_replay_gain_preamp_increase">Raise preamp</string>
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain</string>
<string name="settings_replay_gain_preamp_reset">Reset</string>
<string name="settings_replay_gain_preamp_title">Preamp</string>
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
<string name="settings_replay_gain_prompt">Normalise loudness using the ReplayGain tags from the server</string>
<string name="settings_replay_gain_title">ReplayGain</string>
<string name="settings_network_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
@@ -4,7 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
const val TEST_TIMEOUT_MS = 30_000L
@@ -18,5 +18,14 @@ fun awaitUntil(
) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms $message", predicate())
if (predicate()) return
// A wall-clock wait that expires means something stalled rather than ran slow: the suite
// finishes in milliseconds per test. Print every thread's stack in the failure message so the
// stall is diagnosable from the CI log, which shows exception messages but not the test XML.
fail("Timed out after ${TEST_TIMEOUT_MS}ms $message\n\n${threadDump()}")
}
private fun threadDump(): String =
Thread.getAllStackTraces().entries.joinToString("\n\n") { (thread, frames) ->
"${thread.name} [${thread.state}]\n" + frames.joinToString("\n") { " at $it" }
}
@@ -188,6 +188,34 @@ class UserPreferencesTest {
prefs.setVerboseLogging(true)
assertEquals(true, prefs.verboseLogging().first())
file.delete()
}
@Test
fun replayGainDefaultsToTrackModeAndRoundTrips() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainMode(ReplayGainMode.Album)
prefs.setReplayGainPreampDb(3.5f)
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(99f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(-99f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(Float.NaN)
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
file.delete()
}
}
@@ -2,6 +2,8 @@ package com.subtracks.data.repo
import android.content.Context
import android.content.res.Resources
import androidx.paging.PagingDataEvent
import androidx.paging.PagingDataPresenter
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
@@ -9,16 +11,23 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Album
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
@@ -32,7 +41,10 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.CoroutineContext
@RunWith(AndroidJUnit4::class)
class LibraryRepositoryTest {
@@ -163,6 +175,75 @@ class LibraryRepositoryTest {
assertEquals(2L, repository.albumSongOrdinal(sourceId, "al1", "s3"))
}
@Test
fun theAlbumGridPresentsTheWholeLibraryAndSurvivesAReload() =
runBlocking {
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
db.libraryDao().upsertAlbums((0 until 250).map { seedAlbum(sourceId, it) })
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val presenter = RecordingPresenter(presenterDispatcher)
val job =
launch {
repository
.albums(sourceId, AlbumSort.Name, false, StarredFilter.Any, "", false)
.collect { presenter.collectFrom(it) }
}
try {
awaitRefreshes(presenter, 1)
assertEquals(250, presented(presenter, presenterDispatcher))
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 0).copy(playCount = 1)))
awaitRefreshes(presenter, 2)
assertEquals("a reload must not collapse the grid to one page", 250, presented(presenter, presenterDispatcher))
} finally {
job.cancelAndJoin()
presenterDispatcher.close()
}
}
private suspend fun awaitRefreshes(
presenter: RecordingPresenter,
target: Int,
) {
withTimeout(30_000) {
while (presenter.refreshes.get() < target) delay(10)
}
}
private suspend fun presented(
presenter: RecordingPresenter,
dispatcher: CoroutineDispatcher,
): Int = withContext(dispatcher) { presenter.size }
private fun seedAlbum(
sourceId: Long,
index: Int,
) = Album(
sourceId = sourceId,
id = "al-" + index.toString().padStart(4, '0'),
artistId = "ar1",
name = "Album " + index.toString().padStart(4, '0'),
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
)
private class RecordingPresenter(
context: CoroutineContext,
) : PagingDataPresenter<Album>(context) {
val refreshes = AtomicInteger()
override suspend fun presentPagingDataEvent(event: PagingDataEvent<Album>) {
if (event is PagingDataEvent.Refresh) refreshes.incrementAndGet()
}
}
private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
val server = MockWebServer()
server.start()
@@ -256,6 +256,75 @@ class SubsonicSourceTest {
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun songsKeepTheOpenSubsonicReplayGain() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/search3.view") return MockResponse().setResponseCode(404)
return MockResponse().setBody(
"<subsonic-response status=\"ok\"><searchResult3>" +
"<song id=\"s1\" title=\"Gained\">" +
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
"</song></searchResult3></subsonic-response>",
)
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(-2.1f, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun albumSongsKeepTheOpenSubsonicReplayGain() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
return when (url.encodedPath) {
"/rest/search3.view" -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
"/rest/getAlbumList2.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><albumList2>" +
"<album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>" +
"</albumList2></subsonic-response>",
)
}
"/rest/getAlbum.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><album id=\"al1\">" +
"<song id=\"s1\" title=\"Gained\">" +
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
"</song></album></subsonic-response>",
)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(-2.1f, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun entitiesWithEmptyIdsAreDropped() =
runBlocking {
@@ -608,6 +677,40 @@ class SubsonicSourceTest {
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
}
@Test
fun albumSongsAreEmittedInBoundedBatches() =
runBlocking {
val albumCount = 200
val songsPerAlbum = 3
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
return when (url.encodedPath) {
"/rest/search3.view" -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
"/rest/getAlbumList2.view" -> {
MockResponse().setBody(albumListOf(albumCount))
}
"/rest/getAlbum.view" -> {
MockResponse().setBody(albumWithSongs(url.queryParameter("id")!!, songsPerAlbum))
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
}
val batches = SubsonicSource(1, client()).songs().toList().map { it.size }
assertEquals(listOf(500, 100), batches)
}
@Test
fun playlistEntriesWithoutIdsAreSkipped() =
runBlocking {
@@ -705,6 +808,13 @@ class SubsonicSourceTest {
"<song id=\"$albumId-s1\" title=\"One\"/>" +
"</album></subsonic-response>"
private fun albumWithSongs(
albumId: String,
count: Int,
) = "<subsonic-response status=\"ok\"><album id=\"$albumId\">" +
(1..count).joinToString("") { "<song id=\"$albumId-s$it\" title=\"Title $it\"/>" } +
"</album></subsonic-response>"
private fun failed(
code: Int,
message: String,
@@ -33,6 +33,14 @@ class SubsonicXmlTest {
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
).map { it.toAlbum() }
private fun songs(xml: String) =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = SubsonicXml::songChild,
).map { it.toSong() }
@Test
fun mapsArtists() {
val artists =
@@ -156,51 +164,155 @@ class SubsonicXmlTest {
@Test
fun mapsSongGenresAndIgnoresEmptyOnes() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Multi">
<genres name="Rock"/>
<genres name="Electronic"/>
<genres name=""/>
<genres name=" "/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Multi">
<genres name="Rock"/>
<genres name="Electronic"/>
<genres name=""/>
<genres name=" "/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun ignoresSongGenresThatAreNotDirectChildren() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Nested">
<wrapper><genres name="Rock"/></wrapper>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
fun mapsSongReplayGain() {
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Gained">
<replayGain trackGain="-3.2" albumGain="-2.1" trackPeak="0.98" albumPeak="0.99"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
val song = songs.single()
assertEquals(-3.2f, song.trackGain)
assertEquals(-2.1f, song.albumGain)
assertEquals(0.98f, song.trackPeak)
assertEquals(0.99f, song.albumPeak)
}
@Test
fun leavesMissingReplayGainFieldsUnset() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Track gain only">
<replayGain trackGain="-3.2"/>
</song>
<song id="s2" title="No gain"/>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(-3.2f, songs[0].trackGain)
assertEquals(null, songs[0].albumGain)
assertEquals(null, songs[0].trackPeak)
assertEquals(null, songs[0].albumPeak)
assertEquals(null, songs[1].trackGain)
assertEquals(null, songs[1].albumGain)
assertEquals(null, songs[1].trackPeak)
assertEquals(null, songs[1].albumPeak)
}
@Test
fun mapsReplayGainPeaksWithoutGains() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Peaks only">
<replayGain trackPeak="0.98" albumPeak="0.99"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(null, songs.single().trackGain)
assertEquals(null, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun keepsTheFieldsOfEveryReplayGainElement() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Split">
<replayGain trackGain="-3.2"/>
<replayGain trackPeak="0.98"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(0.98f, songs.single().trackPeak)
}
@Test
fun ignoresNonFiniteReplayGainValues() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Junk">
<replayGain trackGain="Infinity" albumGain="NaN" trackPeak="1e40" albumPeak="-Infinity"/>
</song>
<song id="s2" title="Mostly fine">
<replayGain trackGain="-3.2" albumGain="NaN" trackPeak="0.98"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(null, songs[0].trackGain)
assertEquals(null, songs[0].albumGain)
assertEquals(null, songs[0].trackPeak)
assertEquals(null, songs[0].albumPeak)
assertEquals(-3.2f, songs[1].trackGain)
assertEquals(null, songs[1].albumGain)
assertEquals(0.98f, songs[1].trackPeak)
}
@Test
fun ignoresSongGenresThatAreNotDirectChildren() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Nested">
<wrapper><genres name="Rock"/></wrapper>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(emptyList<String>(), songs.single().genres)
}
@@ -887,6 +887,47 @@ class SyncServiceTest {
assertEquals(before, totalChanges())
}
@Test
fun storesReplayGainAndClearsItWhenTheServerStopsSendingIt() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(
trackGain = -3.2f,
albumGain = -2.1f,
trackPeak = 0.98f,
albumPeak = 0.99f,
),
),
)
SyncService(db, source).sync()
val stored = db.libraryDao().song(1, "s1").first()!!
assertEquals(-3.2f, stored.trackGain)
assertEquals(-2.1f, stored.albumGain)
assertEquals(0.98f, stored.trackPeak)
assertEquals(0.99f, stored.albumPeak)
val before = totalChanges()
SyncService(db, source).sync()
assertEquals(before, totalChanges())
source.songs = listOf(song("s1"))
SyncService(db, source).sync()
val cleared = db.libraryDao().song(1, "s1").first()!!
assertEquals(null, cleared.trackGain)
assertEquals(null, cleared.albumGain)
assertEquals(null, cleared.trackPeak)
assertEquals(null, cleared.albumPeak)
}
private suspend fun totalChanges(): Long =
db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement ->
@@ -0,0 +1,83 @@
package com.subtracks.playback
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class KnownDurationPlayerTest {
private fun player(
declaredDurationMs: Long?,
live: Boolean = true,
): Player =
object : SimpleBasePlayer(Looper.getMainLooper()) {
override fun getState(): State {
val metadata =
MediaMetadata
.Builder()
.setTitle("Track")
.setDurationMs(declaredDurationMs)
.build()
val item =
MediaItemData
.Builder("track")
.setMediaItem(
MediaItem
.Builder()
.setMediaId("s1")
.setMediaMetadata(metadata)
.build(),
).setDurationUs(PLAYER_DURATION_US)
.setLiveConfiguration(if (live) MediaItem.LiveConfiguration.UNSET else null)
.build()
return State
.Builder()
.setPlaylist(listOf(item))
.build()
}
}
@Test
fun aLiveTrackWithADeclaredDurationIsReportedFinite() {
val player = player(declaredDurationMs = 100_000L)
assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive())
}
@Test
fun aLiveTrackWithoutADeclaredDurationStaysLive() {
val player = player(declaredDurationMs = null)
assertTrue(KnownDurationPlayer(player).isCurrentMediaItemLive())
}
@Test
fun aFiniteTrackWithADeclaredDurationStaysFinite() {
val player = player(declaredDurationMs = 100_000L, live = false)
assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive())
}
@Test
fun theWrappedDurationIsThePlayersNotTheMetadata() {
val player = player(declaredDurationMs = 100_000L)
assertEquals(PLAYER_DURATION_MS, KnownDurationPlayer(player).getDuration())
}
private companion object {
const val PLAYER_DURATION_MS = 200_000L
const val PLAYER_DURATION_US = PLAYER_DURATION_MS * 1000
}
}
@@ -54,4 +54,39 @@ class MediaItemTest {
assertEquals("Song", item.mediaMetadata.title.toString())
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
}
@Test
fun theItemCarriesTheReplayGain() {
val gain = ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f)
val item =
queueMediaItem(
song.copy(replayGain = gain),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(gain, item.replayGain())
}
@Test
fun anItemWithoutGainReadsBackAsNoGain() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals(ReplayGain(), item.replayGain())
}
@Test
fun aZeroGainIsNotMistakenForNoGain() {
val item =
queueMediaItem(
song.copy(replayGain = ReplayGain(trackGain = 0f, trackPeak = 0.5f)),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(ReplayGain(trackGain = 0f, trackPeak = 0.5f), item.replayGain())
}
}
@@ -0,0 +1,60 @@
package com.subtracks.playback
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Song
import org.junit.Assert.assertEquals
import org.junit.Test
class QueueItemTest {
@Test
fun carriesTheSongsReplayGain() {
val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
trackGain = -3.2f,
albumGain = -2.1f,
trackPeak = 0.98f,
albumPeak = 0.99f,
)
val item = AlbumSongItem(song, coverArt = "al1").toQueueItem()
assertEquals(
ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f),
item.replayGain,
)
}
@Test
fun aSongWithoutGainCarriesNoGain() {
val item =
AlbumSongItem(
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
),
coverArt = null,
).toQueueItem()
assertEquals(ReplayGain(), item.replayGain)
}
}
@@ -0,0 +1,82 @@
package com.subtracks.playback
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReplayGainControllerTest {
private val applied = mutableListOf<Float>()
private val controller = ReplayGainController { applied += it }
@Test
fun appliesTheItemsGain() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
@Test
fun recomputesWhenTheSettingsChange() {
controller.applyItem(item("a", trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 6.0206f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun switchingOffRestoresUnityGain() {
controller.applyItem(item("a", trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.setSettings(ReplayGainSettings(ReplayGainMode.Off, 0f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun anItemWithoutGainPlaysAtUnity() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = null))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun theLatestItemLeavesItsGainApplied() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = 6.0206f))
controller.applyItem(item("b", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
private fun item(
id: String,
trackGain: Float?,
) = queueMediaItem(
QueueItem(
id = id,
title = id,
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
),
local = null,
stream = "http://server/$id",
transcode = false,
)
private companion object {
const val TOLERANCE = 0.001f
}
}
@@ -0,0 +1,205 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.source.SinglePeriodTimeline
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.nio.ByteBuffer
import java.nio.ByteOrder
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class ReplayGainProcessorTest {
@Test
fun scalesSixteenBitSamples() {
val processor = processor(gain = 0.5f)
processor.queueInput(sixteenBit(100, -200, 1000, -1000))
assertEquals(listOf(50, -100, 500, -500), readShorts(processor.getOutput()))
}
@Test
fun passesSamplesThroughAtUnityGain() {
val processor = processor(gain = 1f)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun saturatesInsteadOfWrappingWhenTheGainOverflows() {
val processor = processor(gain = 4f)
processor.queueInput(sixteenBit(20000, -20000))
assertEquals(listOf(32767, -32768), readShorts(processor.getOutput()))
}
@Test
fun scalesFloatSamples() {
val processor = processor(gain = 0.5f, encoding = C.ENCODING_PCM_FLOAT)
processor.queueInput(floatBuffer(1f, -0.5f))
assertEquals(listOf(0.5f, -0.25f), readFloats(processor.getOutput()))
}
@Test
fun passesAnUnsupportedEncodingThroughUnchanged() {
val processor = processor(gain = 2f, encoding = C.ENCODING_PCM_24BIT)
processor.queueInput(ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
assertEquals(listOf<Byte>(1, 2, 3), readBytes(processor.getOutput()))
}
@Test
fun passesSamplesThroughWhenTheGainIsNaN() {
val processor = processor(gain = Float.NaN)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun passesSamplesThroughWhenTheGainIsInfinite() {
val processor = processor(gain = Float.POSITIVE_INFINITY)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun keepsTheInputFormatSoTheSinkKeepsItInThePipeline() {
val processor = ReplayGainProcessor()
val configured = processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
assertEquals(C.ENCODING_PCM_16BIT, configured.encoding)
assertEquals(true, processor.isActive())
}
@Test
fun aFlushReportsTheItemBeingFlushed() {
val reported = mutableListOf<MediaItem>()
val item = item("a", trackGain = -6.0206f)
ReplayGainProcessor { reported += it }.flush(streamMetadata(item))
assertEquals(listOf(item), reported)
}
@Test
fun aFlushWithoutAPeriodLeavesTheGainAlone() {
lateinit var processor: ReplayGainProcessor
val controller = ReplayGainController { processor.setLinearGain(it) }
processor = ReplayGainProcessor { controller.applyItem(it) }
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
processor.flush(StreamMetadata.DEFAULT)
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
}
@Test
fun aFlushMovesTheGainToTheNextItem() {
lateinit var processor: ReplayGainProcessor
val controller = ReplayGainController { processor.setLinearGain(it) }
processor = ReplayGainProcessor { controller.applyItem(it) }
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
processor.flush(streamMetadata(item("b", trackGain = 6.0206f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(2000), readShorts(processor.getOutput()))
}
private fun processor(
gain: Float,
encoding: Int = C.ENCODING_PCM_16BIT,
): ReplayGainProcessor =
ReplayGainProcessor().apply {
setLinearGain(gain)
configure(AudioFormat(SAMPLE_RATE, CHANNELS, encoding))
flush(StreamMetadata.DEFAULT)
}
private fun item(
id: String,
trackGain: Float,
): MediaItem =
queueMediaItem(
QueueItem(
id = id,
title = id,
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
),
local = null,
stream = "http://server/$id",
transcode = false,
)
private fun streamMetadata(item: MediaItem): StreamMetadata {
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
return StreamMetadata
.Builder()
.setTimeline(timeline)
.setPeriodUid(timeline.getUidOfPeriod(0))
.build()
}
private fun sixteenBit(vararg samples: Int): ByteBuffer =
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putShort(it.toShort()) }
flip()
}
private fun floatBuffer(vararg samples: Float): ByteBuffer =
ByteBuffer.allocate(samples.size * 4).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putFloat(it) }
flip()
}
private fun readShorts(buffer: ByteBuffer): List<Int> =
buildList {
while (buffer.remaining() >= 2) add(buffer.short.toInt())
}
private fun readFloats(buffer: ByteBuffer): List<Float> =
buildList {
while (buffer.remaining() >= 4) add(buffer.float)
}
private fun readBytes(buffer: ByteBuffer): List<Byte> =
buildList {
while (buffer.hasRemaining()) add(buffer.get())
}
private companion object {
const val SAMPLE_RATE = 44_100
const val CHANNELS = 2
const val DURATION_US = 1_000_000L
}
}
@@ -0,0 +1,33 @@
package com.subtracks.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.audio.AudioProcessorChain
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class ReplayGainRenderersFactoryTest {
@Test
fun installsTheGainProcessorInTheAudioSink() {
val context = ApplicationProvider.getApplicationContext<Context>()
val processor = ReplayGainProcessor()
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
// DefaultAudioSink exposes no accessor for the chain it was built with.
val chain =
DefaultAudioSink::class.java
.getDeclaredField("audioProcessorChain")
.apply { isAccessible = true }
.get(sink) as AudioProcessorChain
assertEquals(true, chain.audioProcessors.any { it === processor })
}
}
@@ -0,0 +1,66 @@
package com.subtracks.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId
import androidx.media3.exoplayer.source.SinglePeriodTimeline
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.nio.ByteBuffer
import java.nio.ByteOrder
// Pins the media3 behaviour the whole feature rests on: DefaultAudioSink hands the processor the
// timeline and the period being configured, on every configuration, which is what lets the gain be
// set before a new item's buffers. See androidx/media#418.
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class ReplayGainSinkTest {
@Test
fun theSinkHandsTheItemBeingConfiguredToTheProcessor() {
val context = ApplicationProvider.getApplicationContext<Context>()
val reported = mutableListOf<MediaItem>()
val processor = ReplayGainProcessor { reported += it }
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
val item = MediaItem.Builder().setMediaId("b").build()
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
sink.configure(
AudioSink.AudioSinkConfig
.Builder(pcmFormat())
.setTimeline(timeline)
.setMediaPeriodId(MediaPeriodId(timeline.getUidOfPeriod(0)))
.build(),
)
sink.handleBuffer(sixteenBit(1000), 0, 1)
assertEquals(listOf(item), reported)
}
private fun pcmFormat() =
Format
.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
.setPcmEncoding(C.ENCODING_PCM_16BIT)
.setChannelCount(2)
.setSampleRate(44_100)
.build()
private fun sixteenBit(vararg samples: Int): ByteBuffer =
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putShort(it.toShort()) }
flip()
}
private companion object {
const val DURATION_US = 1_000_000L
}
}
@@ -0,0 +1,195 @@
package com.subtracks.playback
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
class ReplayGainTest {
@Test
fun trackModeAppliesTheTrackGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun trackModeFallsBackToTheAlbumGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(albumGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun albumModeAppliesTheAlbumGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Album, 0f),
),
TOLERANCE,
)
}
@Test
fun albumModeFallsBackToTheTrackGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Album, 0f),
),
TOLERANCE,
)
}
@Test
fun offAppliesUnityGain() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Off, 0f),
),
TOLERANCE,
)
}
@Test
fun aSongWithoutGainPlaysAtUnity() {
assertEquals(
1f,
replayGainLinearGain(ReplayGain(), ReplayGainSettings(ReplayGainMode.Track, 3f)),
TOLERANCE,
)
}
@Test
fun thePreampShiftsTheAppliedGain() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
),
TOLERANCE,
)
}
@Test
fun thePeakCapsAPositiveGain() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakCapsUsingTheAlbumPeakWhenTheTrackPeakIsMissing() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, albumPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun aMissingOrZeroPeakDoesNotCap() {
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
val expected = 3.9810717f
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
}
@Test
fun aTruePeakAboveFullScaleCapsBelowUnity() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = 6f, trackPeak = 2f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakNeverLowersAnAttenuation() {
assertEquals(
0.31622776f,
replayGainLinearGain(
ReplayGain(trackGain = -10f, trackPeak = 0.9f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun aNonFinitePeakDoesNotCap() {
assertEquals(
3.9810717f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun playsAtUnityWhenTheGainIsNotFinite() {
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.POSITIVE_INFINITY), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NEGATIVE_INFINITY), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NaN), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = -Float.MAX_VALUE), settings), TOLERANCE)
}
@Test
fun playsAtUnityWhenANonFiniteGainWouldOtherwiseBeCapped() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = Float.POSITIVE_INFINITY, trackPeak = 0.001f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakFallsBackWhenThePreferredOneCarriesNoData() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = 0f, albumPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
private companion object {
const val TOLERANCE = 0.001f
}
}
@@ -12,11 +12,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.paging.PagingData
@@ -340,6 +344,22 @@ class AppScreenshotTest {
@Test
fun settings() {
setSettingsContent()
awaitText("Navidrome")
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
}
@Test
fun replayGainPreampDialog() {
setSettingsContent()
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Preamp"))
composeRule.onAllNodesWithText("Preamp")[0].performClick()
composeRule.waitForIdle()
composeRule.onAllNodesWithText("Reset").assertCountEquals(1)
composeRule.onRoot().captureRoboImage("src/test/screenshots/replay_gain_preamp.png")
}
private fun setSettingsContent() {
composeRule.setContent {
SubtracksTheme {
SettingsScreen(
@@ -367,8 +387,6 @@ class AppScreenshotTest {
)
}
}
awaitText("Navidrome")
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
}
@Test
@@ -444,38 +462,31 @@ class AppScreenshotTest {
}
@Test
fun nowPlaying() {
composeRule.setContent {
SubtracksTheme {
NowPlayingScreen(
state = Fixtures.playbackState(),
positionMs = 62_000,
title = "Kid A",
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
onBack = {},
onQueue = {},
onPlayPause = {},
onNext = {},
onPrevious = {},
onSeek = {},
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing.png")
}
fun nowPlaying() = nowPlayingShot(Fixtures.playbackState(), "art-al-kid-a", "now_playing.png")
@Test
fun nowPlayingModes() {
fun nowPlayingModes() =
nowPlayingShot(
Fixtures.playbackState().copy(shuffle = true, repeat = RepeatMode.One),
"art-al-kid-a",
"now_playing_modes.png",
)
@Test
fun nowPlayingNonSquare() = nowPlayingShot(Fixtures.playbackState(), "art-nonsquare", "now_playing_nonsquare.png")
private fun nowPlayingShot(
state: PlaybackState,
coverArtId: String,
file: String,
) {
composeRule.setContent {
SubtracksTheme {
NowPlayingScreen(
state = Fixtures.playbackState().copy(shuffle = true, repeat = RepeatMode.One),
state = state,
positionMs = 62_000,
title = "Kid A",
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
coverArt = CoverArtRef(coverArtId, "test:$coverArtId"),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
onBack = {},
onQueue = {},
@@ -488,7 +499,7 @@ class AppScreenshotTest {
}
}
awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing_modes.png")
composeRule.onRoot().captureRoboImage("src/test/screenshots/$file")
}
@Test
@@ -725,6 +736,7 @@ private object Fixtures {
fun artEngine(): FakeImageLoaderEngine {
val builder = FakeImageLoaderEngine.Builder()
builder.intercept("art-nonsquare", art("art-nonsquare", ArtKind.Album, width = 640, height = 256))
albums.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Album)) }
playlists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Playlist)) }
artists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Artist)) }
@@ -734,9 +746,14 @@ private object Fixtures {
private fun art(
key: String,
kind: ArtKind,
width: Int = 256,
height: Int = 256,
): Image {
val size = 256
val f = size.toFloat()
val w = width.toFloat()
val h = height.toFloat()
val cx = w / 2f
val cy = h / 2f
val span = maxOf(w, h)
val random = Random(key.hashCode())
val base = random.nextFloat() * 360f
@@ -752,21 +769,21 @@ private object Fixtures {
listOf(color(0f), color(170f + random.nextFloat() * 30f))
}
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
canvas.drawColor(palette[0])
when (kind) {
ArtKind.Album -> {
val stripe = f / 9f
val stripe = w / 9f
canvas.save()
canvas.rotate(-30f, f / 2f, f / 2f)
var x = -f
canvas.rotate(-30f, cx, cy)
var x = -span
var index = 1
while (x < f * 2f) {
while (x < span * 2f) {
paint.color = palette[index % palette.size]
canvas.drawRect(x, -f, x + stripe * 0.6f, f * 2f, paint)
canvas.drawRect(x, -span, x + stripe * 0.6f, span * 2f, paint)
x += stripe
index++
}
@@ -774,12 +791,12 @@ private object Fixtures {
}
ArtKind.Playlist -> {
val step = f / 4f
val step = w / 4f
var row = 1
var y = step / 2f
while (y < f) {
while (y < h) {
var x = step / 2f
while (x < f) {
while (x < w) {
paint.color = palette[row % palette.size]
canvas.drawCircle(x, y, step * 0.3f, paint)
x += step
@@ -791,12 +808,12 @@ private object Fixtures {
ArtKind.Artist -> {
paint.style = Paint.Style.STROKE
paint.strokeWidth = f / 13f
var radius = f * 0.5f
paint.strokeWidth = span / 13f
var radius = span * 0.5f
var index = 1
while (radius > 0f) {
paint.color = palette[index % palette.size]
canvas.drawCircle(f / 2f, f / 2f, radius, paint)
canvas.drawCircle(cx, cy, radius, paint)
radius -= paint.strokeWidth
index++
}
@@ -0,0 +1,61 @@
package com.subtracks.ui.library
import androidx.activity.ComponentActivity
import androidx.compose.runtime.remember
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.unit.dp
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.model.Album
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class AlbumsPlaceholderTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun anUnloadedCellIsDrawnAsAPlaceholder() {
composeRule.setContent {
SubtracksTheme {
val items =
remember { flowOf(PagingData.from(listOf(album(0)), placeholdersAfter = 2)) }
.collectAsLazyPagingItems()
AlbumsContent(items, { _, _ -> null }, 0.dp, {})
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(ALBUM_PLACEHOLDER_TAG).fetchSemanticsNodes().isNotEmpty()
}
assertEquals(2, composeRule.onAllNodesWithTag(ALBUM_PLACEHOLDER_TAG).fetchSemanticsNodes().size)
assertEquals(1, composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().size)
}
private fun album(index: Int) =
Album(
sourceId = 1,
id = "al-" + index.toString().padStart(4, '0'),
artistId = "ar",
name = "Album " + index.toString().padStart(4, '0'),
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
)
}
@@ -4,6 +4,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@@ -285,6 +286,29 @@ class NowPlayingTapsTest {
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertDoesNotExist()
}
@Test
fun theSwipeTargetStaysInsideTheContentMargins() {
render()
val target = composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).fetchSemanticsNode().boundsInRoot
val screen = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
assertEquals(
"the swipe target must not reach into the screen margins",
screen.width - with(composeRule.density) { 48.dp.toPx() },
target.width,
)
}
@Test
fun theNeighboursStayHiddenAtRest() {
render(previousArt = adjacentArt("s-previous"), nextArt = adjacentArt("s-next"))
// TILE_GAP is what keeps them off-screen now that the strip is unclipped.
composeRule.onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true).assertIsNotDisplayed()
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertIsNotDisplayed()
}
@Test
fun aShortSlowDragOnTheCoverDoesNotSkip() {
var next = false
@@ -0,0 +1,59 @@
package com.subtracks.ui.settings
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReplayGainControlsTest {
private val resources = ApplicationProvider.getApplicationContext<Context>().resources
@Test
fun modeLabelsNameTheGainEachOneUses() {
assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off))
assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track))
assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album))
}
@Test
fun preampLabelsCarryTheSignAndTheUnit() {
assertEquals("+3.0 dB", replayGainPreampLabel(resources, 3f))
assertEquals("-2.5 dB", replayGainPreampLabel(resources, -2.5f))
assertEquals("0.0 dB", replayGainPreampLabel(resources, 0f))
}
@Test
fun theSliderHasOneStepPerHalfDecibel() {
assertEquals(59, REPLAY_GAIN_PREAMP_STEPS)
}
@Test
fun thePreampStepsAlongTheGridAndStopsAtTheEnds() {
assertEquals(-1.5f, stepReplayGainPreampDb(-1f, -1), 0f)
assertEquals(-0.5f, stepReplayGainPreampDb(-1f, 1), 0f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MIN_REPLAY_GAIN_PREAMP_DB, -1), 0f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MAX_REPLAY_GAIN_PREAMP_DB, 1), 0f)
}
@Test
fun thePreampSnapsBackOntoTheHalfDecibelGrid() {
assertEquals(0f, snapReplayGainPreampDb(-0f), 0f)
assertEquals(-1f, snapReplayGainPreampDb(-0.99999905f), 0f)
assertEquals(-3f, snapReplayGainPreampDb(-3.000001f), 0f)
assertEquals(14.5f, snapReplayGainPreampDb(14.6f), 0f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(14.9f), 0f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-14.9f), 0f)
}
@Test
fun thePreampIsClampedToItsRange() {
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(20f), 0f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-20f), 0f)
}
}
+5 -3
View File
@@ -88,7 +88,7 @@ Playback is a *context* plus a manual *up next* list. `queue_entries` is the con
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
@@ -98,6 +98,8 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one.
## Data model
`sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`.
@@ -120,7 +122,7 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs, though it still coalesces the songs into 500-row batches before writing them. Every paged loop stops on a short page and is capped at 20,000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
@@ -163,4 +165,4 @@ No analytics or third-party telemetry. Credentials are stored locally in app dat
## Build and CI
The Nix flake devshell provides the whole toolchain, and `direnv` loads it into the working shell. CI (`.forgejo/workflows/ci.yml`) runs `unit`, `lint` and `integration` on a containerized Nix runner, restoring the Nix store from the Actions cache and caching Gradle and the integration test music.
The Nix flake devshell provides the whole toolchain, and `direnv` loads it into the working shell. CI runs `unit`, `lint` and `integration`, restoring the Nix store from the Actions cache and caching Gradle and the integration test music.