3 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
11 changed files with 348 additions and 49 deletions
@@ -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)
@@ -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
+1 -1
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.