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.
This commit is contained in:
nvrl committed 2026-10-12 22:05:05 +02:00
1 parent c6b0457c4d
commit a306d02cf1
15 files changed
+225 -68

No files matched your search

@@ -595,14 +595,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() val engine = NativeRuntime.engine()
snapshots.forEach { snapshot -> snapshots.forEach { snapshot ->
val state = snapshot.state engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
engine.saveWatchState(
snapshot.shareId,
snapshot.linkId,
state?.positionSecs ?: 0.0,
state?.durationSecs,
state?.watched ?: false,
)
} }
} }
}.onFailure(::reportError) }.onFailure(::reportError)
@@ -103,11 +103,14 @@ internal fun EmptyState(title: String, body: String) {
} }
/** /**
* Where Play on a title lands: the most recently played part-watched episode, * Where Play on a title lands: what Continue watching offers
* else the first unwatched one, else the first — the desktop's `next_up`. * ([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<EpisodeRecord>): Int = internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index 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 } ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0 ?: 0
@@ -96,7 +96,13 @@ import uniffi.pstr_android.TitleType
private const val CONTINUE_WATCHING_MAX = 12 private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */ /** 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. */ /** 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) private data class Tile(val title: TitleRecord, val folded: Int)
@@ -126,14 +132,16 @@ internal fun LibraryScreen(
var matching by remember { mutableStateOf<TitleRecord?>(null) } var matching by remember { mutableStateOf<TitleRecord?>(null) }
// Most recently played first, one episode per show: a shelf that lists four // 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.
// 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) { val resumable = remember(state.titles) {
state.titles.mapNotNull { title -> state.titles.mapNotNull { title ->
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
val playlist = title.seasons.flatMap(SeasonRecord::episodes) val playlist = title.seasons.flatMap(SeasonRecord::episodes)
playlist.withIndex() playlist.getOrNull(index)?.let { episode ->
.filter { it.value.resumeAt != null && !it.value.watched } Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
.maxByOrNull { it.value.lastPlayed } }
?.let { Resumable(title, it.value, it.index) } }.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
} }
// Search is an icon until it is wanted: a permanent field above the // 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. // 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 val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) { TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") { 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.Info, "Open") { onTitle(title) }
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) } 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)) { 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) Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play") 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), modifier = Modifier.padding(top = 8.dp),
) )
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -122,7 +122,7 @@ internal fun TitleScreen(
// Display order across seasons: what previous/next and autoplay walk, and // Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses. // the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } 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) val upNext = playlist.getOrNull(nextUp)
// One season on screen at a time, opened on the one Play would land in: a // 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 // forty-episode show as stacked sections is one long scroll to the part
@@ -26,6 +26,7 @@ class HistoryTest {
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(), displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null, directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
franchise = emptyList(), franchise = emptyList(),
continueIndex = null,
) )
@Test @Test
@@ -264,6 +264,9 @@ class ScreenshotTest {
nextEpisode = null, nextEpisode = null,
nextAiringAt = null, nextAiringAt = null,
franchise = emptyList(), 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( val SERIES = title(
+40
View File
@@ -509,6 +509,11 @@ pub struct TitleRecord {
/// Every title of its franchise, in release order, its own key included; /// Every title of its franchise, in release order, its own key included;
/// empty for a title that stands alone. /// empty for a title that stands alone.
pub franchise: Vec<String>, pub franchise: Vec<String>,
/// 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<u32>,
} }
/// Playback preferences that outlive one file, one title and one launch. /// Playback preferences that outlive one file, one title and one launch.
@@ -1599,6 +1604,37 @@ impl AndroidEngine {
.map(|state| state.map(watch_state_record)) .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<WatchStateRecord>,
) -> 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( pub fn save_watch_state(
&self, &self,
share_id: String, share_id: String,
@@ -3203,6 +3239,10 @@ fn title_record(
next_episode: None, next_episode: None,
next_airing_at: None, next_airing_at: None,
franchise: Vec::new(), 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: title
.seasons .seasons
.iter() .iter()
+10 -1
View File
@@ -1312,9 +1312,18 @@ impl App {
})?; })?;
Some((title.name.clone(), episode.watch)) Some((title.name.clone(), episode.watch))
}); });
let Some((name, Some(before))) = found else { let Some((name, before)) = found else {
return; 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( self.engine.save_watch_state(
share_id.clone(), share_id.clone(),
link_id.clone(), link_id.clone(),
+11 -15
View File
@@ -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( fn continue_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
@@ -395,21 +395,17 @@ fn continue_row(
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
for title in titles { for title in titles {
let Some(episode) = title.resume() else { let Some(episode) = title.continue_with() else {
continue; continue;
}; };
let remaining = episode let remaining = match episode.resume_at() {
.watch Some(at) => episode
.and_then(|watch| watch.duration_secs) .watch
.map(|duration| { .and_then(|watch| watch.duration_secs)
format!( .map(|duration| format!("{} left", ui::format_time(duration - at)))
"{} left", .unwrap_or_else(|| episode.label()),
ui::format_time( None => format!("Next: {}", episode.label()),
duration - episode.watch.map_or(0.0, |w| w.position_secs) };
)
)
})
.unwrap_or_else(|| episode.label());
let picture = art.of(title); let picture = art.of(title);
let response = ui::card( let response = ui::card(
@@ -550,7 +546,7 @@ fn tile_menu(
}); });
} }
if continuing if continuing
&& let Some(episode) = title.resume() && let Some(episode) = title.continue_with()
&& ui && ui
.button("Remove from Continue watching") .button("Remove from Continue watching")
.on_hover_text("Forget where you stopped in it") .on_hover_text("Forget where you stopped in it")
+98 -6
View File
@@ -189,10 +189,44 @@ impl Title {
.max_by_key(|episode| episode.last_played()) .max_by_key(|episode| episode.last_played())
} }
/// What a click on the poster should play: whatever is part-watched, else /// What Continue watching offers for this title: the part-watched episode,
/// the first unwatched episode, else the first 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> { 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().find(|episode| !episode.is_watched()))
.or_else(|| self.episodes().next()) .or_else(|| self.episodes().next())
} }
@@ -357,13 +391,13 @@ impl Library {
self.titles.iter().find(|title| title.key == key) self.titles.iter().find(|title| title.key == key)
} }
/// Titles with something part-watched, most recent first. The top row of /// Titles with something to go on with — see [`Title::continue_with`] —
/// the library. /// most recent first. The top row of the library.
pub fn continue_watching(&self) -> Vec<&Title> { pub fn continue_watching(&self) -> Vec<&Title> {
let mut titles: Vec<&Title> = self let mut titles: Vec<&Title> = self
.titles .titles
.iter() .iter()
.filter(|title| title.resume().is_some()) .filter(|title| title.continue_with().is_some())
.collect(); .collect();
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played())); titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
titles titles
@@ -688,6 +722,64 @@ mod tests {
assert_eq!(library.continue_watching().len(), 1); assert_eq!(library.continue_watching().len(), 1);
} }
fn three_episodes() -> Vec<CatalogNode> {
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] #[test]
fn a_watch_state_set_in_place_matches_a_rebuild() { fn a_watch_state_set_in_place_matches_a_rebuild() {
let nodes = vec![ let nodes = vec![
+1 -7
View File
@@ -516,13 +516,7 @@ final class AppModel {
do { do {
try await run { engine in try await run { engine in
for snapshot in snapshots { for snapshot in snapshots {
try engine.saveWatchState( try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state)
shareId: snapshot.shareId,
linkId: snapshot.linkId,
positionSecs: snapshot.state?.positionSecs ?? 0,
durationSecs: snapshot.state?.durationSecs,
watched: snapshot.state?.watched ?? false
)
} }
} }
} catch { } catch {
+8 -3
View File
@@ -263,9 +263,14 @@ extension View {
func secureContent() -> some View { modifier(SecureContent()) } func secureContent() -> some View { modifier(SecureContent()) }
} }
/// Where Play on a title lands: the most recently played part-watched episode, /// Where Play on a title lands: what Continue watching offers
/// else the first unwatched one, else the first — the desktop's `next_up`. /// (`TitleRecord.continueIndex`), else the most recently played part-watched
func nextUpIndex(_ playlist: [EpisodeRecord]) -> Int { /// 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 }) { if let resumed = playlist.indices.filter({ playlist[$0].resumeAt != nil }).max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) {
return resumed return resumed
} }
+19 -10
View File
@@ -10,6 +10,8 @@ struct Resumable: Identifiable {
let title: TitleRecord let title: TitleRecord
let episode: EpisodeRecord let episode: EpisodeRecord
let index: Int let index: Int
/// When the title was last played, which orders the shelf.
let lastPlayed: Int64
var id: String { "\(episode.shareId)/\(episode.linkId)" } 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 /// 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] { func resumable(_ titles: [TitleRecord]) -> [Resumable] {
titles.compactMap { title -> Resumable? in titles.compactMap { title -> Resumable? in
guard let index = title.continueIndex.map(Int.init) else { return nil }
let playlist = title.playlist let playlist = title.playlist
guard let index = playlist.indices guard playlist.indices.contains(index) else { return nil }
.filter({ playlist[$0].resumeAt != nil && !playlist[$0].watched }) return Resumable(
.max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) title: title,
else { return nil } episode: playlist[index],
return Resumable(title: title, episode: playlist[index], index: index) index: index,
lastPlayed: playlist.map(\.lastPlayed).max() ?? 0
)
} }
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed } .sorted { $0.lastPlayed > $1.lastPlayed }
.prefix(continueWatchingMax) .prefix(continueWatchingMax)
.map { $0 } .map { $0 }
} }
@@ -106,7 +113,7 @@ struct LibraryView: View {
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
TileMenu(heading: title.displayName, caption: title.caption) { TileMenu(heading: title.displayName, caption: title.caption) {
MenuRow("play.fill", playlist.contains { $0.resumeAt != nil } ? "Resume" : "Play") { 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("info.circle", "Open") { onTitle(title) }
MenuRow("arrow.down.circle", "Download all") { DownloadCoordinator.shared.enqueue(playlist) } 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) .textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
} }
HStack(spacing: 8) { 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") Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
} }
.buttonStyle(.accent) .buttonStyle(.accent)
@@ -463,7 +470,9 @@ private struct ContinueCard: View {
.clipShape(RoundedRectangle(cornerRadius: Corner.standard)) .clipShape(RoundedRectangle(cornerRadius: Corner.standard))
Text(entry.title.displayName).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface) Text(entry.title.displayName).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
.lineLimit(1).padding(.top, 8) .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) .textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
} }
.frame(width: 220) .frame(width: 220)
+3 -3
View File
@@ -36,7 +36,7 @@ struct TitleView: View {
self.onOpenTitle = onOpenTitle self.onOpenTitle = onOpenTitle
// One season on screen at a time, opened on the one Play would land in. // One season on screen at a time, opened on the one Play would land in.
let playlist = title.playlist 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 upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0)) _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 { var body: some View {
// Display order across seasons: what previous/next and autoplay walk. // Display order across seasons: what previous/next and autoplay walk.
let playlist = title.playlist 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 upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
let season = title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex] : nil let season = title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex] : nil
let downloads = DownloadCoordinator.shared.records let downloads = DownloadCoordinator.shared.records
@@ -105,7 +105,7 @@ struct TitleView: View {
.onChange(of: title.key) { .onChange(of: title.key) {
overviewOpen = false overviewOpen = false
let playlist = title.playlist 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 seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ private func title(_ key: String, _ episodes: [EpisodeRecord]) -> TitleRecord {
manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)], manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)],
displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [], displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil, directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil,
franchise: [] franchise: [], continueIndex: nil
) )
} }