mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user