From 64695cf5b142122b166b5ac622692f9e6bec80a5 Mon Sep 17 00:00:00 2001 From: Caitlin Wilks Date: Tue, 20 Oct 2026 22:34:42 +0100 Subject: [PATCH 1/4] Search: icons for films, shows, albums, artists and tracks --- crates/flex-app/src/screens/search.rs | 13 ++++++--- crates/flex-app/src/shared.rs | 4 +-- crates/flex-app/src/widgets.rs | 39 +++++++++++++++++++++++++++ crates/flex-core/src/search.rs | 6 ++--- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/flex-app/src/screens/search.rs b/crates/flex-app/src/screens/search.rs index 3f269b2..384fe96 100644 --- a/crates/flex-app/src/screens/search.rs +++ b/crates/flex-app/src/screens/search.rs @@ -87,7 +87,7 @@ impl SearchView { fn open(&self, sh: &mut Shared, i: usize) -> Nav { match &self.results[i].target { - Target::Show { library_id, show_id } => Nav::push(TitlePage::new(*library_id, *show_id)), + Target::Show { library_id, show_id, .. } => Nav::push(TitlePage::new(*library_id, *show_id)), Target::Episode { library_id, file_id } => Nav::push(EpisodePage::new(*library_id, *file_id)), Target::Folder { library_id, rel } => open_folder(sh, *library_id, rel), Target::File { library_id, file_id } => open_file(sh, *library_id, *file_id), @@ -129,7 +129,7 @@ impl Screen for SearchView { if press.is(Action::Menu) { let Some(item) = self.results.get(self.list.selected) else { return Nav::None }; let target = match &item.target { - Target::Show { library_id, show_id } => return Nav::push(super::shows::ShowOptions::new(*library_id, *show_id)), + Target::Show { library_id, show_id, .. } => return Nav::push(super::shows::ShowOptions::new(*library_id, *show_id)), Target::Folder { library_id, rel } => options::Target::Folder { library_id: *library_id, rel: rel.clone() }, Target::File { library_id, file_id } | Target::Episode { library_id, file_id } => { options::Target::File { library_id: *library_id, file_id: *file_id } @@ -193,8 +193,13 @@ impl Screen for SearchView { .iter() .map(|item| { let icon = match item.target { - Target::Show { .. } | Target::Folder { .. } | Target::Album { .. } | Target::Artist { .. } => Icon::Folder, - Target::Episode { .. } | Target::File { .. } | Target::Track { .. } => Icon::Video, + Target::Show { series: true, .. } => Icon::Tv, + Target::Show { series: false, .. } => Icon::Film, + Target::Folder { .. } => Icon::Folder, + Target::Album { .. } => Icon::Album, + Target::Artist { .. } => Icon::Person, + Target::Track { .. } => Icon::Note, + Target::Episode { .. } | Target::File { .. } => Icon::Video, }; Row::new(icon, item.title.as_str()).detail(item.context.as_str()) }) diff --git a/crates/flex-app/src/shared.rs b/crates/flex-app/src/shared.rs index ac20891..4fe0670 100644 --- a/crates/flex-app/src/shared.rs +++ b/crates/flex-app/src/shared.rs @@ -2171,7 +2171,7 @@ impl Shared { Some(y) => format!("{} · {y}", l.lib.name), None => l.lib.name.clone(), }; - let target = Target::Show { library_id: l.lib.id, show_id: entry.show.id }; + let target = Target::Show { library_id: l.lib.id, show_id: entry.show.id, series: entry.is_series() }; items.push(Item::new(entry.show.title.clone(), context, target, 0, &also)); if entry.is_series() { for e in &entry.episodes { @@ -2200,7 +2200,7 @@ impl Shared { .into_iter() .filter_map(|item| { let (library, show, file) = match item.target { - Target::Show { library_id, show_id } => (library_id, Some(show_id), None), + Target::Show { library_id, show_id, .. } => (library_id, Some(show_id), None), Target::Episode { library_id, file_id } | Target::File { library_id, file_id } => (library_id, None, Some(file_id)), _ => return None, }; diff --git a/crates/flex-app/src/widgets.rs b/crates/flex-app/src/widgets.rs index 6bb0694..544829b 100644 --- a/crates/flex-app/src/widgets.rs +++ b/crates/flex-app/src/widgets.rs @@ -17,6 +17,14 @@ pub enum Icon { Refresh, Remove, Star, + /// A film reel. + Film, + Tv, + /// A record, for albums. + Album, + Person, + /// A musical note, for tracks. + Note, } pub struct Row { @@ -140,6 +148,37 @@ pub fn paint_icon(painter: &Painter, c: Pos2, icon: Icon, color: Color32) { painter.add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); } } + Icon::Film => { + painter.circle_stroke(c, 13.0, Stroke::new(3.0, color)); + painter.circle_filled(c, 2.5, color); + for i in 0..5 { + let a = (-90.0 + 72.0 * i as f32).to_radians(); + painter.circle_filled(c + vec2(a.cos(), a.sin()) * 7.0, 2.5, color); + } + // The film running off the reel. + painter.line_segment([c + vec2(0.0, 13.0), c + vec2(16.0, 13.0)], Stroke::new(3.0, color)); + } + Icon::Tv => { + let screen = Rect::from_center_size(c + vec2(0.0, 3.0), vec2(30.0, 20.0)); + painter.rect_stroke(screen, 3, Stroke::new(3.0, color), egui::StrokeKind::Inside); + let top = c + vec2(0.0, -7.0); + painter.line_segment([top, c + vec2(-6.0, -14.0)], Stroke::new(2.5, color)); + painter.line_segment([top, c + vec2(6.0, -14.0)], Stroke::new(2.5, color)); + } + Icon::Album => { + painter.circle_stroke(c, 12.5, Stroke::new(4.0, color)); + painter.circle_filled(c, 4.0, color); + } + Icon::Person => { + painter.circle_filled(c + vec2(0.0, -7.0), 6.5, color); + let body = Rect::from_min_max(c + vec2(-12.0, 2.0), c + vec2(12.0, 14.0)); + painter.rect_filled(body, egui::CornerRadius { nw: 11, ne: 11, sw: 2, se: 2 }, color); + } + Icon::Note => { + painter.circle_filled(c + vec2(-5.0, 8.0), 5.5, color); + painter.line_segment([c + vec2(0.0, 8.0), c + vec2(0.0, -13.0)], Stroke::new(3.0, color)); + painter.line_segment([c + vec2(-1.5, -13.0), c + vec2(9.0, -7.0)], Stroke::new(3.5, color)); + } } } diff --git a/crates/flex-core/src/search.rs b/crates/flex-core/src/search.rs index ab707bb..75411fc 100644 --- a/crates/flex-core/src/search.rs +++ b/crates/flex-core/src/search.rs @@ -11,8 +11,8 @@ use crate::tree::Tree; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Target { - /// A show or film (opens its page). - Show { library_id: i64, show_id: i64 }, + /// A show or film (opens its page). `series` when it has episodes. + Show { library_id: i64, show_id: i64, series: bool }, /// An episode (opens its page). Episode { library_id: i64, file_id: i64 }, Folder { library_id: i64, rel: String }, @@ -249,7 +249,7 @@ mod tests { ); let mut items = path_items(&lib, &tree); let show = |id, title: &str, also: &[String]| { - Item::new(title.into(), "Anime".into(), Target::Show { library_id: 1, show_id: id }, 0, also) + Item::new(title.into(), "Anime".into(), Target::Show { library_id: 1, show_id: id, series: true }, 0, also) }; items.push(show(10, "Dorohedoro", &[])); items.push(show(11, "Attack on Titan", &["Shingeki no Kyojin".to_owned()])); From bfe5598132b128b0af2d81911b8e97016b3815d0 Mon Sep 17 00:00:00 2001 From: Caitlin Wilks Date: Tue, 20 Oct 2026 22:35:20 +0100 Subject: [PATCH 2/4] YouTube: Refresh button --- crates/flex-app/src/screens/youtube.rs | 16 +++++++++++++--- crates/flex-app/src/shared.rs | 12 ++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/flex-app/src/screens/youtube.rs b/crates/flex-app/src/screens/youtube.rs index e7ff625..ae1ed63 100644 --- a/crates/flex-app/src/screens/youtube.rs +++ b/crates/flex-app/src/screens/youtube.rs @@ -235,9 +235,15 @@ pub struct YouTubeHome { loaded: Option, } -/// 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" }] +/// Search, Channels, History, Refresh, and which channels' videos to show. +fn home_buttons(sh: &Shared) -> [&'static str; 5] { + [ + "Search…", + "Channels", + "History", + if sh.youtube.refreshing { "Refreshing…" } else { "Refresh" }, + if sh.youtube_focused_only() { "Showing: focused" } else { "Showing: all channels" }, + ] } impl YouTubeHome { @@ -276,6 +282,10 @@ impl Screen for YouTubeHome { GridInput::Button(0) => Nav::push(TextEntry::youtube_search()), GridInput::Button(1) => Nav::push(ChannelList::default()), GridInput::Button(2) => Nav::push(YouTubeHistory::default()), + GridInput::Button(3) => { + sh.refresh_youtube_feeds(); + Nav::None + } GridInput::Button(_) => { let on = !sh.youtube_focused_only(); sh.set_youtube_focused_only(on); diff --git a/crates/flex-app/src/shared.rs b/crates/flex-app/src/shared.rs index 4fe0670..3ca4039 100644 --- a/crates/flex-app/src/shared.rs +++ b/crates/flex-app/src/shared.rs @@ -1811,8 +1811,7 @@ impl Shared { } } self.youtube.version += 1; - let ids = self.yt_channels().into_iter().map(|c| c.id).collect(); - self.youtube.refresh(ids); + self.refresh_youtube_feeds(); Ok(added) } @@ -1841,11 +1840,16 @@ impl Shared { } } if self.youtube_enabled() && self.youtube.tools.is_some() && self.youtube.refresh_due() { - let ids = self.yt_channels().into_iter().map(|c| c.id).collect(); - self.youtube.refresh(ids); + self.refresh_youtube_feeds(); } } + /// Reads every subscription's feed again now (in the background). + pub fn refresh_youtube_feeds(&mut self) { + let ids = self.yt_channels().into_iter().map(|c| c.id).collect(); + self.youtube.refresh(ids); + } + /// Handles plugin messages, tells plugins about playback, keeps the computer awake /// while something plays and updates lights. /// `playing`: what the top screen is playing, if anything, and the library file. From 08e9634b6b1deef31dd9c8f8670b25bb892f10a4 Mon Sep 17 00:00:00 2001 From: Caitlin Wilks Date: Tue, 20 Oct 2026 22:38:25 +0100 Subject: [PATCH 3/4] YouTube: "for you" suggestions from related videos --- README.md | 7 +- crates/flex-app/src/screens/youtube.rs | 43 ++++++- crates/flex-app/src/shared.rs | 33 ++++- crates/flex-app/src/youtube.rs | 61 +++++++++- crates/flex-core/src/youtube/mod.rs | 1 + crates/flex-core/src/youtube/recommend.rs | 139 ++++++++++++++++++++++ 6 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 crates/flex-core/src/youtube/recommend.rs diff --git a/README.md b/README.md index 6543737..0511c2d 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,11 @@ date, since YouTube changes things a lot and a newer yt-dlp is usually the fix. Add channels under Channels by pasting a link or an @handle, or import your existing 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 +file. New videos are picked up from each channel's feed every half an hour (or press +Refresh), so there's no API key to set up. The Showing button switches between all your +channels, just the focused ones, and *for you*: suggestions made from the related videos +of what you've played lately and your subscriptions' new videos, leaving out anything +you've watched. Search works too, either on the YouTube page or from the main search (pick the YouTube scope at the top of the results). History lists the videos you've played, most recent first. diff --git a/crates/flex-app/src/screens/youtube.rs b/crates/flex-app/src/screens/youtube.rs index ae1ed63..600bccb 100644 --- a/crates/flex-app/src/screens/youtube.rs +++ b/crates/flex-app/src/screens/youtube.rs @@ -227,12 +227,15 @@ fn video_input(input: GridInput, items: &[VideoItem]) -> Option