forked from 1b-agent/opencadstudio-upstream
feat(web): persist browser state
Keep settings, aliases, and recent drawings across browser sessions. Load Start-page videos from a Pages-generated snapshot.
This commit is contained in:
1 parent
25166b9508
commit
d4e792b2bc
15 files changed
+546
-88
No files matched your search
+85
-8
@@ -1,11 +1,8 @@
|
||||
//! Start-page tutorial-video list, sourced live from the official YouTube
|
||||
//! playlist at boot — nothing is baked into the binary or the installer.
|
||||
//! Start-page tutorial-video list, sourced from the official YouTube playlist.
|
||||
//!
|
||||
//! 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.
|
||||
//! Native builds scan the playlist at boot and cache the result. Web builds
|
||||
//! read a same-origin `videos.json` and thumbnails generated by the Pages
|
||||
//! workflow, avoiding YouTube's browser CORS restrictions.
|
||||
|
||||
/// The official tutorials playlist.
|
||||
pub const PLAYLIST_URL: &str =
|
||||
@@ -14,11 +11,12 @@ pub const PLAYLIST_URL: &str =
|
||||
const PLAYLIST_ID: &str = "PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl";
|
||||
|
||||
/// One playlist entry, ready for the Start page.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct VideoEntry {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// JPEG bytes of the thumbnail (from cache or the network), if available.
|
||||
#[serde(default)]
|
||||
pub thumb: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -173,3 +171,82 @@ pub fn fetch_playlist() -> Result<Vec<VideoEntry>, String> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn fetch_playlist_web() -> Result<Vec<VideoEntry>, String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let response = JsFuture::from(window.fetch_with_str("videos.json"))
|
||||
.await
|
||||
.map_err(|_| "videos.json fetch failed")?
|
||||
.dyn_into::<web_sys::Response>()
|
||||
.map_err(|_| "videos.json response is invalid")?;
|
||||
if !response.ok() {
|
||||
return Err(format!("videos.json HTTP {}", response.status()));
|
||||
}
|
||||
let text = JsFuture::from(
|
||||
response
|
||||
.text()
|
||||
.map_err(|_| "videos.json text() unavailable")?,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "videos.json body read failed")?;
|
||||
let body = text.as_string().ok_or("videos.json body is not a string")?;
|
||||
let mut entries: Vec<VideoEntry> =
|
||||
serde_json::from_str(&body).map_err(|error| error.to_string())?;
|
||||
entries.retain(|entry| entry.id.len() == 11 && !entry.title.trim().is_empty());
|
||||
if entries.is_empty() {
|
||||
return Err("videos.json contains no videos".into());
|
||||
}
|
||||
|
||||
let thumbnails = iced::futures::future::join_all(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| fetch_thumbnail_web(entry.id.clone())),
|
||||
)
|
||||
.await;
|
||||
for (entry, thumbnail) in entries.iter_mut().zip(thumbnails) {
|
||||
entry.thumb = thumbnail.ok();
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn fetch_thumbnail_web(id: String) -> Result<Vec<u8>, String> {
|
||||
let local = format!("video_thumbs/{id}.jpg");
|
||||
match fetch_thumbnail_url(&local).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(_) => {
|
||||
// Local development uses the checked-in JSON snapshot without
|
||||
// binary thumbnails. YouTube's image CDN explicitly permits CORS,
|
||||
// so it is a safe fallback; deployed Pages builds use local files.
|
||||
fetch_thumbnail_url(&format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg")).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn fetch_thumbnail_url(url: &str) -> Result<Vec<u8>, String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let response = JsFuture::from(window.fetch_with_str(url))
|
||||
.await
|
||||
.map_err(|_| "thumbnail fetch failed")?
|
||||
.dyn_into::<web_sys::Response>()
|
||||
.map_err(|_| "thumbnail response is invalid")?;
|
||||
if !response.ok() {
|
||||
return Err(format!("thumbnail HTTP {}", response.status()));
|
||||
}
|
||||
let buffer = JsFuture::from(
|
||||
response
|
||||
.array_buffer()
|
||||
.map_err(|_| "thumbnail arrayBuffer() unavailable")?,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "thumbnail body read failed")?;
|
||||
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||
}
|
||||
Reference in new issue
Block a user