Add offline cache & preload, Save/add-to-playlist UI, Settings page; player stream fallback + progressive-first ordering; protocol-asset feature; refresh Windows installers
This commit is contained in:
33
README.md
33
README.md
@@ -18,6 +18,13 @@ or Node runtime.
|
|||||||
- 🎵 **Audio-only mode** — great for music, saves bandwidth
|
- 🎵 **Audio-only mode** — great for music, saves bandwidth
|
||||||
- 📂 **On-device playlists** — create, rename, delete, add/remove videos. Stored
|
- 📂 **On-device playlists** — create, rename, delete, add/remove videos. Stored
|
||||||
locally as JSON; nothing leaves your machine
|
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**
|
- 🕘 **Watch history**
|
||||||
- ⏯ Full controls: seek, volume, playback speed, quality switching, fullscreen,
|
- ⏯ Full controls: seek, volume, playback speed, quality switching, fullscreen,
|
||||||
next/prev, and keyboard shortcuts (`space`, `←/→`, `f`, `m`)
|
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[] } }` |
|
| `yt.streams { videoId }` | `ytStreams` | `{ ok, data:{ meta, audioUrl, qualities[] } }` |
|
||||||
| `store.load {}` | `storeLoad` | playlists / history / settings |
|
| `store.load {}` | `storeLoad` | playlists / history / settings |
|
||||||
| `store.save { data }` | `storeSave` | `{ ok }` |
|
| `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 `<app_cache_dir>/videos/<videoId>.<ext>` 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
|
Because the bridge is size-limited, the Zig handlers parse `yt-dlp`'s large JSON
|
||||||
and return only the compact fields the UI needs.
|
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`).
|
`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.
|
`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
|
> 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
|
> 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
|
> your Windows desktop — see [Setup & run](#setup--run). That needs WSL running
|
||||||
|
|||||||
388
frontend/app.js
388
frontend/app.js
@@ -28,16 +28,32 @@ const API = {
|
|||||||
loadData: () => call('store.load', 'store_load', {}),
|
loadData: () => call('store.load', 'store_load', {}),
|
||||||
// data is sent pre-stringified so the native side can write it verbatim.
|
// data is sent pre-stringified so the native side can write it verbatim.
|
||||||
saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }),
|
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 ----------
|
// ---------- State ----------
|
||||||
let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false } };
|
let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true } };
|
||||||
let view = { type: 'search' }; // 'search' | 'history' | 'playlist'
|
let view = { type: 'search' }; // 'search' | 'history' | 'playlist' | 'settings'
|
||||||
let searchResults = [];
|
let searchResults = [];
|
||||||
let queue = []; // list of video objects for autoplay
|
let queue = []; // list of video objects for autoplay
|
||||||
let queueIndex = -1;
|
let queueIndex = -1;
|
||||||
let current = null; // { meta, qualities, audioUrl }
|
let current = null; // { meta, qualities, audioUrl, localUrl? }
|
||||||
let saveTimer = null;
|
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 ----------
|
// ---------- DOM ----------
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -73,6 +89,8 @@ const els = {
|
|||||||
playlistList: $('playlistList'),
|
playlistList: $('playlistList'),
|
||||||
newPlaylistBtn: $('newPlaylistBtn'),
|
newPlaylistBtn: $('newPlaylistBtn'),
|
||||||
audioOnlyToggle: $('audioOnlyToggle'),
|
audioOnlyToggle: $('audioOnlyToggle'),
|
||||||
|
saveBtn: $('saveBtn'),
|
||||||
|
addPlaylistBtn: $('addPlaylistBtn'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Persistence ----------
|
// ---------- Persistence ----------
|
||||||
@@ -99,6 +117,63 @@ function toast(msg) {
|
|||||||
toast._t = setTimeout(() => t.classList.add('hidden'), 2200);
|
toast._t = setTimeout(() => t.classList.add('hidden'), 2200);
|
||||||
}
|
}
|
||||||
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
|
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
|
// Player engine — single video, or video+audio synced (adaptive), or audio-only
|
||||||
@@ -113,16 +188,44 @@ const Player = {
|
|||||||
return this.mode === 'dual' ? els.audio : this.master;
|
return this.mode === 'dual' ? els.audio : this.master;
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadVideo(videoObj) {
|
async loadVideo(videoObj, { preferStream = false } = {}) {
|
||||||
showSpinner(true);
|
showSpinner(true);
|
||||||
els.placeholder.classList.add('hidden');
|
els.placeholder.classList.add('hidden');
|
||||||
try {
|
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);
|
const res = await API.getStreams(videoObj.id);
|
||||||
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
|
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
|
||||||
current = res.data;
|
current = res.data;
|
||||||
// Merge richer metadata we may already have from the list card.
|
// Merge richer metadata we may already have from the list card.
|
||||||
current.meta = { ...videoObj, ...current.meta };
|
current.meta = { ...videoObj, ...current.meta };
|
||||||
|
|
||||||
|
this.afterLoad();
|
||||||
|
const q = chooseQuality();
|
||||||
|
this.buildFallbackQueue(q);
|
||||||
|
this.attach(q);
|
||||||
|
} catch (err) {
|
||||||
|
showSpinner(false);
|
||||||
|
toast('⚠ ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Shared UI updates once `current` is populated (cached or streamed).
|
||||||
|
afterLoad() {
|
||||||
addToHistory(current.meta);
|
addToHistory(current.meta);
|
||||||
buildQualityMenu();
|
buildQualityMenu();
|
||||||
els.playerPane.classList.remove('empty');
|
els.playerPane.classList.remove('empty');
|
||||||
@@ -130,13 +233,44 @@ const Player = {
|
|||||||
els.nowMeta.classList.remove('hidden');
|
els.nowMeta.classList.remove('hidden');
|
||||||
els.npTitle.textContent = current.meta.title;
|
els.npTitle.textContent = current.meta.title;
|
||||||
els.npChannel.textContent = current.meta.channel || '';
|
els.npChannel.textContent = current.meta.channel || '';
|
||||||
|
updateNowPlayingActions();
|
||||||
markPlayingCard();
|
markPlayingCard();
|
||||||
|
},
|
||||||
|
|
||||||
const q = chooseQuality();
|
// Ordered list of qualities to try if playback errors out. Progressive
|
||||||
this.attach(q);
|
// (single-file, has audio) goes near the front because it's the most reliable;
|
||||||
} catch (err) {
|
// 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);
|
showSpinner(false);
|
||||||
toast('⚠ ' + err.message);
|
toast('⚠ This video couldn’t be played. Try another.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -148,6 +282,33 @@ const Player = {
|
|||||||
V.pause(); A.pause();
|
V.pause(); A.pause();
|
||||||
this.stopDrift();
|
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) {
|
if (audioOnly) {
|
||||||
// Play only audio; show cover art.
|
// Play only audio; show cover art.
|
||||||
this.mode = 'audio';
|
this.mode = 'audio';
|
||||||
@@ -291,7 +452,11 @@ function wirePlayerEvents() {
|
|||||||
el.addEventListener('timeupdate', () => { if (masterIs(el)) updateProgress(); });
|
el.addEventListener('timeupdate', () => { if (masterIs(el)) updateProgress(); });
|
||||||
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
|
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
|
||||||
el.addEventListener('ended', () => { if (masterIs(el)) playNext(); });
|
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(V);
|
||||||
bind(A);
|
bind(A);
|
||||||
@@ -300,6 +465,28 @@ function wirePlayerEvents() {
|
|||||||
function updatePlayBtn() {
|
function updatePlayBtn() {
|
||||||
els.playBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
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() {
|
function updateProgress() {
|
||||||
const cur = Player.master.currentTime || 0;
|
const cur = Player.master.currentTime || 0;
|
||||||
const dur = Player.master.duration || current?.meta?.duration || 0;
|
const dur = Player.master.duration || current?.meta?.duration || 0;
|
||||||
@@ -375,6 +562,8 @@ function currentList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
|
if (view.type === 'settings') { renderSettings(); return; }
|
||||||
|
|
||||||
const list = currentList();
|
const list = currentList();
|
||||||
els.cards.innerHTML = '';
|
els.cards.innerHTML = '';
|
||||||
els.listActions.innerHTML = '';
|
els.listActions.innerHTML = '';
|
||||||
@@ -420,20 +609,162 @@ function renderList() {
|
|||||||
markPlayingCard();
|
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) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="set-group">
|
||||||
|
<div class="set-group-title">Playback</div>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>Default quality</span>
|
||||||
|
<select id="setQuality" class="set-select">${qOptions}</select>
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>Default volume</span>
|
||||||
|
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>Audio-only mode</span>
|
||||||
|
<input id="setAudioOnly" type="checkbox" ${data.settings.audioOnly ? 'checked' : ''} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="set-group">
|
||||||
|
<div class="set-group-title">Offline cache</div>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>
|
||||||
|
Auto-save playlist videos
|
||||||
|
<small>Videos you add to a playlist are downloaded and kept for offline playback.</small>
|
||||||
|
</span>
|
||||||
|
<input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
|
||||||
|
</label>
|
||||||
|
<div class="set-row">
|
||||||
|
<span>Storage used</span>
|
||||||
|
<span id="cacheTotal" class="set-stat">…</span>
|
||||||
|
</div>
|
||||||
|
<div class="set-actions">
|
||||||
|
<button id="clearCacheBtn" class="btn danger">Clear all cached videos</button>
|
||||||
|
</div>
|
||||||
|
<div id="cacheList" class="cache-list"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = '<div class="cache-empty">No videos saved offline yet.</div>';
|
||||||
|
} else {
|
||||||
|
items.sort((a, b) => b.size - a.size);
|
||||||
|
items.forEach((it) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'cache-item';
|
||||||
|
row.innerHTML = `<span class="ci-title"></span><span class="ci-size">${fmtBytes(it.size)}</span><button class="ci-del" title="Delete from cache">✕</button>`;
|
||||||
|
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) {
|
function renderCard(v, index, list) {
|
||||||
const card = document.createElement('div');
|
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.dataset.id = v.id;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
|
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
|
||||||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||||
|
<span class="saved-badge" title="Saved offline">⬇</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
<div class="card-title"></div>
|
<div class="card-title"></div>
|
||||||
<div class="card-channel"></div>
|
<div class="card-channel"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="card-menu" title="Add to playlist / remove">⋯</button>`;
|
<button class="card-menu" title="Add to playlist">+</button>`;
|
||||||
card.querySelector('.card-title').textContent = v.title;
|
card.querySelector('.card-title').textContent = v.title;
|
||||||
card.querySelector('.card-channel').textContent = v.channel || '';
|
card.querySelector('.card-channel').textContent = v.channel || '';
|
||||||
card.addEventListener('click', (e) => {
|
card.addEventListener('click', (e) => {
|
||||||
@@ -471,8 +802,12 @@ function openCardMenu(video) {
|
|||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.textContent = (has ? '✓ ' : '+ ') + pl.name;
|
btn.textContent = (has ? '✓ ' : '+ ') + pl.name;
|
||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
if (has) pl.videos = pl.videos.filter((x) => x.id !== video.id);
|
if (has) {
|
||||||
else pl.videos.push(slim(video));
|
pl.videos = pl.videos.filter((x) => x.id !== video.id);
|
||||||
|
} else {
|
||||||
|
pl.videos.push(slim(video));
|
||||||
|
preload(video); // auto-cache for offline playback
|
||||||
|
}
|
||||||
persist();
|
persist();
|
||||||
closeModal();
|
closeModal();
|
||||||
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
|
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
|
||||||
@@ -515,6 +850,7 @@ function newPlaylist(addVideo) {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] };
|
const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] };
|
||||||
data.playlists.push(pl);
|
data.playlists.push(pl);
|
||||||
|
if (addVideo) preload(addVideo); // auto-cache for offline playback
|
||||||
persist();
|
persist();
|
||||||
closeModal();
|
closeModal();
|
||||||
view = { type: 'playlist', id: pl.id };
|
view = { type: 'playlist', id: pl.id };
|
||||||
@@ -606,6 +942,25 @@ function wireUI() {
|
|||||||
|
|
||||||
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
|
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
|
// Controls
|
||||||
els.playBtn.addEventListener('click', () => Player.toggle());
|
els.playBtn.addEventListener('click', () => Player.toggle());
|
||||||
els.nextBtn.addEventListener('click', playNext);
|
els.nextBtn.addEventListener('click', playNext);
|
||||||
@@ -691,7 +1046,7 @@ async function boot() {
|
|||||||
data = {
|
data = {
|
||||||
playlists: loaded.playlists || [],
|
playlists: loaded.playlists || [],
|
||||||
history: loaded.history || [],
|
history: loaded.history || [],
|
||||||
settings: { quality: 'auto', volume: 1, audioOnly: false, ...(loaded.settings || {}) },
|
settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, ...(loaded.settings || {}) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -700,6 +1055,11 @@ async function boot() {
|
|||||||
els.volume.value = String(data.settings.volume ?? 1);
|
els.volume.value = String(data.settings.volume ?? 1);
|
||||||
els.quality.value = data.settings.quality || 'auto';
|
els.quality.value = data.settings.quality || 'auto';
|
||||||
els.audioOnlyToggle.checked = !!data.settings.audioOnly;
|
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();
|
render();
|
||||||
els.searchInput.focus();
|
els.searchInput.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="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;"
|
content="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;"
|
||||||
/>
|
/>
|
||||||
<title>YT Player</title>
|
<title>YT Player</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<button class="nav-item active" data-view="search">🔍 Search</button>
|
<button class="nav-item active" data-view="search">🔍 Search</button>
|
||||||
<button class="nav-item" data-view="history">🕘 History</button>
|
<button class="nav-item" data-view="history">🕘 History</button>
|
||||||
|
<button class="nav-item" data-view="settings">⚙ Settings</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="pl-header">
|
<div class="pl-header">
|
||||||
@@ -113,9 +114,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="nowPlayingMeta" class="now-meta hidden">
|
<div id="nowPlayingMeta" class="now-meta hidden">
|
||||||
|
<div class="np-text">
|
||||||
<div class="np-title" id="npTitle"></div>
|
<div class="np-title" id="npTitle"></div>
|
||||||
<div class="np-channel" id="npChannel"></div>
|
<div class="np-channel" id="npChannel"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="np-actions">
|
||||||
|
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
|
||||||
|
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- List pane -->
|
<!-- List pane -->
|
||||||
|
|||||||
@@ -405,7 +405,31 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
}
|
}
|
||||||
.sel select:hover { border-color: var(--accent); }
|
.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 {
|
.np-title {
|
||||||
font-family: var(--display);
|
font-family: var(--display);
|
||||||
font-weight: 700;
|
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-channel { font-size: 12px; color: var(--text-dim); margin-top: 5px; }
|
||||||
.card-menu {
|
.card-menu {
|
||||||
background: transparent;
|
background: var(--bg-3);
|
||||||
border: 1px solid transparent;
|
border: 1px solid var(--line);
|
||||||
color: var(--text-dim);
|
color: var(--text-2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: 30px; height: 30px;
|
width: 32px; height: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 17px;
|
font-size: 19px;
|
||||||
|
line-height: 1;
|
||||||
transition: all 0.16s;
|
transition: all 0.16s;
|
||||||
opacity: 0;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.card:hover .card-menu { opacity: 1; }
|
.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 ===================== */
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
17
scripts/push.sh
Normal file
17
scripts/push.sh
Normal file
@@ -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."
|
||||||
52
scripts/release.ps1
Normal file
52
scripts/release.ps1
Normal file
@@ -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
|
||||||
@@ -10,7 +10,7 @@ rust-version = "1.77"
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -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 qualities = Vec::new();
|
||||||
let mut seen: HashSet<i64> = HashSet::new();
|
let mut seen: HashSet<i64> = HashSet::new();
|
||||||
for want_progressive in [false, true] {
|
for want_progressive in [true, false] {
|
||||||
for f in formats {
|
for f in formats {
|
||||||
let vcodec = str_or(f, "vcodec", "none");
|
let vcodec = str_or(f, "vcodec", "none");
|
||||||
let acodec = str_or(f, "acodec", "none");
|
let acodec = str_or(f, "acodec", "none");
|
||||||
@@ -238,6 +240,155 @@ fn data_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|||||||
Ok(dir.join("ytplayer-data.json"))
|
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 <app_cache_dir>/videos
|
||||||
|
// and are named "<videoId>.<ext>". They persist until explicitly deleted.
|
||||||
|
// ============================================================================
|
||||||
|
fn cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
fn store_load(app: tauri::AppHandle) -> Value {
|
fn store_load(app: tauri::AppHandle) -> Value {
|
||||||
let default = json!({
|
let default = json!({
|
||||||
@@ -273,7 +424,12 @@ fn main() {
|
|||||||
yt_search,
|
yt_search,
|
||||||
yt_streams,
|
yt_streams,
|
||||||
store_load,
|
store_load,
|
||||||
store_save
|
store_save,
|
||||||
|
cache_download,
|
||||||
|
cache_status,
|
||||||
|
cache_list,
|
||||||
|
cache_delete,
|
||||||
|
cache_clear
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
|
|||||||
Reference in New Issue
Block a user