diff --git a/README.md b/README.md index f23d5bc..c23b2fc 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,13 @@ or Node runtime. - 🎡 **Audio-only mode** β€” great for music, saves bandwidth - πŸ“‚ **On-device playlists** β€” create, rename, delete, add/remove videos. Stored locally as JSON; nothing leaves your machine +- πŸ’Ύ **Offline cache / preload** β€” a **Save** button and an **Add to playlist** + button on the now-playing video download a self-contained copy into a permanent + file cache, so it plays instantly and works offline. Videos added to a playlist + are auto-preloaded and kept until you remove them. +- βš™ **Settings page** β€” playback defaults (quality, volume, audio-only) plus cache + management: see storage used, toggle auto-preload, and delete cached videos + individually or all at once - πŸ•˜ **Watch history** - ⏯ Full controls: seek, volume, playback speed, quality switching, fullscreen, next/prev, and keyboard shortcuts (`space`, `←/β†’`, `f`, `m`) @@ -57,6 +64,17 @@ The web UI talks to the native side over whichever bridge is present | `yt.streams { videoId }` | `ytStreams` | `{ ok, data:{ meta, audioUrl, qualities[] } }` | | `store.load {}` | `storeLoad` | playlists / history / settings | | `store.save { data }` | `storeSave` | `{ ok }` | +| `cache.download { videoId }` | `cache_download` | downloads a single-file copy into the offline cache | +| `cache.status { videoId }` | `cache_status` | `{ ok, cached, path?, size? }` | +| `cache.list {}` | `cache_list` | `{ ok, items:[{id,size,path}], total }` | +| `cache.delete { videoId }` | `cache_delete` | removes one cached file | +| `cache.clear {}` | `cache_clear` | removes all cached files | + +> The offline cache is implemented in the **Tauri (Windows)** shell. Files live +> in `/videos/.` and persist until deleted from the +> Settings page. The frontend falls back to live streaming if a cached file is +> missing, and the cache calls degrade gracefully on shells that don't implement +> them. Because the bridge is size-limited, the Zig handlers parse `yt-dlp`'s large JSON and return only the compact fields the UI needs. @@ -98,6 +116,21 @@ The installer lands in `src-tauri\target\release\bundle\` (`nsis\*-setup.exe` and `msi\*.msi`). `yt-dlp.exe` is bundled as an app resource, so the installed app is self-contained. +### One-shot release + +`scripts\release.ps1` does the whole flow β€” build, copy installers into +`.\releases`, then commit and push through WSL git: + +```powershell +pwsh -File scripts\release.ps1 +``` + +To only commit + push from WSL (e.g. after building separately): + +```bash +bash scripts/push.sh "your commit message" +``` + > Already in WSL and just want it running fast? Your WSL is WSLg-enabled, so you > can instead build the **Linux** (zero-native) target and its window appears on > your Windows desktop β€” see [Setup & run](#setup--run). That needs WSL running diff --git a/frontend/app.js b/frontend/app.js index f196d56..9a174c3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -28,16 +28,32 @@ const API = { loadData: () => call('store.load', 'store_load', {}), // data is sent pre-stringified so the native side can write it verbatim. saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }), + // Offline cache (Tauri shell). Calls are wrapped where used so the Linux + // shell β€” which doesn't implement these yet β€” degrades gracefully. + cacheDownload: (videoId) => call('cache.download', 'cache_download', { videoId }), + cacheStatus: (videoId) => call('cache.status', 'cache_status', { videoId }), + cacheList: () => call('cache.list', 'cache_list', {}), + cacheDelete: (videoId) => call('cache.delete', 'cache_delete', { videoId }), + cacheClear: () => call('cache.clear', 'cache_clear', {}), }; +// Resolve a native file path to a URL the WebView can load (Tauri asset +// protocol). Returns null on shells without it. +function toAssetUrl(path) { + if (TAURI && typeof TAURI.convertFileSrc === 'function') return TAURI.convertFileSrc(path); + return null; +} + // ---------- State ---------- -let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false } }; -let view = { type: 'search' }; // 'search' | 'history' | 'playlist' +let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true } }; +let view = { type: 'search' }; // 'search' | 'history' | 'playlist' | 'settings' let searchResults = []; let queue = []; // list of video objects for autoplay let queueIndex = -1; -let current = null; // { meta, qualities, audioUrl } +let current = null; // { meta, qualities, audioUrl, localUrl? } let saveTimer = null; +const cachedIds = new Set(); // video ids that exist in the offline cache +const downloading = new Set(); // video ids with an in-flight download // ---------- DOM ---------- const $ = (id) => document.getElementById(id); @@ -73,6 +89,8 @@ const els = { playlistList: $('playlistList'), newPlaylistBtn: $('newPlaylistBtn'), audioOnlyToggle: $('audioOnlyToggle'), + saveBtn: $('saveBtn'), + addPlaylistBtn: $('addPlaylistBtn'), }; // ---------- Persistence ---------- @@ -99,6 +117,63 @@ function toast(msg) { toast._t = setTimeout(() => t.classList.add('hidden'), 2200); } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } +function fmtBytes(n) { + if (!n) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB']; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return (i ? n.toFixed(1) : n) + ' ' + u[i]; +} + +// ============================================================================ +// Offline cache (preload) +// ============================================================================ +async function refreshCachedIds() { + cachedIds.clear(); + try { + const res = await API.cacheList(); + if (res && res.ok) for (const it of res.items || []) cachedIds.add(it.id); + } catch { /* shell without cache support β€” leave empty */ } +} + +// Download a video into the permanent offline cache. Safe to call repeatedly. +async function preload(video, { quiet = false } = {}) { + const id = video.id; + if (!id || cachedIds.has(id) || downloading.has(id)) return; + downloading.add(id); + markCardCacheState(id, 'downloading'); + if (!quiet) toast(`Saving β€œ${video.title}” for offline…`); + try { + const res = await API.cacheDownload(id); + if (res && res.ok && res.cached) { + cachedIds.add(id); + if (!quiet) toast(`Saved β€œ${video.title}” βœ“`); + } else if (!quiet) { + toast('⚠ ' + ((res && res.error) || 'Could not save video')); + } + } catch (e) { + if (!quiet) toast('⚠ Saving not supported in this build.'); + } finally { + downloading.delete(id); + markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); + if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); + if (view.type === 'settings') renderList(); + } +} + +// Auto-preload every video in a playlist (respecting the setting). +function preloadPlaylist(pl) { + if (!data.settings.autoPreload || !pl) return; + pl.videos.forEach((v) => preload(v, { quiet: true })); +} + +// Update a single card's saved badge without a full re-render. +function markCardCacheState(id, state) { + document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => { + c.classList.toggle('cached', state === 'cached'); + c.classList.toggle('downloading', state === 'downloading'); + }); +} // ============================================================================ // Player engine β€” single video, or video+audio synced (adaptive), or audio-only @@ -113,26 +188,35 @@ const Player = { return this.mode === 'dual' ? els.audio : this.master; }, - async loadVideo(videoObj) { + async loadVideo(videoObj, { preferStream = false } = {}) { showSpinner(true); els.placeholder.classList.add('hidden'); try { + // Play from the offline cache when available β€” instant and works offline. + if (!preferStream && cachedIds.has(videoObj.id)) { + let localUrl = null; + try { + const st = await API.cacheStatus(videoObj.id); + if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path); + } catch { /* fall through to streaming */ } + if (localUrl) { + current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl }; + this.afterLoad(); + this.fallbackQueue = []; this.fbIndex = 0; + this.attach(null); + return; + } + } + const res = await API.getStreams(videoObj.id); if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams'); current = res.data; // Merge richer metadata we may already have from the list card. current.meta = { ...videoObj, ...current.meta }; - addToHistory(current.meta); - buildQualityMenu(); - els.playerPane.classList.remove('empty'); - els.controls.classList.remove('hidden'); - els.nowMeta.classList.remove('hidden'); - els.npTitle.textContent = current.meta.title; - els.npChannel.textContent = current.meta.channel || ''; - markPlayingCard(); - + this.afterLoad(); const q = chooseQuality(); + this.buildFallbackQueue(q); this.attach(q); } catch (err) { showSpinner(false); @@ -140,6 +224,56 @@ const Player = { } }, + // Shared UI updates once `current` is populated (cached or streamed). + afterLoad() { + addToHistory(current.meta); + buildQualityMenu(); + els.playerPane.classList.remove('empty'); + els.controls.classList.remove('hidden'); + els.nowMeta.classList.remove('hidden'); + els.npTitle.textContent = current.meta.title; + els.npChannel.textContent = current.meta.channel || ''; + updateNowPlayingActions(); + markPlayingCard(); + }, + + // Ordered list of qualities to try if playback errors out. Progressive + // (single-file, has audio) goes near the front because it's the most reliable; + // then we walk from the lowest resolution up. + fallbackQueue: [], + fbIndex: 0, + buildFallbackQueue(chosen) { + const qs = (current.qualities || []).slice().sort((a, b) => a.height - b.height); + const seen = new Set(); + const queue = []; + const push = (x) => { if (x && !seen.has(x.label)) { seen.add(x.label); queue.push(x); } }; + push(chosen); + qs.filter((q) => q.hasAudio).forEach(push); // progressive = sturdiest + qs.forEach(push); // then everything, low β†’ high + this.fallbackQueue = queue; + this.fbIndex = 0; + }, + onMediaError() { + // A cached file failed to play β€” fall back to live streaming. + if (current && current.localUrl) { + const meta = current.meta; + current.localUrl = null; + toast('Cached copy unavailable β€” streaming instead…'); + this.loadVideo(meta, { preferStream: true }); + return; + } + // Advance to the next candidate stream; give up with a clear message at the end. + if (this.fbIndex < this.fallbackQueue.length - 1) { + this.fbIndex++; + const next = this.fallbackQueue[this.fbIndex]; + toast('Playback hiccup β€” trying ' + next.label + '…'); + this.attach(next); + } else { + showSpinner(false); + toast('⚠ This video couldn’t be played. Try another.'); + } + }, + attach(quality) { const V = els.video, A = els.audio; const audioOnly = data.settings.audioOnly; @@ -148,6 +282,33 @@ const Player = { V.pause(); A.pause(); this.stopDrift(); + if (current.localUrl) { + // Cached single file (carries its own audio). Play it directly. + if (audioOnly) { + this.mode = 'audio'; + this.master = A; + this.secondary = null; + A.src = current.localUrl; + V.removeAttribute('src'); V.load(); + els.art.classList.remove('hidden'); + els.artImg.src = current.meta.thumbnail || ''; + } else { + this.mode = 'progressive'; + this.master = V; + this.secondary = null; + V.muted = false; + V.src = current.localUrl; + A.removeAttribute('src'); A.load(); + els.art.classList.add('hidden'); + } + this.applyVolume(); + this.applySpeed(); + this.master.load(); + const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); }; + this.master.addEventListener('canplay', onReadyLocal); + return; + } + if (audioOnly) { // Play only audio; show cover art. this.mode = 'audio'; @@ -291,7 +452,11 @@ function wirePlayerEvents() { el.addEventListener('timeupdate', () => { if (masterIs(el)) updateProgress(); }); el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); }); el.addEventListener('ended', () => { if (masterIs(el)) playNext(); }); - el.addEventListener('error', () => { if (masterIs(el)) { showSpinner(false); } }); + el.addEventListener('error', () => { + // A failed master stream, or a failed synced-audio track in dual mode, + // both warrant falling back to the next candidate. + if (masterIs(el) || el === Player.secondary) Player.onMediaError(); + }); } bind(V); bind(A); @@ -300,6 +465,28 @@ function wirePlayerEvents() { function updatePlayBtn() { els.playBtn.textContent = Player.master.paused ? 'β–Ά' : '⏸'; } +// Reflect cache state on the now-playing Save button. +function updateNowPlayingActions() { + if (!current || !current.meta) return; + const id = current.meta.id; + const btn = els.saveBtn; + if (!btn) return; + if (cachedIds.has(id)) { + btn.textContent = 'βœ“ Saved'; + btn.classList.add('done'); + btn.disabled = false; + btn.title = 'Saved for offline β€” click to remove from cache'; + } else if (downloading.has(id)) { + btn.textContent = '⏳ Saving…'; + btn.classList.remove('done'); + btn.disabled = true; + } else { + btn.textContent = '⬇ Save'; + btn.classList.remove('done'); + btn.disabled = false; + btn.title = 'Save this video for offline playback'; + } +} function updateProgress() { const cur = Player.master.currentTime || 0; const dur = Player.master.duration || current?.meta?.duration || 0; @@ -375,6 +562,8 @@ function currentList() { } function renderList() { + if (view.type === 'settings') { renderSettings(); return; } + const list = currentList(); els.cards.innerHTML = ''; els.listActions.innerHTML = ''; @@ -420,20 +609,162 @@ function renderList() { markPlayingCard(); } +// ============================================================================ +// Settings page +// ============================================================================ +function videoTitleById(id) { + for (const pl of data.playlists) { + const v = pl.videos.find((x) => x.id === id); + if (v) return v.title; + } + const h = data.history.find((x) => x.id === id); + if (h) return h.title; + return id; +} + +async function renderSettings() { + els.listTitle.textContent = 'Settings'; + els.listActions.innerHTML = ''; + const c = els.cards; + els.status.classList.add('hidden'); + c.innerHTML = ''; + + const wrap = document.createElement('div'); + wrap.className = 'settings'; + + // ---- Playback ---- + const qualities = ['auto', '1080p', '720p', '480p', '360p', '240p']; + const qOptions = qualities + .map((q) => ``) + .join(''); + + wrap.innerHTML = ` +
+
Playback
+ + + +
+ +
+
Offline cache
+ +
+ Storage used + … +
+
+ +
+
+
`; + + c.appendChild(wrap); + + // ---- Wire playback controls ---- + $('setQuality').addEventListener('change', (e) => { + data.settings.quality = e.target.value; + els.quality.value = e.target.value; + persist(); + }); + $('setVolume').addEventListener('input', (e) => { + data.settings.volume = parseFloat(e.target.value); + els.volume.value = e.target.value; + Player.applyVolume(); + els.muteBtn.textContent = data.settings.volume === 0 ? 'πŸ”‡' : 'πŸ”Š'; + persist(); + }); + $('setAudioOnly').addEventListener('change', (e) => { + data.settings.audioOnly = e.target.checked; + els.audioOnlyToggle.checked = e.target.checked; + persist(); + }); + $('setAutoPreload').addEventListener('change', (e) => { + data.settings.autoPreload = e.target.checked; + persist(); + if (e.target.checked) data.playlists.forEach(preloadPlaylist); + }); + + // ---- Cache management ---- + $('clearCacheBtn').addEventListener('click', () => { + showModal('Clear all cached videos?', document.createTextNode('This frees disk space. Playlists keep their entries and will re-download on demand.'), [ + { label: 'Cancel', onClick: closeModal }, + { + label: 'Clear all', danger: true, onClick: async () => { + try { await API.cacheClear(); } catch {} + cachedIds.clear(); + closeModal(); + toast('Cache cleared'); + if (current) updateNowPlayingActions(); + renderList(); + }, + }, + ]); + }); + + // ---- Populate live cache stats ---- + try { + const res = await API.cacheList(); + if (view.type !== 'settings') return; // user navigated away + const total = (res && res.total) || 0; + const items = (res && res.items) || []; + $('cacheTotal').textContent = `${fmtBytes(total)} Β· ${items.length} video${items.length === 1 ? '' : 's'}`; + const listEl = $('cacheList'); + if (!items.length) { + listEl.innerHTML = '
No videos saved offline yet.
'; + } else { + items.sort((a, b) => b.size - a.size); + items.forEach((it) => { + const row = document.createElement('div'); + row.className = 'cache-item'; + row.innerHTML = `${fmtBytes(it.size)}`; + row.querySelector('.ci-title').textContent = videoTitleById(it.id); + row.querySelector('.ci-del').addEventListener('click', async () => { + try { await API.cacheDelete(it.id); } catch {} + cachedIds.delete(it.id); + if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions(); + toast('Removed from cache'); + renderList(); + }); + listEl.appendChild(row); + }); + } + } catch { + const t = $('cacheTotal'); + if (t) t.textContent = 'Offline cache not available in this build'; + } +} + function renderCard(v, index, list) { const card = document.createElement('div'); - card.className = 'card'; + card.className = 'card' + (cachedIds.has(v.id) ? ' cached' : '') + (downloading.has(v.id) ? ' downloading' : ''); card.dataset.id = v.id; card.innerHTML = `
${v.duration ? `${fmtTime(v.duration)}` : ''} + ⬇
- `; + `; card.querySelector('.card-title').textContent = v.title; card.querySelector('.card-channel').textContent = v.channel || ''; card.addEventListener('click', (e) => { @@ -471,8 +802,12 @@ function openCardMenu(video) { const btn = document.createElement('button'); btn.textContent = (has ? 'βœ“ ' : '+ ') + pl.name; btn.onclick = () => { - if (has) pl.videos = pl.videos.filter((x) => x.id !== video.id); - else pl.videos.push(slim(video)); + if (has) { + pl.videos = pl.videos.filter((x) => x.id !== video.id); + } else { + pl.videos.push(slim(video)); + preload(video); // auto-cache for offline playback + } persist(); closeModal(); toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`); @@ -515,6 +850,7 @@ function newPlaylist(addVideo) { if (!name) return; const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] }; data.playlists.push(pl); + if (addVideo) preload(addVideo); // auto-cache for offline playback persist(); closeModal(); view = { type: 'playlist', id: pl.id }; @@ -606,6 +942,25 @@ function wireUI() { els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null)); + // Now-playing: Save (preload) + Add to playlist + els.saveBtn.addEventListener('click', async () => { + if (!current || !current.meta) return; + const id = current.meta.id; + if (cachedIds.has(id)) { + // Already saved β†’ remove from cache. + try { await API.cacheDelete(id); } catch {} + cachedIds.delete(id); + toast('Removed from offline cache'); + updateNowPlayingActions(); + markCardCacheState(id, 'none'); + } else { + await preload(current.meta); + } + }); + els.addPlaylistBtn.addEventListener('click', () => { + if (current && current.meta) openCardMenu(current.meta); + }); + // Controls els.playBtn.addEventListener('click', () => Player.toggle()); els.nextBtn.addEventListener('click', playNext); @@ -691,7 +1046,7 @@ async function boot() { data = { playlists: loaded.playlists || [], history: loaded.history || [], - settings: { quality: 'auto', volume: 1, audioOnly: false, ...(loaded.settings || {}) }, + settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, ...(loaded.settings || {}) }, }; } } catch { @@ -700,6 +1055,11 @@ async function boot() { els.volume.value = String(data.settings.volume ?? 1); els.quality.value = data.settings.quality || 'auto'; els.audioOnlyToggle.checked = !!data.settings.audioOnly; + + // Learn what's already cached, then top up any playlist videos that aren't. + await refreshCachedIds(); + data.playlists.forEach(preloadPlaylist); + render(); els.searchInput.focus(); } diff --git a/frontend/index.html b/frontend/index.html index d06ada4..b35edf5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ YT Player @@ -27,6 +27,7 @@
@@ -113,8 +114,14 @@
diff --git a/frontend/styles.css b/frontend/styles.css index becfa6e..84518d7 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -405,7 +405,31 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } } .sel select:hover { border-color: var(--accent); } -.now-meta { margin-top: 18px; } +.now-meta { + margin-top: 18px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.np-text { min-width: 0; } +.np-actions { display: flex; gap: 8px; flex-shrink: 0; } +.np-btn { + background: var(--bg-3); + border: 1px solid var(--line); + color: var(--text-2); + border-radius: 9px; + padding: 9px 14px; + cursor: pointer; + font-family: var(--ui); + font-size: 13px; + font-weight: 600; + white-space: nowrap; + transition: all 0.16s var(--ease); +} +.np-btn:hover { color: var(--text); border-color: var(--accent); background: var(--bg-2); transform: translateY(-1px); } +.np-btn:disabled { opacity: 0.6; cursor: default; transform: none; } +.np-btn.done { color: #7ee0a8; border-color: rgba(126,224,168,0.35); } .np-title { font-family: var(--display); font-weight: 700; @@ -539,20 +563,98 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } } .card-channel { font-size: 12px; color: var(--text-dim); margin-top: 5px; } .card-menu { - background: transparent; - border: 1px solid transparent; - color: var(--text-dim); + background: var(--bg-3); + border: 1px solid var(--line); + color: var(--text-2); border-radius: 8px; - width: 30px; height: 30px; + width: 32px; height: 32px; cursor: pointer; align-self: center; flex-shrink: 0; - font-size: 17px; + font-size: 19px; + line-height: 1; transition: all 0.16s; - opacity: 0; + opacity: 0.9; } .card:hover .card-menu { opacity: 1; } -.card-menu:hover { color: var(--accent-bright); border-color: var(--accent); background: var(--bg-3); } +.card-menu:hover { color: #fff; border-color: var(--accent); background: var(--accent); } + +/* Saved-offline badge on cards */ +.saved-badge { + position: absolute; left: 5px; bottom: 5px; + display: none; + align-items: center; justify-content: center; + width: 18px; height: 18px; + background: rgba(126, 224, 168, 0.92); + color: #06281a; + font-size: 10px; font-weight: 700; + border-radius: 5px; + backdrop-filter: blur(2px); +} +.card.cached .saved-badge { display: flex; } +.card.downloading .saved-badge { + display: flex; + background: rgba(255, 200, 80, 0.92); + color: #2a1d00; + animation: pulse 1s ease-in-out infinite; +} +.card.downloading .saved-badge::before { content: "⏳"; } +.card.downloading .saved-badge { font-size: 0; } +.card.downloading .saved-badge::before { font-size: 10px; } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } + +/* ===================== Settings page ===================== */ +.settings { display: flex; flex-direction: column; gap: 18px; padding: 4px 6px 28px; } +.set-group { + background: linear-gradient(180deg, var(--bg-2), var(--bg-1)); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px 18px; +} +.set-group-title { + font-family: var(--mono); + font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.16em; + color: var(--accent); + margin-bottom: 14px; +} +.set-row { + display: flex; align-items: center; justify-content: space-between; + gap: 14px; + padding: 10px 0; + border-top: 1px solid var(--line-soft); +} +.set-group .set-row:first-of-type { border-top: none; } +.set-row > span { display: flex; flex-direction: column; gap: 3px; color: var(--text); font-size: 13.5px; font-weight: 500; } +.set-row small { color: var(--text-dim); font-size: 11.5px; font-weight: 400; line-height: 1.4; max-width: 280px; } +.set-row input[type="range"] { width: 150px; } +.set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; } +.set-select { + background: var(--bg-3); color: var(--text); + border: 1px solid var(--line); border-radius: 9px; + padding: 8px 10px; font-family: var(--mono); font-size: 12px; cursor: pointer; +} +.set-select:hover { border-color: var(--accent); } +.set-stat { font-family: var(--mono); font-size: 12px; color: var(--text-2); } +.set-actions { padding-top: 14px; } +.cache-list { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; } +.cache-empty { color: var(--text-dim); font-size: 12.5px; padding: 8px 0; } +.cache-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; + background: var(--bg-3); + border: 1px solid var(--line-soft); + border-radius: 8px; +} +.cache-item .ci-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; } +.cache-item .ci-size { font-family: var(--mono); font-size: 11px; color: var(--text-dim); } +.cache-item .ci-del { + background: transparent; border: 1px solid var(--line); + color: var(--text-dim); width: 24px; height: 24px; + border-radius: 6px; cursor: pointer; font-size: 12px; line-height: 1; + transition: all 0.16s; +} +.cache-item .ci-del:hover { color: #fff; background: var(--accent); border-color: var(--accent); } /* ===================== Modal ===================== */ .modal-backdrop { diff --git a/releases/YTPlayer_1.0.0_x64-setup.exe b/releases/YTPlayer_1.0.0_x64-setup.exe index 5bda48a..2073903 100644 Binary files a/releases/YTPlayer_1.0.0_x64-setup.exe and b/releases/YTPlayer_1.0.0_x64-setup.exe differ diff --git a/releases/YTPlayer_1.0.0_x64_en-US.msi b/releases/YTPlayer_1.0.0_x64_en-US.msi index 37a0983..404f474 100644 Binary files a/releases/YTPlayer_1.0.0_x64_en-US.msi and b/releases/YTPlayer_1.0.0_x64_en-US.msi differ diff --git a/releases/ytplayer.exe b/releases/ytplayer.exe index 2e17445..a122221 100644 Binary files a/releases/ytplayer.exe and b/releases/ytplayer.exe differ diff --git a/scripts/push.sh b/scripts/push.sh new file mode 100644 index 0000000..38a8b9e --- /dev/null +++ b/scripts/push.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# push.sh β€” commit and push YT Player from WSL. +# Usage: bash scripts/push.sh ["optional commit message"] +set -euo pipefail + +cd "$(dirname "$0")/.." + +msg="${1:-Offline cache + Save/Add-to-playlist + Settings page; refresh Windows release}" + +git add -A +if git diff --cached --quiet; then + echo "Nothing to commit." +else + git commit -m "$msg" +fi +git push +echo "Pushed." diff --git a/scripts/release.ps1 b/scripts/release.ps1 new file mode 100644 index 0000000..e385c2f --- /dev/null +++ b/scripts/release.ps1 @@ -0,0 +1,52 @@ +<# + release.ps1 β€” one-shot Windows release for YT Player. + + Builds the Tauri (WebView2) app on Windows, copies the installers into + .\releases, then commits and pushes through WSL git (as requested). + + Run from a *native Windows* PowerShell (not WSL) inside the repo: + + pwsh -File scripts\release.ps1 + # or: powershell -ExecutionPolicy Bypass -File scripts\release.ps1 + + Prereqs (one time): Rust (MSVC), Microsoft C++ Build Tools, Node.js, + WebView2 runtime (preinstalled on Win11). +#> + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan } + +Step "Installing npm deps + Tauri CLI" +npm install + +Step "Fetching yt-dlp.exe into .\bin" +npm run setup + +if (-not (Test-Path "src-tauri\icons\icon.ico")) { + Step "Generating app icons" + npm run make-icon + npm run tauri icon .\appicon.png +} + +Step "Building Windows app (npm run tauri:build)" +npm run tauri:build + +Step "Collecting artifacts into .\releases" +New-Item -ItemType Directory -Force -Path releases | Out-Null +$bundle = "src-tauri\target\release\bundle" +Get-ChildItem "$bundle\nsis\*.exe" -ErrorAction SilentlyContinue | Copy-Item -Destination releases -Force +Get-ChildItem "$bundle\msi\*.msi" -ErrorAction SilentlyContinue | Copy-Item -Destination releases -Force +Copy-Item "src-tauri\target\release\ytplayer.exe" releases -Force -ErrorAction SilentlyContinue +Get-ChildItem releases | Format-Table Name, Length -AutoSize + +Step "Committing + pushing via WSL git" +$msg = "Offline cache + Save/Add-to-playlist + Settings page; refresh Windows release" +# Resolve the repo's path inside WSL and run git there. +$wslPath = (wsl wslpath -a "$($root -replace '\\','/')") 2>$null +if (-not $wslPath) { $wslPath = "~/development/personal/ytplayer" } +wsl bash -lc "cd '$wslPath' && git add -A && git commit -m '$msg' && git push" + +Write-Host "Done β€” installers are in .\releases and changes are pushed." -ForegroundColor Green diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ebecd46..6f0aa85 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,7 +10,7 @@ rust-version = "1.77" tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f4fef48..100e43c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -166,10 +166,12 @@ async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value { } } - // Quality list: adaptive video-only first, then progressive; dedupe by height. + // Quality list: progressive (single-file, has audio) first so it survives the + // per-height dedupe β€” it's the most reliable fallback when the dual adaptive + // path fails. Adaptive video-only fills in the heights progressive doesn't cover. let mut qualities = Vec::new(); let mut seen: HashSet = HashSet::new(); - for want_progressive in [false, true] { + for want_progressive in [true, false] { for f in formats { let vcodec = str_or(f, "vcodec", "none"); let acodec = str_or(f, "acodec", "none"); @@ -238,6 +240,155 @@ fn data_file(app: &tauri::AppHandle) -> Result { Ok(dir.join("ytplayer-data.json")) } +// ============================================================================ +// Offline cache β€” download a single self-contained file per video so playlist +// items play instantly and work offline. Files live in /videos +// and are named ".". They persist until explicitly deleted. +// ============================================================================ +fn cache_dir(app: &tauri::AppHandle) -> Result { + let dir = app + .path() + .app_cache_dir() + .map_err(|e| e.to_string())? + .join("videos"); + std::fs::create_dir_all(&dir).ok(); + Ok(dir) +} + +/// Find an already-cached file for a video id (any extension), if present. +fn cached_file(dir: &PathBuf, video_id: &str) -> Option { + let entries = std::fs::read_dir(dir).ok()?; + for e in entries.flatten() { + let p = e.path(); + if p.file_stem().and_then(|s| s.to_str()) == Some(video_id) { + // Ignore partial yt-dlp downloads. + let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); + if ext != "part" && ext != "ytdl" { + return Some(p); + } + } + } + None +} + +fn file_size(p: &PathBuf) -> u64 { + std::fs::metadata(p).map(|m| m.len()).unwrap_or(0) +} + +/// Download a video into the cache as a single progressive mp4 (audio+video in +/// one file, so it needs no ffmpeg muxing and plays offline). Idempotent. +#[tauri::command] +fn cache_download(app: tauri::AppHandle, video_id: String) -> Value { + if video_id.is_empty() { + return json!({ "ok": false, "error": "missing videoId" }); + } + let dir = match cache_dir(&app) { + Ok(d) => d, + Err(e) => return json!({ "ok": false, "error": e }), + }; + if let Some(p) = cached_file(&dir, &video_id) { + return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }); + } + let url = format!("https://www.youtube.com/watch?v={}", video_id); + let out_tmpl = dir.join(format!("{}.%(ext)s", video_id)); + let out_tmpl = out_tmpl.to_string_lossy().to_string(); + // Prefer a progressive mp4 (single file with audio); fall back to best single + // file. Avoids ffmpeg by not requesting separate streams that need merging. + let res = run_ytdlp( + &app, + &[ + "--no-playlist", + "--no-warnings", + "-f", + "best[ext=mp4][acodec!=none][vcodec!=none]/best[acodec!=none][vcodec!=none]/best", + "-o", + &out_tmpl, + &url, + ], + ); + if let Err(e) = res { + return json!({ "ok": false, "error": e }); + } + match cached_file(&dir, &video_id) { + Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }), + None => json!({ "ok": false, "error": "download finished but no file was produced" }), + } +} + +#[tauri::command] +fn cache_status(app: tauri::AppHandle, video_id: String) -> Value { + let dir = match cache_dir(&app) { + Ok(d) => d, + Err(e) => return json!({ "ok": false, "error": e }), + }; + match cached_file(&dir, &video_id) { + Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }), + None => json!({ "ok": true, "cached": false }), + } +} + +#[tauri::command] +fn cache_list(app: tauri::AppHandle) -> Value { + let dir = match cache_dir(&app) { + Ok(d) => d, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let mut items = Vec::new(); + let mut total: u64 = 0; + if let Ok(entries) = std::fs::read_dir(&dir) { + for e in entries.flatten() { + let p = e.path(); + if !p.is_file() { + continue; + } + let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); + if ext == "part" || ext == "ytdl" { + continue; + } + if let Some(id) = p.file_stem().and_then(|s| s.to_str()) { + let size = file_size(&p); + total += size; + items.push(json!({ "id": id, "size": size, "path": p.to_string_lossy() })); + } + } + } + json!({ "ok": true, "items": items, "total": total }) +} + +#[tauri::command] +fn cache_delete(app: tauri::AppHandle, video_id: String) -> Value { + let dir = match cache_dir(&app) { + Ok(d) => d, + Err(e) => return json!({ "ok": false, "error": e }), + }; + if let Some(p) = cached_file(&dir, &video_id) { + match std::fs::remove_file(&p) { + Ok(_) => json!({ "ok": true }), + Err(e) => json!({ "ok": false, "error": e.to_string() }), + } + } else { + json!({ "ok": true }) + } +} + +#[tauri::command] +fn cache_clear(app: tauri::AppHandle) -> Value { + let dir = match cache_dir(&app) { + Ok(d) => d, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let mut removed = 0u32; + if let Ok(entries) = std::fs::read_dir(&dir) { + for e in entries.flatten() { + let p = e.path(); + if p.is_file() && std::fs::remove_file(&p).is_ok() { + removed += 1; + } + } + } + json!({ "ok": true, "removed": removed }) +} + #[tauri::command] fn store_load(app: tauri::AppHandle) -> Value { let default = json!({ @@ -273,7 +424,12 @@ fn main() { yt_search, yt_streams, store_load, - store_save + store_save, + cache_download, + cache_status, + cache_list, + cache_delete, + cache_clear ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f5a019f..428ef39 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,11 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost" + "csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost", + "assetProtocol": { + "enable": true, + "scope": ["**"] + } } }, "bundle": {