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