mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
feat(app,android,ios): swipe gestures, touch lock and sleep timer
On Android and iOS a swipe up or down on the left half of the picture sets the brightness and on the right half the volume; a sideways swipe seeks, showing where it will land and seeking only when the finger lifts. Swipes that start at the top or bottom edge are left to the system. Android turns the device's media volume; iOS lets no app do that, so there the swipe turns the player's own, as its slider does. Brightness set by a swipe is handed back when the player goes. Lock touch, in the player's menu, makes the picture ignore touches and the back gesture; a tap brings up Unlock for a few seconds. The sleep timer pauses after 30 or 60 minutes, or stops at the end of the episode playing instead of going on to the next, with no up-next card. On the desktop it is a panel like the others, behind a moon button that shows what is left; on Android and iOS it lives with the player host, so it keeps running with the screen off.
This commit is contained in:
1 parent
cf8ee58f6b
commit
973a8f2749
11 files changed
+827
-25
No files matched your search
@@ -1,5 +1,6 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.ui.ThumbnailSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -105,6 +106,15 @@ data class NowPlaying(
|
||||
val hasNext: Boolean,
|
||||
)
|
||||
|
||||
/** A sleep timer that is running. */
|
||||
sealed interface SleepTimer {
|
||||
/** Stop when [media] ends, rather than going on to the next episode. */
|
||||
data class EndOf(val media: String) : SleepTimer
|
||||
|
||||
/** Pause at [at], on the [SystemClock.elapsedRealtime] clock. */
|
||||
data class At(val at: Long) : SleepTimer
|
||||
}
|
||||
|
||||
data class MpvTrack(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
@@ -131,6 +141,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
||||
private val mutableAdvancing = MutableStateFlow(false)
|
||||
private val mutableProblem = MutableStateFlow<String?>(null)
|
||||
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
|
||||
private var sleepJob: Job? = null
|
||||
private var poller: Job? = null
|
||||
/** The episode key and load generation the native player currently holds. */
|
||||
@Volatile private var currentMedia: String? = null
|
||||
@@ -178,6 +190,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
*/
|
||||
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
||||
|
||||
/**
|
||||
* The sleep timer. Kept here rather than on the screen, because the
|
||||
* episode it is about keeps playing with the screen off.
|
||||
*/
|
||||
val sleep: StateFlow<SleepTimer?> = mutableSleep.asStateFlow()
|
||||
|
||||
/** Start a sleep timer, replacing any running one, or turn it off. */
|
||||
fun setSleepTimer(timer: SleepTimer?) {
|
||||
sleepJob?.cancel()
|
||||
mutableSleep.value = timer
|
||||
if (timer is SleepTimer.At) {
|
||||
sleepJob = scope.launch {
|
||||
delay((timer.at - SystemClock.elapsedRealtime()).coerceAtLeast(0))
|
||||
mutableSleep.value = null
|
||||
setPaused(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the current [problem]. */
|
||||
fun clearProblem() { mutableProblem.value = null }
|
||||
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.BrightnessMedium
|
||||
import androidx.compose.material.icons.filled.FastForward
|
||||
import androidx.compose.material.icons.filled.FastRewind
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What a swipe on the picture is doing, for the bubble that says so. */
|
||||
sealed interface Swipe {
|
||||
/** The window's brightness, from 0 to 1. */
|
||||
data class Brightness(val level: Float) : Swipe
|
||||
|
||||
/** The device's media volume, from 0 to 1. */
|
||||
data class Volume(val level: Float) : Swipe
|
||||
|
||||
/** Where the playhead will land when the finger lifts, and where it was. */
|
||||
data class Seek(val from: Double, val to: Double) : Swipe
|
||||
}
|
||||
|
||||
/** How much of the file one swipe across the whole width is worth, in seconds. */
|
||||
private const val SEEK_SPAN_SECONDS = 90.0
|
||||
|
||||
/**
|
||||
* A finger that comes down this close to the top or bottom of the screen is
|
||||
* the system's: pulling down the shade, swiping home.
|
||||
*/
|
||||
private val SYSTEM_EDGE = 48.dp
|
||||
|
||||
/** Which way a swipe went, settled by its first movement past the touch slop. */
|
||||
private enum class Axis { Brightness, Volume, Seek, Ignored }
|
||||
|
||||
/**
|
||||
* The swipes every phone video player has taught: up and down on the left for
|
||||
* brightness, on the right for volume, and sideways to seek — shown while the
|
||||
* finger is down and only seeking when it lifts, because a seek per movement
|
||||
* would throw away every block in flight.
|
||||
*/
|
||||
fun Modifier.playerSwipes(
|
||||
enabled: Boolean,
|
||||
activity: Activity?,
|
||||
position: () -> Double,
|
||||
duration: () -> Double,
|
||||
onSwipe: (Swipe?) -> Unit,
|
||||
onSeek: (Double) -> Unit,
|
||||
): Modifier = pointerInput(enabled, activity) {
|
||||
if (!enabled) return@pointerInput
|
||||
val audio = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
val edge = SYSTEM_EDGE.toPx()
|
||||
var axis = Axis.Ignored
|
||||
var start = Offset.Zero
|
||||
var travelled = Offset.Zero
|
||||
var level = 0f
|
||||
var seekFrom = 0.0
|
||||
var seekTo = 0.0
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
start = offset
|
||||
travelled = Offset.Zero
|
||||
axis = if (offset.y < edge || offset.y > size.height - edge) Axis.Ignored else Axis.Seek
|
||||
},
|
||||
onDragEnd = {
|
||||
if (axis == Axis.Seek && seekTo != seekFrom) onSeek(seekTo)
|
||||
onSwipe(null)
|
||||
},
|
||||
onDragCancel = { onSwipe(null) },
|
||||
) { change, amount ->
|
||||
if (axis == Axis.Ignored) return@detectDragGestures
|
||||
change.consume()
|
||||
val first = travelled == Offset.Zero
|
||||
travelled += amount
|
||||
if (first) {
|
||||
// The first movement past the slop decides what the swipe is for.
|
||||
axis = when {
|
||||
abs(amount.x) > abs(amount.y) -> Axis.Seek
|
||||
start.x < size.width / 2 -> Axis.Brightness
|
||||
else -> Axis.Volume
|
||||
}
|
||||
level = when (axis) {
|
||||
Axis.Brightness -> activity?.brightness() ?: 0.5f
|
||||
Axis.Volume -> audio?.mediaLevel() ?: 0f
|
||||
else -> 0f
|
||||
}
|
||||
seekFrom = position()
|
||||
seekTo = seekFrom
|
||||
}
|
||||
when (axis) {
|
||||
// A swipe the whole height of the screen goes from none to all.
|
||||
Axis.Brightness -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
activity?.setBrightness(level)
|
||||
onSwipe(Swipe.Brightness(level))
|
||||
}
|
||||
Axis.Volume -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
audio?.setMediaLevel(level)
|
||||
onSwipe(Swipe.Volume(level))
|
||||
}
|
||||
Axis.Seek -> {
|
||||
val length = duration()
|
||||
if (length <= 0.0) return@detectDragGestures
|
||||
seekTo = (seekFrom + travelled.x / size.width * SEEK_SPAN_SECONDS).coerceIn(0.0, length)
|
||||
onSwipe(Swipe.Seek(seekFrom, seekTo))
|
||||
}
|
||||
Axis.Ignored -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's own brightness, or the screen's when the window has none. */
|
||||
private fun Activity.brightness(): Float {
|
||||
val own = window.attributes.screenBrightness
|
||||
if (own >= 0f) return own
|
||||
return runCatching {
|
||||
android.provider.Settings.System.getInt(contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) / 255f
|
||||
}.getOrDefault(0.5f)
|
||||
}
|
||||
|
||||
private fun Activity.setBrightness(level: Float) {
|
||||
window.attributes = window.attributes.apply {
|
||||
// Never quite off: a black screen under a playing film looks broken.
|
||||
screenBrightness = level.coerceAtLeast(0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand brightness back to the system, as the player is left. */
|
||||
fun Activity.releaseBrightness() {
|
||||
window.attributes = window.attributes.apply {
|
||||
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioManager.mediaLevel(): Float {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
|
||||
return getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
|
||||
}
|
||||
|
||||
private fun AudioManager.setMediaLevel(level: Float) {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
// No flags: the bubble is the feedback, and the system's own volume panel
|
||||
// over the picture would be a second one.
|
||||
setStreamVolume(AudioManager.STREAM_MUSIC, (level * max).roundToInt(), 0)
|
||||
}
|
||||
|
||||
/** The bubble in the middle of the picture while a swipe is under way. */
|
||||
@Composable
|
||||
fun SwipeBubble(swipe: Swipe, format: (Double) -> String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
when (swipe) {
|
||||
is Swipe.Brightness -> Level(Icons.Default.BrightnessMedium, "Brightness", swipe.level)
|
||||
is Swipe.Volume -> Level(Icons.AutoMirrored.Filled.VolumeUp, "Volume", swipe.level)
|
||||
is Swipe.Seek -> {
|
||||
val delta = swipe.to - swipe.from
|
||||
Icon(
|
||||
if (delta < 0) Icons.Default.FastRewind else Icons.Default.FastForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Text(
|
||||
(if (delta < 0) "−" else "+") + format(abs(delta)),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(format(swipe.to), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Level(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, level: Float) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
|
||||
Text("$label ${(level * 100).roundToInt()}%", color = Color.White, style = MaterialTheme.typography.labelLarge)
|
||||
Box(Modifier.width(120.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { level },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = Color.White.copy(alpha = 0.25f),
|
||||
drawStopIndicator = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.SystemClock
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
@@ -35,7 +36,10 @@ import androidx.compose.material.icons.automirrored.filled.Toc
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Audiotrack
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Forward30
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
@@ -145,6 +149,9 @@ private const val DOUBLE_TAP_SECONDS = 10
|
||||
/** How long the double-tap seek mark stays up. */
|
||||
private const val NUDGE_MS = 700L
|
||||
|
||||
/** How long the unlock button stays up after a tap on a locked player. */
|
||||
private const val UNLOCK_SHOWN_MS = 3_000L
|
||||
|
||||
/** How close to the end the next episode's opening blocks are fetched. */
|
||||
private const val WARM_NEXT_SECONDS = 120.0
|
||||
|
||||
@@ -371,8 +378,22 @@ fun PlayerScreen(
|
||||
// own reading — never on a failure, and never on the outgoing file's.
|
||||
// Cancelled from the end-of-file card: the viewer chose to stay.
|
||||
var stay by remember(key) { mutableStateOf(false) }
|
||||
// The sleep timer set for the end of this episode stops it there instead.
|
||||
val sleep by nativeHost.sleep.collectAsState()
|
||||
val stopsAfterThis = (sleep as? SleepTimer.EndOf)?.media == key
|
||||
LaunchedEffect(state.endReason, state.media) {
|
||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance()
|
||||
if (state.endReason != EndReason.Eof || state.media != key) return@LaunchedEffect
|
||||
if (stopsAfterThis) {
|
||||
nativeHost.setSleepTimer(null)
|
||||
} else if (autoplay && !stay && next != null) {
|
||||
advance()
|
||||
}
|
||||
}
|
||||
// A timer for the end of an episode the viewer has since moved on from
|
||||
// was about that episode.
|
||||
LaunchedEffect(key) {
|
||||
val timer = nativeHost.sleep.value
|
||||
if (timer is SleepTimer.EndOf && timer.media != key) nativeHost.setSleepTimer(null)
|
||||
}
|
||||
// A file mpv could not play ends like any other. Saying so is the whole
|
||||
// difference between a broken episode and an app that silently skipped one.
|
||||
@@ -472,6 +493,21 @@ fun PlayerScreen(
|
||||
}
|
||||
}
|
||||
val current by rememberUpdatedState(state)
|
||||
// Locked, the picture takes no touches but one: the button that unlocks
|
||||
// it, which a tap brings up for a moment. For a phone in a pocket, or in
|
||||
// a child's hands.
|
||||
var locked by remember { mutableStateOf(false) }
|
||||
var unlockShown by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(unlockShown) {
|
||||
if (unlockShown > 0) {
|
||||
delay(UNLOCK_SHOWN_MS)
|
||||
unlockShown = 0
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = locked && !minimized) { unlockShown++ }
|
||||
var swipe by remember { mutableStateOf<Swipe?>(null) }
|
||||
// Brightness set by a swipe is the player's, not the app's.
|
||||
DisposableEffect(activity) { onDispose { activity?.releaseBrightness() } }
|
||||
// Where a remote's keys land while the controls are down. Once they are
|
||||
// up, the D-pad moves between their buttons as it does anywhere else.
|
||||
val keys = remember { FocusRequester() }
|
||||
@@ -515,7 +551,22 @@ fun PlayerScreen(
|
||||
// Double-tap a side to seek ten seconds, as every phone video
|
||||
// player has taught; the middle plays or pauses. A single tap
|
||||
// waits out the double-tap timeout, which is the price of both.
|
||||
.pointerInput(host) {
|
||||
.playerSwipes(
|
||||
enabled = !locked,
|
||||
activity = activity,
|
||||
position = { current.position },
|
||||
duration = { current.duration },
|
||||
onSwipe = { swipe = it },
|
||||
onSeek = { target ->
|
||||
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
host.seek(target)
|
||||
},
|
||||
)
|
||||
.pointerInput(host, locked) {
|
||||
if (locked) {
|
||||
detectTapGestures(onTap = { unlockShown++ })
|
||||
return@pointerInput
|
||||
}
|
||||
detectTapGestures(
|
||||
onTap = { showControls = !showControls },
|
||||
onDoubleTap = { offset ->
|
||||
@@ -559,7 +610,7 @@ fun PlayerScreen(
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
visible = showControls && !locked,
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
@@ -622,7 +673,7 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
visible = showControls && !locked,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
@@ -645,9 +696,34 @@ fun PlayerScreen(
|
||||
landscapeLocked = activity?.toggleLandscape() ?: false
|
||||
},
|
||||
landscapeLocked = landscapeLocked,
|
||||
media = key,
|
||||
sleep = sleep,
|
||||
onLock = {
|
||||
locked = true
|
||||
showControls = false
|
||||
unlockShown++
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
swipe?.let { SwipeBubble(it, ::formatTime, Modifier.align(Alignment.Center)) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = locked && unlockShown > 0,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
TonalButton(onClick = {
|
||||
locked = false
|
||||
unlockShown = 0
|
||||
showControls = true
|
||||
}) {
|
||||
Icon(Icons.Default.LockOpen, contentDescription = null)
|
||||
Text("Unlock", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn whether or not the rest of the controls are up: an opening lasts
|
||||
// ninety seconds, and a button you have to wake with a tap is one nobody
|
||||
// reaches in time.
|
||||
@@ -684,7 +760,7 @@ fun PlayerScreen(
|
||||
}
|
||||
UpNextCard(
|
||||
next = next,
|
||||
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
|
||||
visible = autoplay && !stay && !stopsAfterThis && !state.paused && !state.ended && playingThis &&
|
||||
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
||||
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
||||
onPlayNow = { advance() },
|
||||
@@ -697,7 +773,7 @@ fun PlayerScreen(
|
||||
|
||||
// Played to the end with nothing starting after it: the last episode,
|
||||
// autoplay off, or the viewer stayed. A black screen said none of that.
|
||||
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
|
||||
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay || stopsAfterThis)) {
|
||||
EndCard(
|
||||
finished = episode.label,
|
||||
show = title.displayName,
|
||||
@@ -871,8 +947,12 @@ private fun PlayerControls(
|
||||
onEpisode: (Int) -> Unit,
|
||||
onRotate: () -> Unit,
|
||||
landscapeLocked: Boolean,
|
||||
media: String,
|
||||
sleep: SleepTimer?,
|
||||
onLock: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var showSleep by remember { mutableStateOf(false) }
|
||||
var choosing by remember { mutableStateOf<String?>(null) }
|
||||
var showEpisodes by remember { mutableStateOf(false) }
|
||||
var showChapters by remember { mutableStateOf(false) }
|
||||
@@ -980,6 +1060,8 @@ private fun PlayerControls(
|
||||
onRotate,
|
||||
),
|
||||
)
|
||||
add(Triple(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true })
|
||||
add(Triple(Icons.Default.Lock, "Lock touch", onLock))
|
||||
}
|
||||
entries.forEach { (icon, label, action) ->
|
||||
DropdownMenuItem(
|
||||
@@ -1028,11 +1110,29 @@ private fun PlayerControls(
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onClick = onRotate,
|
||||
)
|
||||
CompactIcon(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true }
|
||||
CompactIcon(Icons.Default.Lock, "Lock touch", onClick = onLock)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSleep) {
|
||||
SleepChooser(
|
||||
current = sleep,
|
||||
onDismiss = { showSleep = false },
|
||||
onSelect = { minutes ->
|
||||
showSleep = false
|
||||
host.setSleepTimer(
|
||||
when (minutes) {
|
||||
null -> null
|
||||
0 -> SleepTimer.EndOf(media)
|
||||
else -> SleepTimer.At(SystemClock.elapsedRealtime() + minutes * 60_000L)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showSpeeds) {
|
||||
SpeedChooser(
|
||||
current = speed,
|
||||
@@ -1157,6 +1257,59 @@ private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Doub
|
||||
)
|
||||
}
|
||||
|
||||
/** What the sleep timer button says: whether one is running, and how long it has. */
|
||||
private fun sleepLabel(sleep: SleepTimer?): String = when (sleep) {
|
||||
null -> "Sleep timer"
|
||||
is SleepTimer.EndOf -> "Sleep timer: end of episode"
|
||||
is SleepTimer.At -> {
|
||||
val left = ((sleep.at - SystemClock.elapsedRealtime()) / 60_000L + 1).coerceAtLeast(1)
|
||||
"Sleep timer: $left min"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off, the end of this episode, or a number of minutes. [onSelect] gets
|
||||
* `null` for off, `0` for the end of the episode, and the minutes otherwise.
|
||||
*/
|
||||
@Composable
|
||||
private fun SleepChooser(current: SleepTimer?, onDismiss: () -> Unit, onSelect: (Int?) -> Unit) {
|
||||
val choices = listOf<Pair<Int?, String>>(
|
||||
null to "Off",
|
||||
0 to "End of this episode",
|
||||
30 to "30 minutes",
|
||||
60 to "60 minutes",
|
||||
)
|
||||
val chosen: Int? = when (current) {
|
||||
null -> null
|
||||
is SleepTimer.EndOf -> 0
|
||||
is SleepTimer.At -> -1
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Sleep timer") },
|
||||
text = {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (current is SleepTimer.At) {
|
||||
Text(
|
||||
sleepLabel(current).removePrefix("Sleep timer: ") + " left",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
choices.forEach { (minutes, label) ->
|
||||
EdgedButton(
|
||||
onClick = { onSelect(minutes) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) {
|
||||
Text((if (minutes == chosen) "✓ " else "") + label)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Where each chapter begins, ticked onto the seek bar. */
|
||||
@Composable
|
||||
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
|
||||
|
||||
Reference in new issue
Block a user