Files
ytplayer/frontend/app.js
Jonathan Sykes bac6665b3d Add Windows support via a Tauri (WebView2) shell
zero-native has no Windows target yet, so add a parallel Tauri shell
for Windows that reuses the same frontend:

- src-tauri/: Rust commands (yt_search, yt_streams, store_load,
  store_save) mirroring the Zig bridge; spawns bundled yt-dlp.exe with
  CREATE_NO_WINDOW; data stored in app_data_dir
- frontend bridge now auto-detects window.__TAURI__ vs window.zero and
  routes accordingly; CSP updated for Tauri IPC
- tauri.conf.json bundles yt-dlp.exe as a resource and builds nsis/msi
- scripts/make-icon.js generates appicon.png for 'tauri icon'
- README: native Windows build steps + WSLg fallback note
2026-06-14 10:04:27 +08:00

708 lines
24 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) }),
};
// ---------- 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);