8 Commits
Author SHA1 Message Date
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
16 changed files with 365 additions and 81 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
+1 -21
View File
@@ -117,31 +117,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") {
@@ -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
}
@@ -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 }
}
@@ -130,6 +130,7 @@ class PlaybackService : MediaSessionService() {
}
},
)
val player = KnownDurationPlayer(exoPlayer)
val loader =
CoverArtBitmapLoader(
applicationContext,
@@ -139,7 +140,7 @@ class PlaybackService : MediaSessionService() {
artworkLoader = loader
session =
MediaSession
.Builder(this, exoPlayer)
.Builder(this, player)
.setSessionActivity(
PendingIntent.getActivity(
this,
@@ -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)
@@ -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" }
}
@@ -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()
@@ -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
}
}
@@ -444,38 +444,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 +481,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 +718,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 +728,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 +751,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 +773,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 +790,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
+2 -2
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.
@@ -163,4 +163,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.