diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt index eb4a4a4..c1db6c3 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt @@ -19,9 +19,11 @@ import android.media.session.MediaSession import android.media.session.PlaybackState import android.os.Binder import android.os.IBinder +import android.os.SystemClock import androidx.core.content.ContextCompat import io.narl.protonstream.MainActivity import io.narl.protonstream.R +import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.ui.loadArtwork import io.narl.protonstream.ui.loadThumbnail @@ -33,6 +35,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** Owns libmpv so audio survives Activity recreation and rotation. */ @@ -81,6 +85,9 @@ class PlaybackService : Service() { private var notified: NotificationShape? = null /** The pending teardown, held so an episode transition can call it off. */ private var retire: Job? = null + /** The open episode's latest reading, which [track] saves. */ + private var lastReading: MpvPlaybackState? = null + private var lastSave = 0L /** The episode's artwork, and the source key it was fetched for. */ private var artwork: Pair? = null private val binder = PlaybackBinder() @@ -115,7 +122,7 @@ class PlaybackService : Service() { } mediaSession.isActive = true } - onStateChanged = { updateSession(it) } + onStateChanged = { updateSession(it); track(it) } onExplicitStop = { retirePlayback() } } mediaSession = MediaSession(this, "proton-stream").apply { @@ -164,6 +171,22 @@ class PlaybackService : Service() { return START_NOT_STICKY } + /** + * Swiped away from Recents. With background audio on, this service would + * otherwise play on with no screen to return to and no autoplay, so it + * saves where the episode got to and stops. + */ + override fun onTaskRemoved(rootIntent: Intent?) { + lastReading?.let { reading -> + // Bounded and on this thread: the process may go as soon as the + // service stops, and a save launched into its scope would go with it. + runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } } + } + host?.stop() + retirePlayback() + super.onTaskRemoved(rootIntent) + } + override fun onDestroy() { scope.cancel() runCatching { unregisterReceiver(becomingNoisy) } @@ -225,6 +248,52 @@ class PlaybackService : Service() { return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED } + /** + * Keep the resume position from here rather than from the player screen. + * + * This service outlives the activity whenever background audio is on, and a + * position the screen alone kept stopped being saved the moment the screen + * went. Readings are attributed by [MpvPlaybackState.media], so an episode + * change saves the outgoing episode's last reading before the new one's. + */ + private fun track(state: MpvPlaybackState) { + val media = state.media ?: return + if (state.duration <= 0.0) return + val previous = lastReading + lastReading = state + val now = SystemClock.elapsedRealtime() + val due = when { + previous != null && previous.media != media -> { + scope.launch(Dispatchers.IO) { save(previous) } + true + } + state.ended -> previous?.ended != true + state.paused -> previous?.paused != true + else -> now - lastSave >= SAVE_EVERY_MS + } + if (due) { + lastSave = now + scope.launch(Dispatchers.IO) { save(state) } + } + } + + private suspend fun save(state: MpvPlaybackState) { + val media = state.media ?: return + // Played to the end, or near enough that what is left is credits. A + // failure or a stop also "ends", and recording either as watched would + // retire an episode nobody saw. + val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION + runCatching { + NativeRuntime.engine().saveWatchState( + media.substringBeforeLast(':'), + media.substringAfterLast(':'), + state.position.coerceIn(0.0, state.duration), + state.duration, + watched, + ) + } + } + private fun updateSession(state: MpvPlaybackState) { refreshSession(state, host?.nowPlaying?.value) if (state.ended) scheduleRetire() else cancelRetire() @@ -419,6 +488,9 @@ class PlaybackService : Service() { private const val TRANSITION_GRACE_MS = 30_000L /** How far the volume drops for a navigation prompt or a notification. */ private const val DUCK_FACTOR = 0.3 + private const val SAVE_EVERY_MS = 5_000L + /** How long a swipe from Recents may hold the main thread to save. */ + private const val SAVE_ON_EXIT_MS = 2_000L /** Catppuccin Mocha mauve, the accent the app is themed with. */ private const val ACCENT = 0xFFCBA6F7.toInt() 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 f84a093..fd500e3 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -71,6 +72,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -107,6 +109,7 @@ import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TrackPreferencesRecord import uniffi.pstr_android.skipOffer +import kotlin.math.ceil import kotlin.math.roundToInt /** How long the controls stay up after a tap. */ @@ -116,7 +119,7 @@ private const val CONTROLS_SECONDS = 4L private const val UP_NEXT_SECONDS = 10 /** Past this much of an episode it counts as watched. */ -private const val WATCHED_FRACTION = 0.9 +internal const val WATCHED_FRACTION = 0.9 /** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */ private const val MAX_VOLUME = 100f @@ -191,6 +194,10 @@ fun PlayerScreen( var retryFrom by remember(key) { mutableStateOf(null) } val session = remember(key, attempt) { RustStreamSession(episode) } var playbackError by remember(key) { mutableStateOf(null) } + // From the click until mpv has the file: the share session, the revision + // and its block list all come first, and on a slow link that is seconds of + // black screen unless something says so. + var opening by remember(key, attempt) { mutableStateOf(true) } LaunchedEffect(session, host) { runCatching { @@ -229,6 +236,7 @@ fun PlayerScreen( nativeHost.setSpeed(startup.speed) session.transferToPlayer() }.onFailure { playbackError = it.message ?: "Playback failed" } + opening = false } // Per episode: the stream Rust holds open. The surface is the screen's, not @@ -266,12 +274,8 @@ fun PlayerScreen( ) } } - LaunchedEffect(key) { - while (true) { - delay(5_000) - save() - } - } + // The five-second save is PlaybackService's, which outlives this screen; + // these are the moments only the screen sees. DisposableEffect(key) { onDispose { save() } } val next = episodes.getOrNull(index + 1) @@ -323,8 +327,10 @@ fun PlayerScreen( // Autoplay fires on a clean end of file only, and only for this episode'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. + var stay by remember(key) { mutableStateOf(false) } LaunchedEffect(state.endReason, state.media) { - if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance() + if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance() } // 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. @@ -491,21 +497,45 @@ fun PlayerScreen( ) { Text(skip.label) } } + // A file with no chapters has no credits to count over, so the card + // takes its last seconds instead: without it the next episode cut in + // with no warning and no way to stay. Counted on the file's own clock, + // so it reaches zero at the end of file, which is what advances. + val tailLeft = (state.duration - state.position).takeIf { + chapters.creditsStart == null && state.duration > UP_NEXT_SECONDS * 6 && it <= UP_NEXT_SECONDS + } UpNextCard( next = next, - visible = autoplay && !state.paused && playingThis && - chapters.creditsStart?.let { state.position >= it } == true, + visible = autoplay && !stay && !state.paused && !state.ended && playingThis && + (tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true), + secondsLeft = tailLeft?.let { ceil(it).toInt() }, onPlayNow = { advance() }, + onStay = { stay = true }, modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding(WindowInsets.safeDrawing) .padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp), ) + // 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)) { + EndCard( + finished = episode.label, + show = title.displayName, + next = next, + onPlayNext = { advance() }, + // mpv unloads a file at its end, so this opens it again. + onReplay = { retryFrom = 0.0; attempt++ }, + onClose = onClose, + modifier = Modifier.align(Alignment.Center), + ) + } + // Only while nothing else explains the still picture: an error is the // more useful thing to say, and a spinner over it reads as "still // trying". - if (state.stalled && playbackError == null) { + if ((opening || state.stalled) && playbackError == null) { Column( Modifier .align(Alignment.Center) @@ -517,10 +547,13 @@ fun PlayerScreen( color = MaterialTheme.colorScheme.primary, modifier = Modifier.size(36.dp), ) - if (state.buffering) { + if (opening || state.buffering) { Text( - state.cachePercent.takeIf { it > 0.0 } - ?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…", + when { + opening -> "Opening ${episode.label}…" + state.cachePercent > 0.0 -> "Buffering… ${state.cachePercent.roundToInt()}%" + else -> "Buffering…" + }, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 12.dp), @@ -1016,24 +1049,31 @@ private fun EpisodeChooser( * * "Watch till the end" holds for the rest of the file: someone who wants the * post-credits scene should have to say so once, not once every ten seconds. + * + * With [secondsLeft] the card counts down the end of a file that has no credits + * chapter, on the file's own clock; the end of file is what advances, and the + * second button is "Cancel", since there are no credits to watch. */ @Composable private fun UpNextCard( next: EpisodeRecord?, visible: Boolean, + secondsLeft: Int?, onPlayNow: () -> Unit, + onStay: () -> Unit, modifier: Modifier = Modifier, ) { var held by remember(next?.linkId) { mutableStateOf(false) } - var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) } + var counted by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) } + val remaining = secondsLeft ?: counted val upNext = next val showing = visible && upNext != null && !held - LaunchedEffect(showing) { - if (!showing) return@LaunchedEffect - remaining = UP_NEXT_SECONDS - while (remaining > 0) { + LaunchedEffect(showing, secondsLeft == null) { + if (!showing || secondsLeft != null) return@LaunchedEffect + counted = UP_NEXT_SECONDS + while (counted > 0) { delay(1_000) - remaining -= 1 + counted -= 1 } onPlayNow() } @@ -1096,8 +1136,49 @@ private fun UpNextCard( ) } } - QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) { - Text("Watch till the end") + if (secondsLeft != null) { + QuietButton(onClick = { held = true; onStay() }, modifier = Modifier.padding(top = 4.dp)) { + Text("Cancel") + } + } else { + QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) { + Text("Watch till the end") + } + } + } +} + +/** What the player says once an episode ends and nothing starts after it. */ +@Composable +private fun EndCard( + finished: String, + show: String, + next: EpisodeRecord?, + onPlayNext: () -> Unit, + onReplay: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier + .widthIn(max = 360.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + if (next == null) "That was the last episode of $show" else "Finished $finished", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) { + if (next != null) { + AccentButton(onClick = onPlayNext) { Text("Play next") } + } else { + QuietButton(onClick = onReplay) { Text("Watch again") } + } + QuietButton(onClick = onClose, modifier = Modifier.padding(start = 8.dp)) { Text("Close") } } } } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 6c43578..1abd261 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -262,6 +262,10 @@ pub struct UpNext { /// The viewer said to play this one out, or the countdown has already /// fired. Either way, do not ask again about this file. dismissed: bool, + /// The card is counting down the last seconds of a file with no credits + /// chapter, rather than the credits. Dismissing it then also means "do not + /// start the next one", since there are no credits to watch till the end. + tail: bool, } /// Which way to step through a title's files. @@ -1110,9 +1114,20 @@ impl App { }; } + // A file with no chapters has no credits to count over, so the card + // takes its last seconds instead: without it the next episode cut in + // with no warning and no way to stay. Not on a clip shorter than a + // few countdowns, where the card would cover most of it. + let tail = !playback.knows_credits() + && playback + .duration + .is_some_and(|duration| duration > UP_NEXT_SECONDS * 6.0) + && playback + .remaining() + .is_some_and(|left| left <= UP_NEXT_SECONDS); let counting = playback.loaded && !playback.paused - && playback.in_credits() + && (playback.in_credits() || tail) && has_next && autoplay && !self.up_next.dismissed; @@ -1120,10 +1135,20 @@ impl App { self.up_next.started = None; return None; } + self.up_next.tail = tail; let now = ctx.input(|input| input.time); let started = *self.up_next.started.get_or_insert(now); - let left = UP_NEXT_SECONDS - (now - started); + let left = if tail { + // Counted on the file's own clock, so it reaches zero at the end + // of file, which is what starts the next one. + playback.remaining().unwrap_or_default() + } else { + UP_NEXT_SECONDS - (now - started) + }; + if tail && left <= 0.0 { + return None; + } if left <= 0.0 { // Latched, not cleared: the next file takes a moment to open, and // without this the countdown would fire again on every frame of @@ -1157,6 +1182,7 @@ impl App { left: (left / UP_NEXT_SECONDS) as f32, caption: next.map(|target| target.caption()).unwrap_or_default(), still, + tail, }) } @@ -1228,6 +1254,16 @@ impl App { if !self.engine.playback_prefs().autoplay_next { return; } + // Cancelled from the end-of-file card: the viewer chose to stay. + let cancelled = self.up_next.tail + && self.up_next.dismissed + && self + .playback + .as_ref() + .is_some_and(|playback| playback.id == self.up_next.playback_id); + if cancelled { + return; + } let Some(target) = self.adjacent(Adjacent::Next) else { return; }; diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 7f6bf0b..e16985c 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -338,6 +338,18 @@ impl Playback { (end > self.position + 0.5).then_some((label, end)) } + /// Whether the chapters say where the credits start. Most files that are + /// not anime have no chapters at all. + pub fn knows_credits(&self) -> bool { + self.credits_at.is_some() + } + + /// Seconds of the file left to play, once its length is known. + pub fn remaining(&self) -> Option { + self.duration + .map(|duration| (duration - self.position).max(0.0)) + } + /// Whether the playhead is inside the run of credits and preview that ends /// the file — what "the episode is over" means when a file states its /// chapters. diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index 811507c..41770ed 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -139,6 +139,9 @@ pub struct UpNextCard { pub caption: String, /// Its still, once there is one. pub still: Option, + /// Counting down the end of a file with no credits chapter, where the + /// second button cancels the next episode rather than deferring it. + pub tail: bool, } impl Overlay { @@ -566,11 +569,15 @@ fn up_next_card( actions.push(Action::PlayAdjacent(Adjacent::Next)); } ui.add_space(theme::space::S); - if ui - .button("Watch till the end") - .on_hover_text("Play this one out — nothing will be skipped") - .clicked() - { + let (label, hint) = if card.tail { + ("Cancel", "Stay here — the next episode will not start") + } else { + ( + "Watch till the end", + "Play this one out — nothing will be skipped", + ) + }; + if ui.button(label).on_hover_text(hint).clicked() { actions.push(Action::WatchToEnd); } }); diff --git a/ios/ProtonStream/Player/PlayerHost.swift b/ios/ProtonStream/Player/PlayerHost.swift index 15f81f5..d061b45 100644 --- a/ios/ProtonStream/Player/PlayerHost.swift +++ b/ios/ProtonStream/Player/PlayerHost.swift @@ -88,6 +88,9 @@ final class PlayerHost { private var retryFrom: Double? /// When the current stall began; see `tick`. private var stalledSince: Date? + /// Cancelled from the end-of-file card: the viewer chose to stay, so the + /// end of this file does not start the next one. + var stay = false /// Read once per episode: a preference the viewer changes mid-episode /// applies to the next one. private(set) var autoSkip = false @@ -202,6 +205,14 @@ final class PlayerHost { onClosed?() } + /// Play the open episode again from the start. mpv unloads a file at its + /// end, so this opens it again rather than seeking. + func replay() { + guard episode != nil else { return } + retryFrom = 0 + load() + } + /// Open the episode again from scratch — a new stream, through the /// bridge's session refresh — where the failed one stopped. func retry() { @@ -217,6 +228,7 @@ final class PlayerHost { let resumeFrom = retryFrom retryFrom = nil stalledSince = nil + stay = false loadTask?.cancel() session?.close() playbackError = nil @@ -345,7 +357,7 @@ final class PlayerHost { guard media == key else { return } save() // Autoplay fires on a clean end of file only — never on a failure. - if reason == .eof, autoplay, next != nil { + if reason == .eof, autoplay, !stay, next != nil { advance() } else if reason == .failed { playbackError = player?.problem ?? "This episode could not be played." diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift index 4d76a1c..50d44d1 100644 --- a/ios/ProtonStream/Player/PlayerView.swift +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -61,11 +61,24 @@ struct PlayerView: View { if let offer = host.offer, !host.autoSkip { Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal) } + // A file with no chapters has no credits to count over, so + // the card takes its last seconds instead, on the file's + // own clock: the end of file is what advances. + let tailLeft: Double? = { + let left = player.duration - player.position + guard host.chapters.creditsStart == nil, + player.duration > Double(upNextSeconds * 6), + left <= Double(upNextSeconds) else { return nil } + return left + }() UpNextCard( next: host.next, - visible: host.autoplay && !player.paused && host.playingOpenEpisode - && host.chapters.creditsStart.map { player.position >= $0 } == true, - onPlayNow: host.advance + visible: host.autoplay && !host.stay && !player.paused && player.endReason == nil + && host.playingOpenEpisode + && (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true), + secondsLeft: tailLeft.map { Int($0.rounded(.up)) }, + onPlayNow: host.advance, + onStay: { host.stay = true } ) } .frame(maxWidth: .infinity, alignment: .trailing) @@ -88,6 +101,13 @@ struct PlayerView: View { .background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard)) } + // 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 { + EndCard(host: host) + } + if let message = host.playbackError ?? player.problem { VStack(spacing: 4) { Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center) @@ -316,7 +336,11 @@ private struct CompactIcon: View { private struct UpNextCard: View { let next: EpisodeRecord? let visible: Bool + /// Counting the end of a file with no credits chapter, on the file's own + /// clock; the second button then cancels rather than deferring. + let secondsLeft: Int? let onPlayNow: () -> Void + let onStay: () -> Void @Environment(\.scheme) private var scheme @State private var held = false @State private var remaining = upNextSeconds @@ -340,10 +364,10 @@ private struct UpNextCard: View { Circle().fill(.black.opacity(0.5)) Circle().stroke(.white.opacity(0.25), lineWidth: 3) Circle() - .trim(from: 0, to: Double(remaining) / Double(upNextSeconds)) + .trim(from: 0, to: Double(secondsLeft ?? remaining) / Double(upNextSeconds)) .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 1), value: remaining) + .animation(.linear(duration: 1), value: secondsLeft ?? remaining) Image(systemName: "play.fill").foregroundStyle(.white) } .frame(width: 40, height: 40) @@ -353,13 +377,14 @@ private struct UpNextCard: View { .buttonStyle(PressableStyle(wash: .white)) .accessibilityLabel("Play now") VStack(alignment: .leading, spacing: 2) { - Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant) + Text("Up next in \(secondsLeft ?? remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant) Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2) } } - Button("Watch till the end") { + Button(secondsLeft == nil ? "Watch till the end" : "Cancel") { held = true heldFor = upNext.linkId + if secondsLeft != nil { onStay() } } .buttonStyle(.quiet) } @@ -367,6 +392,7 @@ private struct UpNextCard: View { .frame(width: 280) .background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard)) .task(id: upNext.linkId) { + guard secondsLeft == nil else { return } remaining = upNextSeconds while remaining > 0 { try? await Task.sleep(for: .seconds(1)) @@ -380,6 +406,35 @@ private struct UpNextCard: View { } } +/// What the player says once an episode ends and nothing starts after it. +private struct EndCard: View { + let host: PlayerHost + @Environment(\.scheme) private var scheme + + var body: some View { + VStack(spacing: 12) { + Text(host.next == nil + ? "That was the last episode of \(host.title?.displayName ?? "this title")" + : "Finished \(host.episode?.label ?? "this episode")") + .textStyle(.titleMedium) + .foregroundStyle(scheme.onSurface) + .multilineTextAlignment(.center) + HStack(spacing: 8) { + if host.next != nil { + Button("Play next", action: host.advance).buttonStyle(.accent) + } else { + Button("Watch again", action: host.replay).buttonStyle(.quiet) + } + Button("Close", action: host.close).buttonStyle(.quiet) + } + } + .padding(20) + .frame(maxWidth: 360) + .background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard)) + .padding(32) + } +} + private struct SpeedSheet: View { let current: Double let onSelect: (Double) -> Void