From 1b5a2b3e6bae78bcc949b402d875f79bc39392a3 Mon Sep 17 00:00:00 2001 From: Caitlin Wilks Date: Sun, 18 Oct 2026 23:51:53 +0100 Subject: [PATCH 1/2] YouTube: History page Lists the videos you've played, most recent first, from YouTube's saved progress, so it syncs between devices like the rest of it. Videos only marked watched from a list aren't included. --- README.md | 5 ++- crates/flex-app/src/screens/youtube.rs | 60 ++++++++++++++++++++++++-- crates/flex-app/src/shared.rs | 4 ++ crates/flex-core/src/youtube/store.rs | 30 +++++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 90237f8..7af08cc 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ what you've watched: Settings → Sync with other devices, then pick a folder th reach, like a folder on your NAS. Each Flex keeps its own libraries (pointing at wherever the media is on that machine, as long as the libraries have the same names) and they swap watched state, resume points, history, the watchlist, matches you fixed by hand and -YouTube subscriptions through that folder every 30 seconds or so. +YouTube subscriptions and what you've watched there through that folder every 30 seconds or so. ## YouTube @@ -77,7 +77,8 @@ Add channels under Channels by pasting a link or an @handle, or import your exis subscriptions from Google Takeout (`subscriptions.csv`), FreeTube, NewPipe or an OPML file. New videos are picked up from each channel's feed every half an hour, so there's no API key to set up. Search works too, either on the YouTube page or from the main search -(pick the YouTube scope at the top of the results). +(pick the YouTube scope at the top of the results). History lists the videos you've +played, most recent first. Videos take a few seconds to start, since yt-dlp has to ask YouTube for them first, but Flex starts on whichever video you have highlighted, so it's usually ready by the time you diff --git a/crates/flex-app/src/screens/youtube.rs b/crates/flex-app/src/screens/youtube.rs index f643269..503d9ae 100644 --- a/crates/flex-app/src/screens/youtube.rs +++ b/crates/flex-app/src/screens/youtube.rs @@ -235,9 +235,9 @@ pub struct YouTubeHome { loaded: Option, } -/// Search, Channels, and which channels' videos to show. -fn home_buttons(sh: &Shared) -> [&'static str; 3] { - ["Search…", "Channels", if sh.youtube_focused_only() { "Showing: focused" } else { "Showing: all channels" }] +/// Search, Channels, History, and which channels' videos to show. +fn home_buttons(sh: &Shared) -> [&'static str; 4] { + ["Search…", "Channels", "History", if sh.youtube_focused_only() { "Showing: focused" } else { "Showing: all channels" }] } impl YouTubeHome { @@ -275,6 +275,7 @@ impl Screen for YouTubeHome { match self.view.handle(press, home_buttons(sh).len(), self.items.len()) { GridInput::Button(0) => Nav::push(TextEntry::youtube_search()), GridInput::Button(1) => Nav::push(ChannelList::default()), + GridInput::Button(2) => Nav::push(YouTubeHistory::default()), GridInput::Button(_) => { let on = !sh.youtube_focused_only(); sh.set_youtube_focused_only(on); @@ -307,6 +308,59 @@ impl Screen for YouTubeHome { } } +const HISTORY: usize = 500; + +/// Videos played, most recently first (synced like the rest of YouTube's progress). +#[derive(Default)] +pub struct YouTubeHistory { + view: VideoGrid, + items: Vec, + loaded: Option, +} + +impl YouTubeHistory { + fn refresh(&mut self, sh: &Shared) { + if self.loaded == Some(sh.youtube.version) { + return; + } + self.loaded = Some(sh.youtube.version); + let now = now_unix(); + self.items = sh + .yt_history(HISTORY) + .into_iter() + .map(|(id, p)| { + let how = match (p.watched, p.position, p.duration) { + (true, _, _) => "watched".to_owned(), + (false, Some(pos), Some(dur)) => format!("{} left", theme::format_time(dur - pos)), + _ => "started".to_owned(), + }; + let detail = format!("{} · {} · {how}", p.channel, ago(p.updated_at, now)); + let item = VideoItem { id, title: p.title.clone(), channel: p.channel.clone(), channel_id: String::new(), detail, fraction: None, watched: false }; + with_progress(item, Some(&p)) + }) + .collect(); + } +} + +impl Screen for YouTubeHistory { + fn handle(&mut self, sh: &mut Shared, press: Press) -> Nav { + if press.is(Action::Back) { + return Nav::Pop; + } + self.refresh(sh); + video_input(self.view.handle(press, 0, self.items.len()), &self.items).unwrap_or(Nav::None) + } + + fn ui(&mut self, ui: &mut Ui, sh: &mut Shared, rect: Rect, focused: bool) -> Nav { + self.refresh(sh); + let body = widgets::header(ui.painter(), rect, "History", "YouTube", ui.style()); + let items = std::mem::take(&mut self.items); + self.view.ui(ui, sh, body, &[], &items, "Nothing watched yet", focused); + self.items = items; + Nav::None + } +} + /// One channel's videos. pub struct ChannelPage { id: String, diff --git a/crates/flex-app/src/shared.rs b/crates/flex-app/src/shared.rs index d0cefc6..8c69193 100644 --- a/crates/flex-app/src/shared.rs +++ b/crates/flex-app/src/shared.rs @@ -1744,6 +1744,10 @@ impl Shared { self.db.yt_progress(id).ok().flatten() } + pub fn yt_history(&self, limit: usize) -> Vec<(String, flex_core::youtube::store::WatchProgress)> { + self.db.yt_history(limit).unwrap_or_default() + } + pub fn yt_progress_all(&self) -> HashMap { self.db.yt_progress_all().unwrap_or_default() } diff --git a/crates/flex-core/src/youtube/store.rs b/crates/flex-core/src/youtube/store.rs index a69146a..1171059 100644 --- a/crates/flex-core/src/youtube/store.rs +++ b/crates/flex-core/src/youtube/store.rs @@ -145,6 +145,22 @@ impl Db { Ok(rows.collect::>()?) } + /// Videos actually played (not only marked watched), most recently first: (id, progress). + /// One entry per video, at the last time it was played. + pub fn yt_history(&self, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, title, channel, position, duration, watched, updated_at FROM yt_progress + WHERE duration IS NOT NULL ORDER BY updated_at DESC LIMIT ?1", + )?; + let rows = stmt.query_map([limit as i64], |r| { + Ok(( + r.get::<_, String>(0)?, + WatchProgress { title: r.get(1)?, channel: r.get(2)?, position: r.get(3)?, duration: r.get(4)?, watched: r.get(5)?, updated_at: r.get(6)? }, + )) + })?; + Ok(rows.collect::>()?) + } + /// Records where playback got to. Past `threshold` (e.g. 0.9) of the way through, the /// video counts as watched and won't resume. Returns the saved progress. #[allow(clippy::too_many_arguments)] @@ -269,4 +285,18 @@ mod tests { assert!(!db.yt_progress("v").unwrap().unwrap().watched); assert_eq!(db.yt_progress_all().unwrap().len(), 2); } + + #[test] + fn history_lists_played_videos_newest_first() { + let db = Db::open_in_memory().unwrap(); + db.record_yt_progress("a", "A", "C", 60.0, 600.0, 0.9, 10).unwrap(); + db.record_yt_progress("b", "B", "C", 590.0, 600.0, 0.9, 20).unwrap(); + // Marked watched from a list without being played: not history. + db.set_yt_watched("c", "C", "C", true, 30).unwrap(); + let ids = |db: &Db| db.yt_history(10).unwrap().into_iter().map(|(id, _)| id).collect::>(); + assert_eq!(ids(&db), ["b", "a"]); + db.record_yt_progress("a", "A", "C", 120.0, 600.0, 0.9, 40).unwrap(); + assert_eq!(ids(&db), ["a", "b"], "playing again moves it to the top"); + assert_eq!(db.yt_history(1).unwrap().len(), 1); + } } From ce611f41a87cfcc119083875edf8cf2ac289ee42 Mon Sep 17 00:00:00 2001 From: Caitlin Wilks Date: Sun, 18 Oct 2026 23:52:01 +0100 Subject: [PATCH 2/2] Version 0.17.0 --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- crates/flex-app/Cargo.toml | 6 +++--- crates/flex-core/Cargo.toml | 2 +- crates/flex-web/Cargo.toml | 2 +- plugins/anilist/Cargo.toml | 2 +- plugins/anilist/plugin.toml | 2 +- plugins/lotus-lamp/Cargo.toml | 2 +- plugins/lotus-lamp/plugin.toml | 2 +- plugins/trakt/Cargo.toml | 2 +- plugins/trakt/plugin.toml | 2 +- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1662a5e..73c5ecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "flex" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "chrono", @@ -1304,7 +1304,7 @@ dependencies = [ [[package]] name = "flex-anilist" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flex-plugin", "ureq", @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "flex-core" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "directories", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "flex-lotus-lamp" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flex-plugin", "lotus-lamp", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "flex-plugin" -version = "0.16.1" +version = "0.17.0" dependencies = [ "serde", "serde_json", @@ -1354,7 +1354,7 @@ dependencies = [ [[package]] name = "flex-trakt" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flex-plugin", "ureq", @@ -1362,7 +1362,7 @@ dependencies = [ [[package]] name = "flex-web" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 3ad935a..c990c98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ default-members = ["crates/*"] [workspace.package] edition = "2024" -version = "0.16.1" +version = "0.17.0" license = "MIT" [profile.release] diff --git a/crates/flex-app/Cargo.toml b/crates/flex-app/Cargo.toml index cf827f5..e72e5c5 100644 --- a/crates/flex-app/Cargo.toml +++ b/crates/flex-app/Cargo.toml @@ -9,9 +9,9 @@ anyhow = "1.0.104" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts"] } env_logger = "0.11.11" -flex-core = { version = "0.16.1", path = "../flex-core" } -flex-plugin = { version = "0.16.1", path = "../flex-plugin" } -flex-web = { version = "0.16.1", path = "../flex-web" } +flex-core = { version = "0.17.0", path = "../flex-core" } +flex-plugin = { version = "0.17.0", path = "../flex-plugin" } +flex-web = { version = "0.17.0", path = "../flex-web" } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } libloading = "0.9" log = "0.4.34" diff --git a/crates/flex-core/Cargo.toml b/crates/flex-core/Cargo.toml index 83b33fb..dbd1203 100644 --- a/crates/flex-core/Cargo.toml +++ b/crates/flex-core/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] anyhow = "1.0.104" directories = "6.0.0" -flex-plugin = { version = "0.16.1", path = "../flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../flex-plugin" } hunch = "2" lofty = "0.25.4" log = "0.4.34" diff --git a/crates/flex-web/Cargo.toml b/crates/flex-web/Cargo.toml index 7e05202..15ad9bc 100644 --- a/crates/flex-web/Cargo.toml +++ b/crates/flex-web/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] anyhow = "1.0.104" axum = "0.8.9" -flex-core = { version = "0.16.1", path = "../flex-core" } +flex-core = { version = "0.17.0", path = "../flex-core" } log = "0.4.34" serde = { version = "1.0.229", features = ["derive", "rc"] } serde_json = "1.0.151" diff --git a/plugins/anilist/Cargo.toml b/plugins/anilist/Cargo.toml index fedb288..5dc4fd1 100644 --- a/plugins/anilist/Cargo.toml +++ b/plugins/anilist/Cargo.toml @@ -7,5 +7,5 @@ license.workspace = true publish = false [dependencies] -flex-plugin = { version = "0.16.1", path = "../../crates/flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../../crates/flex-plugin" } ureq = { version = "3", features = ["json"] } diff --git a/plugins/anilist/plugin.toml b/plugins/anilist/plugin.toml index 0502cc4..8d61e6c 100644 --- a/plugins/anilist/plugin.toml +++ b/plugins/anilist/plugin.toml @@ -1,6 +1,6 @@ name = "AniList" description = "Updates your AniList progress as you watch anime in Flex" -version = "0.16.1" +version = "0.17.0" command = "flex-anilist" subscribe = ["playback"] diff --git a/plugins/lotus-lamp/Cargo.toml b/plugins/lotus-lamp/Cargo.toml index f6da222..0c3762a 100644 --- a/plugins/lotus-lamp/Cargo.toml +++ b/plugins/lotus-lamp/Cargo.toml @@ -7,6 +7,6 @@ license.workspace = true publish = false [dependencies] -flex-plugin = { version = "0.16.1", path = "../../crates/flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../../crates/flex-plugin" } lotus-lamp = { git = "https://github.com/bombpersons/lotus-lamp-rs", rev = "59cf5ccaa0007f006f1812fd02cc5991aff11e72" } tokio = { version = "1.53.2", features = ["rt", "time", "sync", "macros"] } diff --git a/plugins/lotus-lamp/plugin.toml b/plugins/lotus-lamp/plugin.toml index 0d4d655..dd63a73 100644 --- a/plugins/lotus-lamp/plugin.toml +++ b/plugins/lotus-lamp/plugin.toml @@ -1,6 +1,6 @@ name = "LED strip (Lotus Lamp)" description = "Lights a Bluetooth LED strip behind the TV with the colours on screen" -version = "0.16.1" +version = "0.17.0" command = "flex-lotus-lamp" subscribe = ["light"] diff --git a/plugins/trakt/Cargo.toml b/plugins/trakt/Cargo.toml index dc0cbd1..f97d159 100644 --- a/plugins/trakt/Cargo.toml +++ b/plugins/trakt/Cargo.toml @@ -7,5 +7,5 @@ license.workspace = true publish = false [dependencies] -flex-plugin = { version = "0.16.1", path = "../../crates/flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../../crates/flex-plugin" } ureq = { version = "3", features = ["json"] } diff --git a/plugins/trakt/plugin.toml b/plugins/trakt/plugin.toml index 93e9c1d..f042d05 100644 --- a/plugins/trakt/plugin.toml +++ b/plugins/trakt/plugin.toml @@ -1,6 +1,6 @@ name = "Trakt" description = "Keeps your Trakt history up to date with what you watch in Flex" -version = "0.16.1" +version = "0.17.0" command = "flex-trakt" subscribe = ["playback"]