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 2510a2b..f84a093 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 @@ -58,6 +58,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -184,7 +185,11 @@ fun PlayerScreen( } val key = mediaKey(episode) - val session = remember(key) { RustStreamSession(episode) } + // Retry opens the episode again from scratch: a new stream, through the + // bridge's session refresh, from where the failed one stopped. + var attempt by remember(key) { mutableIntStateOf(0) } + var retryFrom by remember(key) { mutableStateOf(null) } + val session = remember(key, attempt) { RustStreamSession(episode) } var playbackError by remember(key) { mutableStateOf(null) } LaunchedEffect(session, host) { @@ -204,7 +209,7 @@ fun PlayerScreen( size = session.size(), // A finished episode restarts rather than resuming three // seconds from its own credits. - startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0, + startPosition = retryFrom ?: watch?.takeUnless { it.watched }?.positionSecs ?: 0.0, audioLanguage = show?.audioLanguage ?: global.audioLanguage, subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage, subtitles = show?.subtitles ?: global.subtitles, @@ -328,6 +333,14 @@ fun PlayerScreen( playbackError = "This episode could not be played." } } + // A stall that does not clear is a dropped connection far more often than + // a slow one, and "Buffering…" for ever offers no way out of it. + LaunchedEffect(state.buffering, key, attempt) { + if (state.buffering) { + delay(STALL_LIMIT_MS) + playbackError = "Still loading. The connection may have dropped." + } + } // What mpv itself complained about, which the play call never sees: by the // time a header turns out to be unreadable it has long since returned. val problem by nativeHost.problem.collectAsState() @@ -528,14 +541,29 @@ fun PlayerScreen( // Dismissible, because the message is not always fatal — a // subtitle track that failed to load leaves an episode that // plays perfectly well behind an error that used to be permanent. - QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) { - Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer) + Row { + QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) { + Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer) + } + QuietButton( + onClick = { + retryFrom = state.position.takeIf { state.media == key && it > 0.0 } ?: retryFrom + playbackError = null + nativeHost.clearProblem() + attempt++ + }, + ) { + Text("Retry", color = MaterialTheme.colorScheme.onErrorContainer) + } } } } } } +/** How long a stall lasts before the player offers to open the episode again. */ +private const val STALL_LIMIT_MS = 20_000L + private data class Startup( val nativeHandle: ULong, val size: ULong, diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 32a5ab0..6c43578 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -688,6 +688,30 @@ impl App { } } + /// Say that the playing file failed, with a button that opens it again + /// where it stopped — a dropped connection is the usual cause, and the + /// session refresh behind the next open is usually the cure. + fn offer_retry(&mut self, ctx: &egui::Context) { + let Some(playback) = &self.playback else { + return; + }; + let mut retry = playback.target.clone(); + let at = if playback.position > 0.0 { + playback.position + } else { + retry.resume_at.unwrap_or(0.0) + }; + retry.resume_at = (at > 0.0).then_some(at); + let text = format!( + "{} stopped playing at {}", + retry.title_name, + crate::ui::format_time(at) + ); + let now = ctx.input(|input| input.time); + self.toasts + .push_error_with(now, text, "Retry", Action::Play(retry)); + } + fn note(&mut self, ctx: &egui::Context, text: impl Into, error: bool) { let now = ctx.input(|input| input.time); self.toasts.push(now, text, error); @@ -894,6 +918,16 @@ impl App { { self.autoplay_next(ctx); } + // mpv gave up partway. The page is about to go back to the + // title, and without this the film would simply vanish. + if mine + && matches!( + event, + pstr_player::PlayerEvent::EndFile(pstr_player::EndReason::Failed) + ) + { + self.offer_retry(ctx); + } } Event::PlayerStopped { id } => { if self.playback.as_ref().is_some_and(|p| p.id == id) { diff --git a/crates/pstr-app/src/ui/toast.rs b/crates/pstr-app/src/ui/toast.rs index 12dba8b..19e4496 100644 --- a/crates/pstr-app/src/ui/toast.rs +++ b/crates/pstr-app/src/ui/toast.rs @@ -52,6 +52,17 @@ impl Toasts { self.push_inner(now, text.into(), false, Some((label, action))); } + /// An error with a button on it — "Retry". + pub fn push_error_with( + &mut self, + now: f64, + text: impl Into, + label: &'static str, + action: Action, + ) { + self.push_inner(now, text.into(), true, Some((label, action))); + } + fn push_inner( &mut self, now: f64, diff --git a/ios/ProtonStream/Player/PlayerHost.swift b/ios/ProtonStream/Player/PlayerHost.swift index 9934d77..15f81f5 100644 --- a/ios/ProtonStream/Player/PlayerHost.swift +++ b/ios/ProtonStream/Player/PlayerHost.swift @@ -10,6 +10,9 @@ let upNextSeconds = 10 /// Past this much of an episode it counts as watched. private let watchedFraction = 0.9 +/// How long a stall lasts before the player offers to open the episode again. +private let stallLimit: TimeInterval = 20 + /// How an episode is named in watch state and player readings. func mediaKey(_ episode: EpisodeRecord) -> String { "\(episode.shareId):\(episode.linkId)" } @@ -81,6 +84,10 @@ final class PlayerHost { /// block list, all before mpv has a byte. On an old iPad that is seconds /// of a black screen unless something says so. private(set) var opening = false + /// Where a retry reopens the episode, when it got that far. + private var retryFrom: Double? + /// When the current stall began; see `tick`. + private var stalledSince: Date? /// Read once per episode: a preference the viewer changes mid-episode /// applies to the next one. private(set) var autoSkip = false @@ -195,8 +202,21 @@ final class PlayerHost { onClosed?() } + /// Open the episode again from scratch — a new stream, through the + /// bridge's session refresh — where the failed one stopped. + func retry() { + guard episode != nil else { return } + if let player, player.media == key, player.position > 0 { retryFrom = player.position } + player?.problem = nil + load() + } + private func load() { guard let title, let episode else { return } + // Taken once: only the load a retry asked for starts from it. + let resumeFrom = retryFrom + retryFrom = nil + stalledSince = nil loadTask?.cancel() session?.close() playbackError = nil @@ -248,7 +268,7 @@ final class PlayerHost { handle: handle, // A finished episode restarts rather than resuming three // seconds from its own credits. - start: watch.flatMap { $0.watched ? nil : $0.positionSecs } ?? 0, + start: resumeFrom ?? watch.flatMap { $0.watched ? nil : $0.positionSecs } ?? 0, audio: show?.audioLanguage ?? global.audioLanguage, subtitle: show?.subtitleLanguage ?? global.subtitleLanguage, subtitles: show?.subtitles ?? global.subtitles, @@ -336,6 +356,17 @@ final class PlayerHost { private func tick() { guard let player, playingOpenEpisode else { return } replanChapters(player) + // A stall that does not clear is a dropped connection far more often + // than a slow one, and "Buffering…" for ever offers no way out of it. + if player.buffering, !player.paused { + let since = stalledSince ?? .now + stalledSince = since + if playbackError == nil, Date.now.timeIntervalSince(since) >= stallLimit { + playbackError = "Still loading. The connection may have dropped." + } + } else { + stalledSince = nil + } // Auto-skip, once per chapter. if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position), let chapter = chapters.entries.last(where: { player.position + 0.001 >= $0.start }), diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift index b24727d..4d76a1c 100644 --- a/ios/ProtonStream/Player/PlayerView.swift +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -94,9 +94,14 @@ struct PlayerView: View { // Dismissible, because the message is not always fatal // — a subtitle track that failed to load leaves an // episode that plays perfectly well behind it. - Button("Dismiss", action: host.dismissProblem) - .buttonStyle(.quiet) - .tint(scheme.onErrorContainer) + HStack { + Button("Dismiss", action: host.dismissProblem) + .buttonStyle(.quiet) + .tint(scheme.onErrorContainer) + Button("Retry", action: host.retry) + .buttonStyle(.quiet) + .tint(scheme.onErrorContainer) + } } .textStyle(.bodyLarge) .padding(16)