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;