Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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 { }
}

Expand Down Expand Up @@ -338,40 +352,10 @@ class PlaybackService : MediaSessionService() {
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = session

private fun buttons(
station: Station?,
isFavourite: Boolean,
shuffled: Boolean,
): ImmutableList<CommandButton> = 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<CommandButton> = buildMediaButtons(station, isFavourite, shuffled, ::iconUri)

/** android.resource URIs resolve cross-process in SystemUI, like res ids did. */
private fun iconUri(@DrawableRes resId: Int): Uri =
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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<CommandButton> {
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<Int>,
skipFwdFlow: Flow<Int>,
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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading