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:
Hakan Seven 2026-07-21 10:07:21 +03:00
commit 7699f79424
6 changed files with 388 additions and 133 deletions

170
src/videos.rs Normal file
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)
}