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:
398
frontend/app.js
398
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) => `<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) {
|
||||
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 = `
|
||||
<div class="thumb">
|
||||
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
|
||||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||
<span class="saved-badge" title="Saved offline">⬇</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title"></div>
|
||||
<div class="card-channel"></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-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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user