Add ad-free YouTube player built on zero-native
Desktop YouTube player with no login and no ads. Extracts direct video/audio streams with yt-dlp and plays them in a native window via zero-native (Zig + system WebView) for a tiny footprint vs Electron. - Dual-stream engine: muted video synced with separate audio track for high quality without ffmpeg muxing; progressive fallback - On-device playlists, watch history, audio-only mode - Full custom controls: seek, volume, speed, quality, fullscreen, queue - Zig bridge handlers spawn yt-dlp and return slimmed JSON to the web UI - Cinematic dark UI: Bricolage/Hanken/JetBrains Mono, vermilion accent
This commit is contained in:
701
frontend/app.js
Normal file
701
frontend/app.js
Normal file
@@ -0,0 +1,701 @@
|
||||
/* ============================================================================
|
||||
* 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 ----------
|
||||
async function invoke(command, payload = {}) {
|
||||
if (window.zero && typeof window.zero.invoke === 'function') {
|
||||
return await window.zero.invoke(command, payload);
|
||||
}
|
||||
throw new Error('Native bridge unavailable — run this inside zero-native (zig build run).');
|
||||
}
|
||||
|
||||
const API = {
|
||||
search: (query) => invoke('yt.search', { query }),
|
||||
getStreams: (videoId) => invoke('yt.streams', { videoId }),
|
||||
loadData: () => invoke('store.load', {}),
|
||||
// data is sent pre-stringified so the native side can write it verbatim.
|
||||
saveData: (data) => invoke('store.save', { data: JSON.stringify(data) }),
|
||||
};
|
||||
|
||||
// ---------- State ----------
|
||||
let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false } };
|
||||
let view = { type: 'search' }; // 'search' | 'history' | 'playlist'
|
||||
let searchResults = [];
|
||||
let queue = []; // list of video objects for autoplay
|
||||
let queueIndex = -1;
|
||||
let current = null; // { meta, qualities, audioUrl }
|
||||
let saveTimer = null;
|
||||
|
||||
// ---------- 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'),
|
||||
};
|
||||
|
||||
// ---------- 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); }
|
||||
|
||||
// ============================================================================
|
||||
// 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) {
|
||||
showSpinner(true);
|
||||
els.placeholder.classList.add('hidden');
|
||||
try {
|
||||
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();
|
||||
|
||||
const q = chooseQuality();
|
||||
this.attach(q);
|
||||
} catch (err) {
|
||||
showSpinner(false);
|
||||
toast('⚠ ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
attach(quality) {
|
||||
const V = els.video, A = els.audio;
|
||||
const audioOnly = data.settings.audioOnly;
|
||||
|
||||
// Reset
|
||||
V.pause(); A.pause();
|
||||
this.stopDrift();
|
||||
|
||||
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', () => { if (masterIs(el)) { showSpinner(false); } });
|
||||
}
|
||||
bind(V);
|
||||
bind(A);
|
||||
}
|
||||
|
||||
function updatePlayBtn() {
|
||||
els.playBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
||||
}
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
|
||||
function renderCard(v, index, list) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
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>` : ''}
|
||||
</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>`;
|
||||
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));
|
||||
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);
|
||||
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));
|
||||
|
||||
// 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, ...(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;
|
||||
render();
|
||||
els.searchInput.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
Reference in New Issue
Block a user