Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ default-members = ["crates/*"]

[workspace.package]
edition = "2024"
version = "0.18.0"
version = "0.19.0"
license = "MIT"

[profile.release]
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions crates/flex-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.18.0", path = "../flex-core" }
flex-plugin = { version = "0.18.0", path = "../flex-plugin" }
flex-web = { version = "0.18.0", path = "../flex-web" }
flex-core = { version = "0.19.0", path = "../flex-core" }
flex-plugin = { version = "0.19.0", path = "../flex-plugin" }
flex-web = { version = "0.19.0", path = "../flex-web" }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
libloading = "0.9"
log = "0.4.34"
Expand Down
13 changes: 9 additions & 4 deletions crates/flex-app/src/screens/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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())
})
Expand Down
57 changes: 50 additions & 7 deletions crates/flex-app/src/screens/youtube.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,17 +227,30 @@ fn video_input(input: GridInput, items: &[VideoItem]) -> Option<Nav> {
})
}

/// YouTube's main page: part-watched videos, then the latest from subscriptions.
/// YouTube's main page: part-watched videos, then the latest from subscriptions (or
/// suggestions, "for you").
#[derive(Default)]
pub struct YouTubeHome {
view: VideoGrid,
items: Vec<VideoItem>,
loaded: Option<u64>,
/// Suggestions are on but there's nothing played or subscribed to suggest from.
no_seeds: bool,
}

/// 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" },
match (sh.youtube_for_you(), sh.youtube_focused_only()) {
(true, _) => "Showing: for you",
(false, true) => "Showing: focused",
(false, false) => "Showing: all channels",
},
]
}

impl YouTubeHome {
Expand All @@ -261,7 +274,13 @@ impl YouTubeHome {
})
.collect();
let continuing: Vec<String> = items.iter().map(|v| v.id.clone()).collect();
items.extend(from_store(sh.yt_latest(LATEST), &progress).into_iter().filter(|v| !continuing.contains(&v.id)));
let rest = if sh.youtube_for_you() {
let suggested = sh.youtube.for_you.as_ref().and_then(|r| r.as_ref().ok());
from_listed(suggested.map_or(&[], Vec::as_slice), &progress).into_iter().filter(|v| !v.watched).collect()
} else {
from_store(sh.yt_latest(LATEST), &progress)
};
items.extend(rest.into_iter().filter(|v| !continuing.contains(&v.id)));
self.items = items;
}
}
Expand All @@ -276,28 +295,52 @@ 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();
if sh.youtube_for_you() {
self.no_seeds = !sh.find_youtube_for_you(true);
}
Nav::None
}
GridInput::Button(_) => {
let on = !sh.youtube_focused_only();
sh.set_youtube_focused_only(on);
// All channels → focused → for you → all channels.
if sh.youtube_for_you() {
sh.set_youtube_for_you(false);
sh.set_youtube_focused_only(false);
} else if sh.youtube_focused_only() {
sh.set_youtube_for_you(true);
} else {
sh.set_youtube_focused_only(true);
}
Nav::None
}
input => video_input(input, &self.items).unwrap_or(Nav::None),
}
}

fn ui(&mut self, ui: &mut Ui, sh: &mut Shared, rect: Rect, focused: bool) -> Nav {
let for_you = sh.youtube_for_you();
// With nothing to suggest from, try again only once something changes.
if for_you && sh.youtube.tools.is_some() && (!self.no_seeds || self.loaded != Some(sh.youtube.version)) {
self.no_seeds = !sh.find_youtube_for_you(false);
}
self.refresh(sh);
let channels = sh.yt_channels().len();
let subtitle = match (sh.youtube.tools.is_some(), channels, sh.youtube.refreshing) {
(false, _, _) if sh.youtube.tools_busy => "Setting up (downloading yt-dlp)…".to_owned(),
(false, _, _) => "Not set up: switch YouTube on in Settings → YouTube".to_owned(),
(true, _, _) if for_you && sh.youtube.for_you_busy => "Finding videos for you…".to_owned(),
(true, 0, _) => "Search, or subscribe to channels in Channels".to_owned(),
(true, n, true) => format!("{} · checking for new videos…", count(n, "channel")),
(true, n, false) => format!("{} · no ads, sponsors skipped", count(n, "channel")),
};
let body = widgets::header(ui.painter(), rect, "YouTube", &subtitle, ui.style());
let items = std::mem::take(&mut self.items);
let empty = match (channels, sh.youtube_focused_only()) {
_ if for_you && sh.youtube.for_you_busy => "Finding videos for you…",
_ if for_you && self.no_seeds => "Play a few videos or subscribe to channels, and suggestions will show here",
_ if for_you && matches!(sh.youtube.for_you, Some(Err(_))) => "Couldn't get suggestions from YouTube. Try Refresh",
_ if for_you => "No suggestions yet. Try Refresh",
(0, _) => "Nothing here yet",
(_, true) if !sh.yt_channels().iter().any(|c| c.focused) => "No focused channels yet: hold OK on one in Channels",
_ => "No new videos",
Expand Down
49 changes: 42 additions & 7 deletions crates/flex-app/src/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,37 @@ impl Shared {
self.setting_bool(crate::youtube::FOCUSED_SETTING)
}

/// Whether YouTube's page shows suggestions instead of subscriptions' videos.
pub fn youtube_for_you(&self) -> bool {
self.setting_bool(crate::youtube::FOR_YOU_SETTING)
}

pub fn set_youtube_for_you(&mut self, on: bool) {
self.set_setting_bool(crate::youtube::FOR_YOU_SETTING, on);
self.youtube.version += 1;
}

/// Finds suggestions for YouTube's page if they're due (or now with `force`), from
/// recently played videos and subscriptions' new ones. Returns false if there's
/// nothing to start from.
pub fn find_youtube_for_you(&mut self, force: bool) -> bool {
use flex_core::youtube::recommend;
if !force && !self.youtube.for_you_due() {
return true;
}
let progress = self.yt_progress_all();
let history = self.db.yt_history(20).unwrap_or_default();
let latest = self.db.yt_latest(20, false, false).unwrap_or_default();
let seeds = recommend::seeds(&history, &latest, &progress);
if seeds.is_empty() {
return false;
}
let watched = progress.into_iter().filter(|(_, p)| p.watched).map(|(id, _)| id).collect();
let subscribed = self.yt_channels().into_iter().map(|c| c.id).collect();
self.youtube.start_for_you(seeds, watched, subscribed, self.youtube_shorts());
true
}

pub fn set_youtube_focused_only(&mut self, on: bool) {
self.set_setting_bool(crate::youtube::FOCUSED_SETTING, on);
self.youtube.version += 1;
Expand Down Expand Up @@ -1811,8 +1842,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)
}

Expand All @@ -1837,15 +1867,20 @@ impl Shared {
Msg::Tools(Err(e)) => self.toast(format!("Couldn't set up YouTube: {e}")),
Msg::Channel(Ok((id, title))) => self.subscribe(&id, &title),
Msg::Channel(Err(e)) => self.toast(format!("Couldn't find that channel: {e}")),
Msg::Resolved(..) | Msg::Segments(..) | Msg::Search(..) | Msg::Related(..) => {}
Msg::Resolved(..) | Msg::Segments(..) | Msg::Search(..) | Msg::Related(..) | Msg::ForYou(..) => {}
}
}
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.
Expand Down Expand Up @@ -2171,7 +2206,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 {
Expand Down Expand Up @@ -2200,7 +2235,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,
};
Expand Down
39 changes: 39 additions & 0 deletions crates/flex-app/src/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
}
}
}

Expand Down
Loading
Loading