From c6b0457c4d29dc21a47a9ac514af602f96d53159 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 12 Oct 2026 21:50:02 +0200 Subject: [PATCH] feat(app,android,ios): end an episode without chapters as well as one with them The up-next countdown only ran over a credits chapter, so a file with no chapters - most that are not anime - cut straight into the next episode with no warning and no way to stay. Such a file now shows the same card over its last ten seconds, counted on the file's own clock so it reaches zero at the end of file, and its second button is Cancel rather than Watch till the end. On the phones, an episode that ends with nothing after it - the last one, autoplay off, or cancelled - left a black screen. It now says so, with Play next or Watch again, and Close. Android also: - saves the resume position from PlaybackService rather than the player screen. With background audio on the service outlives the activity, and a position only the screen kept stopped being saved when it went. A swipe from Recents saves and stops instead of playing on with nothing to return to. - shows "Opening ..." while the stream opens, where there was a black screen for as long as the share session and block list took. --- .../protonstream/playback/PlaybackService.kt | 74 ++++++++++- .../protonstream/playback/PlayerScreen.kt | 125 +++++++++++++++--- crates/pstr-app/src/app.rs | 40 +++++- crates/pstr-app/src/playback.rs | 12 ++ crates/pstr-app/src/ui/player.rs | 17 ++- ios/ProtonStream/Player/PlayerHost.swift | 14 +- ios/ProtonStream/Player/PlayerView.swift | 69 +++++++++- 7 files changed, 313 insertions(+), 38 deletions(-) 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