feat: edit watching and watched status

This commit is contained in:
2026-10-17 01:43:50 +02:00
parent bb0a3e58ce
commit 08e4a62228
5 changed files with 476 additions and 42 deletions
+89 -13
View File
@@ -109,10 +109,10 @@
//! Quitting cancels pending metadata/launch processes, not the separately running player.
//! w/v save to watch later/saved videos; W/V choose combined or category scopes in searchable selectors.
//! Alt-w/Alt-v browse the current explicit category's collection, including from collection previews.
//! C opens combined currently watching: acknowledged started videos leave automatically once watched.
//! C opens combined currently watching; m/M mark the selected video watching/watched, including previews.
//! Collection views allow x/X selected/bulk removal, including previews; removal never marks watched.
//! All three collections store offline metadata beside config.toml; channel tags determine category lists.
//! Saved titles are green before watch-later magenta; ✓ marks acknowledged eligible playback reaching 95%.
//! Saved titles are green before watch-later magenta; ✓ marks watched status, set manually or by eligible playback reaching 95%.
//! In watch-later, f cycles All/Unwatched/Watched. X confirms removal of the current filtered/category subset.
//! In saved-video views, X confirms removal of the current category/list subset; other collections and watched state stay unchanged.
//! Configure view.collection.remove.selected and view.collection.remove.bulk; y/Enter confirms bulk, n/Esc cancels.
@@ -151,7 +151,7 @@ use ratatui::{
text::{Line, Span, Text},
widgets::{Block, Cell, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap},
};
use video_state::CollectionKind;
use video_state::{CollectionKind, WatchStatus};
use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
#[derive(Parser)]
@@ -598,12 +598,17 @@ struct KeyHints {
impl KeyHints {
fn new(bindings: &keys::Bindings) -> Self {
let key = |action| bindings.label(action);
let status = format!(
"{} watching · {} watched",
key(Action::WatchStatusWatching),
key(Action::WatchStatusWatched)
);
Self {
footers: [
(
Context::Normal,
format!(
"NORMAL · {}/{} select · {} play · {}/{} later · {}/{} saved · {} search · {} help · {} quit",
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} search · {} help · {} quit",
key(Action::VideoNext),
key(Action::VideoPrevious),
key(Action::VideoPlay),
@@ -619,7 +624,7 @@ impl KeyHints {
(
Context::Info,
format!(
"INFO · {} description · {} later · {} save · {} close · {} help",
"INFO · {} description · {status} · {} later · {} save · {} close · {} help",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
@@ -630,7 +635,7 @@ impl KeyHints {
(
Context::Description,
format!(
"DESCRIPTION · {}/{} scroll · {} info · {} later · {} save · {} close · {} help",
"DESCRIPTION · {}/{} scroll · {} info · {status} · {} later · {} save · {} close · {} help",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -643,7 +648,7 @@ impl KeyHints {
(
Context::CollectionNormal,
format!(
"NORMAL · {}/{} select · {} play · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} help · {} quit",
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} help · {} quit",
key(Action::VideoNext),
key(Action::VideoPrevious),
key(Action::VideoPlay),
@@ -661,7 +666,7 @@ impl KeyHints {
(
Context::CollectionInfo,
format!(
"INFO · {} description · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
"INFO · {} description · {status} · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
@@ -674,7 +679,7 @@ impl KeyHints {
(
Context::CollectionDescription,
format!(
"DESCRIPTION · {}/{} scroll · {} info · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
"DESCRIPTION · {}/{} scroll · {} info · {status} · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -772,14 +777,14 @@ impl KeyHints {
],
preview: [
format!(
"· {} description · {} later · {} save · {} close ",
"· {} description · {status} · {} later · {} save · {} close ",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide)
),
format!(
"· {}/{} scroll · {} info · {} later · {} save · {} close ",
"· {}/{} scroll · {} info · {status} · {} later · {} save · {} close ",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -788,7 +793,7 @@ impl KeyHints {
key(Action::InfoHide)
),
format!(
"· {} description · {} later · {} save · {} remove selected · {} remove list · {} close ",
"· {} description · {status} · {} later · {} save · {} remove selected · {} remove list · {} close ",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
@@ -797,7 +802,7 @@ impl KeyHints {
key(Action::InfoHide)
),
format!(
"· {}/{} scroll · {} info · {} later · {} save · {} remove selected · {} remove list · {} close ",
"· {}/{} scroll · {} info · {status} · {} later · {} save · {} remove selected · {} remove list · {} close ",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -1714,6 +1719,27 @@ impl App {
}
}
fn set_watch_status(&mut self, status: WatchStatus) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|row| view.videos.get(row))
else {
self.status = "Select a video first.".into();
return;
};
let result = self.video_state.set_watch_status(video, status);
self.rebuild_collections();
self.status = match result {
Ok(()) => match status {
WatchStatus::Watching => "Video marked as watching.".into(),
WatchStatus::Watched => "Video marked as watched.".into(),
},
Err(error) => error.to_string(),
};
}
fn filter_watch_later(&mut self) {
let view = &mut self.views[self.view_index];
if !matches!(view.source, ViewSource::WatchLater(_)) {
@@ -2837,6 +2863,8 @@ impl App {
Some(Action::SavedVideoSave) => self.save_collection(CollectionKind::Saved),
Some(Action::SavedVideoBrowse) => self.open_browser(BrowserKind::SavedVideos),
Some(Action::WatchingBrowse) => self.browse_collection(CollectionKind::Watching),
Some(Action::WatchStatusWatching) => self.set_watch_status(WatchStatus::Watching),
Some(Action::WatchStatusWatched) => self.set_watch_status(WatchStatus::Watched),
Some(Action::SavedVideoBrowseCategory) => {
self.browse_category_collection(CollectionKind::Saved)
}
@@ -5739,6 +5767,54 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn manual_status_updates_filtered_lists_and_closes_removed_previews() {
let mut app = app(&["tubular"]);
let document = "[keys]\nvideo.status.watching = 'f6'\nvideo.status.watched = 'f7'\n"
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.apply_config(config::Config {
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
..Default::default()
});
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#,
).unwrap().videos;
app.views[0].loaded = true;
for row in 0..2 {
app.views[0].selection.select(Some(row));
app.save_collection(CollectionKind::WatchLater);
}
app.browse_collection(CollectionKind::WatchLater);
app.filter_watch_later();
app.views[app.view_index].selection.select(Some(0));
app.preview = Some(PreviewMode::Info);
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk");
assert_eq!(app.preview, None);
app.filter_watch_later();
assert_eq!(app.views[app.view_index].videos[0].id, "jNQXAC9IVRw");
app.preview = Some(PreviewMode::Description);
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
assert!(!app.video_state.is_watched("jNQXAC9IVRw"));
assert!(app.views[app.view_index].videos.is_empty());
assert_eq!(app.views[app.view_index].selection.selected(), None);
assert_eq!(app.preview, None);
assert!(
app.video_state
.contains(CollectionKind::Watching, "jNQXAC9IVRw")
);
assert!(
app.video_state
.contains(CollectionKind::WatchLater, "jNQXAC9IVRw")
);
app.browse_collection(CollectionKind::Watching);
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
assert!(app.views[app.view_index].videos.is_empty());
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
}
#[test]
fn watch_later_tracks_current_tags_filters_and_confirms_only_snapshot_removal() {
let root = std::env::temp_dir().join(format!(