/* ============================================================================ * 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 = `${pl.videos.length}`; 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 = `