From 2546bab0a6a68411af27bdfb0ed0967c856c9e87 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 13 Oct 2026 16:40:18 +0200 Subject: [PATCH] perf(android,ios): warm what an episode opens with, and the next one near the end The bridge's open_stream started no fetch until mpv asked, so every open was the head and then the tail, one after the other. It now warms both edges as the desktop does, and the resume block too when the episode resumes. warm_episode fetches another episode's opening blocks into the cache without playing it. Both phones call it for the next episode in the last two minutes or once the credits start, as the desktop already did, so autoplay starts from the cache. --- .../protonstream/playback/PlayerScreen.kt | 18 +++++ crates/pstr-android/src/lib.rs | 72 ++++++++++++++++++- ios/ProtonStream/Player/PlayerHost.swift | 20 ++++++ 3 files changed, 108 insertions(+), 2 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 fd500e3..4c7f80e 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 @@ -118,6 +118,9 @@ private const val CONTROLS_SECONDS = 4L /** How long the "up next" card counts down before it loads the next episode. */ private const val UP_NEXT_SECONDS = 10 +/** How close to the end the next episode's opening blocks are fetched. */ +private const val WARM_NEXT_SECONDS = 120.0 + /** Past this much of an episode it counts as watched. */ internal const val WATCHED_FRACTION = 0.9 @@ -360,6 +363,21 @@ fun PlayerScreen( if (!playingThis) return@remember null runCatching { skipOffer(chapters, state.position) }.getOrNull() } + // Near the end, fetch the next episode's opening blocks, so autoplay or + // "Play now" starts from the cache. The last two minutes or the credits, + // whichever comes first: late enough that stopping after one episode has + // not cost a download, early enough to matter. + val nearEnd = playingThis && state.duration > 0.0 && ( + state.duration - state.position < WARM_NEXT_SECONDS || + chapters.creditsStart?.let { state.position >= it } == true + ) + LaunchedEffect(key, nearEnd) { + val upcoming = next ?: return@LaunchedEffect + if (!nearEnd) return@LaunchedEffect + runCatching { + NativeRuntime.engine().warmEpisode(upcoming.shareId, upcoming.volumeId, upcoming.linkId) + } + } // Auto-skip is offered once per chapter: seeking back into an opening on // purpose must not be undone by the setting that skipped it. val skipped = remember(key) { mutableSetOf() } diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index ae7c7b5..cfdf88e 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -1972,6 +1972,47 @@ impl AndroidEngine { .map_err(BridgeError::from_display)? } + /// Fetch the blocks an episode opens with into the cache, without playing + /// it: the next episode, near the end of this one, so that autoplay or + /// "Play now" starts from the cache rather than from two round trips. + /// + /// Quietly nothing for a downloaded episode, and on any failure: all a + /// failure costs is the head start. + pub async fn warm_episode( + self: Arc, + share_id: String, + volume_id: String, + link_id: String, + ) { + let runtime = Arc::clone(&self.runtime); + let warmed = spawned(&runtime, async move { + let offline = self + .catalog + .lock() + .offline_file(&share_id, &link_id) + .ok() + .flatten() + .is_some(); + if offline { + return; + } + let Ok(connection) = self.connection().await else { + return; + }; + match self + .open_from_source(&connection, &share_id, &node_uid(&volume_id, &link_id)) + .await + { + Ok(stream) => stream.warm_edges(), + Err(error) => log::debug!("warming the next episode failed: {error}"), + } + }) + .await; + if let Err(error) = warmed { + log::debug!("warming the next episode stopped: {error}"); + } + } + pub async fn download_episode( self: Arc, share_id: String, @@ -2670,8 +2711,35 @@ impl AndroidEngine { } let connection = self.connection().await?; - self.open_from_source(&connection, share_id, &node_uid(volume_id, link_id)) - .await + let stream = self + .open_from_source(&connection, share_id, &node_uid(volume_id, link_id)) + .await?; + // Before mpv has the handle: building its core and surface takes a + // moment, and the network can spend that moment on the two reads + // every file opens with — and, resuming, on the one it seeks to. + match self.resume_fraction(share_id, link_id) { + Some(fraction) => stream.warm_resume(fraction), + None => stream.warm_edges(), + } + Ok(stream) + } + + /// How far into the file an episode resumes, as a fraction of its + /// running time, from what was saved the last time it played. `None` for + /// one that starts from the beginning, as the clients do for a finished + /// episode or one barely begun. + fn resume_fraction(&self, share_id: &str, link_id: &str) -> Option { + let state = self + .catalog + .lock() + .watch_state(share_id, link_id) + .ok() + .flatten()?; + if state.watched || state.position_secs < pstr_core::library::RESUME_FLOOR_SECS { + return None; + } + let duration = state.duration_secs.filter(|duration| *duration > 0.0)?; + Some(state.position_secs / duration) } async fn download_episode_inner( diff --git a/ios/ProtonStream/Player/PlayerHost.swift b/ios/ProtonStream/Player/PlayerHost.swift index b2fda74..c08b13f 100644 --- a/ios/ProtonStream/Player/PlayerHost.swift +++ b/ios/ProtonStream/Player/PlayerHost.swift @@ -13,6 +13,9 @@ 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 close to the end the next episode's opening blocks are fetched. +private let warmNextSeconds = 120.0 + /// How an episode is named in watch state and player readings. func mediaKey(_ episode: EpisodeRecord) -> String { "\(episode.shareId):\(episode.linkId)" } @@ -115,6 +118,8 @@ final class PlayerHost { /// Chapters already auto-skipped in this episode: seeking back into an /// opening on purpose must not be undone by the setting that skipped it. @ObservationIgnored private var skipped = Set() + /// The episode whose next one has been warmed; see `tick`. + @ObservationIgnored private var warmedAfter: String? @ObservationIgnored private var pausedByInterruption = false @ObservationIgnored private var artworkFor: String? @@ -390,6 +395,21 @@ final class PlayerHost { skipped.insert(chapter.index).inserted { player.seek(to: offer.target) } + // Near the end, fetch the next episode's opening blocks, so autoplay + // or "Play now" starts from the cache. The last two minutes or the + // credits, whichever comes first: late enough that stopping after one + // episode has not cost a download, early enough to matter. + let nearEnd = player.duration > 0 && ( + player.duration - player.position < warmNextSeconds + || chapters.creditsStart.map { player.position >= $0 } == true + ) + if nearEnd, let key, warmedAfter != key, let next { + warmedAfter = key + let (shareId, volumeId, linkId) = (next.shareId, next.volumeId, next.linkId) + Task.detached { + try? await NativeRuntime.engine().warmEpisode(shareId: shareId, volumeId: volumeId, linkId: linkId) + } + } } /// Re-read the chapters when there is a reason to: a different file, or a