forked from 1b-agent/opencadstudio-upstream
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:
1 parent
c938d56ac6
commit
7699f79424
6 files changed
+388
-133
No files matched your search
@@ -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,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,7 @@ mod entities;
|
||||
mod io;
|
||||
mod modules;
|
||||
mod patreon;
|
||||
mod videos;
|
||||
mod plugin;
|
||||
mod scene;
|
||||
mod snap;
|
||||
|
||||
+170
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user