mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +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
@@ -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<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.
|
||||
@@ -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<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(
|
||||
&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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<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]
|
||||
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
||||
let nodes = vec![
|
||||
|
||||
Reference in new issue
Block a user