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:
nvrl committed 2026-10-31 03:19:04 +01:00
1 parent cf8ee58f6b
commit 973a8f2749
11 files changed
+827 -25

No files matched your search

@@ -1,5 +1,6 @@
package io.narl.protonstream.playback package io.narl.protonstream.playback
import android.os.SystemClock
import android.view.Surface import android.view.Surface
import io.narl.protonstream.ui.ThumbnailSource import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -105,6 +106,15 @@ data class NowPlaying(
val hasNext: Boolean, 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( data class MpvTrack(
val id: Long, val id: Long,
val type: String, val type: String,
@@ -131,6 +141,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null) private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false) private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(null) private val mutableProblem = MutableStateFlow<String?>(null)
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
private var sleepJob: Job? = null
private var poller: Job? = null private var poller: Job? = null
/** The episode key and load generation the native player currently holds. */ /** The episode key and load generation the native player currently holds. */
@Volatile private var currentMedia: String? = null @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() 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]. */ /** Dismiss the current [problem]. */
fun clearProblem() { mutableProblem.value = null } fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended 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.app.Activity
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.SystemClock
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager 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.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Audiotrack 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.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.Speed
import androidx.compose.material.icons.filled.Forward30 import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen 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. */ /** How long the double-tap seek mark stays up. */
private const val NUDGE_MS = 700L 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. */ /** How close to the end the next episode's opening blocks are fetched. */
private const val WARM_NEXT_SECONDS = 120.0 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. // 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. // Cancelled from the end-of-file card: the viewer chose to stay.
var stay by remember(key) { mutableStateOf(false) } 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) { 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 // 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. // difference between a broken episode and an app that silently skipped one.
@@ -472,6 +493,21 @@ fun PlayerScreen(
} }
} }
val current by rememberUpdatedState(state) 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 // 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. // up, the D-pad moves between their buttons as it does anywhere else.
val keys = remember { FocusRequester() } val keys = remember { FocusRequester() }
@@ -515,7 +551,22 @@ fun PlayerScreen(
// Double-tap a side to seek ten seconds, as every phone video // Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap // player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both. // 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( detectTapGestures(
onTap = { showControls = !showControls }, onTap = { showControls = !showControls },
onDoubleTap = { offset -> onDoubleTap = { offset ->
@@ -559,7 +610,7 @@ fun PlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = showControls, visible = showControls && !locked,
modifier = Modifier.align(Alignment.TopStart), modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -622,7 +673,7 @@ fun PlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = showControls, visible = showControls && !locked,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -645,9 +696,34 @@ fun PlayerScreen(
landscapeLocked = activity?.toggleLandscape() ?: false landscapeLocked = activity?.toggleLandscape() ?: false
}, },
landscapeLocked = landscapeLocked, 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 // 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 // ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time. // reaches in time.
@@ -684,7 +760,7 @@ fun PlayerScreen(
} }
UpNextCard( UpNextCard(
next = next, 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), (tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
secondsLeft = tailLeft?.let { ceil(it).toInt() }, secondsLeft = tailLeft?.let { ceil(it).toInt() },
onPlayNow = { advance() }, onPlayNow = { advance() },
@@ -697,7 +773,7 @@ fun PlayerScreen(
// Played to the end with nothing starting after it: the last episode, // 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. // 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( EndCard(
finished = episode.label, finished = episode.label,
show = title.displayName, show = title.displayName,
@@ -871,8 +947,12 @@ private fun PlayerControls(
onEpisode: (Int) -> Unit, onEpisode: (Int) -> Unit,
onRotate: () -> Unit, onRotate: () -> Unit,
landscapeLocked: Boolean, landscapeLocked: Boolean,
media: String,
sleep: SleepTimer?,
onLock: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showSleep by remember { mutableStateOf(false) }
var choosing by remember { mutableStateOf<String?>(null) } var choosing by remember { mutableStateOf<String?>(null) }
var showEpisodes by remember { mutableStateOf(false) } var showEpisodes by remember { mutableStateOf(false) }
var showChapters by remember { mutableStateOf(false) } var showChapters by remember { mutableStateOf(false) }
@@ -980,6 +1060,8 @@ private fun PlayerControls(
onRotate, onRotate,
), ),
) )
add(Triple(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true })
add(Triple(Icons.Default.Lock, "Lock touch", onLock))
} }
entries.forEach { (icon, label, action) -> entries.forEach { (icon, label, action) ->
DropdownMenuItem( DropdownMenuItem(
@@ -1028,11 +1110,29 @@ private fun PlayerControls(
if (landscapeLocked) "Unlock rotation" else "Lock to landscape", if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate, 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) { if (showSpeeds) {
SpeedChooser( SpeedChooser(
current = speed, 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. */ /** Where each chapter begins, ticked onto the seek bar. */
@Composable @Composable
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) { private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
+89 -1
View File
@@ -283,6 +283,8 @@ pub enum Action {
}, },
/// Open or close one of the panels beside the picture. /// Open or close one of the panels beside the picture.
TogglePanel(ui::player::PlayerPanel), TogglePanel(ui::player::PlayerPanel),
/// Pause when this runs out, or turn the timer off.
SetSleep(Option<ui::player::SleepChoice>),
/// Put watch states back as they were — what Undo does. /// Put watch states back as they were — what Undo does.
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>), RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
ClearStreamCache, ClearStreamCache,
@@ -735,6 +737,8 @@ pub struct App {
subtitle_style_unsaved: bool, subtitle_style_unsaved: bool,
/// Which panel is out beside the picture on the player page. /// Which panel is out beside the picture on the player page.
pub player_panel: Option<ui::player::PlayerPanel>, pub player_panel: Option<ui::player::PlayerPanel>,
/// The sleep timer, when one is set.
sleep: Option<Sleep>,
/// Set while the window is fullscreen, so `F` can toggle rather than only /// Set while the window is fullscreen, so `F` can toggle rather than only
/// ever entering. egui has no way to ask the platform. /// ever entering. egui has no way to ask the platform.
pub fullscreen: bool, pub fullscreen: bool,
@@ -845,6 +849,7 @@ impl App {
subtitle_style_unsaved: false, subtitle_style_unsaved: false,
fullscreen: false, fullscreen: false,
player_panel: None, player_panel: None,
sleep: None,
opening: None, opening: None,
connecting: true, connecting: true,
crawling: false, crawling: false,
@@ -1376,7 +1381,7 @@ impl App {
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) -> Option<UpNextCard> { ) -> Option<UpNextCard> {
let has_next = self.neighbours().next; let has_next = self.neighbours().next;
let autoplay = self.engine.playback_prefs().autoplay_next; let autoplay = self.engine.playback_prefs().autoplay_next && !self.stops_after_this();
let next = self.adjacent(Adjacent::Next); let next = self.adjacent(Adjacent::Next);
let Some(playback) = &self.playback else { let Some(playback) = &self.playback else {
@@ -1462,6 +1467,34 @@ impl App {
}) })
} }
/// Whether the sleep timer is set to stop at the end of what is playing.
fn stops_after_this(&self) -> bool {
matches!(
(self.sleep, &self.playback),
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id == id
)
}
/// Pause once the sleep timer runs out. A timer for the end of an episode
/// that is no longer playing — the viewer moved on to another — is
/// dropped: it was about that episode.
fn tick_sleep(&mut self, ctx: &egui::Context) {
match (self.sleep, &self.playback) {
(Some(Sleep::At(at)), Some(playback)) if std::time::Instant::now() >= at => {
if !playback.paused {
playback.send(crate::playback::Command::TogglePause);
}
self.sleep = None;
self.note(ctx, "Sleep timer: paused", false);
}
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id != id => {
self.sleep = None;
}
(Some(_), None) => self.sleep = None,
_ => {}
}
}
/// Once a file has loaded with no chapters naming its opening or ending, /// Once a file has loaded with no chapters naming its opening or ending,
/// ask AniSkip, when the episode's provider entry has a MyAnimeList id. /// ask AniSkip, when the episode's provider entry has a MyAnimeList id.
fn ask_skip_times(&mut self) { fn ask_skip_times(&mut self) {
@@ -1555,6 +1588,11 @@ impl App {
/// end too, and auto-advancing through a broken share one episode at a /// end too, and auto-advancing through a broken share one episode at a
/// time is how a player earns being turned off. /// time is how a player earns being turned off.
fn autoplay_next(&mut self, ctx: &egui::Context) { fn autoplay_next(&mut self, ctx: &egui::Context) {
if self.stops_after_this() {
// The timer has done its job.
self.sleep = None;
return;
}
if !self.engine.playback_prefs().autoplay_next { if !self.engine.playback_prefs().autoplay_next {
return; return;
} }
@@ -2078,6 +2116,29 @@ impl App {
Action::RemoveFromHistory { share_id, link_id } => { Action::RemoveFromHistory { share_id, link_id } => {
self.forget_watch(ctx, share_id, link_id, "history"); self.forget_watch(ctx, share_id, link_id, "history");
} }
Action::SetSleep(choice) => {
self.sleep = match choice {
None => None,
Some(ui::player::SleepChoice::EndOfEpisode) => self
.playback
.as_ref()
.map(|playback| Sleep::EndOfEpisode(playback.id)),
Some(ui::player::SleepChoice::Minutes(minutes)) => Some(Sleep::At(
std::time::Instant::now()
+ std::time::Duration::from_secs(u64::from(minutes) * 60),
)),
};
let text = match choice {
None => "Sleep timer off".to_owned(),
Some(ui::player::SleepChoice::EndOfEpisode) => {
"Stopping after this episode".to_owned()
}
Some(ui::player::SleepChoice::Minutes(minutes)) => {
format!("Pausing in {minutes} min")
}
};
self.flash(ctx, egui_phosphor::regular::MOON, text);
}
Action::TogglePanel(panel) => { Action::TogglePanel(panel) => {
self.player_panel = (self.player_panel != Some(panel)).then_some(panel); self.player_panel = (self.player_panel != Some(panel)).then_some(panel);
} }
@@ -2345,6 +2406,7 @@ impl eframe::App for App {
} }
self.warm_next(); self.warm_next();
self.ask_skip_times(); self.ask_skip_times();
self.tick_sleep(ctx);
self.auto_skip(ctx); self.auto_skip(ctx);
self.save_subtitle_style(ctx); self.save_subtitle_style(ctx);
self.window.observe(ctx); self.window.observe(ctx);
@@ -2404,6 +2466,7 @@ impl eframe::App for App {
metadata, metadata,
episodes, episodes,
player_panel, player_panel,
sleep,
.. ..
} = self; } = self;
// Before the list borrows the art caches for the rest of the frame. // Before the list borrows the art caches for the rest of the frame.
@@ -2498,6 +2561,7 @@ impl eframe::App for App {
episodes: list, episodes: list,
panel: *player_panel, panel: *player_panel,
about, about,
sleep: sleep.map(Sleep::choice),
}, },
&mut actions, &mut actions,
); );
@@ -3165,6 +3229,30 @@ struct PlayerKeys {
skip_to: Option<f64>, skip_to: Option<f64>,
} }
/// A sleep timer that is running.
#[derive(Debug, Clone, Copy)]
enum Sleep {
/// Stop when player `id`'s file ends, rather than going on to the next.
EndOfEpisode(u64),
/// Pause at this moment.
At(std::time::Instant),
}
impl Sleep {
/// What the panel shows as chosen.
fn choice(self) -> ui::player::SleepChoice {
match self {
Self::EndOfEpisode(_) => ui::player::SleepChoice::EndOfEpisode,
Self::At(at) => ui::player::SleepChoice::Minutes(
(at.saturating_duration_since(std::time::Instant::now())
.as_secs()
/ 60) as u32
+ 1,
),
}
}
}
/// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a /// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a
/// tenth, because 1.0 has to be somewhere a viewer can land on again. /// tenth, because 1.0 has to be somewhere a viewer can land on again.
use crate::ui::transport::SPEEDS; use crate::ui::transport::SPEEDS;
+46 -1
View File
@@ -89,8 +89,22 @@ pub enum PlayerPanel {
Tracks, Tracks,
Speed, Speed,
Chapters, Chapters,
Sleep,
} }
/// A sleep timer, as chosen or as it stands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SleepChoice {
/// Stop at the end of the episode playing, rather than go on to the next.
EndOfEpisode,
/// Pause after this many minutes. As it stands, the minutes left, rounded
/// up.
Minutes(u32),
}
/// The timers on offer, besides the end of the episode.
const SLEEP_MINUTES: [u32; 2] = [30, 60];
impl PlayerPanel { impl PlayerPanel {
fn heading(self) -> &'static str { fn heading(self) -> &'static str {
match self { match self {
@@ -98,6 +112,7 @@ impl PlayerPanel {
Self::Tracks => "Audio and subtitles", Self::Tracks => "Audio and subtitles",
Self::Speed => "Playback speed", Self::Speed => "Playback speed",
Self::Chapters => "Chapters", Self::Chapters => "Chapters",
Self::Sleep => "Sleep timer",
} }
} }
} }
@@ -107,6 +122,7 @@ impl PlayerPanel {
struct Panels { struct Panels {
episodes: bool, episodes: bool,
open: Option<PlayerPanel>, open: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
} }
/// What the pause screen says about what is playing. /// What the pause screen says about what is playing.
@@ -172,6 +188,8 @@ pub struct Chrome<'a> {
pub panel: Option<PlayerPanel>, pub panel: Option<PlayerPanel>,
/// What the pause screen shows, while the picture is paused. /// What the pause screen shows, while the picture is paused.
pub about: Option<About>, pub about: Option<About>,
/// The sleep timer, when one is running.
pub sleep: Option<SleepChoice>,
} }
/// The playing title's episodes, for the list beside the picture. /// The playing title's episodes, for the list beside the picture.
@@ -293,6 +311,7 @@ pub fn show(
mut episodes, mut episodes,
panel, panel,
about, about,
sleep,
} = chrome_state; } = chrome_state;
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap(); let rect = ui.available_rect_before_wrap();
@@ -383,6 +402,7 @@ pub fn show(
Panels { Panels {
episodes: episodes.is_some(), episodes: episodes.is_some(),
open: panel, open: panel,
sleep,
}, },
rect, rect,
overlay.chrome_height, overlay.chrome_height,
@@ -403,6 +423,7 @@ pub fn show(
(PlayerPanel::Tracks, _) => track_list(ui, playback, actions), (PlayerPanel::Tracks, _) => track_list(ui, playback, actions),
(PlayerPanel::Speed, _) => speed_list(ui, playback, actions), (PlayerPanel::Speed, _) => speed_list(ui, playback, actions),
(PlayerPanel::Chapters, _) => chapter_list(ui, playback, actions), (PlayerPanel::Chapters, _) => chapter_list(ui, playback, actions),
(PlayerPanel::Sleep, _) => sleep_list(ui, sleep, actions),
(PlayerPanel::Episodes, None) => {} (PlayerPanel::Episodes, None) => {}
}, },
); );
@@ -902,7 +923,7 @@ fn chrome(
.max_rect(content) .max_rect(content)
.layout(egui::Layout::top_down(egui::Align::Min)), .layout(egui::Layout::top_down(egui::Align::Min)),
|ui| { |ui| {
ui::transport::full(ui, playback, neighbours, panels.open, actions); ui::transport::full(ui, playback, neighbours, panels.open, panels.sleep, actions);
ui.min_rect().height() ui.min_rect().height()
}, },
) )
@@ -1112,6 +1133,30 @@ fn speed_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>)
} }
} }
/// The sleep timers, and what the running one has left.
fn sleep_list(ui: &mut egui::Ui, sleep: Option<SleepChoice>, actions: &mut Vec<Action>) {
if let Some(SleepChoice::Minutes(left)) = sleep {
ui.label(
theme::Role::Label
.rich(format!("Pausing in {left} min"))
.color(theme::muted()),
);
ui.add_space(theme::space::S);
}
if option_row(ui, sleep.is_none(), "Off", None).clicked() {
actions.push(Action::SetSleep(None));
}
let end = sleep == Some(SleepChoice::EndOfEpisode);
if option_row(ui, end, "End of this episode", None).clicked() {
actions.push(Action::SetSleep(Some(SleepChoice::EndOfEpisode)));
}
for minutes in SLEEP_MINUTES {
if option_row(ui, false, &format!("{minutes} minutes"), None).clicked() {
actions.push(Action::SetSleep(Some(SleepChoice::Minutes(minutes))));
}
}
}
/// Every chapter, with where it starts, the one playing marked. /// Every chapter, with where it starts, the one playing marked.
fn chapter_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) { fn chapter_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let current = playback.chapter().map(|chapter| chapter.index); let current = playback.chapter().map(|chapter| chapter.index);
+13 -4
View File
@@ -18,7 +18,7 @@ use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback}; use crate::playback::{Command, Playback};
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
use crate::ui::player::PlayerPanel; use crate::ui::player::{PlayerPanel, SleepChoice};
use crate::ui::widgets::Named; use crate::ui::widgets::Named;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it /// Height of the clickable seek bar. Comfortably larger than the few pixels it
@@ -59,6 +59,7 @@ pub fn full(
playback: &Playback, playback: &Playback,
neighbours: Neighbours, neighbours: Neighbours,
panel: Option<PlayerPanel>, panel: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
seek_bar(ui, playback, actions, 6.0); seek_bar(ui, playback, actions, 6.0);
@@ -108,7 +109,7 @@ pub fn full(
// opens its panel down the right of the picture rather than a menu // opens its panel down the right of the picture rather than a menu
// under the pointer: a list of twelve chapters is read, not aimed at. // under the pointer: a list of twelve chapters is read, not aimed at.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
panel_buttons(ui, playback, panel, actions); panel_buttons(ui, playback, panel, sleep, actions);
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
volume(ui, playback, actions); volume(ui, playback, actions);
}); });
@@ -551,12 +552,13 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
} }
/// The buttons that open the player's panels, right to left: tracks, speed, /// The buttons that open the player's panels, right to left: tracks, speed,
/// chapters. Each shows only when there is a choice to make, and says what is /// chapters, sleep. Each shows only when there is a choice to make, and says
/// chosen when that is worth noticing at a glance. /// what is chosen when that is worth noticing at a glance.
fn panel_buttons( fn panel_buttons(
ui: &mut egui::Ui, ui: &mut egui::Ui,
playback: &Playback, playback: &Playback,
open: Option<PlayerPanel>, open: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| { let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| {
@@ -609,6 +611,13 @@ fn panel_buttons(
"Chapters", "Chapters",
); );
} }
let moon = egui_phosphor::regular::MOON;
let sleep = match sleep {
None => moon.to_owned(),
Some(SleepChoice::EndOfEpisode) => format!("{moon} End"),
Some(SleepChoice::Minutes(left)) => format!("{moon} {left} min"),
};
button(ui, PlayerPanel::Sleep, sleep, "Sleep timer");
} }
/// A track in as few words as fit on a button: what distinguishes it from the /// A track in as few words as fit on a button: what distinguishes it from the
+2
View File
@@ -94,6 +94,8 @@ Each of these is the platform's doing, not a choice to diverge.
runs inside a `NavigationStack`, and the title page is shown over the library runs inside a `NavigationStack`, and the title page is shown over the library
by hand, so it keeps its slide in from the edge — Android's shared poster has by hand, so it keeps its slide in from the edge — Android's shared poster has
no counterpart here. no counterpart here.
- **The volume swipe turns the player's own volume**, the one its slider sets.
iOS lets no app set the device's media volume, which Android's swipe turns.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share - **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync arrive without them, and a restored `sync.json` would give two devices one sync
+9 -2
View File
@@ -31,8 +31,15 @@ your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md).
- Tap to show or hide the controls. - Tap to show or hide the controls.
- **Double-tap** the left or right third of the screen to go back or forward 10 seconds. Double-tap - **Double-tap** the left or right third of the screen to go back or forward 10 seconds. Double-tap
the middle to pause. the middle to pause.
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks, and - **Swipe** up and down on the left half for brightness and on the right half for volume. Swipe
**Lock to landscape**. sideways to seek: the bubble shows where you will land, and the seek happens when you let go.
Swipes that start at the very top or bottom of the screen are left to the system.
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks,
**Lock to landscape**, **Sleep timer** and **Lock touch**.
- **Sleep timer** pauses after 30 or 60 minutes, or stops at the end of this episode instead of
going on to the next. It keeps running with the screen off.
- **Lock touch** makes the picture ignore touches, for a phone in a pocket or a child's hands. Tap
to bring up **Unlock**.
- **Skip opening** and **Skip ending** work as on the desktop, AniSkip included: see - **Skip opening** and **Skip ending** work as on the desktop, AniSkip included: see
[The player](USER_GUIDE.md#the-player). [The player](USER_GUIDE.md#the-player).
- Sound keeps playing with the screen off or the app in the background, with controls on the lock - Sound keeps playing with the screen off or the app in the background, with controls on the lock
+5 -2
View File
@@ -140,12 +140,15 @@ The picture plays inside the app's window. Move the mouse to show the controls.
credits** in Settings to skip them without asking. credits** in Settings to skip them without asking.
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose - **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
**Play now**, **Cancel** or **Watch till the end**. **Play now**, **Cancel** or **Watch till the end**.
- **Subtitles and audio**, **Speed** and **Chapters** are buttons at the bottom right. Each opens - **Subtitles and audio**, **Speed**, **Chapters** and **Sleep timer** are buttons at the bottom
a panel down the right of the picture; click the button again, or press Esc, to close it. right. Each opens a panel down the right of the picture; click the button again, or press Esc,
to close it.
- **Subtitles and audio** remembers your choice for each show. The panel also has **Size** and - **Subtitles and audio** remembers your choice for each show. The panel also has **Size** and
**Position**, which are saved like the ones in Settings and change styled subtitles only when **Position**, which are saved like the ones in Settings and change styled subtitles only when
they are restyled, and **Delay**, which moves subtitles earlier or later for this file only. they are restyled, and **Delay**, which moves subtitles earlier or later for this file only.
- **Speed** goes from 0.5× to 3×, and stays for the next episode. - **Speed** goes from 0.5× to 3×, and stays for the next episode.
- **Sleep timer**, the moon, pauses after 30 or 60 minutes, or stops at the end of this episode
instead of going on to the next.
- **Seeking and volume** show what they did in the middle of the picture. Several presses of ← or - **Seeking and volume** show what they did in the middle of the picture. Several presses of ← or
→ add up, so three presses of → say "+ 90 s". → add up, so three presses of → say "+ 90 s".
- **Paused** for 5 seconds with the mouse still, the picture dims and shows what you are watching: - **Paused** for 5 seconds with the mouse still, the picture dims and shows what you are watching:
+46 -1
View File
@@ -105,6 +105,10 @@ final class PlayerHost {
private(set) var autoplay = true private(set) var autoplay = true
/// The rate playing, so the speed list opens on it. /// The rate playing, so the speed list opens on it.
var speed = 1.0 var speed = 1.0
/// The sleep timer, when one is running. Here rather than on the view,
/// because the episode it is about plays on with the screen off.
private(set) var sleep: SleepTimer?
@ObservationIgnored private var sleepTask: Task<Void, Never>?
/// Persist where an episode was left: position, duration, watched. /// Persist where an episode was left: position, duration, watched.
@ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)? @ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)?
@@ -377,11 +381,32 @@ final class PlayerHost {
// MARK: - Reacting to the player // MARK: - Reacting to the player
/// Start a sleep timer, replacing any running one, or turn it off.
func setSleep(_ timer: SleepTimer?) {
sleepTask?.cancel()
sleep = timer
guard case let .at(when) = timer else { return }
sleepTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(max(when.timeIntervalSinceNow, 0)))
guard !Task.isCancelled, let self else { return }
self.sleep = nil
self.setPaused(true)
}
}
/// Whether the sleep timer stops this episode at its end.
var stopsAfterThis: Bool {
if case let .endOf(media) = sleep { media == key } else { false }
}
private func ended(_ reason: EndReason, _ media: String) { private func ended(_ reason: EndReason, _ media: String) {
guard media == key else { return } guard media == key else { return }
save() save()
// Autoplay fires on a clean end of file only — never on a failure. // Autoplay fires on a clean end of file only — never on a failure.
if reason == .eof, autoplay, !stay, next != nil { // The sleep timer set for the end of this episode stops it there.
if reason == .eof, stopsAfterThis {
setSleep(nil)
} else if reason == .eof, autoplay, !stay, next != nil {
advance() advance()
} else if reason == .failed { } else if reason == .failed {
playbackError = player?.problem ?? "This episode could not be played." playbackError = player?.problem ?? "This episode could not be played."
@@ -390,6 +415,9 @@ final class PlayerHost {
} }
private func tick() { private func tick() {
// A timer for the end of an episode the viewer has since moved on
// from was about that episode.
if case let .endOf(media) = sleep, media != key { setSleep(nil) }
guard let player, playingOpenEpisode else { return } guard let player, playingOpenEpisode else { return }
replanChapters(player) replanChapters(player)
// A stall that does not clear is a dropped connection far more often // A stall that does not clear is a dropped connection far more often
@@ -702,3 +730,20 @@ final class PlayerHost {
} }
} }
} }
/// A sleep timer that is running.
enum SleepTimer: Equatable {
/// Stop when this episode ends, rather than going on to the next.
case endOf(String)
/// Pause at this moment.
case at(Date)
/// What its button says: whether one is running, and how long it has.
static func label(_ timer: SleepTimer?) -> String {
switch timer {
case nil: "Sleep timer"
case .endOf: "Sleep timer: end of episode"
case let .at(when): "Sleep timer: \(Int(max(when.timeIntervalSinceNow, 0) / 60) + 1) min"
}
}
}
+207 -8
View File
@@ -15,6 +15,16 @@ private let inlineControlsMinWidth: CGFloat = 560
/// How far a double tap on either side of the picture seeks. /// How far a double tap on either side of the picture seeks.
private let doubleTapSeconds = 10 private let doubleTapSeconds = 10
/// How much of the file one swipe across the whole width is worth, in seconds.
private let seekSpanSeconds = 90.0
/// A finger that comes down this close to the top or bottom of the screen is
/// the system's: Control Centre, the home indicator.
private let systemEdge: CGFloat = 48
/// How long the unlock button stays up after a tap on a locked player.
private let unlockShownSeconds = 3.0
/// The rates worth offering. A short list rather than a slider: the useful /// The rates worth offering. A short list rather than a slider: the useful
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0. /// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
@@ -37,6 +47,16 @@ struct PlayerView: View {
/// to have happened; the count makes a second tap on one side show again. /// to have happened; the count makes a second tap on one side show again.
@State private var nudge: (seconds: Int, count: Int)? @State private var nudge: (seconds: Int, count: Int)?
@Environment(\.accessibilityVoiceOverEnabled) private var voiceOver @Environment(\.accessibilityVoiceOverEnabled) private var voiceOver
/// Locked, the picture takes no touches but one: the button that unlocks
/// it, which a tap brings up for a moment.
@State private var locked = false
@State private var unlockShown = 0
/// What a swipe on the picture is doing, while it is under way.
@State private var swipe: Swipe?
@State private var swipeStart: (axis: SwipeAxis, level: Double, position: Double)?
/// The screen's brightness before the first swipe changed it, put back
/// when the player goes.
@State private var brightnessBefore: CGFloat?
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@@ -49,8 +69,13 @@ struct PlayerView: View {
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.ignoresSafeArea() .ignoresSafeArea()
.onTapGesture(count: 2) { location in doubleTap(at: location.x, width: geometry.size.width) } .onTapGesture(count: 2) { location in
.onTapGesture { setControls(!showControls) } if !locked { doubleTap(at: location.x, width: geometry.size.width) }
}
.onTapGesture {
if locked { unlockShown += 1 } else { setControls(!showControls) }
}
.gesture(swipeGesture(geometry.size), including: locked ? .none : .all)
.accessibilityElement() .accessibilityElement()
.accessibilityLabel(showControls ? "Hide controls" : "Show controls") .accessibilityLabel(showControls ? "Hide controls" : "Show controls")
.accessibilityAddTraits(.isButton) .accessibilityAddTraits(.isButton)
@@ -72,10 +97,28 @@ struct PlayerView: View {
} }
} }
if let swipe {
SwipeBubble(swipe: swipe).allowsHitTesting(false).accessibilityHidden(true)
}
if locked, unlockShown > 0 {
Button("Unlock", systemImage: "lock.open") {
locked = false
unlockShown = 0
setControls(true)
}
.buttonStyle(.tonal)
.transition(.opacity)
.task(id: unlockShown) {
try? await Task.sleep(for: .seconds(unlockShownSeconds))
if !Task.isCancelled { unlockShown = 0 }
}
}
VStack(spacing: 0) { VStack(spacing: 0) {
if showControls { topBar.transition(.opacity) } if showControls, !locked { topBar.transition(.opacity) }
Spacer(minLength: 0) Spacer(minLength: 0)
if showControls { if showControls, !locked {
PlayerControls( PlayerControls(
host: host, host: host,
player: player, player: player,
@@ -87,7 +130,12 @@ struct PlayerView: View {
landscapeLocked = OrientationLock.toggle() landscapeLocked = OrientationLock.toggle()
}, },
onSheet: { sheet = $0 }, onSheet: { sheet = $0 },
onInteract: { setControls(true) } onInteract: { setControls(true) },
onLock: {
locked = true
unlockShown = 1
setControls(false)
}
) )
.transition(.opacity) .transition(.opacity)
} }
@@ -113,7 +161,7 @@ struct PlayerView: View {
}() }()
UpNextCard( UpNextCard(
next: host.next, next: host.next,
visible: host.autoplay && !host.stay && !player.paused && player.endReason == nil visible: host.autoplay && !host.stay && !host.stopsAfterThis && !player.paused && player.endReason == nil
&& host.playingOpenEpisode && host.playingOpenEpisode
&& (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true), && (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true),
secondsLeft: tailLeft.map { Int($0.rounded(.up)) }, secondsLeft: tailLeft.map { Int($0.rounded(.up)) },
@@ -144,7 +192,7 @@ struct PlayerView: View {
// Played to the end with nothing starting after it: the last // Played to the end with nothing starting after it: the last
// episode, autoplay off, or the viewer stayed. // episode, autoplay off, or the viewer stayed.
if host.playingOpenEpisode, player.endReason == .eof, if host.playingOpenEpisode, player.endReason == .eof,
host.next == nil || !host.autoplay || host.stay { host.next == nil || !host.autoplay || host.stay || host.stopsAfterThis {
EndCard(host: host) EndCard(host: host)
} }
@@ -174,6 +222,10 @@ struct PlayerView: View {
.statusBarHidden(!showControls) .statusBarHidden(!showControls)
.persistentSystemOverlays(.hidden) .persistentSystemOverlays(.hidden)
.onAppear { setControls(true) } .onAppear { setControls(true) }
// Brightness set by a swipe is the player's, not the phone's.
.onDisappear {
if let brightnessBefore { Screen.brightness = brightnessBefore }
}
.onChange(of: player.paused) { setControls(showControls) } .onChange(of: player.paused) { setControls(showControls) }
// VoiceOver moves between controls by swiping, and a control that // VoiceOver moves between controls by swiping, and a control that
// fades four seconds after it is reached cannot be found again. // fades four seconds after it is reached cannot be found again.
@@ -192,6 +244,8 @@ struct PlayerView: View {
case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) } case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) }
case let .tracks(type): case let .tracks(type):
TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) } TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) }
case .sleep:
SleepSheet(current: host.sleep, media: host.key, onSelect: host.setSleep)
} }
} }
} }
@@ -246,6 +300,54 @@ struct PlayerView: View {
} }
} }
/// 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. Volume
/// is the player's own: iOS lets no app set the device's.
private func swipeGesture(_ size: CGSize) -> some Gesture {
DragGesture(minimumDistance: 16)
.onChanged { drag in
if swipeStart == nil {
let start = drag.startLocation
guard start.y > systemEdge, start.y < size.height - systemEdge else { return }
let axis: SwipeAxis = abs(drag.translation.width) > abs(drag.translation.height)
? .seek : start.x < size.width / 2 ? .brightness : .volume
let level: Double = switch axis {
case .brightness: Double(Screen.brightness)
case .volume: player.volume / 100
case .seek: 0
}
if axis == .brightness, brightnessBefore == nil { brightnessBefore = Screen.brightness }
swipeStart = (axis, level, player.position)
}
guard let start = swipeStart else { return }
switch start.axis {
// A swipe the whole height of the screen goes from none to all.
case .brightness:
let level = min(max(start.level - drag.translation.height / size.height, 0.01), 1)
Screen.brightness = CGFloat(level)
swipe = .brightness(level)
case .volume:
let level = min(max(start.level - drag.translation.height / size.height, 0), 1)
player.setVolume(level * 100)
swipe = .volume(level)
case .seek:
guard player.duration > 0 else { return }
let target = min(max(start.position + drag.translation.width / size.width * seekSpanSeconds, 0), player.duration)
swipe = .seek(from: start.position, to: target)
}
}
.onEnded { _ in
if case let .seek(from, to) = swipe, to != from {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
host.seek(to: to)
}
if case let .volume(level) = swipe { host.rememberVolume(volume: level * 100) }
swipe = nil
swipeStart = nil
}
}
private func doubleTap(at x: CGFloat, width: CGFloat) { private func doubleTap(at x: CGFloat, width: CGFloat) {
let third = width / 3 let third = width / 3
let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0 let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0
@@ -261,7 +363,7 @@ struct PlayerView: View {
} }
enum PlayerSheet: Identifiable { enum PlayerSheet: Identifiable {
case speed, episodes, chapters case speed, episodes, chapters, sleep
case tracks(String) case tracks(String)
var id: String { var id: String {
@@ -269,11 +371,66 @@ enum PlayerSheet: Identifiable {
case .speed: "speed" case .speed: "speed"
case .episodes: "episodes" case .episodes: "episodes"
case .chapters: "chapters" case .chapters: "chapters"
case .sleep: "sleep"
case let .tracks(type): "tracks-\(type)" case let .tracks(type): "tracks-\(type)"
} }
} }
} }
/// Which way a swipe went, settled by its first movement.
private enum SwipeAxis { case brightness, volume, seek }
/// What a swipe on the picture is doing, for the bubble that says so.
private enum Swipe {
/// From 0 to 1.
case brightness(Double)
case volume(Double)
/// Where the playhead will land when the finger lifts, and where it was.
case seek(from: Double, to: Double)
}
/// The screen the player is on, for its brightness.
@MainActor
private enum Screen {
static var brightness: CGFloat {
get { scene?.screen.brightness ?? 0.5 }
set { scene?.screen.brightness = newValue }
}
private static var scene: UIWindowScene? {
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
}
}
/// The bubble in the middle of the picture while a swipe is under way.
private struct SwipeBubble: View {
let swipe: Swipe
@Environment(\.scheme) private var scheme
var body: some View {
VStack(spacing: 8) {
switch swipe {
case let .brightness(value): level(icon: "sun.max.fill", label: "Brightness", value: value)
case let .volume(value): level(icon: "speaker.wave.2.fill", label: "Volume", value: value)
case let .seek(from, to):
Image(systemName: to < from ? "backward.fill" : "forward.fill").font(.system(size: 28))
Text((to < from ? "−" : "+") + clock(abs(to - from))).textStyle(.titleMedium)
Text(clock(to)).textStyle(.labelMedium).opacity(0.7)
}
}
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 16)
.background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 20))
}
@ViewBuilder private func level(icon: String, label: String, value: Double) -> some View {
Image(systemName: icon).font(.system(size: 28))
Text("\(label) \(Int((value * 100).rounded()))%").textStyle(.labelLarge)
ProgressView(value: value).tint(scheme.primary).frame(width: 120)
}
}
/// Everything the transport offers, in two rows: the seek bar on its own, /// Everything the transport offers, in two rows: the seek bar on its own,
/// then the transport cluster on the left, where a thumb already is, and the /// then the transport cluster on the left, where a thumb already is, and the
/// pickers on the right. /// pickers on the right.
@@ -286,6 +443,7 @@ private struct PlayerControls: View {
let onRotate: () -> Void let onRotate: () -> Void
let onSheet: (PlayerSheet) -> Void let onSheet: (PlayerSheet) -> Void
let onInteract: () -> Void let onInteract: () -> Void
let onLock: () -> Void
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
// Where the thumb is while a finger is on it: a seek per drag step is a // Where the thumb is while a finger is on it: a seek per drag step is a
// block fetch per drag step, and the thumb would snap back under the // block fetch per drag step, and the thumb would snap back under the
@@ -352,6 +510,8 @@ private struct PlayerControls: View {
systemImage: landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right", systemImage: landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
action: onRotate action: onRotate
) )
Button(SleepTimer.label(host.sleep), systemImage: "moon.zzz") { onSheet(.sleep) }
Button("Lock touch", systemImage: "lock", action: onLock)
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
.font(.system(size: 20)) .font(.system(size: 20))
@@ -413,6 +573,8 @@ private struct PlayerControls: View {
landscapeLocked ? "Unlock rotation" : "Lock to landscape", landscapeLocked ? "Unlock rotation" : "Lock to landscape",
action: onRotate action: onRotate
) )
CompactIcon("moon.zzz", SleepTimer.label(host.sleep)) { onSheet(.sleep) }
CompactIcon("lock", "Lock touch", action: onLock)
} }
} }
@@ -550,6 +712,43 @@ private struct EndCard: View {
} }
} }
/// Off, the end of this episode, or a number of minutes.
private struct SleepSheet: View {
let current: SleepTimer?
/// The open episode, which "End of this episode" is about.
let media: String?
let onSelect: (SleepTimer?) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
DialogSheet("Sleep timer") {
if case .at = current {
Text(SleepTimer.label(current).replacingOccurrences(of: "Sleep timer: ", with: "") + " left")
.textStyle(.bodyMedium)
}
choice("Off", chosen: current == nil) { nil }
if let media {
choice("End of this episode", chosen: current == .endOf(media)) { .endOf(media) }
}
ForEach([30, 60], id: \.self) { minutes in
choice("\(minutes) minutes", chosen: false) { .at(.now.addingTimeInterval(Double(minutes) * 60)) }
}
} buttons: {
Button("Close") { dismiss() }.buttonStyle(.tonal)
}
}
private func choice(_ label: String, chosen: Bool, timer: @escaping () -> SleepTimer?) -> some View {
Button {
onSelect(timer())
dismiss()
} label: {
Text((chosen ? "✓ " : "") + label).frame(maxWidth: .infinity)
}
.buttonStyle(.edgedWide)
}
}
private struct SpeedSheet: View { private struct SpeedSheet: View {
let current: Double let current: Double
let onSelect: (Double) -> Void let onSelect: (Double) -> Void