feat(start): tutorial-rail implementation for the playlist fetch

Source half of b672023 (which captured only the removed bundled
thumbnails — the add aborted on the already-staged deletions): the
videos module (playlist scan, oEmbed titles, config-dir thumbnail
cache), the boot fetch on its own thread with the loading state, the
overlay rail + Videos tab on the Start page, and the earlier tabbed-
layout handover.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-21 10:07:21 +03:00
1 parent c938d56ac6
commit 7699f79424
6 files changed
+388 -133

No files matched your search

+66
View File
@@ -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<crate::videos::VideoEntry>,
/// 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<String, iced::widget::image::Handle>,
/// 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<Vec<crate::plugin::external::RegistryEntry>, String>),
/// Patreon supporters fetched at boot for the Start page (name, pledge cents).
PatronsFetched(Result<Vec<(String, i64)>, String>),
/// Tutorial-playlist videos fetched at boot for the Start page.
VideosFetched(Result<Vec<crate::videos::VideoEntry>, String>),
/// Recent-file DWG preview thumbnails decoded on a background thread.
RecentThumbsLoaded(
Vec<(std::path::PathBuf, Option<iced::widget::image::Handle>)>,
),
/// Installable release tags fetched for `owner/repo`.
PluginReleasesFetched(String, Result<Vec<String>, 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<crate::videos::VideoEntry>) {
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,
]),
)
}
+16
View File
@@ -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
+134 -133
View File
@@ -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<String, iced::widget::image::Handle>,
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<iced::widget::image::Handle> = 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<iced::widget::image::Handle> = 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)
+1
View File
@@ -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;
+1
View File
@@ -13,6 +13,7 @@ mod entities;
mod io;
mod modules;
mod patreon;
mod videos;
mod plugin;
mod scene;
mod snap;
+170
View File
@@ -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/<id>.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<Vec<u8>>,
}
/// 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<std::path::PathBuf> {
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<VideoEntry> {
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<Vec<VideoEntry>, 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<String> = 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<VideoEntry> = 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::<serde_json::Value>(&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)
}