From 7943388f5f0544d0a46b440bfdcc86c7e682af1c Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 16 Jul 2026 05:05:24 -0700 Subject: [PATCH] feat(desktop): PeerTube browse + per-video archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-level lazy nav in the Remotes screen (channels → paged videos), on-demand Play via mpv (HLS-only noted), one-click Archive. Co-Authored-By: Claude Opus 4.8 --- src/app.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 205 insertions(+), 30 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6832007..0fc864a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -273,6 +273,17 @@ pub struct App { remote_rx: Option>>, /// Test-connection result for the settings editor: (row index, message). remote_test_rx: Option>, + /// PeerTube browse (desktop two-level nav). `pt_remote` is the selected + /// peer index; None means not browsing a PeerTube peer. + pt_remote: Option, + pt_channels: Option>, + pt_channel: Option, + pt_videos: Vec, + pt_page: usize, + pt_done: bool, + pt_channels_rx: Option, String>>>, + pt_videos_rx: Option, String>>>, + pt_media_rx: Option, String>>>, stats_report: Option, // Per-channel download-options dialog state show_channel_options: bool, @@ -702,6 +713,15 @@ impl App { remote_status: String::new(), remote_rx: None, remote_test_rx: None, + pt_remote: None, + pt_channels: None, + pt_channel: None, + pt_videos: Vec::new(), + pt_page: 0, + pt_done: false, + pt_channels_rx: None, + pt_videos_rx: None, + pt_media_rx: None, stats_report: None, show_channel_options: false, channel_options_target: None, @@ -2574,6 +2594,72 @@ impl App { }); } + /// Begin browsing PeerTube peer `idx`: fetch its channel list on a thread. + fn start_pt_browse(&mut self, idx: usize) { + let Some(client) = self.remotes.get(idx).cloned() else { return }; + self.remote_selected = None; + self.remote_library = None; + self.pt_remote = Some(idx); + self.pt_channels = None; + self.pt_channel = None; + self.pt_videos.clear(); + self.pt_page = 0; + self.pt_done = false; + self.remote_status = format!("Connecting to {}…", client.name()); + let (tx, rx) = std::sync::mpsc::channel(); + self.pt_channels_rx = Some(rx); + let ctx = self.egui_ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(client.pt_channels()); + ctx.request_repaint(); + }); + } + + /// Fetch one page of the selected channel's videos on a thread. `reset` + /// clears the accumulated list (new channel); otherwise it appends. + fn start_pt_videos(&mut self, reset: bool) { + let (Some(idx), Some(handle)) = (self.pt_remote, self.pt_channel.clone()) else { return }; + let Some(client) = self.remotes.get(idx).cloned() else { return }; + if reset { self.pt_videos.clear(); self.pt_page = 0; self.pt_done = false; } + let page = self.pt_page; + self.remote_status = "Loading videos…".to_string(); + let (tx, rx) = std::sync::mpsc::channel(); + self.pt_videos_rx = Some(rx); + let ctx = self.egui_ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(client.pt_channel_videos(&handle, page)); + ctx.request_repaint(); + }); + } + + /// Resolve a video's playable MP4 on a thread; result drained in update(). + fn start_pt_play(&mut self, uuid: String) { + let Some(idx) = self.pt_remote else { return }; + let Some(client) = self.remotes.get(idx).cloned() else { return }; + self.remote_status = "Resolving…".to_string(); + let (tx, rx) = std::sync::mpsc::channel(); + self.pt_media_rx = Some(rx); + let ctx = self.egui_ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(client.pt_video_media(&uuid)); + ctx.request_repaint(); + }); + } + + /// Queue a PeerTube video into the local library via the shared downloader. + fn start_pt_archive(&mut self, uuid: &str) { + let Some(idx) = self.pt_remote else { return }; + let Some(client) = self.remotes.get(idx).cloned() else { return }; + match client.pt_watch_url(uuid) { + Ok(url) => { + let info = crate::platform::classify_url(&url); + self.downloader.start(url, &info, false, crate::downloader::DownloadQuality::Best, false, None); + self.remote_status = "Archiving — see Downloads".to_string(); + } + Err(e) => self.remote_status = e, + } + } + /// Launch the configured player on a remote (absolute, tokenized) URL. /// mpv streams it straight from the peer; no resume tracking (that's local). fn play_remote_url(&mut self, url: &str) { @@ -2587,11 +2673,21 @@ impl App { /// Read-only browser for a federated peer's library. fn remotes_screen(&mut self, ctx: &egui::Context) { // Keep polling while a background fetch is in flight. - if self.remote_rx.is_some() { + if self.remote_rx.is_some() + || self.pt_channels_rx.is_some() + || self.pt_videos_rx.is_some() + || self.pt_media_rx.is_some() + { ctx.request_repaint(); } let mut select_remote: Option = None; let mut play_url: Option = None; + // PeerTube nav actions, applied after the CentralPanel closure. + let mut open_channel: Option = None; + let mut load_more = false; + let mut pt_play: Option = None; + let mut pt_arch: Option = None; + let mut back_to_channels = false; egui::CentralPanel::default().show(ctx, |ui| { ui.horizontal(|ui| { if ui.button("← Library").clicked() { @@ -2600,12 +2696,12 @@ impl App { ui.heading("🌐 Remote libraries"); }); ui.label(egui::RichText::new( - "Browse another Catacomb instance read-only. Playback streams from the peer.") + "Browse a Catacomb or PeerTube peer read-only. Playback streams from the peer.") .weak().small()); ui.separator(); ui.horizontal_wrapped(|ui| { for (i, r) in self.remotes.iter().enumerate() { - let sel = self.remote_selected == Some(i); + let sel = self.remote_selected == Some(i) || self.pt_remote == Some(i); if ui.selectable_label(sel, format!("🌐 {}", r.name())).clicked() { select_remote = Some(i); } @@ -2615,39 +2711,87 @@ impl App { ui.label(egui::RichText::new(&self.remote_status).weak().small()); } ui.separator(); - egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(lib) = &self.remote_library { - if lib.channels.is_empty() { - ui.label(egui::RichText::new("This peer's library is empty.").weak()); - } - for ch in &lib.channels { - egui::CollapsingHeader::new(format!("{} ({})", ch.name, ch.videos.len())) - .show(ui, |ui| { - for v in &ch.videos { - ui.horizontal(|ui| { - let playable = v.video_url.is_some(); - if ui.add_enabled(playable, egui::Button::new("▶")).clicked() { - if let Some(u) = &v.video_url { - play_url = Some(u.clone()); - } - } - let dur = v.duration_secs - .map(format_duration) - .unwrap_or_default(); - ui.label(format!("{} {}", v.title, dur)); - }); + if self.pt_remote.is_some() { + // PeerTube two-level nav (channels → paged videos). + egui::ScrollArea::vertical().id_source("pt-scroll").show(ui, |ui| { + if self.pt_channel.is_none() { + match &self.pt_channels { + None => { ui.label(egui::RichText::new("Loading channels…").weak()); } + Some(chs) if chs.is_empty() => { ui.label(egui::RichText::new("No channels.").weak()); } + Some(chs) => { + for c in chs { + let label = if let Some(n) = c.video_count { + format!("{} ({})", c.display_name, n) + } else { c.display_name.clone() }; + if ui.selectable_label(false, label).clicked() { + open_channel = Some(c.handle.clone()); + } } + } + } + } else { + if ui.button("← Back to channels").clicked() { back_to_channels = true; } + ui.heading(self.pt_channel.clone().unwrap_or_default()); + for v in &self.pt_videos { + ui.horizontal(|ui| { + if ui.button("▶ Play").clicked() { pt_play = Some(v.id.clone()); } + if ui.button("⬇ Archive").clicked() { pt_arch = Some(v.id.clone()); } + let dur = v.duration_secs.map(format_duration).unwrap_or_default(); + ui.label(format!("{} {}", v.title, dur)); }); + } + if !self.pt_done && !self.pt_videos.is_empty() { + if ui.button("Load more").clicked() { load_more = true; } + } } - } else if self.remote_selected.is_none() { - ui.label(egui::RichText::new( - "Pick a peer above to browse its library.").weak()); - } - }); + }); + } else { + egui::ScrollArea::vertical().show(ui, |ui| { + if let Some(lib) = &self.remote_library { + if lib.channels.is_empty() { + ui.label(egui::RichText::new("This peer's library is empty.").weak()); + } + for ch in &lib.channels { + egui::CollapsingHeader::new(format!("{} ({})", ch.name, ch.videos.len())) + .show(ui, |ui| { + for v in &ch.videos { + ui.horizontal(|ui| { + let playable = v.video_url.is_some(); + if ui.add_enabled(playable, egui::Button::new("▶")).clicked() { + if let Some(u) = &v.video_url { + play_url = Some(u.clone()); + } + } + let dur = v.duration_secs + .map(format_duration) + .unwrap_or_default(); + ui.label(format!("{} {}", v.title, dur)); + }); + } + }); + } + } else if self.remote_selected.is_none() { + ui.label(egui::RichText::new( + "Pick a peer above to browse its library.").weak()); + } + }); + } }); if let Some(i) = select_remote { - self.start_remote_fetch(i); + match self.remotes.get(i).map(|r| r.kind()) { + Some(crate::config::RemoteKind::Peertube) => self.start_pt_browse(i), + _ => { + self.pt_remote = None; + self.start_remote_fetch(i); + } + } } + if let Some(h) = open_channel { self.pt_channel = Some(h); self.start_pt_videos(true); } + if back_to_channels { self.pt_channel = None; self.pt_videos.clear(); } + // The drain in update() owns page advancement, so Load more just refetches. + if load_more { self.start_pt_videos(false); } + if let Some(u) = pt_play { self.start_pt_play(u); } + if let Some(u) = pt_arch { self.start_pt_archive(&u); } if let Some(u) = play_url { self.play_remote_url(&u); } @@ -5026,6 +5170,37 @@ impl eframe::App for App { ctx.request_repaint(); } + // ── PeerTube browse result hand-offs ──────────────────────────── + if let Some(res) = self.pt_channels_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.pt_channels_rx = None; + match res { + Ok(chs) => { self.remote_status = format!("{} channels", chs.len()); self.pt_channels = Some(chs); } + Err(e) => { self.remote_status = format!("Error: {e}"); self.pt_channels = Some(Vec::new()); } + } + ctx.request_repaint(); + } + if let Some(res) = self.pt_videos_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.pt_videos_rx = None; + match res { + Ok(vids) => { + if vids.len() < 24 { self.pt_done = true; } else { self.pt_page += 1; } + self.remote_status = format!("{} videos", self.pt_videos.len() + vids.len()); + self.pt_videos.extend(vids); + } + Err(e) => self.remote_status = format!("Error: {e}"), + } + ctx.request_repaint(); + } + if let Some(res) = self.pt_media_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.pt_media_rx = None; + match res { + Ok(Some(url)) => { self.remote_status.clear(); self.play_remote_url(&url); } + Ok(None) => self.remote_status = "HLS-only — archive to watch".to_string(), + Err(e) => self.remote_status = format!("Error: {e}"), + } + ctx.request_repaint(); + } + // ── Background dedup result hand-off ──────────────────────────── // Drain in the main loop so the scan completes (and the button // re-enables) even if the user navigated away from Maintenance