From c5490ab3da2032535bfea6b1624c02960ef72a09 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 12 Oct 2026 21:42:09 +0200 Subject: [PATCH] feat(app,android,ios): say when playback fails and offer to retry from there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file mpv gave up on partway ended the desktop player without a word: the page went back to the title and the film was simply gone. It now leaves an error toast naming where it stopped, with Retry reopening the episode at that position. The phone players already showed an error, but its only button was Dismiss, so a dropped connection meant backing out and finding the episode again. They gain Retry, which opens a fresh stream (through the session refresh) from where the failed one stopped. A stall that lasts twenty seconds now says so and offers the same, rather than showing "Buffering…" for ever. --- .../protonstream/playback/PlayerScreen.kt | 36 ++++++++++++++++--- crates/pstr-app/src/app.rs | 34 ++++++++++++++++++ crates/pstr-app/src/ui/toast.rs | 11 ++++++ ios/ProtonStream/Player/PlayerHost.swift | 33 ++++++++++++++++- ios/ProtonStream/Player/PlayerView.swift | 11 ++++-- 5 files changed, 117 insertions(+), 8 deletions(-) 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)