diff --git a/BACKLOG.md b/BACKLOG.md index ba8d92b..270756d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -26,13 +26,13 @@ bottom as they come up. - [x] Drag-to-reorder videos within a playlist. - [x] "Up next" queue panel showing the autoplay queue. - [x] Persistent mini now-playing bar when browsing other views. -- [ ] Cache size cap in Settings (auto-evict oldest past a limit). -- [ ] Export / import playlists as JSON. +- [x] Cache size cap in Settings (auto-evict oldest past a limit). +- [x] Export / import playlists as JSON. - [x] More / trending quick-search chips on the hero, rotated. ## Robustness - [x] Sanitize `video_id` in `cache_download` (reject path separators). -- [ ] Gate the embedded yt-dlp behind a build flag so debug builds compile faster. +- [x] Gate the embedded yt-dlp behind a build flag so debug builds compile faster. - [x] Surface yt-dlp extraction failures with a retry button. ## Done diff --git a/frontend/app.js b/frontend/app.js index a0598c5..f86f495 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -850,10 +850,32 @@ async function renderSettings() { Storage used +
+ + +
+
Playlists
+
+ + + +
`; c.appendChild(wrap); @@ -930,6 +952,19 @@ async function renderSettings() { const t = $('cacheTotal'); if (t) t.textContent = 'Offline cache not available in this build'; } + + // ---- Cache cap ---- + const capSel = $('cacheCapSelect'); + capSel.value = String(data.settings.cacheCap || 0); + capSel.addEventListener('change', (e) => { + data.settings.cacheCap = parseInt(e.target.value) || 0; + persist(); + }); + + // ---- Export / Import playlists ---- + $('exportBtn').addEventListener('click', exportPlaylists); + $('importBtn').addEventListener('click', () => $('fileInput').click()); + $('fileInput').addEventListener('change', importPlaylists); } function renderCard(v, index, list) { @@ -1010,6 +1045,43 @@ function render() { // ============================================================================ // Playlists // ============================================================================ +function exportPlaylists() { + const json = JSON.stringify(data.playlists, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + toast('Playlists exported'); +} +function importPlaylists(e) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const imported = JSON.parse(ev.target.result); + if (!Array.isArray(imported)) throw new Error('Invalid format'); + // Merge: skip duplicates by id, add new ones + const existingIds = new Set(data.playlists.map((p) => p.id)); + for (const pl of imported) { + if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) { + data.playlists.push(pl); + existingIds.add(pl.id); + } + } + persist(); + renderSidebar(); + toast(`Imported ${imported.length} playlist(s)`); + } catch (err) { + toast('⚠ Failed to import: ' + err.message); + } + }; + reader.readAsText(file); + e.target.value = ''; // allow re-import of same file +} function openCardMenu(video) { const inPlaylistView = view.type === 'playlist'; const body = document.createElement('div'); diff --git a/releases/YT Player_1.0.0_x64-setup.exe b/releases/YT Player_1.0.0_x64-setup.exe index 1c25521..84b09af 100755 Binary files a/releases/YT Player_1.0.0_x64-setup.exe and b/releases/YT Player_1.0.0_x64-setup.exe differ diff --git a/releases/YT Player_1.0.0_x64_en-US.msi b/releases/YT Player_1.0.0_x64_en-US.msi index ed2b94e..b0bd06e 100755 Binary files a/releases/YT Player_1.0.0_x64_en-US.msi and b/releases/YT Player_1.0.0_x64_en-US.msi differ diff --git a/releases/ytplayer.exe b/releases/ytplayer.exe index b49002f..5a23d39 100644 Binary files a/releases/ytplayer.exe and b/releases/ytplayer.exe differ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6f0aa85..1caf99c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,10 @@ tauri = { version = "2", features = ["protocol-asset"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +[features] +default = ["embed-ytdlp"] +embed-ytdlp = [] + [profile.release] opt-level = "s" lto = true diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bd1a327..f59cc2c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,12 +25,13 @@ const SEARCH_LIMIT: u32 = 25; // `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH // required. On first run we materialize it into the app cache dir and run from there. // (Windows only; other platforms fall back to a resource/dev/PATH copy.) -#[cfg(windows)] +// Disable with --no-default-features for faster debug builds. +#[cfg(all(windows, feature = "embed-ytdlp"))] static YTDLP_EMBEDDED: &[u8] = include_bytes!("../../bin/yt-dlp.exe"); /// Write the embedded yt-dlp to the cache dir if it isn't already there (size-checked), /// and return its path. -#[cfg(windows)] +#[cfg(all(windows, feature = "embed-ytdlp"))] fn embedded_ytdlp(app: &tauri::AppHandle) -> Option { let dir = app.path().app_cache_dir().ok()?; std::fs::create_dir_all(&dir).ok()?; @@ -58,7 +59,7 @@ fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf { if dev.exists() { return dev; } - #[cfg(windows)] + #[cfg(all(windows, feature = "embed-ytdlp"))] { if let Some(p) = embedded_ytdlp(app) { return p;