diff --git a/src/app/mod.rs b/src/app/mod.rs index 37c105d44..6e3cf7c99 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -214,6 +214,7 @@ struct AddSelectedRestore { #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum StartSection { Recent, + Videos, #[default] Welcome, Supporters, @@ -241,6 +242,14 @@ pub(super) struct OpenCADStudio { /// Paying Patreon supporters shown on the Start page (name, pledge cents), /// fetched once at boot, highest pledge first. patrons: Vec<(String, i64)>, + /// Tutorial-playlist videos for the Start page: seeded from the on-disk + /// cache at boot, refreshed by a live playlist fetch. + videos: Vec, + /// Decoded thumbnail handles keyed by video id — built once per video when + /// the list arrives, so the view never re-decodes JPEG bytes per frame. + video_thumbs: std::collections::HashMap, + /// True while the boot-time playlist fetch is still in flight. + videos_loading: bool, /// Which Start-page section is shown when the page is too narrow for all /// three side by side and falls back to a tab bar. start_section: StartSection, @@ -1778,6 +1787,12 @@ pub enum Message { PluginRegistryFetched(Result, String>), /// Patreon supporters fetched at boot for the Start page (name, pledge cents). PatronsFetched(Result, String>), + /// Tutorial-playlist videos fetched at boot for the Start page. + VideosFetched(Result, String>), + /// Recent-file DWG preview thumbnails decoded on a background thread. + RecentThumbsLoaded( + Vec<(std::path::PathBuf, Option)>, + ), /// Installable release tags fetched for `owner/repo`. PluginReleasesFetched(String, Result, String>), /// Choose a release tag for a repo (`repo`, `tag`). @@ -2145,6 +2160,26 @@ pub enum Message { } impl OpenCADStudio { + /// Install the Start-page video list, decoding each thumbnail's JPEG into + /// an image Handle exactly once (a fresh Handle per view frame would + /// re-upload the texture every frame). + fn set_videos(&mut self, videos: Vec) { + if videos.is_empty() { + return; + } + self.video_thumbs = videos + .iter() + .filter_map(|v| { + let bytes = v.thumb.clone()?; + Some(( + v.id.clone(), + iced::widget::image::Handle::from_bytes(bytes), + )) + }) + .collect(); + self.videos = videos; + } + fn new() -> Self { // Boot with only the Welcome/Start tab. The user creates drawings // explicitly (File → New); we never auto-spawn Drawing1. @@ -2163,6 +2198,9 @@ impl OpenCADStudio { recent_limit_input: recent::RECENT_DEFAULT.to_string(), command_line: CommandLine::new(), patrons: Vec::new(), + videos: Vec::new(), + video_thumbs: std::collections::HashMap::new(), + videos_loading: false, start_section: StartSection::default(), props_expanded: false, history_content: iced::widget::text_editor::Content::new(), @@ -2582,6 +2620,32 @@ impl OpenCADStudio { ); #[cfg(target_arch = "wasm32")] let patrons_fetch = Task::none(); + // Tutorial videos: show the on-disk cache instantly, refresh from the + // live playlist in the background. Nothing ships in the binary. The + // fetch runs on its own OS thread — its several sequential HTTP + // requests would otherwise sit on the async executor and hold up the + // rest of the boot tasks (the Start page waited on it). + #[cfg(not(target_arch = "wasm32"))] + let videos_fetch = { + s.set_videos(crate::videos::load_cached()); + s.videos_loading = true; + let (tx, rx) = iced::futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let _ = tx.send(crate::videos::fetch_playlist()); + }); + Task::perform( + async move { + rx.await + .unwrap_or_else(|_| Err("video fetch thread died".into())) + }, + Message::VideosFetched, + ) + }; + #[cfg(target_arch = "wasm32")] + let videos_fetch = Task::none(); + // Recent-file thumbnails: decoded off-thread — parsing every recent + // DWG's preview on the boot path held the first frame back. + let thumbs_fetch = s.refresh_recent_thumbs(); ( s, Task::batch([ @@ -2592,6 +2656,8 @@ impl OpenCADStudio { script, assoc_prompt, patrons_fetch, + videos_fetch, + thumbs_fetch, ]), ) } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e2ab273ba..2a7368dd3 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3362,6 +3362,22 @@ impl OpenCADStudio { self.patrons = crate::patreon::merge_manual(Vec::new()); Task::none() } + Message::VideosFetched(Ok(videos)) => { + self.videos_loading = false; + self.set_videos(videos); + Task::none() + } + // Offline / markup change: keep whatever the on-disk cache seeded. + Message::VideosFetched(Err(_)) => { + self.videos_loading = false; + Task::none() + } + Message::RecentThumbsLoaded(thumbs) => { + for (path, handle) in thumbs { + self.recent_thumbs.insert(path, handle); + } + Task::none() + } Message::PluginReleasesFetched(repo, Ok(tags)) => { if let Some(first) = tags.first() { self.repo_selected_tag diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3c1349ff8..e0339f820 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -117,6 +117,9 @@ impl OpenCADStudio { let viewport_3d: Element<'_, Message> = if tab.is_start { start_page_view( &self.patrons, + &self.videos, + self.videos_loading, + &self.video_thumbs, &self.recent_files, &self.recent_thumbs, self.recent_limit, @@ -2236,6 +2239,9 @@ pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Mes pub(super) fn start_page_view<'a>( patrons: &'a [(String, i64)], + videos: &'a [crate::videos::VideoEntry], + videos_loading: bool, + video_thumbs: &'a std::collections::HashMap, recents: &'a [std::path::PathBuf], thumbs: &'a std::collections::HashMap< std::path::PathBuf, @@ -2409,133 +2415,6 @@ pub(super) fn start_page_view<'a>( .spacing_x(12.0) .row_h(44.0); - // Intro videos: clickable thumbnails with a play badge, side by side. - // Native iced has no embedded web player, so a click opens the video in the - // system browser. - const INTRO_VIDEO_URL: &str = "https://youtu.be/uN9zxM7p_fc"; - const INTRO_VIDEO_URL_2: &str = "https://youtu.be/4_glyJFo0qI"; - // Build each image Handle ONCE — `Handle::from_bytes` mints a fresh unique - // id on every call, so doing it per-view re-decodes + re-uploads the JPEG - // each frame (the thumbnail then appears only after a long delay). A cached - // Handle decodes once; cloning it per view shares the id + bytes. - fn intro_thumb_1() -> iced::widget::image::Handle { - use std::sync::OnceLock; - static H: OnceLock = OnceLock::new(); - H.get_or_init(|| { - iced::widget::image::Handle::from_bytes( - include_bytes!("../../../assets/intro_thumb.jpg").to_vec(), - ) - }) - .clone() - } - fn intro_thumb_2() -> iced::widget::image::Handle { - use std::sync::OnceLock; - static H: OnceLock = OnceLock::new(); - H.get_or_init(|| { - iced::widget::image::Handle::from_bytes( - include_bytes!("../../../assets/intro_thumb2.jpg").to_vec(), - ) - }) - .clone() - } - // One clickable video card: cover-fitted thumbnail with a white play triangle - // on a translucent disc, opening `url` in the browser on click. - let video_card = |handle: iced::widget::image::Handle, url: &str, card_w: f32, card_h: f32| { - let thumb = iced::widget::image(handle) - .width(Fill) - .height(Fill) - .content_fit(iced::ContentFit::Cover); - let play_badge = container( - container(crate::ui::icons::arrow_right(24.0, Color::WHITE)) - .width(iced::Length::Fixed(56.0)) - .height(iced::Length::Fixed(56.0)) - .center_x(iced::Length::Fixed(56.0)) - .center_y(iced::Length::Fixed(56.0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.55, - })), - border: Border { - color: Color { - r: 1.0, - g: 1.0, - b: 1.0, - a: 0.85, - }, - width: 2.0, - radius: 28.0.into(), - }, - ..Default::default() - }), - ) - .center_x(Fill) - .center_y(Fill); - mouse_area( - container(iced::widget::stack![thumb, play_badge]) - .width(iced::Length::Fixed(card_w)) - .height(iced::Length::Fixed(card_h)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(CARD_BG)), - border: Border { - color: CARD_BORDER, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() - }) - .clip(true), - ) - .interaction(iced::mouse::Interaction::Pointer) - .on_press(Message::OpenUrl(url.to_string())) - }; - // The two video cards keep their 16:9-ish aspect. They sit side by side - // while there's room; when the width gets tight they WRAP to a stacked - // column; and when even the stack won't fit the remaining height they - // SHRINK to fit — never overflowing onto the status bar. - let cards = container(responsive(move |size| { - const GAP: f32 = 12.0; - const MAX_W: f32 = 352.0; - let aspect = 352.0f32 / 198.0; // width / height - - // Wrap to a stacked column once side-by-side cards would get too small. - let side_w = ((size.width - GAP) / 2.0).min(MAX_W); - let stacked = side_w < 220.0; - - let (mut cw, mut ch, rows) = if stacked { - let w = size.width.min(MAX_W); - (w, w / aspect, 2.0f32) - } else { - (side_w, side_w / aspect, 1.0f32) - }; - - // Shrink to fit the height the page can give the cards. - let needed_h = ch * rows + if rows > 1.0 { GAP } else { 0.0 }; - if needed_h > size.height && needed_h > 1.0 { - let scale = (size.height / needed_h).max(0.0); - cw *= scale; - ch *= scale; - } - - let c1 = video_card(intro_thumb_1(), INTRO_VIDEO_URL, cw, ch); - let c2 = video_card(intro_thumb_2(), INTRO_VIDEO_URL_2, cw, ch); - let inner: Element<'_, Message> = if stacked { - column![c1, c2].spacing(GAP).into() - } else { - iced::widget::row![c1, c2].spacing(GAP).into() - }; - container(inner) - .center_x(Fill) - .center_y(Fill) - .width(Fill) - .height(Fill) - .into() - })) - .width(Fill) - .height(Fill); - // Buttons sit on a transparent container with a large, brand-tinted // ambient shadow (offset = 0, big blur) — produces a soft halo behind // the action row, matching the Thunderbird coloured-glow look against @@ -2569,8 +2448,7 @@ pub(super) fn start_page_view<'a>( container(primary_glow).center_x(Fill), Space::new().height(iced::Length::Fixed(10.0)), container(secondary_row).center_x(Fill), - Space::new().height(iced::Length::Fixed(24.0)), - cards, + Space::new().height(Fill), ] .spacing(0) .width(Fill) @@ -2589,14 +2467,123 @@ pub(super) fn start_page_view<'a>( // while they fit; when the page is too narrow it becomes a tab bar with the // Welcome tab open by default. let recent_w = 280.0f32; + let videos_w = 300.0f32; let sup_w = 240.0f32; let welcome_min = 480.0f32; let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding - let fits_all = avail >= recent_w + welcome_min + sup_w; + // The videos rail overlays the welcome column's left side, and the welcome + // content is CENTRED in that column — so side-by-side only works while the + // centred `welcome_min` block still leaves a rail-sized gap on its left: + // the column must fit welcome_min plus a videos_w + spacing margin on BOTH + // sides (centring is symmetric). Below that, the centre content would + // slide over the rail — switch to the tabbed layout instead. + let fits_all = avail + >= recent_w + sup_w + 2.0 * 16.0 + welcome_min + 2.0 * (videos_w + 16.0); let recent = recent_files_panel(recents, thumbs, recent_limit, recent_limit_input); let welcome = container(content).width(Fill).height(Fill); + // Tutorial-videos rail: the official playlist, fetched at boot (cached on + // disk) — thumbnail card + title per video, click opens the browser. + let videos_panel: Element<'a, Message> = { + const NAME_COLOR: Color = Color { + r: 0.80, + g: 0.80, + b: 0.82, + a: 1.0, + }; + // Inner width = panel 300 − padding 2×16 − scrollbar gutter 14. + const THUMB_H: f32 = (300.0 - 32.0 - 14.0) * 9.0 / 16.0; + let mut list = column![text("Tutorials").size(15).color(TEXT)] + .spacing(10) + .width(Fill) + // Keep the scrollbar off the thumbnails. + .padding(iced::Padding { + right: 14.0, + ..iced::Padding::ZERO + }); + for v in videos { + let mut card = column![].spacing(6).width(Fill); + if let Some(handle) = video_thumbs.get(&v.id) { + card = card.push( + container( + iced::widget::image(handle.clone()) + .width(Fill) + .height(iced::Length::Fixed(THUMB_H)) + .content_fit(iced::ContentFit::Cover), + ) + .width(Fill) + .height(iced::Length::Fixed(THUMB_H)) + .style(|_: &Theme| container::Style { + border: Border { + color: CARD_BORDER, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }) + .clip(true), + ); + } + card = card.push(text(v.title.clone()).size(12).color(NAME_COLOR)); + list = list.push( + mouse_area(card) + .interaction(iced::mouse::Interaction::Pointer) + .on_press(Message::OpenUrl(crate::videos::watch_url(&v.id))), + ); + } + if videos.is_empty() { + let note = if videos_loading { + "Loading videos…" + } else { + "Videos load from the internet." + }; + list = list.push(text(note).size(12).color(NAME_COLOR)); + } + let playlist_btn = mouse_area( + container( + text("Open playlist on YouTube").size(12).color(Color::WHITE), + ) + .padding([6, 10]) + .width(Fill) + .center_x(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.85, + g: 0.15, + b: 0.15, + a: 1.0, + })), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + ..Default::default() + }), + ) + .interaction(iced::mouse::Interaction::Pointer) + .on_press(Message::OpenUrl(crate::videos::PLAYLIST_URL.to_string())); + container(column![ + iced::widget::scrollable(list).height(Fill), + Space::new().height(iced::Length::Fixed(12.0)), + playlist_btn, + ]) + .width(iced::Length::Fixed(videos_w)) + .height(Fill) + .padding(16) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(CARD_BG)), + border: Border { + color: CARD_BORDER, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .into() + }; + // Right rail: Patreon supporters, fetched at boot. When the list is empty // (no token configured / offline) only the "Support on Patreon" button // shows, so the rail always invites support. @@ -2677,9 +2664,17 @@ pub(super) fn start_page_view<'a>( }; let body: Element<'a, Message> = if fits_all { - iced::widget::row![recent, welcome, supporters] - .spacing(16) - .into() + // The videos rail OVERLAYS the welcome column's empty left side + // instead of participating in the row: the welcome content keeps the + // same width and centre as before, so the action buttons don't move. + let base = iced::widget::row![recent, welcome, supporters].spacing(16); + let rail = container(videos_panel) + .padding(iced::Padding { + left: recent_w + 16.0, + ..iced::Padding::ZERO + }) + .height(Fill); + iced::widget::stack![base, rail].into() } else { let tab_btn = |label: &'static str, section: super::StartSection| { let is_active = active == section; @@ -2722,6 +2717,7 @@ pub(super) fn start_page_view<'a>( }; let tab_bar = iced::widget::row![ tab_btn("Recent Files", super::StartSection::Recent), + tab_btn("Videos", super::StartSection::Videos), tab_btn("Welcome", super::StartSection::Welcome), tab_btn("Supporters", super::StartSection::Supporters), ] @@ -2732,6 +2728,11 @@ pub(super) fn start_page_view<'a>( .height(Fill) .center_x(Fill) .into(), + super::StartSection::Videos => container(videos_panel) + .width(Fill) + .height(Fill) + .center_x(Fill) + .into(), super::StartSection::Welcome => welcome.into(), super::StartSection::Supporters => container(supporters) .width(Fill) diff --git a/src/lib.rs b/src/lib.rs index b0dde0913..b333fdb12 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod entities; pub mod io; pub mod modules; pub mod patreon; +pub mod videos; pub mod plugin; pub mod scene; pub mod snap; diff --git a/src/main.rs b/src/main.rs index 4d8992eea..1da70e81e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod entities; mod io; mod modules; mod patreon; +mod videos; mod plugin; mod scene; mod snap; diff --git a/src/videos.rs b/src/videos.rs new file mode 100644 index 000000000..fb2b6035a --- /dev/null +++ b/src/videos.rs @@ -0,0 +1,170 @@ +//! Start-page tutorial-video list, sourced live from the official YouTube +//! playlist at boot — nothing is baked into the binary or the installer. +//! +//! The playlist page is scanned for its ordered video ids, each id's title +//! comes from the stable key-less oEmbed endpoint, and each thumbnail is +//! downloaded once and cached under the user config directory +//! (`video_thumbs/.jpg`), so later launches show the list instantly and +//! offline launches still show everything fetched before. + +/// The official tutorials playlist. +pub const PLAYLIST_URL: &str = + "https://youtube.com/playlist?list=PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl"; + +const PLAYLIST_ID: &str = "PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl"; + +/// One playlist entry, ready for the Start page. +#[derive(Clone, Debug)] +pub struct VideoEntry { + pub id: String, + pub title: String, + /// JPEG bytes of the thumbnail (from cache or the network), if available. + pub thumb: Option>, +} + +/// Watch URL for one of the playlist's videos, keeping the playlist context. +pub fn watch_url(id: &str) -> String { + format!("https://www.youtube.com/watch?v={id}&list={PLAYLIST_ID}") +} + +#[cfg(not(target_arch = "wasm32"))] +const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")); + +/// Directory of the on-disk thumbnail / listing cache. +#[cfg(not(target_arch = "wasm32"))] +fn cache_dir() -> Option { + crate::config::config_dir().map(|d| d.join("video_thumbs")) +} + +/// Load the listing cached by the previous successful fetch — the instant, +/// offline-friendly Start-page state while the live fetch runs. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_cached() -> Vec { + let Some(dir) = cache_dir() else { + return Vec::new(); + }; + let Ok(listing) = std::fs::read_to_string(dir.join("playlist.txt")) else { + return Vec::new(); + }; + listing + .lines() + .filter_map(|line| { + let (id, title) = line.split_once('\t')?; + if id.len() != 11 || title.is_empty() { + return None; + } + Some(VideoEntry { + id: id.to_string(), + title: title.to_string(), + thumb: std::fs::read(dir.join(format!("{id}.jpg"))).ok(), + }) + }) + .collect() +} + +/// Fetch the playlist's current entries, in playlist order, refreshing the +/// on-disk cache. `Err` when offline or the page markup changes — the caller +/// keeps whatever [`load_cached`] provided. +#[cfg(not(target_arch = "wasm32"))] +pub fn fetch_playlist() -> Result, String> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(15))) + .build() + .into(); + let page = agent + .get(PLAYLIST_URL) + .header("User-Agent", "Mozilla/5.0") + .call() + .map_err(|e| e.to_string())? + .body_mut() + .with_config() + .limit(8 * 1024 * 1024) + .read_to_string() + .map_err(|e| e.to_string())?; + + // Ordered, de-duplicated video ids from the page's embedded JSON. The + // `"videoId":"…"` pattern has been stable for years and doubles as the + // canary: no matches → markup changed → keep the cached list. + let mut ids: Vec = Vec::new(); + let mut rest = page.as_str(); + while let Some(pos) = rest.find("\"videoId\":\"") { + let start = pos + "\"videoId\":\"".len(); + let Some(end) = rest[start..].find('"') else { + break; + }; + let id = &rest[start..start + end]; + if id.len() == 11 && !ids.iter().any(|k| k == id) { + ids.push(id.to_string()); + } + rest = &rest[start + end..]; + if ids.len() >= 50 { + break; + } + } + if ids.is_empty() { + return Err("no videos found in playlist page".into()); + } + + let dir = cache_dir(); + if let Some(d) = &dir { + let _ = std::fs::create_dir_all(d); + } + + let mut out: Vec = Vec::with_capacity(ids.len()); + for id in ids { + // Title via oEmbed — stable and key-less. A video without a title + // (deleted / private) is skipped. + let title = agent + .get(&format!( + "https://www.youtube.com/oembed?url=https://youtu.be/{id}&format=json" + )) + .header("User-Agent", UA) + .call() + .ok() + .and_then(|mut r| r.body_mut().read_to_string().ok()) + .and_then(|body| { + serde_json::from_str::(&body) + .ok()? + .get("title")? + .as_str() + .map(str::to_string) + }); + let Some(title) = title else { continue }; + + // Thumbnail: cache first, else download once and store it. + let cache_path = dir.as_ref().map(|d| d.join(format!("{id}.jpg"))); + let mut thumb = cache_path.as_ref().and_then(|p| std::fs::read(p).ok()); + if thumb.is_none() { + let fetched = agent + .get(&format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg")) + .header("User-Agent", UA) + .call() + .ok() + .and_then(|mut r| { + r.body_mut() + .with_config() + .limit(2 * 1024 * 1024) + .read_to_vec() + .ok() + }); + if let (Some(bytes), Some(p)) = (&fetched, &cache_path) { + let _ = std::fs::write(p, bytes); + } + thumb = fetched; + } + out.push(VideoEntry { id, title, thumb }); + } + if out.is_empty() { + return Err("no video titles resolved".into()); + } + + // Persist the listing so the next launch starts from it. + if let Some(d) = &dir { + let listing: String = out + .iter() + .map(|v| format!("{}\t{}\n", v.id, v.title.replace(['\t', '\n'], " "))) + .collect(); + let _ = std::fs::write(d.join("playlist.txt"), listing); + } + Ok(out) +}