From bf4e4d038b67b960b7701ab97ef661849a00906e Mon Sep 17 00:00:00 2001 From: manish0467 Date: Sun, 25 Oct 2026 18:22:30 +0530 Subject: [PATCH] feat(android): add configurable podcast skip controls --- .../java/stream/kleeamp/mobile/KleeampRoot.kt | 6 + .../mobile/playback/PlaybackService.kt | 144 +++++++--- .../mobile/playback/PlayerConnection.kt | 57 +++- .../kleeamp/mobile/player/MiniPlayer.kt | 91 +++++-- .../mobile/player/NowPlayingViewModel.kt | 14 +- .../java/stream/kleeamp/mobile/prefs/Prefs.kt | 10 +- .../mobile/settings/SettingsViewModel.kt | 10 +- .../kleeamp/mobile/play/TransportEditTest.kt | 6 + .../playback/MediaSessionControlsTest.kt | 249 ++++++++++++++++++ .../kleeamp/mobile/playback/SeekTargetTest.kt | 230 ++++++++++++++++ .../player/ExpandedPlayerRegressionTest.kt | 117 ++++++++ .../mobile/player/MiniPlayerControlsTest.kt | 142 ++++++++++ .../kleeamp/mobile/player/SkipLabelTest.kt | 10 +- .../kleeamp/mobile/prefs/SkipDurationsTest.kt | 48 ++++ 14 files changed, 1056 insertions(+), 78 deletions(-) create mode 100644 android/app/src/test/java/stream/kleeamp/mobile/playback/MediaSessionControlsTest.kt create mode 100644 android/app/src/test/java/stream/kleeamp/mobile/player/ExpandedPlayerRegressionTest.kt create mode 100644 android/app/src/test/java/stream/kleeamp/mobile/player/MiniPlayerControlsTest.kt create mode 100644 android/app/src/test/java/stream/kleeamp/mobile/prefs/SkipDurationsTest.kt diff --git a/android/app/src/main/java/stream/kleeamp/mobile/KleeampRoot.kt b/android/app/src/main/java/stream/kleeamp/mobile/KleeampRoot.kt index bf3d4f70..a2dce074 100644 --- a/android/app/src/main/java/stream/kleeamp/mobile/KleeampRoot.kt +++ b/android/app/src/main/java/stream/kleeamp/mobile/KleeampRoot.kt @@ -1086,6 +1086,8 @@ private fun ScopedMiniPlayer( onOpen: () -> Unit, ) { val recent by prefs.history.collectAsState(initial = emptyList()) + val skipBackSec by prefs.skipBackSec.collectAsState(initial = stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC) + val skipFwdSec by prefs.skipFwdSec.collectAsState(initial = stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC) val shown = station ?: recent.firstOrNull() val inGrace = stream.kleeamp.mobile.player.rememberNavGraceSince( stream.kleeamp.mobile.playback.PlaybackBus.stationChangedAtMs, @@ -1100,8 +1102,12 @@ private fun ScopedMiniPlayer( hasNext = hasNext, positionMs = positionMs, durationMs = durationMs, + skipBackSec = skipBackSec, + skipFwdSec = skipFwdSec, onPrev = { player.prev() }, onNext = { player.next() }, + onSkipBack = { player.seekBy(-skipBackSec) }, + onSkipFwd = { player.seekBy(skipFwdSec) }, onOpenUpNext = onOpenUpNext, onToggle = { player.toggle(station ?: recent.firstOrNull()) }, onOpen = onOpen, diff --git a/android/app/src/main/java/stream/kleeamp/mobile/playback/PlaybackService.kt b/android/app/src/main/java/stream/kleeamp/mobile/playback/PlaybackService.kt index 876712b3..c7a90299 100644 --- a/android/app/src/main/java/stream/kleeamp/mobile/playback/PlaybackService.kt +++ b/android/app/src/main/java/stream/kleeamp/mobile/playback/PlaybackService.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -67,6 +68,8 @@ import stream.kleeamp.mobile.model.deEchoArtistLine import stream.kleeamp.mobile.model.wrapNext import stream.kleeamp.mobile.net.Http import stream.kleeamp.mobile.widget.WidgetRenderer +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC import stream.kleeamp.mobile.prefs.Prefs import stream.kleeamp.mobile.prefs.MaxBoostDb import stream.kleeamp.mobile.prefs.playSectionFor @@ -222,6 +225,13 @@ class PlaybackService : MediaSessionService() { ) .build() + player.setSeekBackIncrementMs(secondsToMs(DEFAULT_SKIP_BACK_SEC)) + player.setSeekForwardIncrementMs(secondsToMs(DEFAULT_SKIP_FWD_SEC)) + bindSeekIncrements(prefs0.skipBackSec, prefs0.skipFwdSec, scope) { backMs, fwdMs -> + player.setSeekBackIncrementMs(backMs) + player.setSeekForwardIncrementMs(fwdMs) + } + player.addListener(PlayerEvents()) player.addAnalyticsListener(FormatEvents()) @@ -283,9 +293,13 @@ class PlaybackService : MediaSessionService() { // favouriting on the lockscreen where they belong. scope.launch { val conn = (application as KleeampApp).player - combine(prefs0.favorites, conn.shuffle) { favs, shuffled -> - val url = PlaybackBus.station.value?.url - session?.setMediaButtonPreferences(buttons(favs.any { it.url == url }, shuffled)) + combine( + prefs0.favorites, + conn.shuffle, + PlaybackBus.station, + ) { favs, shuffled, station -> + val isFav = station?.let { s -> favs.any { it.url == s.url } } ?: false + session?.setMediaButtonPreferences(buttons(station, isFav, shuffled)) }.collect { } } @@ -338,40 +352,10 @@ class PlaybackService : MediaSessionService() { override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = session private fun buttons( + station: Station?, isFavourite: Boolean, shuffled: Boolean, - ): ImmutableList = ImmutableList.of( - CommandButton.Builder(CommandButton.ICON_PREVIOUS) - .setDisplayName("Previous") - .setIconUri(iconUri(R.drawable.ic_w_prev)) - .setSessionCommand(SessionCommand(CMD_PREV_STATION, Bundle.EMPTY)) - .build(), - CommandButton.Builder( - if (isFavourite) CommandButton.ICON_STAR_FILLED else CommandButton.ICON_STAR_UNFILLED - ) - .setDisplayName(if (isFavourite) "Remove favourite" else "Favourite") - // Belt and braces: the provider resolves the resource id while - // controllers that load artwork-style URIs take the content URI. - .setIconResId(if (isFavourite) R.drawable.ic_w_heart_filled else R.drawable.ic_w_heart) - .setIconUri(iconUri(if (isFavourite) R.drawable.ic_w_heart_filled else R.drawable.ic_w_heart)) - .setSessionCommand(SessionCommand(CMD_FAVOURITE, Bundle.EMPTY)) - .build(), - CommandButton.Builder( - if (shuffled) CommandButton.ICON_SHUFFLE_ON else CommandButton.ICON_SHUFFLE_OFF - ) - .setDisplayName(if (shuffled) "Stop shuffling" else "Shuffle") - // Same Shuffle glyph as the in-app icon; the on variant carries - // the filled dot under it, mirroring the hero ShuffleAction. - .setIconResId(if (shuffled) R.drawable.ic_w_shuffle_on else R.drawable.ic_w_shuffle) - .setIconUri(iconUri(if (shuffled) R.drawable.ic_w_shuffle_on else R.drawable.ic_w_shuffle)) - .setSessionCommand(SessionCommand(CMD_SHUFFLE, Bundle.EMPTY)) - .build(), - CommandButton.Builder(CommandButton.ICON_NEXT) - .setDisplayName("Next") - .setIconUri(iconUri(R.drawable.ic_w_next)) - .setSessionCommand(SessionCommand(CMD_NEXT_STATION, Bundle.EMPTY)) - .build(), - ) + ): ImmutableList = buildMediaButtons(station, isFavourite, shuffled, ::iconUri) /** android.resource URIs resolve cross-process in SystemUI, like res ids did. */ private fun iconUri(@DrawableRes resId: Int): Uri = @@ -400,10 +384,15 @@ class PlaybackService : MediaSessionService() { .add(SessionCommand(CMD_FAVOURITE, Bundle.EMPTY)) .add(SessionCommand(CMD_SHUFFLE, Bundle.EMPTY)) .build() + val station = PlaybackBus.station.value + val isFav = station?.let { s -> + runBlocking { prefs0.favorites.first().any { it.url == s.url } } + } ?: false + val shuffled = (application as KleeampApp).player.shuffle.value return MediaSession.ConnectionResult.AcceptedResultBuilder(session, controller) .setAvailableSessionCommands(commands) .setAvailablePlayerCommands(playerCommands) - .setMediaButtonPreferences(buttons(false, false)) + .setMediaButtonPreferences(buttons(station, isFav, shuffled)) .build() } @@ -572,6 +561,7 @@ class PlaybackService : MediaSessionService() { val shuffled = (application as KleeampApp).player.shuffle.value session?.setMediaButtonPreferences( buttons( + station, favs.any { it.url == station?.url }, shuffled, ) @@ -997,3 +987,85 @@ internal fun effectiveBoostDb( if (!scoped || station == null) return global.coerceIn(0, MaxBoostDb) return (sections[playSectionFor(station.source).name] ?: global).coerceIn(0, MaxBoostDb) } + +/** Convert seek duration in seconds to milliseconds for ExoPlayer seek increments. */ +internal fun secondsToMs(seconds: Int): Long = seconds * 1000L + +private fun emptyBundle(): Bundle = Bundle.EMPTY ?: Bundle() + +/** + * Builds the 4-button media session row for notifications, lock screen, and external controls. + * Podcast stations use Skip Back (Rewind) and Skip Forward buttons, while all other sources + * (Radio, Local music, Provider tracks) retain Previous and Next station buttons. + */ +@UnstableApi +internal fun buildMediaButtons( + station: Station?, + isFavourite: Boolean, + shuffled: Boolean, + iconUriProvider: (Int) -> Uri?, +): ImmutableList { + val bundle = emptyBundle() + val favRes = if (isFavourite) R.drawable.ic_w_heart_filled else R.drawable.ic_w_heart + val favBuilder = CommandButton.Builder( + if (isFavourite) CommandButton.ICON_STAR_FILLED else CommandButton.ICON_STAR_UNFILLED + ) + .setDisplayName(if (isFavourite) "Remove favourite" else "Favourite") + .setIconResId(favRes) + .setSessionCommand(SessionCommand(PlaybackService.CMD_FAVOURITE, bundle)) + iconUriProvider(favRes)?.let { favBuilder.setIconUri(it) } + val favBtn = favBuilder.build() + + val shuffleRes = if (shuffled) R.drawable.ic_w_shuffle_on else R.drawable.ic_w_shuffle + val shuffleBuilder = CommandButton.Builder( + if (shuffled) CommandButton.ICON_SHUFFLE_ON else CommandButton.ICON_SHUFFLE_OFF + ) + .setDisplayName(if (shuffled) "Stop shuffling" else "Shuffle") + .setIconResId(shuffleRes) + .setSessionCommand(SessionCommand(PlaybackService.CMD_SHUFFLE, bundle)) + iconUriProvider(shuffleRes)?.let { shuffleBuilder.setIconUri(it) } + val shuffleBtn = shuffleBuilder.build() + + val (leftBtn, rightBtn) = if (station?.source == StationSource.Podcast) { + val rewind = CommandButton.Builder(CommandButton.ICON_REWIND) + .setDisplayName("Skip back") + .setPlayerCommand(Player.COMMAND_SEEK_BACK) + .build() + val forward = CommandButton.Builder(CommandButton.ICON_FAST_FORWARD) + .setDisplayName("Skip forward") + .setPlayerCommand(Player.COMMAND_SEEK_FORWARD) + .build() + rewind to forward + } else { + val prevBuilder = CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setDisplayName("Previous") + .setSessionCommand(SessionCommand(PlaybackService.CMD_PREV_STATION, bundle)) + iconUriProvider(R.drawable.ic_w_prev)?.let { prevBuilder.setIconUri(it) } + + val nextBuilder = CommandButton.Builder(CommandButton.ICON_NEXT) + .setDisplayName("Next") + .setSessionCommand(SessionCommand(PlaybackService.CMD_NEXT_STATION, bundle)) + iconUriProvider(R.drawable.ic_w_next)?.let { nextBuilder.setIconUri(it) } + + prevBuilder.build() to nextBuilder.build() + } + + return ImmutableList.of(leftBtn, favBtn, shuffleBtn, rightBtn) +} + +/** + * Keeps player seek back/forward increments aligned with DataStore preferences. + * Runs in [scope] and updates [onIncrementsChanged] whenever either duration changes. + */ +internal fun bindSeekIncrements( + skipBackFlow: Flow, + skipFwdFlow: Flow, + scope: CoroutineScope, + onIncrementsChanged: (backMs: Long, fwdMs: Long) -> Unit, +): Job = scope.launch { + combine(skipBackFlow, skipFwdFlow) { back, fwd -> + back to fwd + }.collect { (back, fwd) -> + onIncrementsChanged(secondsToMs(back), secondsToMs(fwd)) + } +} diff --git a/android/app/src/main/java/stream/kleeamp/mobile/playback/PlayerConnection.kt b/android/app/src/main/java/stream/kleeamp/mobile/playback/PlayerConnection.kt index d4f02ad1..2f1b9d35 100644 --- a/android/app/src/main/java/stream/kleeamp/mobile/playback/PlayerConnection.kt +++ b/android/app/src/main/java/stream/kleeamp/mobile/playback/PlayerConnection.kt @@ -58,6 +58,9 @@ private const val FIRED_SEEK_HOLD_MS = 2_000L /** Close enough to the fired target to call the seek landed. */ private const val SEEK_LANDED_TOLERANCE_MS = 1_500L +/** Minimum flight time before a fired seek can be declared landed by tolerance. */ +private const val MIN_SEEK_FLIGHT_MS = 300L + /** * Which clock the UI reads this tick. Pure so unit tests can pin the * priority: a primed/fired seek target wins over the player's still-settling @@ -101,6 +104,24 @@ internal fun seekByTargetMs(positionMs: Long, durationMs: Long, deltaSec: Int): return (positionMs + deltaSec * 1000L).coerceIn(0L, end) } +/** + * Determines the playback position to seek from. Prioritizes active pending + * and in-flight fired seek targets over stale player clock readings during + * rapid skip taps. Pure so it can be unit-tested on the JVM. + */ +internal fun resolveSeekBasePosition( + pendingTargetMs: Long?, + firedTargetMs: Long?, + rawPositionMs: Long?, + itemMatchesStation: Boolean, + statePositionMs: Long, +): Long = when { + pendingTargetMs != null -> pendingTargetMs.coerceAtLeast(0L) + firedTargetMs != null -> firedTargetMs.coerceAtLeast(0L) + rawPositionMs != null && rawPositionMs >= 0L && itemMatchesStation -> rawPositionMs + else -> statePositionMs.coerceAtLeast(0L) +} + /** * The UI's handle on playback. Transport goes through a MediaController rather * than straight to the ExoPlayer, so the app, the notification and any @@ -427,7 +448,10 @@ class PlayerConnection( firedSeekStationId = null } var firedTarget = firedSeekTargetMs - if (firedTarget != null && kotlin.math.abs(rawPosition - firedTarget) < SEEK_LANDED_TOLERANCE_MS) { + if (firedTarget != null && + nowMs - firedSeekAtMs >= MIN_SEEK_FLIGHT_MS && + kotlin.math.abs(rawPosition - firedTarget) < SEEK_LANDED_TOLERANCE_MS + ) { firedTarget = null firedSeekTargetMs = null firedSeekStationId = null @@ -609,15 +633,24 @@ class PlayerConnection( if (!station.isTrack) return val c = controller val d = c?.duration?.takeIf { it != C.TIME_UNSET && it > 0 } + ?: _state.value.durationMs.takeIf { it > 0 && station.id == prevStationId } ?: station.durationMs.takeIf { it > 0 } - ?: run { - // No duration yet: prime the middle so a skip still wakes - // the track instead of no-oping; sync refines nothing here. - primeSeek(station, 0.5f) - return - } - val pos = (c?.currentPosition?.takeIf { it > 0 } ?: _state.value.positionMs) - .coerceAtLeast(0) + ?: return + val nowMs = System.currentTimeMillis() + val pendingTarget = pendingSeekFraction + ?.takeIf { pendingSeekStationId == station.id } + ?.let { seekTargetMs(d, it) } + val firedTarget = firedSeekTargetMs + ?.takeIf { firedSeekStationId == station.id && nowMs - firedSeekAtMs < FIRED_SEEK_HOLD_MS } + val rawPos = c?.currentPosition?.takeIf { it >= 0 } + val itemMatches = c?.currentMediaItem?.mediaId == station.id + val pos = resolveSeekBasePosition( + pendingTargetMs = pendingTarget, + firedTargetMs = firedTarget, + rawPositionMs = rawPos, + itemMatchesStation = itemMatches, + statePositionMs = _state.value.positionMs, + ) val target = seekByTargetMs(pos, d, seconds) ?: return primeSeek(station, target.toFloat() / d) } @@ -636,11 +669,12 @@ class PlayerConnection( play(station) return } - if (!applyPendingSeek(c)) sync() + applyPendingSeek(c) + sync() } /** Applies a primed seek when the player can take it. True when fired. */ - @Suppress("ReturnCount") + @Suppress("ReturnCount", "CyclomaticComplexMethod") private fun applyPendingSeek(c: Player): Boolean { val pending = pendingSeekFraction ?: return false val pendingId = pendingSeekStationId ?: return false @@ -667,6 +701,7 @@ class PlayerConnection( return false } val d = c.duration.takeIf { it != C.TIME_UNSET && it > 0 } + ?: _state.value.durationMs.takeIf { it > 0 && stationNow.id == prevStationId } ?: stationNow.durationMs.takeIf { it > 0 } ?: return false val target = seekTargetMs(d, pending) diff --git a/android/app/src/main/java/stream/kleeamp/mobile/player/MiniPlayer.kt b/android/app/src/main/java/stream/kleeamp/mobile/player/MiniPlayer.kt index 6bbd268b..a5dae6d3 100644 --- a/android/app/src/main/java/stream/kleeamp/mobile/player/MiniPlayer.kt +++ b/android/app/src/main/java/stream/kleeamp/mobile/player/MiniPlayer.kt @@ -45,6 +45,8 @@ import stream.kleeamp.mobile.art.SeedPlate import stream.kleeamp.mobile.model.Station import stream.kleeamp.mobile.art.StationArtSource import stream.kleeamp.mobile.model.StationSource +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC import stream.kleeamp.mobile.theme.KleeampShape import stream.kleeamp.mobile.theme.KleeampType import stream.kleeamp.mobile.theme.LocalPalette @@ -52,6 +54,7 @@ import stream.kleeamp.mobile.theme.Mono /** The lockscreen widget's in-app twin: art plate, one key. */ @Composable +@Suppress("LongParameterList") fun MiniPlayer( station: Station?, streamTitle: String, @@ -63,8 +66,12 @@ fun MiniPlayer( /** Elapsed/duration ms: paints the position hairline on tracks. */ positionMs: Long = 0L, durationMs: Long = 0L, + skipBackSec: Int = DEFAULT_SKIP_BACK_SEC, + skipFwdSec: Int = DEFAULT_SKIP_FWD_SEC, onPrev: () -> Unit = {}, onNext: () -> Unit = {}, + onSkipBack: () -> Unit = {}, + onSkipFwd: () -> Unit = {}, onOpenUpNext: () -> Unit, onToggle: () -> Unit, onOpen: () -> Unit, @@ -97,7 +104,7 @@ fun MiniPlayer( // Position hairline along the bar's bottom edge on tracks with // a known length: overlay only, so the bar measures exactly // as before with or without it. - if (!empty && station?.isTrack == true && durationMs > 0) { + if (!empty && station.isTrack && durationMs > 0) { val done = (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) Box( Modifier @@ -148,16 +155,27 @@ fun MiniPlayer( .microPress(onClick = onOpenUpNext), tint = p.ink, ) + val isPodcast = station?.source == StationSource.Podcast Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - MiniKey( - icon = KleeampIcons.Prev, - label = "previous", - enabled = hasPrev, - onClick = onPrev, - ) + if (isPodcast) { + MiniKey( + icon = KleeampIcons.Rewind, + label = miniKeyLabel(MiniKeyKind.SkipBack, skipBackSec, skipFwdSec), + enabled = true, + onClick = onSkipBack, + iconModifier = Modifier.size(13.dp), + ) + } else { + MiniKey( + icon = KleeampIcons.Prev, + label = miniKeyLabel(MiniKeyKind.Prev, skipBackSec, skipFwdSec), + enabled = hasPrev, + onClick = onPrev, + ) + } Box( Modifier .size(38.dp) @@ -173,12 +191,22 @@ fun MiniPlayer( tint = if (p.dark) p.onAccent else p.ground, ) } - MiniKey( - icon = KleeampIcons.Next, - label = "next", - enabled = hasNext, - onClick = onNext, - ) + if (isPodcast) { + MiniKey( + icon = KleeampIcons.Forward, + label = miniKeyLabel(MiniKeyKind.SkipForward, skipBackSec, skipFwdSec), + enabled = true, + onClick = onSkipFwd, + iconModifier = Modifier.size(13.dp), + ) + } else { + MiniKey( + icon = KleeampIcons.Next, + label = miniKeyLabel(MiniKeyKind.Next, skipBackSec, skipFwdSec), + enabled = hasNext, + onClick = onNext, + ) + } } } } @@ -223,12 +251,45 @@ private fun Modifier.swipeToStep(onPrev: () -> Unit, onNext: () -> Unit): Modifi ) } -/** A small prev/next key for the mini bar transport cluster. */@Composable +/** Kind of key shown on the left/right flanks of the mini bar transport cluster. */ +internal enum class MiniKeyKind { + Prev, + Next, + SkipBack, + SkipForward, +} + +/** Pure helper to determine left key type. */ +internal fun miniLeftKeyKind(station: Station?): MiniKeyKind = + if (station?.source == StationSource.Podcast) MiniKeyKind.SkipBack else MiniKeyKind.Prev + +/** Pure helper to determine right key type. */ +internal fun miniRightKeyKind(station: Station?): MiniKeyKind = + if (station?.source == StationSource.Podcast) MiniKeyKind.SkipForward else MiniKeyKind.Next + +/** Pure helper to format accessible content description. */ +internal fun miniKeyLabel(kind: MiniKeyKind, skipBackSec: Int, skipFwdSec: Int): String = when (kind) { + MiniKeyKind.Prev -> "previous" + MiniKeyKind.Next -> "next" + MiniKeyKind.SkipBack -> "skip back $skipBackSec seconds" + MiniKeyKind.SkipForward -> "skip forward $skipFwdSec seconds" +} + +/** Pure helper for seek delta seconds. */ +internal fun miniKeySeekDelta(kind: MiniKeyKind, skipBackSec: Int, skipFwdSec: Int): Int? = when (kind) { + MiniKeyKind.SkipBack -> -skipBackSec + MiniKeyKind.SkipForward -> skipFwdSec + MiniKeyKind.Prev, MiniKeyKind.Next -> null +} + +/** A small prev/next/skip key for the mini bar transport cluster. */ +@Composable private fun MiniKey( icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, enabled: Boolean, onClick: () -> Unit, + iconModifier: Modifier = Modifier.size(width = 14.dp, height = 11.dp), ) { val p = LocalPalette.current Row( @@ -244,7 +305,7 @@ private fun MiniKey( Icon( icon, label, - Modifier.size(width = 14.dp, height = 11.dp), + iconModifier, tint = if (enabled) p.ink else p.inkFaint, ) } diff --git a/android/app/src/main/java/stream/kleeamp/mobile/player/NowPlayingViewModel.kt b/android/app/src/main/java/stream/kleeamp/mobile/player/NowPlayingViewModel.kt index 2932e633..ede46362 100644 --- a/android/app/src/main/java/stream/kleeamp/mobile/player/NowPlayingViewModel.kt +++ b/android/app/src/main/java/stream/kleeamp/mobile/player/NowPlayingViewModel.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.launch import stream.kleeamp.mobile.common.stateInUi import stream.kleeamp.mobile.playback.effectiveBoostDb import stream.kleeamp.mobile.prefs.Prefs +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC import stream.kleeamp.mobile.prefs.playSectionFor import stream.kleeamp.mobile.model.Station import stream.kleeamp.mobile.playback.PlaybackBus @@ -39,8 +41,8 @@ class NowPlayingViewModel( val boostSupported: Boolean = false, val boostSections: Boolean = true, val showSkipKeys: Boolean = false, - val skipBackSec: Int = 15, - val skipFwdSec: Int = 30, + val skipBackSec: Int = DEFAULT_SKIP_BACK_SEC, + val skipFwdSec: Int = DEFAULT_SKIP_FWD_SEC, ) sealed interface Event { @@ -66,8 +68,8 @@ class NowPlayingViewModel( /** Skip-key durations, chained onto [LibState] below. */ private data class SkipKeysState( - val backSec: Int = 15, - val fwdSec: Int = 30, + val backSec: Int = DEFAULT_SKIP_BACK_SEC, + val fwdSec: Int = DEFAULT_SKIP_FWD_SEC, ) /** Library prefs and derived flags that dress the transport. */ @@ -80,8 +82,8 @@ class NowPlayingViewModel( val speedSections: Boolean = true, val boostSections: Boolean = true, val boostMaps: Map = emptyMap(), - val skipBackSec: Int = 15, - val skipFwdSec: Int = 30, + val skipBackSec: Int = DEFAULT_SKIP_BACK_SEC, + val skipFwdSec: Int = DEFAULT_SKIP_FWD_SEC, ) val state: StateFlow = combine( diff --git a/android/app/src/main/java/stream/kleeamp/mobile/prefs/Prefs.kt b/android/app/src/main/java/stream/kleeamp/mobile/prefs/Prefs.kt index 5f0056db..bebcf638 100644 --- a/android/app/src/main/java/stream/kleeamp/mobile/prefs/Prefs.kt +++ b/android/app/src/main/java/stream/kleeamp/mobile/prefs/Prefs.kt @@ -81,11 +81,11 @@ fun playSectionFor(source: StationSource): PlaySection = when (source) { const val MaxBoostDb = 12 /** Skip-key durations on offer, seconds. */ -val SKIP_DURATIONS = listOf(5, 10, 15, 30, 60) +val SKIP_DURATIONS = listOf(10, 15, 20, 30, 60, 90) /** Out-of-the-box skip seconds: rewind, forward. */ -const val DEFAULT_SKIP_BACK_SEC = 15 -const val DEFAULT_SKIP_FWD_SEC = 30 +const val DEFAULT_SKIP_BACK_SEC = 10 +const val DEFAULT_SKIP_FWD_SEC = 10 /** * Settings only. @@ -242,11 +242,11 @@ class Prefs(private val context: Context) { } /** Whether each section keeps its own boost. On by default, like speed. */ val boostPerSection: Flow = context.settingsStore.data.map { it[K.boostPerSection] ?: true } - /** Rewind seconds for the skip-back key; one of 5/10/15/30/60. */ + /** Rewind seconds for the skip-back key; one of 10/15/20/30/60/90. */ val skipBackSec: Flow = context.settingsStore.data.map { (it[K.skipBackSec] ?: DEFAULT_SKIP_BACK_SEC).let { v -> if (v in SKIP_DURATIONS) v else DEFAULT_SKIP_BACK_SEC } } - /** Forward seconds for the skip-forward key; one of 5/10/15/30/60. */ + /** Forward seconds for the skip-forward key; one of 10/15/20/30/60/90. */ val skipFwdSec: Flow = context.settingsStore.data.map { (it[K.skipFwdSec] ?: DEFAULT_SKIP_FWD_SEC).let { v -> if (v in SKIP_DURATIONS) v else DEFAULT_SKIP_FWD_SEC } } diff --git a/android/app/src/main/java/stream/kleeamp/mobile/settings/SettingsViewModel.kt b/android/app/src/main/java/stream/kleeamp/mobile/settings/SettingsViewModel.kt index 50a82af1..1e683441 100644 --- a/android/app/src/main/java/stream/kleeamp/mobile/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/stream/kleeamp/mobile/settings/SettingsViewModel.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.launch import stream.kleeamp.mobile.radio.DirectoryStats import stream.kleeamp.mobile.common.stateInUi import stream.kleeamp.mobile.prefs.Prefs +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC import stream.kleeamp.mobile.podcasts.CleanupScope import stream.kleeamp.mobile.radio.RadioRepository import stream.kleeamp.mobile.model.Station @@ -41,8 +43,8 @@ class SettingsViewModel( val resumeLocal: Boolean = false, val speedPerSection: Boolean = true, val boostPerSection: Boolean = true, - val skipBackSec: Int = 15, - val skipFwdSec: Int = 30, + val skipBackSec: Int = DEFAULT_SKIP_BACK_SEC, + val skipFwdSec: Int = DEFAULT_SKIP_FWD_SEC, val listenBrainzOn: Boolean = false, val scrobbleRadio: Boolean = false, val scrobblePending: Int = 0, @@ -103,8 +105,8 @@ class SettingsViewModel( val parallelDownloads: Int = 3, val speedPerSection: Boolean = true, val boostPerSection: Boolean = true, - val skipBackSec: Int = 15, - val skipFwdSec: Int = 30, + val skipBackSec: Int = DEFAULT_SKIP_BACK_SEC, + val skipFwdSec: Int = DEFAULT_SKIP_FWD_SEC, ) /** Library counts, scrobble flag and directory stats. */ diff --git a/android/app/src/test/java/stream/kleeamp/mobile/play/TransportEditTest.kt b/android/app/src/test/java/stream/kleeamp/mobile/play/TransportEditTest.kt index 3f84de49..02af89e3 100644 --- a/android/app/src/test/java/stream/kleeamp/mobile/play/TransportEditTest.kt +++ b/android/app/src/test/java/stream/kleeamp/mobile/play/TransportEditTest.kt @@ -12,10 +12,16 @@ class TransportEditTest { @Test fun seekByMovesForward() { assertEquals(45_000L, seekByTargetMs(15_000L, 120_000L, 30)) + assertEquals(35_000L, seekByTargetMs(15_000L, 120_000L, 20)) + assertEquals(75_000L, seekByTargetMs(15_000L, 120_000L, 60)) + assertEquals(105_000L, seekByTargetMs(15_000L, 120_000L, 90)) } @Test fun seekByMovesBack() { assertEquals(5_000L, seekByTargetMs(15_000L, 120_000L, -10)) + assertEquals(10_000L, seekByTargetMs(30_000L, 120_000L, -20)) + assertEquals(30_000L, seekByTargetMs(90_000L, 120_000L, -60)) + assertEquals(10_000L, seekByTargetMs(100_000L, 120_000L, -90)) } @Test fun seekByClampsToZero() { diff --git a/android/app/src/test/java/stream/kleeamp/mobile/playback/MediaSessionControlsTest.kt b/android/app/src/test/java/stream/kleeamp/mobile/playback/MediaSessionControlsTest.kt new file mode 100644 index 00000000..87b68d11 --- /dev/null +++ b/android/app/src/test/java/stream/kleeamp/mobile/playback/MediaSessionControlsTest.kt @@ -0,0 +1,249 @@ +package stream.kleeamp.mobile.playback + +import android.net.Uri +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import stream.kleeamp.mobile.model.Station +import stream.kleeamp.mobile.model.StationSource +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC +import stream.kleeamp.mobile.prefs.SKIP_DURATIONS + +@UnstableApi +class MediaSessionControlsTest { + + private fun station( + source: StationSource, + id: String = "test-id", + name: String = "test-station", + url: String = "http://example.com/audio", + ) = Station( + id = id, + name = name, + url = url, + source = source, + ) + + private val testIconUriProvider: (Int) -> Uri? = { null } + + @Test + fun secondsToMsConvertsAllSupportedDurations() { + for (sec in SKIP_DURATIONS) { + assertEquals(sec * 1000L, secondsToMs(sec)) + } + assertEquals(0L, secondsToMs(0)) + assertEquals(10_000L, secondsToMs(10)) + assertEquals(15_000L, secondsToMs(15)) + assertEquals(20_000L, secondsToMs(20)) + assertEquals(30_000L, secondsToMs(30)) + assertEquals(60_000L, secondsToMs(60)) + assertEquals(90_000L, secondsToMs(90)) + } + + @Test + fun defaultSkipIncrementsMatchDefaults() { + assertEquals(10_000L, secondsToMs(DEFAULT_SKIP_BACK_SEC)) + assertEquals(10_000L, secondsToMs(DEFAULT_SKIP_FWD_SEC)) + } + + @Test + fun podcastExposesSeekBackAndSeekForwardCommandButtons() { + val podcast = station(StationSource.Podcast) + val buttons = buildMediaButtons( + station = podcast, + isFavourite = false, + shuffled = false, + iconUriProvider = testIconUriProvider, + ) + + assertEquals(4, buttons.size) + + // Left button: Skip back + val left = buttons[0] + assertEquals(CommandButton.ICON_REWIND, left.icon) + assertEquals("Skip back", left.displayName) + assertEquals(Player.COMMAND_SEEK_BACK, left.playerCommand) + assertNull(left.sessionCommand) + + // Right button: Skip forward + val right = buttons[3] + assertEquals(CommandButton.ICON_FAST_FORWARD, right.icon) + assertEquals("Skip forward", right.displayName) + assertEquals(Player.COMMAND_SEEK_FORWARD, right.playerCommand) + assertNull(right.sessionCommand) + } + + @Test + fun radioExposesPreviousAndNextSessionCommandButtons() { + val radio = station(StationSource.Directory) + val buttons = buildMediaButtons( + station = radio, + isFavourite = false, + shuffled = false, + iconUriProvider = testIconUriProvider, + ) + + assertEquals(4, buttons.size) + + // Left button: Previous + val left = buttons[0] + assertEquals(CommandButton.ICON_PREVIOUS, left.icon) + assertEquals("Previous", left.displayName) + assertNotNull(left.sessionCommand) + assertEquals(PlaybackService.CMD_PREV_STATION, left.sessionCommand?.customAction) + + // Right button: Next + val right = buttons[3] + assertEquals(CommandButton.ICON_NEXT, right.icon) + assertEquals("Next", right.displayName) + assertNotNull(right.sessionCommand) + assertEquals(PlaybackService.CMD_NEXT_STATION, right.sessionCommand?.customAction) + } + + @Test + fun nonPodcastSourcesRetainPreviousAndNextButtons() { + val nonPodcastSources = listOf( + StationSource.Directory, + StationSource.Custom, + StationSource.Local, + StationSource.Provider, + StationSource.Cliamp, + ) + + for (source in nonPodcastSources) { + val buttons = buildMediaButtons( + station = station(source), + isFavourite = false, + shuffled = false, + iconUriProvider = testIconUriProvider, + ) + assertEquals("Source $source must have Previous left button", + PlaybackService.CMD_PREV_STATION, buttons[0].sessionCommand?.customAction) + assertEquals("Source $source must have Next right button", + PlaybackService.CMD_NEXT_STATION, buttons[3].sessionCommand?.customAction) + } + } + + @Test + fun nullStationRetainsPreviousAndNextButtonsSafely() { + val buttons = buildMediaButtons( + station = null, + isFavourite = false, + shuffled = false, + iconUriProvider = testIconUriProvider, + ) + + assertEquals(4, buttons.size) + assertEquals(PlaybackService.CMD_PREV_STATION, buttons[0].sessionCommand?.customAction) + assertEquals(PlaybackService.CMD_NEXT_STATION, buttons[3].sessionCommand?.customAction) + } + + @Test + fun favouriteAndShuffleFlagsReflectedAccurately() { + val unfavUnshuffled = buildMediaButtons( + station = station(StationSource.Podcast), + isFavourite = false, + shuffled = false, + iconUriProvider = testIconUriProvider, + ) + assertEquals(CommandButton.ICON_STAR_UNFILLED, unfavUnshuffled[1].icon) + assertEquals("Favourite", unfavUnshuffled[1].displayName) + assertEquals(PlaybackService.CMD_FAVOURITE, unfavUnshuffled[1].sessionCommand?.customAction) + assertEquals(CommandButton.ICON_SHUFFLE_OFF, unfavUnshuffled[2].icon) + assertEquals("Shuffle", unfavUnshuffled[2].displayName) + assertEquals(PlaybackService.CMD_SHUFFLE, unfavUnshuffled[2].sessionCommand?.customAction) + + val favShuffled = buildMediaButtons( + station = station(StationSource.Directory), + isFavourite = true, + shuffled = true, + iconUriProvider = testIconUriProvider, + ) + assertEquals(CommandButton.ICON_STAR_FILLED, favShuffled[1].icon) + assertEquals("Remove favourite", favShuffled[1].displayName) + assertEquals(PlaybackService.CMD_FAVOURITE, favShuffled[1].sessionCommand?.customAction) + assertEquals(CommandButton.ICON_SHUFFLE_ON, favShuffled[2].icon) + assertEquals("Stop shuffling", favShuffled[2].displayName) + assertEquals(PlaybackService.CMD_SHUFFLE, favShuffled[2].sessionCommand?.customAction) + } + + @Test + fun stationTransitionTogglesBetweenSkipAndStepButtons() { + val radioButtons = buildMediaButtons(station(StationSource.Directory), false, false, testIconUriProvider) + assertEquals(PlaybackService.CMD_PREV_STATION, radioButtons[0].sessionCommand?.customAction) + assertEquals(PlaybackService.CMD_NEXT_STATION, radioButtons[3].sessionCommand?.customAction) + + val podcastButtons = buildMediaButtons(station(StationSource.Podcast), false, false, testIconUriProvider) + assertEquals(Player.COMMAND_SEEK_BACK, podcastButtons[0].playerCommand) + assertEquals(Player.COMMAND_SEEK_FORWARD, podcastButtons[3].playerCommand) + + val localButtons = buildMediaButtons(station(StationSource.Local), false, false, testIconUriProvider) + assertEquals(PlaybackService.CMD_PREV_STATION, localButtons[0].sessionCommand?.customAction) + assertEquals(PlaybackService.CMD_NEXT_STATION, localButtons[3].sessionCommand?.customAction) + } + + @Test + fun bindSeekIncrementsUpdatesDynamically() = runTest { + val backFlow = MutableStateFlow(10) + val fwdFlow = MutableStateFlow(10) + val updates = mutableListOf>() + + val job = bindSeekIncrements(backFlow, fwdFlow, this) { b, f -> + updates.add(b to f) + } + testScheduler.advanceUntilIdle() + + assertEquals(1, updates.size) + assertEquals(10_000L to 10_000L, updates.last()) + + // Change skip backward to 30s + backFlow.value = 30 + testScheduler.advanceUntilIdle() + assertEquals(2, updates.size) + assertEquals(30_000L to 10_000L, updates.last()) + + // Change skip forward to 60s + fwdFlow.value = 60 + testScheduler.advanceUntilIdle() + assertEquals(3, updates.size) + assertEquals(30_000L to 60_000L, updates.last()) + + // Scope cancellation stops updates + job.cancel() + backFlow.value = 90 + testScheduler.advanceUntilIdle() + assertEquals(3, updates.size) + } + + @Test + fun bindSeekIncrementsHandlesRapidPreferenceUpdates() = runTest { + val backFlow = MutableStateFlow(10) + val fwdFlow = MutableStateFlow(10) + val updates = mutableListOf>() + + val job = bindSeekIncrements(backFlow, fwdFlow, this) { b, f -> + updates.add(b to f) + } + testScheduler.advanceUntilIdle() + + // Rapid emissions across all durations + for (duration in SKIP_DURATIONS) { + backFlow.value = duration + fwdFlow.value = duration + } + testScheduler.advanceUntilIdle() + + assertEquals(90_000L to 90_000L, updates.last()) + job.cancel() + } +} diff --git a/android/app/src/test/java/stream/kleeamp/mobile/playback/SeekTargetTest.kt b/android/app/src/test/java/stream/kleeamp/mobile/playback/SeekTargetTest.kt index a131f940..a1d8bddf 100644 --- a/android/app/src/test/java/stream/kleeamp/mobile/playback/SeekTargetTest.kt +++ b/android/app/src/test/java/stream/kleeamp/mobile/playback/SeekTargetTest.kt @@ -58,4 +58,234 @@ class SeekTargetTest { // Live player clock wins once it matches the station. assertEquals(10_000L, resolveKeptPosition(10_000L, true, true, 5_000L, null, null)) } + + @Test + fun resolveSeekBasePositionPrioritizesPendingTarget() { + val base = resolveSeekBasePosition( + pendingTargetMs = 45_000L, + firedTargetMs = 30_000L, + rawPositionMs = 15_000L, + itemMatchesStation = true, + statePositionMs = 15_000L, + ) + assertEquals(45_000L, base) + } + + @Test + fun resolveSeekBasePositionPrioritizesFiredTargetOverStaleClock() { + // Rapid skip tap: fired target must beat player's settling/stale clock + val base = resolveSeekBasePosition( + pendingTargetMs = null, + firedTargetMs = 30_000L, + rawPositionMs = 10_000L, + itemMatchesStation = true, + statePositionMs = 10_000L, + ) + assertEquals(30_000L, base) + } + + @Test + fun resolveSeekBasePositionUsesRawPositionWhenItemMatches() { + val base = resolveSeekBasePosition( + pendingTargetMs = null, + firedTargetMs = null, + rawPositionMs = 15_000L, + itemMatchesStation = true, + statePositionMs = 0L, + ) + assertEquals(15_000L, base) + + // Even at position zero + val atZero = resolveSeekBasePosition( + pendingTargetMs = null, + firedTargetMs = null, + rawPositionMs = 0L, + itemMatchesStation = true, + statePositionMs = 50_000L, + ) + assertEquals(0L, atZero) + } + + @Test + fun resolveSeekBasePositionIgnoresStaleItemClockOnTransition() { + // Station changed, player still holds previous item + val base = resolveSeekBasePosition( + pendingTargetMs = null, + firedTargetMs = null, + rawPositionMs = 50_000L, + itemMatchesStation = false, + statePositionMs = 0L, + ) + assertEquals(0L, base) + } + + @Test + fun resolveSeekBasePositionNeverReturnsNegative() { + assertEquals(0L, resolveSeekBasePosition(null, null, -5L, true, -10L)) + assertEquals(0L, resolveSeekBasePosition(-5L, null, null, false, 0L)) + assertEquals(0L, resolveSeekBasePosition(null, -10L, null, false, 0L)) + } + + @Test + fun multipleRapidForwardSkipsAccumulateCorrectly() { + // Start at 10s into a 120s episode + val durationMs = 120_000L + val rawPlayerClock = 10_000L // Stale player clock doesn't change during rapid taps + var firedTarget: Long? = null + + // Tap 1 (+10s) + val base1 = resolveSeekBasePosition(null, firedTarget, rawPlayerClock, true, rawPlayerClock) + val target1 = seekByTargetMs(base1, durationMs, 10)!! + assertEquals(20_000L, target1) + firedTarget = target1 + + // Tap 2 (+10s, rawPlayerClock still 10_000L) + val base2 = resolveSeekBasePosition(null, firedTarget, rawPlayerClock, true, rawPlayerClock) + val target2 = seekByTargetMs(base2, durationMs, 10)!! + assertEquals(30_000L, target2) + firedTarget = target2 + + // Tap 3 (+10s, rawPlayerClock still 10_000L) + val base3 = resolveSeekBasePosition(null, firedTarget, rawPlayerClock, true, rawPlayerClock) + val target3 = seekByTargetMs(base3, durationMs, 10)!! + assertEquals(40_000L, target3) + + // 3x 10s forward skips target exactly 30s ahead + assertEquals(30_000L, target3 - 10_000L) + } + + @Test + fun multipleRapidBackwardSkipsAccumulateCorrectly() { + val durationMs = 120_000L + val initialPos = 50_000L + val rawPlayerClock = initialPos + var firedTarget: Long? = null + + // Tap 1 (-10s) + val base1 = resolveSeekBasePosition(null, firedTarget, rawPlayerClock, true, rawPlayerClock) + val target1 = seekByTargetMs(base1, durationMs, -10)!! + assertEquals(40_000L, target1) + firedTarget = target1 + + // Tap 2 (-10s, rawPlayerClock still 50_000L) + val base2 = resolveSeekBasePosition(null, firedTarget, rawPlayerClock, true, rawPlayerClock) + val target2 = seekByTargetMs(base2, durationMs, -10)!! + assertEquals(30_000L, target2) + firedTarget = target2 + + // Tap 3 (-10s, rawPlayerClock still 50_000L) + val base3 = resolveSeekBasePosition(null, firedTarget, rawPlayerClock, true, rawPlayerClock) + val target3 = seekByTargetMs(base3, durationMs, -10)!! + assertEquals(20_000L, target3) + + // 3x 10s backward skips target exactly 30s behind + assertEquals(30_000L, initialPos - target3) + } + + @Test + fun mixedRapidForwardAndBackwardSkips() { + val durationMs = 120_000L + val initialPos = 30_000L + val staleClock = initialPos + var firedTarget: Long? = null + + // Tap 1: +15s -> 45s + var base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, 15)!! + assertEquals(45_000L, firedTarget) + + // Tap 2: +20s -> 65s + base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, 20)!! + assertEquals(65_000L, firedTarget) + + // Tap 3: -10s -> 55s + base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, -10)!! + assertEquals(55_000L, firedTarget) + + // Tap 4: -30s -> 25s + base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, -30)!! + assertEquals(25_000L, firedTarget) + } + + @Test + fun seekingNearBeginningClampsAtZeroAndRecovers() { + val durationMs = 120_000L + val staleClock = 8_000L + var firedTarget: Long? = null + + // Tap 1: -10s from 8s -> clamps to 0s + var base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, -10)!! + assertEquals(0L, firedTarget) + + // Tap 2: another -10s rapid tap while at 0s -> stays at 0s + base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, -10)!! + assertEquals(0L, firedTarget) + + // Tap 3: +10s forward from 0s -> advances to 10s + base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, 10)!! + assertEquals(10_000L, firedTarget) + } + + @Test + fun seekingNearEndClampsShortOfTailAndRecovers() { + val durationMs = 100_000L + val tailLimit = 100_000L - SEEK_END_MARGIN_MS // 99_750L + val staleClock = 95_000L + var firedTarget: Long? = null + + // Tap 1: +10s from 95s -> clamps to 99_750L + var base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, 10)!! + assertEquals(tailLimit, firedTarget) + + // Tap 2: another +10s rapid tap -> stays clamped at 99_750L + base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, 10)!! + assertEquals(tailLimit, firedTarget) + + // Tap 3: -10s back from clamped end -> moves to 89_750L + base = resolveSeekBasePosition(null, firedTarget, staleClock, true, staleClock) + firedTarget = seekByTargetMs(base, durationMs, -10)!! + assertEquals(tailLimit - 10_000L, firedTarget) + } + + @Test + fun unknownDurationReturnsNullAndDoesNotSeek() { + // Unknown or invalid duration must safely return null + assertNull(seekByTargetMs(10_000L, 0L, 10)) + assertNull(seekByTargetMs(10_000L, -1L, 10)) + assertNull(seekByTargetMs(10_000L, C.TIME_UNSET, 10)) + assertNull(seekTargetMs(0L, 0.5f)) + assertNull(seekTargetMs(-100L, 0.5f)) + assertNull(seekTargetMs(C.TIME_UNSET, 0.5f)) + } + + @Test + fun bufferingPendingSeeksAccumulate() { + // When player is unready/buffering, pending target is kept and accumulates + val durationMs = 120_000L + val staleClock = 0L + var pendingTarget: Long? = null + + // Tap 1 (+10s while buffering) + var base = resolveSeekBasePosition(pendingTarget, null, staleClock, false, 0L) + pendingTarget = seekByTargetMs(base, durationMs, 10)!! + assertEquals(10_000L, pendingTarget) + + // Tap 2 (+10s while still buffering) + base = resolveSeekBasePosition(pendingTarget, null, staleClock, false, 0L) + pendingTarget = seekByTargetMs(base, durationMs, 10)!! + assertEquals(20_000L, pendingTarget) + + // Tap 3 (+10s while still buffering) + base = resolveSeekBasePosition(pendingTarget, null, staleClock, false, 0L) + pendingTarget = seekByTargetMs(base, durationMs, 10)!! + assertEquals(30_000L, pendingTarget) + } } diff --git a/android/app/src/test/java/stream/kleeamp/mobile/player/ExpandedPlayerRegressionTest.kt b/android/app/src/test/java/stream/kleeamp/mobile/player/ExpandedPlayerRegressionTest.kt new file mode 100644 index 00000000..63c54256 --- /dev/null +++ b/android/app/src/test/java/stream/kleeamp/mobile/player/ExpandedPlayerRegressionTest.kt @@ -0,0 +1,117 @@ +package stream.kleeamp.mobile.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import stream.kleeamp.mobile.model.Station +import stream.kleeamp.mobile.model.StationSource +import stream.kleeamp.mobile.playback.resolveSeekBasePosition +import stream.kleeamp.mobile.playback.seekByTargetMs +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC +import stream.kleeamp.mobile.prefs.SKIP_DURATIONS + +class ExpandedPlayerRegressionTest { + + @Test + fun defaultStateHasTenSecondSkipDurations() { + val defaultUiState = NowPlayingViewModel.UiState() + assertEquals(10, DEFAULT_SKIP_BACK_SEC) + assertEquals(10, DEFAULT_SKIP_FWD_SEC) + assertEquals(DEFAULT_SKIP_BACK_SEC, defaultUiState.skipBackSec) + assertEquals(DEFAULT_SKIP_FWD_SEC, defaultUiState.skipFwdSec) + } + + @Test + fun tracksEnableSkipKeysWhileLiveRadioKeepsThreeKeyRow() { + val podcast = Station( + id = "podcast-1", + name = "Podcast Episode", + url = "https://example.com/ep.mp3", + source = StationSource.Podcast, + ) + val radio = Station( + id = "radio-1", + name = "Live FM", + url = "https://example.com/live", + source = StationSource.Directory, + ) + + assertTrue(podcast.isTrack) + assertFalse(radio.isTrack) + + // NowPlayingViewModel uses shownStation?.isTrack == true + val podcastShowKeys = podcast.isTrack + val radioShowKeys = radio.isTrack + + assertTrue("Podcast episodes must show skip keys", podcastShowKeys) + assertFalse("Live radio must retain plain 3-key row", radioShowKeys) + } + + @Test + fun skipLabelsFormatAllSupportedDurations() { + for (duration in SKIP_DURATIONS) { + assertEquals("-${duration}s", skipLabel(-duration)) + assertEquals("+${duration}s", skipLabel(duration)) + } + } + + @Test + fun expandedPlayerActionDeltasMatchConfiguredPreferences() { + // PlayerActions maps: + // onSkipBack = { vm.player.seekBy(-uiState.skipBackSec) } + // onSkipFwd = { vm.player.seekBy(uiState.skipFwdSec) } + for (backSec in SKIP_DURATIONS) { + val deltaBack = -backSec + assertTrue(deltaBack < 0) + assertEquals(-backSec, deltaBack) + } + for (fwdSec in SKIP_DURATIONS) { + val deltaFwd = fwdSec + assertTrue(deltaFwd > 0) + assertEquals(fwdSec, deltaFwd) + } + } + + @Test + fun rapidSkipsFromExpandedPlayerAccumulateAccurately() { + val durationMs = 180_000L + val initialPosition = 30_000L + val skipFwdSec = 10 + + var firedTarget: Long? = null + val staleClock = initialPosition + + // 3 rapid forward taps on the expanded player skip key + for (i in 1..3) { + val base = resolveSeekBasePosition( + pendingTargetMs = null, + firedTargetMs = firedTarget, + rawPositionMs = staleClock, + itemMatchesStation = true, + statePositionMs = staleClock, + ) + firedTarget = seekByTargetMs(base, durationMs, skipFwdSec)!! + assertEquals(initialPosition + i * skipFwdSec * 1000L, firedTarget) + } + + // Final position is exactly 30s ahead + assertEquals(60_000L, firedTarget) + } + + @Test + fun resettingPreferencesRestoresExpandedPlayerDefaults() { + var configuredBack = 60 + var configuredFwd = 90 + assertEquals(60, configuredBack) + assertEquals(90, configuredFwd) + + // Reset action dispatched from SettingsScreen + configuredBack = DEFAULT_SKIP_BACK_SEC + configuredFwd = DEFAULT_SKIP_FWD_SEC + + assertEquals(10, configuredBack) + assertEquals(10, configuredFwd) + } +} diff --git a/android/app/src/test/java/stream/kleeamp/mobile/player/MiniPlayerControlsTest.kt b/android/app/src/test/java/stream/kleeamp/mobile/player/MiniPlayerControlsTest.kt new file mode 100644 index 00000000..9eb0071f --- /dev/null +++ b/android/app/src/test/java/stream/kleeamp/mobile/player/MiniPlayerControlsTest.kt @@ -0,0 +1,142 @@ +package stream.kleeamp.mobile.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import stream.kleeamp.mobile.model.Station +import stream.kleeamp.mobile.model.StationSource +import stream.kleeamp.mobile.playback.resolveSeekBasePosition +import stream.kleeamp.mobile.playback.seekByTargetMs +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_BACK_SEC +import stream.kleeamp.mobile.prefs.DEFAULT_SKIP_FWD_SEC +import stream.kleeamp.mobile.prefs.SKIP_DURATIONS + +class MiniPlayerControlsTest { + + private fun stationWith(source: StationSource) = Station( + id = "station-id", + name = "Station Name", + url = "https://example.com/audio", + source = source, + ) + + @Test + fun podcastRendersSkipControls() { + val podcast = stationWith(StationSource.Podcast) + assertEquals(MiniKeyKind.SkipBack, miniLeftKeyKind(podcast)) + assertEquals(MiniKeyKind.SkipForward, miniRightKeyKind(podcast)) + } + + @Test + fun radioRendersPreviousAndNextControls() { + val radioDirectory = stationWith(StationSource.Directory) + val radioCliamp = stationWith(StationSource.Cliamp) + val radioCustom = stationWith(StationSource.Custom) + + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(radioDirectory)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(radioDirectory)) + + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(radioCliamp)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(radioCliamp)) + + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(radioCustom)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(radioCustom)) + } + + @Test + fun localMusicAndProviderTracksRetainPreviousAndNextControls() { + val local = stationWith(StationSource.Local) + val provider = stationWith(StationSource.Provider) + + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(local)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(local)) + + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(provider)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(provider)) + } + + @Test + fun nullStationRetainsPreviousAndNextControls() { + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(null)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(null)) + } + + @Test + fun backwardAndForwardCallbacksUseCorrectSignsAndConfiguredValues() { + for (back in SKIP_DURATIONS) { + val deltaBack = miniKeySeekDelta(MiniKeyKind.SkipBack, back, DEFAULT_SKIP_FWD_SEC) + assertEquals(-back, deltaBack) + } + + for (fwd in SKIP_DURATIONS) { + val deltaFwd = miniKeySeekDelta(MiniKeyKind.SkipForward, DEFAULT_SKIP_BACK_SEC, fwd) + assertEquals(fwd, deltaFwd) + } + + assertNull(miniKeySeekDelta(MiniKeyKind.Prev, DEFAULT_SKIP_BACK_SEC, DEFAULT_SKIP_FWD_SEC)) + assertNull(miniKeySeekDelta(MiniKeyKind.Next, DEFAULT_SKIP_BACK_SEC, DEFAULT_SKIP_FWD_SEC)) + } + + @Test + fun labelsAccuratelyCommunicateDirectionAndDuration() { + for (back in SKIP_DURATIONS) { + val label = miniKeyLabel(MiniKeyKind.SkipBack, back, DEFAULT_SKIP_FWD_SEC) + assertEquals("skip back $back seconds", label) + } + + for (fwd in SKIP_DURATIONS) { + val label = miniKeyLabel(MiniKeyKind.SkipForward, DEFAULT_SKIP_BACK_SEC, fwd) + assertEquals("skip forward $fwd seconds", label) + } + + assertEquals("previous", miniKeyLabel(MiniKeyKind.Prev, 10, 10)) + assertEquals("next", miniKeyLabel(MiniKeyKind.Next, 10, 10)) + } + + @Test + fun switchingSourcesUpdatesControlsDynamically() { + var currentStation: Station? = stationWith(StationSource.Podcast) + assertEquals(MiniKeyKind.SkipBack, miniLeftKeyKind(currentStation)) + assertEquals(MiniKeyKind.SkipForward, miniRightKeyKind(currentStation)) + + // Switch to live radio + currentStation = stationWith(StationSource.Directory) + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(currentStation)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(currentStation)) + + // Switch to local music + currentStation = stationWith(StationSource.Local) + assertEquals(MiniKeyKind.Prev, miniLeftKeyKind(currentStation)) + assertEquals(MiniKeyKind.Next, miniRightKeyKind(currentStation)) + + // Switch back to podcast + currentStation = stationWith(StationSource.Podcast) + assertEquals(MiniKeyKind.SkipBack, miniLeftKeyKind(currentStation)) + assertEquals(MiniKeyKind.SkipForward, miniRightKeyKind(currentStation)) + } + + @Test + fun rapidSkipAccumulationThroughMiniPlayerDeltas() { + val durationMs = 120_000L + val initialPosition = 20_000L + val skipFwdSec = 10 + val delta = miniKeySeekDelta(MiniKeyKind.SkipForward, DEFAULT_SKIP_BACK_SEC, skipFwdSec)!! + + var firedTarget: Long? = null + val staleClock = initialPosition + + for (i in 1..3) { + val base = resolveSeekBasePosition( + pendingTargetMs = null, + firedTargetMs = firedTarget, + rawPositionMs = staleClock, + itemMatchesStation = true, + statePositionMs = staleClock, + ) + firedTarget = seekByTargetMs(base, durationMs, delta)!! + assertEquals(initialPosition + i * skipFwdSec * 1000L, firedTarget) + } + + assertEquals(50_000L, firedTarget) + } +} diff --git a/android/app/src/test/java/stream/kleeamp/mobile/player/SkipLabelTest.kt b/android/app/src/test/java/stream/kleeamp/mobile/player/SkipLabelTest.kt index 02e0b178..bfabde9d 100644 --- a/android/app/src/test/java/stream/kleeamp/mobile/player/SkipLabelTest.kt +++ b/android/app/src/test/java/stream/kleeamp/mobile/player/SkipLabelTest.kt @@ -6,14 +6,22 @@ import org.junit.Test class SkipLabelTest { @Test fun backNamesSecondsWithMinus() { + assertEquals("-10s", skipLabel(-10)) assertEquals("-15s", skipLabel(-15)) - assertEquals("-5s", skipLabel(-5)) + assertEquals("-20s", skipLabel(-20)) + assertEquals("-30s", skipLabel(-30)) + assertEquals("-60s", skipLabel(-60)) + assertEquals("-90s", skipLabel(-90)) } @Test fun forwardNamesSecondsWithPlus() { + assertEquals("+10s", skipLabel(10)) + assertEquals("+15s", skipLabel(15)) + assertEquals("+20s", skipLabel(20)) assertEquals("+30s", skipLabel(30)) assertEquals("+60s", skipLabel(60)) + assertEquals("+90s", skipLabel(90)) } @Test diff --git a/android/app/src/test/java/stream/kleeamp/mobile/prefs/SkipDurationsTest.kt b/android/app/src/test/java/stream/kleeamp/mobile/prefs/SkipDurationsTest.kt new file mode 100644 index 00000000..f61d2dc4 --- /dev/null +++ b/android/app/src/test/java/stream/kleeamp/mobile/prefs/SkipDurationsTest.kt @@ -0,0 +1,48 @@ +package stream.kleeamp.mobile.prefs + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SkipDurationsTest { + + @Test + fun availableDurationsMatchRequiredOptions() { + assertEquals(listOf(10, 15, 20, 30, 60, 90), SKIP_DURATIONS) + } + + @Test + fun defaultDurationsAreTenSeconds() { + assertEquals(10, DEFAULT_SKIP_BACK_SEC) + assertEquals(10, DEFAULT_SKIP_FWD_SEC) + assertTrue(DEFAULT_SKIP_BACK_SEC in SKIP_DURATIONS) + assertTrue(DEFAULT_SKIP_FWD_SEC in SKIP_DURATIONS) + } + + @Test + fun legacyOrInvalidDurationsFallbackToDefault() { + val invalidDurations = listOf(null, 0, -1, 5, 25, 45, 100, 120) + for (invalid in invalidDurations) { + val back = (invalid ?: DEFAULT_SKIP_BACK_SEC).let { v -> + if (v in SKIP_DURATIONS) v else DEFAULT_SKIP_BACK_SEC + } + val fwd = (invalid ?: DEFAULT_SKIP_FWD_SEC).let { v -> + if (v in SKIP_DURATIONS) v else DEFAULT_SKIP_FWD_SEC + } + assertEquals("Invalid $invalid must fall back to back default", DEFAULT_SKIP_BACK_SEC, back) + assertEquals("Invalid $invalid must fall back to forward default", DEFAULT_SKIP_FWD_SEC, fwd) + } + } + + @Test + fun validDurationsArePreservedIndependently() { + for (backOption in SKIP_DURATIONS) { + for (fwdOption in SKIP_DURATIONS) { + val back = backOption.let { if (it in SKIP_DURATIONS) it else DEFAULT_SKIP_BACK_SEC } + val fwd = fwdOption.let { if (it in SKIP_DURATIONS) it else DEFAULT_SKIP_FWD_SEC } + assertEquals(backOption, back) + assertEquals(fwdOption, fwd) + } + } + } +}