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 <noreply@anthropic.com>
This commit is contained in:
parent
c938d56ac6
commit
7699f79424
6 changed files with 388 additions and 133 deletions
|
|
@ -214,6 +214,7 @@ struct AddSelectedRestore {
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||||
pub enum StartSection {
|
pub enum StartSection {
|
||||||
Recent,
|
Recent,
|
||||||
|
Videos,
|
||||||
#[default]
|
#[default]
|
||||||
Welcome,
|
Welcome,
|
||||||
Supporters,
|
Supporters,
|
||||||
|
|
@ -241,6 +242,14 @@ pub(super) struct OpenCADStudio {
|
||||||
/// Paying Patreon supporters shown on the Start page (name, pledge cents),
|
/// Paying Patreon supporters shown on the Start page (name, pledge cents),
|
||||||
/// fetched once at boot, highest pledge first.
|
/// fetched once at boot, highest pledge first.
|
||||||
patrons: Vec<(String, i64)>,
|
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
|
/// 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.
|
/// three side by side and falls back to a tab bar.
|
||||||
start_section: StartSection,
|
start_section: StartSection,
|
||||||
|
|
@ -1778,6 +1787,12 @@ pub enum Message {
|
||||||
PluginRegistryFetched(Result<Vec<crate::plugin::external::RegistryEntry>, String>),
|
PluginRegistryFetched(Result<Vec<crate::plugin::external::RegistryEntry>, String>),
|
||||||
/// Patreon supporters fetched at boot for the Start page (name, pledge cents).
|
/// Patreon supporters fetched at boot for the Start page (name, pledge cents).
|
||||||
PatronsFetched(Result<Vec<(String, i64)>, String>),
|
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`.
|
/// Installable release tags fetched for `owner/repo`.
|
||||||
PluginReleasesFetched(String, Result<Vec<String>, String>),
|
PluginReleasesFetched(String, Result<Vec<String>, String>),
|
||||||
/// Choose a release tag for a repo (`repo`, `tag`).
|
/// Choose a release tag for a repo (`repo`, `tag`).
|
||||||
|
|
@ -2145,6 +2160,26 @@ pub enum Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenCADStudio {
|
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 {
|
fn new() -> Self {
|
||||||
// Boot with only the Welcome/Start tab. The user creates drawings
|
// Boot with only the Welcome/Start tab. The user creates drawings
|
||||||
// explicitly (File → New); we never auto-spawn Drawing1.
|
// explicitly (File → New); we never auto-spawn Drawing1.
|
||||||
|
|
@ -2163,6 +2198,9 @@ impl OpenCADStudio {
|
||||||
recent_limit_input: recent::RECENT_DEFAULT.to_string(),
|
recent_limit_input: recent::RECENT_DEFAULT.to_string(),
|
||||||
command_line: CommandLine::new(),
|
command_line: CommandLine::new(),
|
||||||
patrons: Vec::new(),
|
patrons: Vec::new(),
|
||||||
|
videos: Vec::new(),
|
||||||
|
video_thumbs: std::collections::HashMap::new(),
|
||||||
|
videos_loading: false,
|
||||||
start_section: StartSection::default(),
|
start_section: StartSection::default(),
|
||||||
props_expanded: false,
|
props_expanded: false,
|
||||||
history_content: iced::widget::text_editor::Content::new(),
|
history_content: iced::widget::text_editor::Content::new(),
|
||||||
|
|
@ -2582,6 +2620,32 @@ impl OpenCADStudio {
|
||||||
);
|
);
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let patrons_fetch = Task::none();
|
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,
|
s,
|
||||||
Task::batch([
|
Task::batch([
|
||||||
|
|
@ -2592,6 +2656,8 @@ impl OpenCADStudio {
|
||||||
script,
|
script,
|
||||||
assoc_prompt,
|
assoc_prompt,
|
||||||
patrons_fetch,
|
patrons_fetch,
|
||||||
|
videos_fetch,
|
||||||
|
thumbs_fetch,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3362,6 +3362,22 @@ impl OpenCADStudio {
|
||||||
self.patrons = crate::patreon::merge_manual(Vec::new());
|
self.patrons = crate::patreon::merge_manual(Vec::new());
|
||||||
Task::none()
|
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)) => {
|
Message::PluginReleasesFetched(repo, Ok(tags)) => {
|
||||||
if let Some(first) = tags.first() {
|
if let Some(first) = tags.first() {
|
||||||
self.repo_selected_tag
|
self.repo_selected_tag
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,9 @@ impl OpenCADStudio {
|
||||||
let viewport_3d: Element<'_, Message> = if tab.is_start {
|
let viewport_3d: Element<'_, Message> = if tab.is_start {
|
||||||
start_page_view(
|
start_page_view(
|
||||||
&self.patrons,
|
&self.patrons,
|
||||||
|
&self.videos,
|
||||||
|
self.videos_loading,
|
||||||
|
&self.video_thumbs,
|
||||||
&self.recent_files,
|
&self.recent_files,
|
||||||
&self.recent_thumbs,
|
&self.recent_thumbs,
|
||||||
self.recent_limit,
|
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>(
|
pub(super) fn start_page_view<'a>(
|
||||||
patrons: &'a [(String, i64)],
|
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],
|
recents: &'a [std::path::PathBuf],
|
||||||
thumbs: &'a std::collections::HashMap<
|
thumbs: &'a std::collections::HashMap<
|
||||||
std::path::PathBuf,
|
std::path::PathBuf,
|
||||||
|
|
@ -2409,133 +2415,6 @@ pub(super) fn start_page_view<'a>(
|
||||||
.spacing_x(12.0)
|
.spacing_x(12.0)
|
||||||
.row_h(44.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
|
// Buttons sit on a transparent container with a large, brand-tinted
|
||||||
// ambient shadow (offset = 0, big blur) — produces a soft halo behind
|
// ambient shadow (offset = 0, big blur) — produces a soft halo behind
|
||||||
// the action row, matching the Thunderbird coloured-glow look against
|
// 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),
|
container(primary_glow).center_x(Fill),
|
||||||
Space::new().height(iced::Length::Fixed(10.0)),
|
Space::new().height(iced::Length::Fixed(10.0)),
|
||||||
container(secondary_row).center_x(Fill),
|
container(secondary_row).center_x(Fill),
|
||||||
Space::new().height(iced::Length::Fixed(24.0)),
|
Space::new().height(Fill),
|
||||||
cards,
|
|
||||||
]
|
]
|
||||||
.spacing(0)
|
.spacing(0)
|
||||||
.width(Fill)
|
.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
|
// while they fit; when the page is too narrow it becomes a tab bar with the
|
||||||
// Welcome tab open by default.
|
// Welcome tab open by default.
|
||||||
let recent_w = 280.0f32;
|
let recent_w = 280.0f32;
|
||||||
|
let videos_w = 300.0f32;
|
||||||
let sup_w = 240.0f32;
|
let sup_w = 240.0f32;
|
||||||
let welcome_min = 480.0f32;
|
let welcome_min = 480.0f32;
|
||||||
let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding
|
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 recent = recent_files_panel(recents, thumbs, recent_limit, recent_limit_input);
|
||||||
let welcome = container(content).width(Fill).height(Fill);
|
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
|
// Right rail: Patreon supporters, fetched at boot. When the list is empty
|
||||||
// (no token configured / offline) only the "Support on Patreon" button
|
// (no token configured / offline) only the "Support on Patreon" button
|
||||||
// shows, so the rail always invites support.
|
// 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 {
|
let body: Element<'a, Message> = if fits_all {
|
||||||
iced::widget::row![recent, welcome, supporters]
|
// The videos rail OVERLAYS the welcome column's empty left side
|
||||||
.spacing(16)
|
// instead of participating in the row: the welcome content keeps the
|
||||||
.into()
|
// 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 {
|
} else {
|
||||||
let tab_btn = |label: &'static str, section: super::StartSection| {
|
let tab_btn = |label: &'static str, section: super::StartSection| {
|
||||||
let is_active = active == section;
|
let is_active = active == section;
|
||||||
|
|
@ -2722,6 +2717,7 @@ pub(super) fn start_page_view<'a>(
|
||||||
};
|
};
|
||||||
let tab_bar = iced::widget::row![
|
let tab_bar = iced::widget::row![
|
||||||
tab_btn("Recent Files", super::StartSection::Recent),
|
tab_btn("Recent Files", super::StartSection::Recent),
|
||||||
|
tab_btn("Videos", super::StartSection::Videos),
|
||||||
tab_btn("Welcome", super::StartSection::Welcome),
|
tab_btn("Welcome", super::StartSection::Welcome),
|
||||||
tab_btn("Supporters", super::StartSection::Supporters),
|
tab_btn("Supporters", super::StartSection::Supporters),
|
||||||
]
|
]
|
||||||
|
|
@ -2732,6 +2728,11 @@ pub(super) fn start_page_view<'a>(
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
.center_x(Fill)
|
.center_x(Fill)
|
||||||
.into(),
|
.into(),
|
||||||
|
super::StartSection::Videos => container(videos_panel)
|
||||||
|
.width(Fill)
|
||||||
|
.height(Fill)
|
||||||
|
.center_x(Fill)
|
||||||
|
.into(),
|
||||||
super::StartSection::Welcome => welcome.into(),
|
super::StartSection::Welcome => welcome.into(),
|
||||||
super::StartSection::Supporters => container(supporters)
|
super::StartSection::Supporters => container(supporters)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ pub mod entities;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
pub mod patreon;
|
pub mod patreon;
|
||||||
|
pub mod videos;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod snap;
|
pub mod snap;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ mod entities;
|
||||||
mod io;
|
mod io;
|
||||||
mod modules;
|
mod modules;
|
||||||
mod patreon;
|
mod patreon;
|
||||||
|
mod videos;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod scene;
|
mod scene;
|
||||||
mod snap;
|
mod snap;
|
||||||
|
|
|
||||||
170
src/videos.rs
Normal file
170
src/videos.rs
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue