Files
ytplayer/frontend/app.js

1076 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================================
* YT Player — frontend logic
* Talks to the native (Zig) side over the zero-native bridge: window.zero.invoke
* Handlers (implemented in src/bridge.zig):
* yt.search { query } -> { ok, results:[{id,title,channel,duration,thumbnail}] }
* yt.streams { videoId } -> { ok, data:{ meta, audioUrl, qualities:[{label,height,url,hasAudio,ext}] } }
* store.load {} -> { playlists, history, settings }
* store.save { data } -> { ok }
* ========================================================================== */
// ---------- Native bridge adapter ----------
// Works against two shells from the same frontend:
// • Tauri (Windows / WebView2): window.__TAURI__.core.invoke, snake_case commands
// • zero-native (Linux / macOS): window.zero.invoke, dotted commands
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null;
// call(zeroName, tauriName, payload) — routes to whichever shell is present.
async function call(zeroName, tauriName, payload = {}) {
if (TAURI) return await TAURI.invoke(tauriName, payload);
if (ZERO) return await ZERO.invoke(zeroName, payload);
throw new Error('No native bridge available — run inside the YT Player app.');
}
const API = {
search: (query) => call('yt.search', 'yt_search', { query }),
getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }),
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, 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, 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);
const els = {
video: $('video'),
audio: $('audio'),
art: $('artFallback'),
artImg: $('artImg'),
placeholder: $('playerPlaceholder'),
spinner: $('bufferSpinner'),
controls: $('controls'),
playerPane: $('playerPane'),
nowMeta: $('nowPlayingMeta'),
npTitle: $('npTitle'),
npChannel: $('npChannel'),
seek: $('seek'),
curTime: $('curTime'),
durTime: $('durTime'),
playBtn: $('playBtn'),
prevBtn: $('prevBtn'),
nextBtn: $('nextBtn'),
muteBtn: $('muteBtn'),
volume: $('volume'),
speed: $('speedSelect'),
quality: $('qualitySelect'),
fsBtn: $('fsBtn'),
cards: $('cards'),
listTitle: $('listTitle'),
listActions: $('listActions'),
status: $('status'),
searchForm: $('searchForm'),
searchInput: $('searchInput'),
playlistList: $('playlistList'),
newPlaylistBtn: $('newPlaylistBtn'),
audioOnlyToggle: $('audioOnlyToggle'),
saveBtn: $('saveBtn'),
addPlaylistBtn: $('addPlaylistBtn'),
};
// ---------- Persistence ----------
function persist() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400);
}
// ---------- Helpers ----------
function fmtTime(sec) {
if (!sec || !isFinite(sec)) return '0:00';
sec = Math.floor(sec);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const mm = h ? String(m).padStart(2, '0') : String(m);
return (h ? h + ':' : '') + mm + ':' + String(s).padStart(2, '0');
}
function toast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.remove('hidden');
clearTimeout(toast._t);
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
// ============================================================================
const Player = {
mode: 'progressive', // 'progressive' | 'dual' | 'audio'
master: els.video,
secondary: null, // synced audio element in dual mode
driftTimer: null,
get soundEl() {
return this.mode === 'dual' ? els.audio : this.master;
},
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 };
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);
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 couldnt be played. Try another.');
}
},
attach(quality) {
const V = els.video, A = els.audio;
const audioOnly = data.settings.audioOnly;
// Reset
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';
this.master = A;
this.secondary = null;
A.src = current.audioUrl || (quality && quality.url) || '';
V.removeAttribute('src'); V.load();
els.art.classList.remove('hidden');
els.artImg.src = current.meta.thumbnail || '';
} else if (quality && quality.hasAudio) {
// Progressive single stream (video already carries audio).
this.mode = 'progressive';
this.master = V;
this.secondary = null;
V.muted = false;
V.src = quality.url;
A.removeAttribute('src'); A.load();
els.art.classList.add('hidden');
} else if (quality && current.audioUrl) {
// Adaptive: muted video + synced audio for high quality without muxing.
this.mode = 'dual';
this.master = V;
this.secondary = A;
V.muted = true;
V.src = quality.url;
A.src = current.audioUrl;
els.art.classList.add('hidden');
} else {
showSpinner(false);
toast('No playable stream found for this video.');
return;
}
this.applyVolume();
this.applySpeed();
this.master.load();
if (this.secondary) this.secondary.load();
const onReady = () => {
this.play();
this.master.removeEventListener('canplay', onReady);
};
this.master.addEventListener('canplay', onReady);
this.startDrift();
},
play() {
this.master.play().catch(() => {});
if (this.secondary) {
this.secondary.currentTime = this.master.currentTime;
this.secondary.play().catch(() => {});
}
},
pause() {
this.master.pause();
if (this.secondary) this.secondary.pause();
},
toggle() {
if (this.master.paused) this.play(); else this.pause();
},
seek(t) {
this.master.currentTime = t;
if (this.secondary) this.secondary.currentTime = t;
},
applyVolume() {
const v = data.settings.volume;
this.soundEl.volume = v;
this.soundEl.muted = v === 0;
// make sure the non-sound elements stay muted
if (this.mode === 'dual') els.video.muted = true;
},
applySpeed() {
const r = parseFloat(els.speed.value) || 1;
this.master.playbackRate = r;
if (this.secondary) this.secondary.playbackRate = r;
},
startDrift() {
this.stopDrift();
this.driftTimer = setInterval(() => {
if (this.mode !== 'dual' || this.master.paused) return;
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
}, 1000);
},
stopDrift() {
if (this.driftTimer) clearInterval(this.driftTimer);
this.driftTimer = null;
},
};
function showSpinner(on) {
els.spinner.classList.toggle('hidden', !on);
}
function chooseQuality() {
const qs = current.qualities || [];
if (!qs.length) return null;
const pref = data.settings.quality;
if (pref && pref !== 'auto') {
const match = qs.find((q) => q.label === pref);
if (match) return match;
}
// auto: best quality at or below 720p, else the lowest available.
const sorted = [...qs].sort((a, b) => b.height - a.height);
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
}
function buildQualityMenu() {
const qs = current.qualities || [];
els.quality.innerHTML = '';
const auto = document.createElement('option');
auto.value = 'auto';
auto.textContent = 'Auto';
els.quality.appendChild(auto);
const seen = new Set();
for (const q of qs) {
if (seen.has(q.label)) continue;
seen.add(q.label);
const o = document.createElement('option');
o.value = q.label;
o.textContent = q.label + (q.hasAudio ? '' : '');
els.quality.appendChild(o);
}
els.quality.value = data.settings.quality || 'auto';
}
// ---------- Player media events (master mirrors to secondary) ----------
function wirePlayerEvents() {
const V = els.video, A = els.audio;
const masterIs = (el) => Player.master === el;
function bind(el) {
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) Player.secondary.pause(); updatePlayBtn(); });
el.addEventListener('seeking', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; });
el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } });
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
el.addEventListener('timeupdate', () => { if (masterIs(el)) updateProgress(); });
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
el.addEventListener('ended', () => { if (masterIs(el)) playNext(); });
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);
}
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;
els.curTime.textContent = fmtTime(cur);
els.durTime.textContent = fmtTime(dur);
if (dur) els.seek.value = String((cur / dur) * 1000);
}
// ============================================================================
// History
// ============================================================================
function addToHistory(meta) {
data.history = data.history.filter((v) => v.id !== meta.id);
data.history.unshift({
id: meta.id, title: meta.title, channel: meta.channel,
duration: meta.duration, thumbnail: meta.thumbnail,
});
if (data.history.length > 200) data.history.length = 200;
persist();
if (view.type === 'history') renderList();
}
// ============================================================================
// Queue / navigation
// ============================================================================
function playFromList(list, index) {
queue = list;
queueIndex = index;
Player.loadVideo(list[index]);
}
function playNext() {
if (queueIndex >= 0 && queueIndex < queue.length - 1) {
queueIndex++;
Player.loadVideo(queue[queueIndex]);
} else {
updatePlayBtn();
}
}
function playPrev() {
if (Player.master.currentTime > 3) { Player.seek(0); return; }
if (queueIndex > 0) {
queueIndex--;
Player.loadVideo(queue[queueIndex]);
}
}
// ============================================================================
// Rendering
// ============================================================================
function renderSidebar() {
els.playlistList.innerHTML = '';
for (const pl of data.playlists) {
const item = document.createElement('div');
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
item.innerHTML = `<span class="pl-name"></span><span class="pl-count">${pl.videos.length}</span>`;
item.querySelector('.pl-name').textContent = pl.name;
item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
els.playlistList.appendChild(item);
}
document.querySelectorAll('.nav-item').forEach((b) => {
b.classList.toggle('active', b.dataset.view === view.type);
});
}
function currentList() {
if (view.type === 'search') return searchResults;
if (view.type === 'history') return data.history;
if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
return pl ? pl.videos : [];
}
return [];
}
function renderList() {
if (view.type === 'settings') { renderSettings(); return; }
const list = currentList();
els.cards.innerHTML = '';
els.listActions.innerHTML = '';
if (view.type === 'search') {
els.listTitle.textContent = 'Search results';
} else if (view.type === 'history') {
els.listTitle.textContent = 'History';
if (list.length) {
const clear = document.createElement('button');
clear.textContent = 'Clear';
clear.onclick = () => { data.history = []; persist(); renderList(); };
els.listActions.appendChild(clear);
}
} else if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
els.listTitle.textContent = pl ? pl.name : 'Playlist';
if (pl) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0); };
const rename = document.createElement('button');
rename.textContent = 'Rename';
rename.onclick = () => renamePlaylist(pl);
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => deletePlaylist(pl);
els.listActions.append(playAll, rename, del);
}
}
if (!list.length) {
els.status.classList.remove('hidden');
els.status.textContent =
view.type === 'search' ? 'Search for something to begin.'
: view.type === 'history' ? 'Nothing watched yet.'
: 'This playlist is empty. Add videos from search.';
return;
}
els.status.classList.add('hidden');
list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list)));
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' + (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"></button>`;
card.querySelector('.card-title').textContent = v.title;
card.querySelector('.card-channel').textContent = v.channel || '';
card.addEventListener('click', (e) => {
if (e.target.closest('.card-menu')) return;
playFromList(list, index);
});
card.querySelector('.card-menu').addEventListener('click', (e) => {
e.stopPropagation();
openCardMenu(v);
});
return card;
}
function markPlayingCard() {
document.querySelectorAll('.card').forEach((c) => {
c.classList.toggle('playing', current && c.dataset.id === current.meta.id);
});
}
function render() {
renderSidebar();
renderList();
}
// ============================================================================
// Playlists
// ============================================================================
function openCardMenu(video) {
const inPlaylistView = view.type === 'playlist';
const body = document.createElement('div');
body.className = 'modal-list';
data.playlists.forEach((pl) => {
const has = pl.videos.some((x) => x.id === video.id);
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));
preload(video); // auto-cache for offline playback
}
persist();
closeModal();
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
render();
};
body.appendChild(btn);
});
const newBtn = document.createElement('button');
newBtn.textContent = ' New playlist…';
newBtn.onclick = () => { closeModal(); newPlaylist(video); };
body.appendChild(newBtn);
const actions = [{ label: 'Close', onClick: closeModal }];
if (inPlaylistView) {
actions.unshift({
label: 'Remove from this playlist', danger: true, onClick: () => {
const pl = data.playlists.find((p) => p.id === view.id);
if (pl) { pl.videos = pl.videos.filter((x) => x.id !== video.id); persist(); }
closeModal(); render();
},
});
}
showModal('Add to playlist', body, actions);
}
function slim(v) {
return { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail };
}
function newPlaylist(addVideo) {
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Playlist name';
showModal('New playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Create', primary: true, onClick: () => {
const name = input.value.trim();
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 };
render();
toast(`Created “${name}`);
},
},
]);
setTimeout(() => input.focus(), 50);
}
function renamePlaylist(pl) {
const input = document.createElement('input');
input.type = 'text';
input.value = pl.name;
showModal('Rename playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Save', primary: true, onClick: () => {
const name = input.value.trim();
if (name) { pl.name = name; persist(); }
closeModal(); render();
},
},
]);
setTimeout(() => { input.focus(); input.select(); }, 50);
}
function deletePlaylist(pl) {
showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Delete', danger: true, onClick: () => {
data.playlists = data.playlists.filter((p) => p.id !== pl.id);
persist();
closeModal();
view = { type: 'search' };
render();
},
},
]);
}
// ---------- Modal ----------
function showModal(title, bodyNode, actions) {
$('modalTitle').textContent = title;
const body = $('modalBody');
body.innerHTML = '';
body.appendChild(bodyNode);
const act = $('modalActions');
act.innerHTML = '';
actions.forEach((a) => {
const b = document.createElement('button');
b.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
b.textContent = a.label;
b.onclick = a.onClick;
act.appendChild(b);
});
$('modal').classList.remove('hidden');
}
function closeModal() { $('modal').classList.add('hidden'); }
// ============================================================================
// Events
// ============================================================================
function wireUI() {
els.searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
const q = els.searchInput.value.trim();
if (!q) return;
view = { type: 'search' };
render();
els.status.classList.remove('hidden');
els.status.textContent = 'Searching…';
els.cards.innerHTML = '';
try {
const res = await API.search(q);
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
searchResults = res.results || [];
renderList();
} catch (err) {
els.status.textContent = '⚠ ' + err.message;
}
});
document.querySelectorAll('.nav-item').forEach((b) => {
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
});
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
// Landing-hero quick-search chips
document.querySelectorAll('.chip').forEach((c) => {
c.addEventListener('click', () => {
els.searchInput.value = c.dataset.q || c.textContent.trim();
els.searchForm.requestSubmit();
});
});
// 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);
els.prevBtn.addEventListener('click', playPrev);
els.fsBtn.addEventListener('click', () => {
const stage = els.video.parentElement;
if (document.fullscreenElement) document.exitFullscreen();
else stage.requestFullscreen?.();
});
els.seek.addEventListener('input', () => {
const dur = Player.master.duration || 0;
if (dur) Player.seek((parseFloat(els.seek.value) / 1000) * dur);
});
els.volume.addEventListener('input', () => {
data.settings.volume = parseFloat(els.volume.value);
Player.applyVolume();
els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
persist();
});
els.muteBtn.addEventListener('click', () => {
if (data.settings.volume > 0) { els.muteBtn.dataset.prev = data.settings.volume; data.settings.volume = 0; }
else { data.settings.volume = parseFloat(els.muteBtn.dataset.prev || '1'); }
els.volume.value = String(data.settings.volume);
Player.applyVolume();
els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
persist();
});
els.speed.addEventListener('change', () => Player.applySpeed());
els.quality.addEventListener('change', () => {
data.settings.quality = els.quality.value;
persist();
if (!current) return;
// reload at the new quality, preserving position + play state
const t = Player.master.currentTime;
const wasPlaying = !Player.master.paused;
const q = chooseQuality();
Player.attach(q);
const restore = () => {
Player.seek(t);
if (!wasPlaying) Player.pause();
Player.master.removeEventListener('canplay', restore);
};
Player.master.addEventListener('canplay', restore);
});
els.audioOnlyToggle.addEventListener('change', () => {
data.settings.audioOnly = els.audioOnlyToggle.checked;
persist();
if (!current) return;
const t = Player.master.currentTime;
const q = chooseQuality();
Player.attach(q);
const restore = () => { Player.seek(t); Player.master.removeEventListener('canplay', restore); };
Player.master.addEventListener('canplay', restore);
});
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.code === 'Space') { e.preventDefault(); Player.toggle(); }
else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5);
else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5));
else if (e.key === 'f') els.fsBtn.click();
else if (e.key === 'm') els.muteBtn.click();
});
$('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
}
// ============================================================================
// Boot
// ============================================================================
async function boot() {
wirePlayerEvents();
wireUI();
try {
const loaded = await API.loadData();
if (loaded && typeof loaded === 'object') {
data = {
playlists: loaded.playlists || [],
history: loaded.history || [],
settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, ...(loaded.settings || {}) },
};
}
} catch {
// first run / bridge not ready — start with defaults
}
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();
}
document.addEventListener('DOMContentLoaded', boot);