From a306d02cf1076a12ee83b9ccff819ab20f191644 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 12 Oct 2026 22:05:05 +0200 Subject: [PATCH] feat(core,app,android,ios): keep a show in Continue watching after an episode ends Continue watching listed only part-watched episodes, so finishing one - the most ordinary moment to want the next - dropped the show from the shelf. Title::continue_with now reads the episode touched last: a part-watched one is resumed, a finished one offers the episode after it ("Next: S01E04"), and one reset to the start (what leaving the shelf does) keeps the title off. A few seconds of an episode are passed over. The phones computed their own shelf from episode records. The bridge now exports the engine's choice as TitleRecord.continue_index, and Play on a title lands there on every client. Undo on the phones re-saved a watch state with a fresh time, which both reordered the shelf and could not express "never played". A new restore_watch_state puts the state back as it was read. --- .../io/narl/protonstream/ui/AppViewModel.kt | 9 +- .../java/io/narl/protonstream/ui/Common.kt | 11 +- .../io/narl/protonstream/ui/LibraryScreen.kt | 30 +++-- .../io/narl/protonstream/ui/TitleScreen.kt | 2 +- .../io/narl/protonstream/ui/HistoryTest.kt | 1 + .../io/narl/protonstream/ui/ScreenshotTest.kt | 3 + crates/pstr-android/src/lib.rs | 40 +++++++ crates/pstr-app/src/app.rs | 11 +- crates/pstr-app/src/ui/library.rs | 26 ++--- crates/pstr-core/src/library.rs | 104 +++++++++++++++++- ios/ProtonStream/App/AppModel.swift | 8 +- ios/ProtonStream/UI/Common.swift | 11 +- ios/ProtonStream/UI/LibraryView.swift | 29 +++-- ios/ProtonStream/UI/TitleView.swift | 6 +- ios/ProtonStreamTests/UiLogicTests.swift | 2 +- 15 files changed, 225 insertions(+), 68 deletions(-) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 6009f59..26f7376 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -595,14 +595,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie withContext(Dispatchers.IO) { val engine = NativeRuntime.engine() snapshots.forEach { snapshot -> - val state = snapshot.state - engine.saveWatchState( - snapshot.shareId, - snapshot.linkId, - state?.positionSecs ?: 0.0, - state?.durationSecs, - state?.watched ?: false, - ) + engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state) } } }.onFailure(::reportError) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 66220b2..b3f3e83 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -103,11 +103,14 @@ internal fun EmptyState(title: String, body: String) { } /** - * Where Play on a title lands: the most recently played part-watched episode, - * else the first unwatched one, else the first — the desktop's `next_up`. + * Where Play on a title lands: what Continue watching offers + * ([TitleRecord.continueIndex]), else the most recently played part-watched + * episode, else the first unwatched one, else the first — the desktop's + * `next_up`. */ -internal fun nextUpIndex(playlist: List): Int = - playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index +internal fun nextUpIndex(playlist: List, continueIndex: UInt? = null): Int = + continueIndex?.toInt()?.takeIf { it in playlist.indices } + ?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 } ?: 0 diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index d89621e..28fea5d 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -96,7 +96,13 @@ import uniffi.pstr_android.TitleType private const val CONTINUE_WATCHING_MAX = 12 /** One row of the continue-watching shelf: which episode, and where it sits. */ -private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int) +private data class Resumable( + val title: TitleRecord, + val episode: EpisodeRecord, + val index: Int, + /** When the title was last played, which orders the shelf. */ + val lastPlayed: Long, +) /** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */ private data class Tile(val title: TitleRecord, val folded: Int) @@ -126,14 +132,16 @@ internal fun LibraryScreen( var matching by remember { mutableStateOf(null) } // Most recently played first, one episode per show: a shelf that lists four // episodes of the same series is a shelf with room for nothing else. + // What to go on with is the engine's call (`continueIndex`): the + // part-watched episode, or the one after the episode finished last. val resumable = remember(state.titles) { state.titles.mapNotNull { title -> + val index = title.continueIndex?.toInt() ?: return@mapNotNull null val playlist = title.seasons.flatMap(SeasonRecord::episodes) - playlist.withIndex() - .filter { it.value.resumeAt != null && !it.value.watched } - .maxByOrNull { it.value.lastPlayed } - ?.let { Resumable(title, it.value, it.index) } - }.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX) + playlist.getOrNull(index)?.let { episode -> + Resumable(title, episode, index, playlist.maxOf { it.lastPlayed }) + } + }.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX) } // Search is an icon until it is wanted: a permanent field above the // library spent a band of every visit on something used in few of them. @@ -199,7 +207,7 @@ internal fun LibraryScreen( val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) { MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") { - onResume(title, nextUpIndex(playlist)) + onResume(title, nextUpIndex(playlist, title.continueIndex)) } MenuRow(Icons.Default.Info, "Open") { onTitle(title) } MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) } @@ -567,7 +575,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { - AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) { + AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) { Icon(Icons.Default.PlayArrow, contentDescription = null) Spacer(Modifier.width(6.dp)) Text(if (resume != null) "Resume" else "Play") @@ -609,7 +617,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () modifier = Modifier.padding(top = 8.dp), ) Text( - listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "), + if (entry.episode.resumeAt == null) { + "Next: ${entry.episode.label}" + } else { + listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · ") + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 5255f09..515d883 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -122,7 +122,7 @@ internal fun TitleScreen( // Display order across seasons: what previous/next and autoplay walk, and // the same order the desktop client uses. val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } - val nextUp = remember(playlist) { nextUpIndex(playlist) } + val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) } val upNext = playlist.getOrNull(nextUp) // One season on screen at a time, opened on the one Play would land in: a // forty-episode show as stacked sections is one long scroll to the part diff --git a/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt index 7d4c2c7..279389e 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt @@ -26,6 +26,7 @@ class HistoryTest { displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(), directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null, franchise = emptyList(), + continueIndex = null, ) @Test diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index 896a63e..eb9b83d 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -264,6 +264,9 @@ class ScreenshotTest { nextEpisode = null, nextAiringAt = null, franchise = emptyList(), + // What the engine would pick for these fixtures: the part-watched episode. + continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null } + .takeIf { it >= 0 }?.toUInt(), ) val SERIES = title( diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 1aa4c31..a39fc2b 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -509,6 +509,11 @@ pub struct TitleRecord { /// Every title of its franchise, in release order, its own key included; /// empty for a title that stands alone. pub franchise: Vec, + /// Where Continue watching picks this title up, as an index into its + /// episodes in display order: the part-watched one, or the one after the + /// episode finished last. `None` keeps it off the shelf. See + /// `pstr_core::library::Title::continue_with`. + pub continue_index: Option, } /// Playback preferences that outlive one file, one title and one launch. @@ -1599,6 +1604,37 @@ impl AndroidEngine { .map(|state| state.map(watch_state_record)) } + /// Put one file's watch state back exactly as [`Self::watch_state`] read + /// it — what Undo does. Unlike [`Self::save_watch_state`] it keeps the + /// original time, so an undone change sorts where it did before. `None`, + /// a file that had never been played, becomes a reset older than anything, + /// which every later state outranks. + pub fn restore_watch_state( + &self, + share_id: String, + link_id: String, + state: Option, + ) -> Result<(), BridgeError> { + let state = state.map_or( + WatchState { + position_secs: 0.0, + duration_secs: None, + watched: false, + updated_at: 0, + }, + |state| WatchState { + position_secs: state.position_secs, + duration_secs: state.duration_secs, + watched: state.watched, + updated_at: state.updated_at, + }, + ); + self.catalog + .lock() + .set_watch_state(&share_id, &link_id, &state) + .map_err(BridgeError::from_display) + } + pub fn save_watch_state( &self, share_id: String, @@ -3203,6 +3239,10 @@ fn title_record( next_episode: None, next_airing_at: None, franchise: Vec::new(), + continue_index: title.continue_with().and_then(|episode| { + let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?; + u32::try_from(at).ok() + }), seasons: title .seasons .iter() diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 1abd261..5dd34b7 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -1312,9 +1312,18 @@ impl App { })?; Some((title.name.clone(), episode.watch)) }); - let Some((name, Some(before))) = found else { + let Some((name, before)) = found else { return; }; + // An episode Continue watching offers next has never been played. + // Its undo is a reset as old as time, which the finished episode + // before it outranks. + let before = before.unwrap_or(pstr_core::catalog::WatchState { + position_secs: 0.0, + duration_secs: None, + watched: false, + updated_at: 0, + }); self.engine.save_watch_state( share_id.clone(), link_id.clone(), diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 30f07c9..13c0dec 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -382,7 +382,7 @@ fn tile_shape(tiles: Tiles) -> (f32, f32) { } } -/// The top shelf: one card per part-watched title, scrolling sideways. +/// The top shelf: one card per title in progress, scrolling sideways. fn continue_row( ui: &mut egui::Ui, art: &mut Art<'_>, @@ -395,21 +395,17 @@ fn continue_row( .show(ui, |ui| { ui.horizontal(|ui| { for title in titles { - let Some(episode) = title.resume() else { + let Some(episode) = title.continue_with() else { continue; }; - let remaining = episode - .watch - .and_then(|watch| watch.duration_secs) - .map(|duration| { - format!( - "{} left", - ui::format_time( - duration - episode.watch.map_or(0.0, |w| w.position_secs) - ) - ) - }) - .unwrap_or_else(|| episode.label()); + let remaining = match episode.resume_at() { + Some(at) => episode + .watch + .and_then(|watch| watch.duration_secs) + .map(|duration| format!("{} left", ui::format_time(duration - at))) + .unwrap_or_else(|| episode.label()), + None => format!("Next: {}", episode.label()), + }; let picture = art.of(title); let response = ui::card( @@ -550,7 +546,7 @@ fn tile_menu( }); } if continuing - && let Some(episode) = title.resume() + && let Some(episode) = title.continue_with() && ui .button("Remove from Continue watching") .on_hover_text("Forget where you stopped in it") diff --git a/crates/pstr-core/src/library.rs b/crates/pstr-core/src/library.rs index d1ba7b4..6174562 100644 --- a/crates/pstr-core/src/library.rs +++ b/crates/pstr-core/src/library.rs @@ -189,10 +189,44 @@ impl Title { .max_by_key(|episode| episode.last_played()) } - /// What a click on the poster should play: whatever is part-watched, else - /// the first unwatched episode, else the first episode. + /// What Continue watching offers for this title: the part-watched episode, + /// or the one after the episode finished last. + /// + /// Read off whichever episode was touched last. Finishing an episode is + /// the most ordinary moment to want the next, and a shelf that dropped the + /// show right then was missing its point. An episode reset to the start — + /// which is what leaving Continue watching does — says the viewer is done + /// with it, so the title is left out. One opened for a few seconds says + /// nothing either way and is passed over. + pub fn continue_with(&self) -> Option<&Episode> { + let latest = self + .episodes() + .filter(|episode| { + episode.watch.is_some_and(|watch| { + watch.position_secs == 0.0 + || watch.position_secs >= RESUME_FLOOR_SECS + || episode.is_watched() + }) + }) + .max_by_key(|episode| episode.last_played())?; + if latest.resume_at().is_some() { + return Some(latest); + } + if !latest.is_watched() { + return None; + } + let at = self.position_of(&latest.node.share_id, &latest.node.link_id)?; + self.episodes() + .skip(at + 1) + .find(|episode| !episode.is_watched()) + } + + /// What a click on the poster should play: what Continue watching offers, + /// else whatever is part-watched, else the first unwatched episode, else + /// the first episode. pub fn next_up(&self) -> Option<&Episode> { - self.resume() + self.continue_with() + .or_else(|| self.resume()) .or_else(|| self.episodes().find(|episode| !episode.is_watched())) .or_else(|| self.episodes().next()) } @@ -357,13 +391,13 @@ impl Library { self.titles.iter().find(|title| title.key == key) } - /// Titles with something part-watched, most recent first. The top row of - /// the library. + /// Titles with something to go on with — see [`Title::continue_with`] — + /// most recent first. The top row of the library. pub fn continue_watching(&self) -> Vec<&Title> { let mut titles: Vec<&Title> = self .titles .iter() - .filter(|title| title.resume().is_some()) + .filter(|title| title.continue_with().is_some()) .collect(); titles.sort_by_key(|title| std::cmp::Reverse(title.last_played())); titles @@ -688,6 +722,64 @@ mod tests { assert_eq!(library.continue_watching().len(), 1); } + fn three_episodes() -> Vec { + vec![ + node("s", "1", "Show - S01E01", Some(1), Some(1)), + node("s", "2", "Show - S01E02", Some(1), Some(2)), + node("s", "3", "Show - S01E03", Some(1), Some(3)), + ] + } + + #[test] + fn a_finished_episode_puts_the_next_one_in_continue_watching() { + let mut states = HashMap::new(); + states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10)); + let library = Library::build(three_episodes(), &states); + let next = library.titles[0].continue_with().unwrap(); + assert_eq!(next.node.link_id, "2"); + assert_eq!(next.resume_at(), None); + assert_eq!(library.continue_watching().len(), 1); + } + + #[test] + fn a_finished_last_episode_leaves_continue_watching() { + let mut states = HashMap::new(); + for link in ["1", "2", "3"] { + states.insert(("s".into(), link.into()), watch(1440.0, 1440.0, true, 10)); + } + let library = Library::build(three_episodes(), &states); + assert!(library.continue_watching().is_empty()); + } + + /// Leaving Continue watching resets the episode to the start. Offering the + /// one after the last finished episode instead would bring the title + /// straight back. + #[test] + fn an_episode_reset_to_the_start_keeps_its_title_out_of_continue_watching() { + let mut states = HashMap::new(); + states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10)); + states.insert(("s".into(), "2".into()), watch(0.0, 1440.0, false, 20)); + let library = Library::build(three_episodes(), &states); + assert!(library.continue_watching().is_empty()); + } + + #[test] + fn a_few_seconds_of_the_next_episode_do_not_hide_the_one_part_watched() { + let mut states = HashMap::new(); + states.insert(("s".into(), "1".into()), watch(700.0, 1440.0, false, 10)); + states.insert(("s".into(), "2".into()), watch(4.0, 1440.0, false, 20)); + let library = Library::build(three_episodes(), &states); + assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "1"); + } + + #[test] + fn the_episode_after_the_finished_one_is_offered_past_an_older_unwatched_one() { + let mut states = HashMap::new(); + states.insert(("s".into(), "2".into()), watch(1440.0, 1440.0, true, 10)); + let library = Library::build(three_episodes(), &states); + assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "3"); + } + #[test] fn a_watch_state_set_in_place_matches_a_rebuild() { let nodes = vec![ diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index 2b9d65c..b01b9d3 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -516,13 +516,7 @@ final class AppModel { do { try await run { engine in for snapshot in snapshots { - try engine.saveWatchState( - shareId: snapshot.shareId, - linkId: snapshot.linkId, - positionSecs: snapshot.state?.positionSecs ?? 0, - durationSecs: snapshot.state?.durationSecs, - watched: snapshot.state?.watched ?? false - ) + try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state) } } } catch { diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 3d4f4dd..75b01ee 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -263,9 +263,14 @@ extension View { func secureContent() -> some View { modifier(SecureContent()) } } -/// Where Play on a title lands: the most recently played part-watched episode, -/// else the first unwatched one, else the first — the desktop's `next_up`. -func nextUpIndex(_ playlist: [EpisodeRecord]) -> Int { +/// Where Play on a title lands: what Continue watching offers +/// (`TitleRecord.continueIndex`), else the most recently played part-watched +/// episode, else the first unwatched one, else the first — the desktop's +/// `next_up`. +func nextUpIndex(_ playlist: [EpisodeRecord], continueIndex: UInt32? = nil) -> Int { + if let index = continueIndex.map(Int.init), playlist.indices.contains(index) { + return index + } if let resumed = playlist.indices.filter({ playlist[$0].resumeAt != nil }).max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) { return resumed } diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 391e22b..7753d13 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -10,6 +10,8 @@ struct Resumable: Identifiable { let title: TitleRecord let episode: EpisodeRecord let index: Int + /// When the title was last played, which orders the shelf. + let lastPlayed: Int64 var id: String { "\(episode.shareId)/\(episode.linkId)" } } @@ -30,17 +32,22 @@ private struct Shelf: Identifiable { } /// Most recently played first, one episode per show: a shelf that lists four -/// episodes of the same series is a shelf with room for nothing else. +/// episodes of the same series is a shelf with room for nothing else. Which +/// episode is the engine's call (`continueIndex`): the part-watched one, or the +/// one after the episode finished last. func resumable(_ titles: [TitleRecord]) -> [Resumable] { titles.compactMap { title -> Resumable? in + guard let index = title.continueIndex.map(Int.init) else { return nil } let playlist = title.playlist - guard let index = playlist.indices - .filter({ playlist[$0].resumeAt != nil && !playlist[$0].watched }) - .max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) - else { return nil } - return Resumable(title: title, episode: playlist[index], index: index) + guard playlist.indices.contains(index) else { return nil } + return Resumable( + title: title, + episode: playlist[index], + index: index, + lastPlayed: playlist.map(\.lastPlayed).max() ?? 0 + ) } - .sorted { $0.episode.lastPlayed > $1.episode.lastPlayed } + .sorted { $0.lastPlayed > $1.lastPlayed } .prefix(continueWatchingMax) .map { $0 } } @@ -106,7 +113,7 @@ struct LibraryView: View { let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount TileMenu(heading: title.displayName, caption: title.caption) { MenuRow("play.fill", playlist.contains { $0.resumeAt != nil } ? "Resume" : "Play") { - onResume(title, nextUpIndex(playlist)) + onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex)) } MenuRow("info.circle", "Open") { onTitle(title) } MenuRow("arrow.down.circle", "Download all") { DownloadCoordinator.shared.enqueue(playlist) } @@ -426,7 +433,7 @@ private struct FeaturedBanner: View { .textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6) } HStack(spacing: 8) { - Button { onPlay(resume?.index ?? nextUpIndex(title.playlist)) } label: { + Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: { Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill") } .buttonStyle(.accent) @@ -463,7 +470,9 @@ private struct ContinueCard: View { .clipShape(RoundedRectangle(cornerRadius: Corner.standard)) Text(entry.title.displayName).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface) .lineLimit(1).padding(.top, 8) - Text([entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · ")) + Text(entry.episode.resumeAt == nil + ? "Next: \(entry.episode.label)" + : [entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · ")) .textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1) } .frame(width: 220) diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 8850670..7790bb7 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -36,7 +36,7 @@ struct TitleView: View { self.onOpenTitle = onOpenTitle // One season on screen at a time, opened on the one Play would land in. let playlist = title.playlist - let nextUp = nextUpIndex(playlist) + let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex) let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil _seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0)) } @@ -44,7 +44,7 @@ struct TitleView: View { var body: some View { // Display order across seasons: what previous/next and autoplay walk. let playlist = title.playlist - let nextUp = nextUpIndex(playlist) + let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex) let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil let season = title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex] : nil let downloads = DownloadCoordinator.shared.records @@ -105,7 +105,7 @@ struct TitleView: View { .onChange(of: title.key) { overviewOpen = false let playlist = title.playlist - let upNext = playlist.indices.contains(nextUpIndex(playlist)) ? playlist[nextUpIndex(playlist)] : nil + let upNext = playlist.indices.contains(nextUpIndex(playlist, continueIndex: title.continueIndex)) ? playlist[nextUpIndex(playlist, continueIndex: title.continueIndex)] : nil seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0 } } diff --git a/ios/ProtonStreamTests/UiLogicTests.swift b/ios/ProtonStreamTests/UiLogicTests.swift index 7cfabff..9d901b8 100644 --- a/ios/ProtonStreamTests/UiLogicTests.swift +++ b/ios/ProtonStreamTests/UiLogicTests.swift @@ -20,7 +20,7 @@ private func title(_ key: String, _ episodes: [EpisodeRecord]) -> TitleRecord { manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)], displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [], directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil, - franchise: [] + franchise: [], continueIndex: nil ) }