diff --git a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt index f8abdf7..8815ca8 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt @@ -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(null) private val mutableAdvancing = MutableStateFlow(false) private val mutableProblem = MutableStateFlow(null) + private val mutableSleep = MutableStateFlow(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 = 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 = 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 diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerGestures.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerGestures.kt new file mode 100644 index 0000000..9d24e73 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerGestures.kt @@ -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 = {}, + ) + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt index 6c98371..904bfc9 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -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(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(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>( + 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, duration: Double, modifier: Modifier) { diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index b7c23fd..beeb21c 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -283,6 +283,8 @@ pub enum Action { }, /// Open or close one of the panels beside the picture. TogglePanel(ui::player::PlayerPanel), + /// Pause when this runs out, or turn the timer off. + SetSleep(Option), /// Put watch states back as they were — what Undo does. RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>), ClearStreamCache, @@ -735,6 +737,8 @@ pub struct App { subtitle_style_unsaved: bool, /// Which panel is out beside the picture on the player page. pub player_panel: Option, + /// The sleep timer, when one is set. + sleep: Option, /// Set while the window is fullscreen, so `F` can toggle rather than only /// ever entering. egui has no way to ask the platform. pub fullscreen: bool, @@ -845,6 +849,7 @@ impl App { subtitle_style_unsaved: false, fullscreen: false, player_panel: None, + sleep: None, opening: None, connecting: true, crawling: false, @@ -1376,7 +1381,7 @@ impl App { actions: &mut Vec, ) -> Option { 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 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, /// ask AniSkip, when the episode's provider entry has a MyAnimeList id. 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 /// time is how a player earns being turned off. 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 { return; } @@ -2078,6 +2116,29 @@ impl App { Action::RemoveFromHistory { share_id, link_id } => { 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) => { self.player_panel = (self.player_panel != Some(panel)).then_some(panel); } @@ -2345,6 +2406,7 @@ impl eframe::App for App { } self.warm_next(); self.ask_skip_times(); + self.tick_sleep(ctx); self.auto_skip(ctx); self.save_subtitle_style(ctx); self.window.observe(ctx); @@ -2404,6 +2466,7 @@ impl eframe::App for App { metadata, episodes, player_panel, + sleep, .. } = self; // Before the list borrows the art caches for the rest of the frame. @@ -2498,6 +2561,7 @@ impl eframe::App for App { episodes: list, panel: *player_panel, about, + sleep: sleep.map(Sleep::choice), }, &mut actions, ); @@ -3165,6 +3229,30 @@ struct PlayerKeys { skip_to: Option, } +/// 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 /// tenth, because 1.0 has to be somewhere a viewer can land on again. use crate::ui::transport::SPEEDS; diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index 419b4fa..0c6e051 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -89,8 +89,22 @@ pub enum PlayerPanel { Tracks, Speed, 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 { fn heading(self) -> &'static str { match self { @@ -98,6 +112,7 @@ impl PlayerPanel { Self::Tracks => "Audio and subtitles", Self::Speed => "Playback speed", Self::Chapters => "Chapters", + Self::Sleep => "Sleep timer", } } } @@ -107,6 +122,7 @@ impl PlayerPanel { struct Panels { episodes: bool, open: Option, + sleep: Option, } /// What the pause screen says about what is playing. @@ -172,6 +188,8 @@ pub struct Chrome<'a> { pub panel: Option, /// What the pause screen shows, while the picture is paused. pub about: Option, + /// The sleep timer, when one is running. + pub sleep: Option, } /// The playing title's episodes, for the list beside the picture. @@ -293,6 +311,7 @@ pub fn show( mut episodes, panel, about, + sleep, } = chrome_state; let ctx = ui.ctx().clone(); let rect = ui.available_rect_before_wrap(); @@ -383,6 +402,7 @@ pub fn show( Panels { episodes: episodes.is_some(), open: panel, + sleep, }, rect, overlay.chrome_height, @@ -403,6 +423,7 @@ pub fn show( (PlayerPanel::Tracks, _) => track_list(ui, playback, actions), (PlayerPanel::Speed, _) => speed_list(ui, playback, actions), (PlayerPanel::Chapters, _) => chapter_list(ui, playback, actions), + (PlayerPanel::Sleep, _) => sleep_list(ui, sleep, actions), (PlayerPanel::Episodes, None) => {} }, ); @@ -902,7 +923,7 @@ fn chrome( .max_rect(content) .layout(egui::Layout::top_down(egui::Align::Min)), |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() }, ) @@ -1112,6 +1133,30 @@ fn speed_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec) } } +/// The sleep timers, and what the running one has left. +fn sleep_list(ui: &mut egui::Ui, sleep: Option, actions: &mut Vec) { + 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. fn chapter_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec) { let current = playback.chapter().map(|chapter| chapter.index); diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs index 9f2946a..e66a420 100644 --- a/crates/pstr-app/src/ui/transport.rs +++ b/crates/pstr-app/src/ui/transport.rs @@ -18,7 +18,7 @@ use crate::app::{Action, Adjacent, Page}; use crate::playback::{Command, Playback}; use crate::theme; use crate::ui; -use crate::ui::player::PlayerPanel; +use crate::ui::player::{PlayerPanel, SleepChoice}; use crate::ui::widgets::Named; /// Height of the clickable seek bar. Comfortably larger than the few pixels it @@ -59,6 +59,7 @@ pub fn full( playback: &Playback, neighbours: Neighbours, panel: Option, + sleep: Option, actions: &mut Vec, ) { 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 // 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| { - panel_buttons(ui, playback, panel, actions); + panel_buttons(ui, playback, panel, sleep, actions); ui.add_space(theme::space::M); 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, -/// chapters. Each shows only when there is a choice to make, and says what is -/// chosen when that is worth noticing at a glance. +/// chapters, sleep. Each shows only when there is a choice to make, and says +/// what is chosen when that is worth noticing at a glance. fn panel_buttons( ui: &mut egui::Ui, playback: &Playback, open: Option, + sleep: Option, actions: &mut Vec, ) { let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| { @@ -609,6 +611,13 @@ fn panel_buttons( "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 diff --git a/docs/IOS.md b/docs/IOS.md index 3e28a9c..d212806 100644 --- a/docs/IOS.md +++ b/docs/IOS.md @@ -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 by hand, so it keeps its slide in from the edge — Android's shared poster has 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 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 diff --git a/docs/MOBILE.md b/docs/MOBILE.md index 98c5724..fbe5d9d 100644 --- a/docs/MOBILE.md +++ b/docs/MOBILE.md @@ -31,8 +31,15 @@ your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md). - 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 the middle to pause. -- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks, and - **Lock to landscape**. +- **Swipe** up and down on the left half for brightness and on the right half for volume. Swipe + 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 [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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fed6e66..b34fc41 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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. - **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose **Play now**, **Cancel** or **Watch till the end**. -- **Subtitles and audio**, **Speed** and **Chapters** are buttons at the bottom right. Each opens - a panel down the right of the picture; click the button again, or press Esc, to close it. +- **Subtitles and audio**, **Speed**, **Chapters** and **Sleep timer** are buttons at the bottom + 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 **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. - **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 → 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: diff --git a/ios/ProtonStream/Player/PlayerHost.swift b/ios/ProtonStream/Player/PlayerHost.swift index 1501cc6..15e2e01 100644 --- a/ios/ProtonStream/Player/PlayerHost.swift +++ b/ios/ProtonStream/Player/PlayerHost.swift @@ -105,6 +105,10 @@ final class PlayerHost { private(set) var autoplay = true /// The rate playing, so the speed list opens on it. 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? /// Persist where an episode was left: position, duration, watched. @ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)? @@ -377,11 +381,32 @@ final class PlayerHost { // 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) { guard media == key else { return } save() // 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() } else if reason == .failed { playbackError = player?.problem ?? "This episode could not be played." @@ -390,6 +415,9 @@ final class PlayerHost { } 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 } replanChapters(player) // 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" + } + } +} diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift index 5c4dfb9..e57308b 100644 --- a/ios/ProtonStream/Player/PlayerView.swift +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -15,6 +15,16 @@ private let inlineControlsMinWidth: CGFloat = 560 /// How far a double tap on either side of the picture seeks. 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 /// 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] @@ -37,6 +47,16 @@ struct PlayerView: View { /// to have happened; the count makes a second tap on one side show again. @State private var nudge: (seconds: Int, count: Int)? @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 { GeometryReader { geometry in @@ -49,8 +69,13 @@ struct PlayerView: View { Color.clear .contentShape(Rectangle()) .ignoresSafeArea() - .onTapGesture(count: 2) { location in doubleTap(at: location.x, width: geometry.size.width) } - .onTapGesture { setControls(!showControls) } + .onTapGesture(count: 2) { location in + 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() .accessibilityLabel(showControls ? "Hide controls" : "Show controls") .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) { - if showControls { topBar.transition(.opacity) } + if showControls, !locked { topBar.transition(.opacity) } Spacer(minLength: 0) - if showControls { + if showControls, !locked { PlayerControls( host: host, player: player, @@ -87,7 +130,12 @@ struct PlayerView: View { landscapeLocked = OrientationLock.toggle() }, onSheet: { sheet = $0 }, - onInteract: { setControls(true) } + onInteract: { setControls(true) }, + onLock: { + locked = true + unlockShown = 1 + setControls(false) + } ) .transition(.opacity) } @@ -113,7 +161,7 @@ struct PlayerView: View { }() UpNextCard( 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 && (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true), 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 // episode, autoplay off, or the viewer stayed. 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) } @@ -174,6 +222,10 @@ struct PlayerView: View { .statusBarHidden(!showControls) .persistentSystemOverlays(.hidden) .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) } // VoiceOver moves between controls by swiping, and a control that // 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 let .tracks(type): 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) { let third = width / 3 let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0 @@ -261,7 +363,7 @@ struct PlayerView: View { } enum PlayerSheet: Identifiable { - case speed, episodes, chapters + case speed, episodes, chapters, sleep case tracks(String) var id: String { @@ -269,11 +371,66 @@ enum PlayerSheet: Identifiable { case .speed: "speed" case .episodes: "episodes" case .chapters: "chapters" + case .sleep: "sleep" 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, /// then the transport cluster on the left, where a thumb already is, and the /// pickers on the right. @@ -286,6 +443,7 @@ private struct PlayerControls: View { let onRotate: () -> Void let onSheet: (PlayerSheet) -> Void let onInteract: () -> Void + let onLock: () -> Void @Environment(\.scheme) private var scheme // 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 @@ -352,6 +510,8 @@ private struct PlayerControls: View { systemImage: landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right", action: onRotate ) + Button(SleepTimer.label(host.sleep), systemImage: "moon.zzz") { onSheet(.sleep) } + Button("Lock touch", systemImage: "lock", action: onLock) } label: { Image(systemName: "ellipsis.circle") .font(.system(size: 20)) @@ -413,6 +573,8 @@ private struct PlayerControls: View { landscapeLocked ? "Unlock rotation" : "Lock to landscape", 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 { let current: Double let onSelect: (Double) -> Void