10558 lines
451 KiB
JavaScript
Executable File
10558 lines
451 KiB
JavaScript
Executable File
/* ============================================================================
|
||
* 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
|
||
// Sanitize video_id: strip path separators and dangerous chars
|
||
function sanitizeId(id) {
|
||
if (!id || typeof id !== 'string') return '';
|
||
return id.replace(/[/\\:?<>|*"]/g, '').trim();
|
||
}
|
||
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
|
||
const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null;
|
||
// WEB mode: running as a PWA served from the Hono server (no native bridge)
|
||
const WEB = !TAURI && !ZERO;
|
||
const APP_VERSION = '1.0.0';
|
||
|
||
// Timing marks for the speed work (plans/). performance.measure entries are
|
||
// visible in DevTools → Performance; __ytpPerf() prints the latest ones.
|
||
function perfMark(name) { try { performance.mark(name); } catch { /* old browser */ } }
|
||
function perfMeasure(name, start) {
|
||
try { performance.measure(name, start); } catch { /* start mark missing */ }
|
||
}
|
||
window.__ytpPerf = () => {
|
||
const out = {};
|
||
try { for (const m of performance.getEntriesByType('measure')) if (m.name.startsWith('ytp:')) out[m.name] = Math.round(m.duration); } catch { /* none */ }
|
||
return out;
|
||
};
|
||
|
||
// call(zeroName, tauriName, payload) — routes to whichever native 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.');
|
||
}
|
||
|
||
// ---------- WEB-mode helpers ----------
|
||
|
||
// Generic fetch wrapper — returns parsed JSON or throws with a human message.
|
||
async function webFetch(path, opts = {}) {
|
||
const res = await fetch(path, opts);
|
||
if (!res.ok) {
|
||
let msg = `HTTP ${res.status}`;
|
||
try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ }
|
||
throw new Error(msg);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
// Persist data to localStorage and fire-and-forget sync to the server.
|
||
function loadDataFromStorage() {
|
||
try {
|
||
const raw = localStorage.getItem('_ytpdata');
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function saveDataToStorage(jsonStr) {
|
||
try { localStorage.setItem('_ytpdata', jsonStr); } catch { /* storage full / blocked */ }
|
||
// Sync playlists to the server (non-blocking — failures are silent)
|
||
try {
|
||
const d = JSON.parse(jsonStr);
|
||
fetch('/api/user/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
|
||
playlists: d.playlists || [],
|
||
appVersion: APP_VERSION,
|
||
}),
|
||
}).catch(() => {});
|
||
} catch { /* ignore parse errors */ }
|
||
}
|
||
|
||
// Can this device decode the server's HEVC copies? The compression lane
|
||
// re-encodes cached videos to HEVC (8-bit, hvc1) when that is much smaller;
|
||
// the server hands those out only to clients that send hevc=1. canPlayType
|
||
// is the browser's own claim; if a copy still fails to play here, the device
|
||
// is remembered (NO_HEVC_KEY) and stops asking — it gets H.264 as before.
|
||
const NO_HEVC_KEY = 'ytpNoHevc';
|
||
function hevcCapable() {
|
||
try { if (localStorage.getItem(NO_HEVC_KEY) === '1') return false; } catch { /* storage blocked */ }
|
||
try {
|
||
return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== '';
|
||
} catch { return false; }
|
||
}
|
||
|
||
// Recent searches — device-local only (localStorage), never synced to the
|
||
// server or another device. Most-recent-first, case-insensitive de-dupe.
|
||
// A paired store (RESULTS_KEY) caches the actual result cards for each
|
||
// recent query, so re-running one from the dropdown can paint instantly
|
||
// instead of waiting on a fresh /api/search round-trip (yt-dlp, several
|
||
// seconds). Cards are already slim ({id,title,channel,channelId,
|
||
// channelUrl,duration,thumbnail}, server's slimEntry()) so no field
|
||
// trimming is needed; entries are pruned to whatever's still in the query
|
||
// list so the two stores never drift apart.
|
||
const RecentSearches = (() => {
|
||
const KEY = 'ytpRecentSearches';
|
||
const RESULTS_KEY = 'ytpRecentSearchResults';
|
||
const MAX = 10;
|
||
const RESULTS_PER_QUERY_MAX = 50; // defensive cap; server returns ~45
|
||
const norm = (q) => (q || '').trim().toLowerCase();
|
||
const load = () => { try { const l = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(l) ? l : []; } catch { return []; } };
|
||
const save = (list) => { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* storage full / blocked */ } };
|
||
const loadResults = () => { try { const o = JSON.parse(localStorage.getItem(RESULTS_KEY) || '{}'); return o && typeof o === 'object' ? o : {}; } catch { return {}; } };
|
||
const saveResults = (obj) => {
|
||
try { localStorage.setItem(RESULTS_KEY, JSON.stringify(obj)); } catch {
|
||
// Quota exceeded — drop the (non-essential) results cache rather than
|
||
// risk the query-list save above also failing later.
|
||
try { localStorage.removeItem(RESULTS_KEY); } catch { /* storage blocked */ }
|
||
}
|
||
};
|
||
// Drop any cached result set whose query is no longer in `list`.
|
||
const pruneResults = (list) => {
|
||
const keep = new Set(list.map(norm));
|
||
const r = loadResults();
|
||
let changed = false;
|
||
for (const k of Object.keys(r)) if (!keep.has(k)) { delete r[k]; changed = true; }
|
||
if (changed) saveResults(r);
|
||
};
|
||
return {
|
||
list: load,
|
||
add(q) {
|
||
q = (q || '').trim();
|
||
if (!q) return;
|
||
const list = load().filter((x) => x.toLowerCase() !== q.toLowerCase());
|
||
list.unshift(q);
|
||
const trimmed = list.slice(0, MAX);
|
||
save(trimmed);
|
||
pruneResults(trimmed);
|
||
},
|
||
remove(q) {
|
||
const list = load().filter((x) => x.toLowerCase() !== (q || '').toLowerCase());
|
||
save(list);
|
||
pruneResults(list);
|
||
},
|
||
clear() { save([]); saveResults({}); },
|
||
// Cache the just-fetched result set for `q` (called on every successful search).
|
||
cacheResults(q, results) {
|
||
q = (q || '').trim();
|
||
if (!q || !Array.isArray(results) || !results.length) return;
|
||
const r = loadResults();
|
||
r[norm(q)] = { query: q, results: results.slice(0, RESULTS_PER_QUERY_MAX), ts: Date.now() };
|
||
saveResults(r);
|
||
pruneResults(load());
|
||
},
|
||
// Cached {query, results, ts} for `q`, or null.
|
||
getResults(q) {
|
||
const hit = loadResults()[norm(q)];
|
||
return hit && Array.isArray(hit.results) && hit.results.length ? hit : null;
|
||
},
|
||
// Normalized queries that have a cached result set — one localStorage
|
||
// read for the whole dropdown, rather than one per row.
|
||
cachedQueries() { return new Set(Object.keys(loadResults())); },
|
||
};
|
||
})();
|
||
|
||
// OPFS bridge wrappers — return the same shape as the Tauri cache_* commands
|
||
// so the rest of app.js works without changes.
|
||
|
||
async function opfsDownload(videoId, { mux = false } = {}) {
|
||
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||
return { ok: false, error: 'OPFS not supported in this browser' };
|
||
}
|
||
const fp = window.getFingerprint ? window.getFingerprint() : '';
|
||
const params = new URLSearchParams();
|
||
if (fp) params.set('fp', fp);
|
||
// mux=1 asks the server to compile bestvideo+bestaudio into a single mp4
|
||
// (used by "Save before playing"); default saves keep the progressive
|
||
// stream exactly as before.
|
||
if (mux) params.set('mux', '1');
|
||
if (hevcCapable()) params.set('hevc', '1');
|
||
const qs = params.toString();
|
||
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
|
||
|
||
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
|
||
// so a big save never touches the main thread (no UI jank, no audio
|
||
// stutter). ANY worker failure — unsupported API or a mid-download error —
|
||
// falls back to the legacy main-thread streaming below; the worker's error
|
||
// is kept so it can be reported if the fallback fails too.
|
||
let workerError = null;
|
||
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
|
||
const w = await window.OPFS.downloadVideo(videoId, url);
|
||
if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 };
|
||
workerError = w.error || null;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok) {
|
||
const j = await res.json().catch(() => ({}));
|
||
return { ok: false, error: j.error || `HTTP ${res.status}` };
|
||
}
|
||
// Determine file extension from Content-Type
|
||
const ct = res.headers.get('content-type') || 'video/mp4';
|
||
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
|
||
await window.OPFS.writeFromResponse(videoId, ext, res);
|
||
return { ok: true, cached: true };
|
||
} catch (err) {
|
||
return { ok: false, error: workerError || err.message };
|
||
}
|
||
}
|
||
|
||
// "Edit & download": ask the server to trim `sourceId` to the given keep
|
||
// segments and store the resulting custom cut in OPFS under `customId` (which
|
||
// is NOT a real YouTube id — it's edit_<src>_<uid>). Same worker-first /
|
||
// main-thread-fallback strategy as opfsDownload, but the URL targets the
|
||
// source video with ?edit=1&keep=… while the file is written under customId.
|
||
async function opfsDownloadEdited(customId, sourceId, keepParam) {
|
||
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||
return { ok: false, error: 'OPFS not supported in this browser' };
|
||
}
|
||
const fp = window.getFingerprint ? window.getFingerprint() : '';
|
||
const params = new URLSearchParams();
|
||
params.set('edit', '1');
|
||
params.set('keep', keepParam);
|
||
if (fp) params.set('fp', fp);
|
||
const url = `/api/download/${encodeURIComponent(sourceId)}?${params.toString()}`;
|
||
|
||
let workerError = null;
|
||
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
|
||
const w = await window.OPFS.downloadVideo(customId, url);
|
||
if (w.ok) return { ok: true, cached: true };
|
||
workerError = w.error || null;
|
||
}
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok) {
|
||
const j = await res.json().catch(() => ({}));
|
||
return { ok: false, error: j.error || `HTTP ${res.status}` };
|
||
}
|
||
const ct = res.headers.get('content-type') || 'video/mp4';
|
||
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
|
||
await window.OPFS.writeFromResponse(customId, ext, res);
|
||
return { ok: true, cached: true };
|
||
} catch (err) {
|
||
return { ok: false, error: workerError || err.message };
|
||
}
|
||
}
|
||
|
||
async function opfsStatus(videoId) {
|
||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, cached: false };
|
||
try {
|
||
const cached = await window.OPFS.hasVideo(videoId);
|
||
return { ok: true, cached };
|
||
} catch {
|
||
return { ok: true, cached: false };
|
||
}
|
||
}
|
||
|
||
async function opfsList() {
|
||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, items: [], total: 0 };
|
||
try {
|
||
const items = await window.OPFS.listVideos();
|
||
const total = items.reduce((s, i) => s + (i.size || 0), 0);
|
||
return { ok: true, items, total };
|
||
} catch {
|
||
return { ok: true, items: [], total: 0 };
|
||
}
|
||
}
|
||
|
||
async function opfsDelete(videoId) {
|
||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
|
||
try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ }
|
||
if (window.DeviceDB) { await window.DeviceDB.deleteFile(videoId); if (window.P2PClient) window.P2PClient.changed(); }
|
||
return { ok: true };
|
||
}
|
||
|
||
async function opfsClear() {
|
||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
|
||
try { await window.OPFS.clearAll(); } catch { /* ignore */ }
|
||
if (window.DeviceDB) { await window.DeviceDB.clear(); if (window.P2PClient) window.P2PClient.changed(); }
|
||
return { ok: true };
|
||
}
|
||
|
||
// Blob URL for the currently playing OPFS video — revoked on next video load.
|
||
let _currentBlobUrl = null;
|
||
|
||
const API = {
|
||
search: (query) => WEB
|
||
? webFetch(`/api/search?q=${encodeURIComponent(query)}`)
|
||
: call('yt.search', 'yt_search', { query }),
|
||
getChannel: (channel) => WEB
|
||
? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`)
|
||
: call('yt.channel', 'yt_channel', { channel }),
|
||
expandPlaylist: (url) => WEB
|
||
? webFetch(`/api/playlist/expand?url=${encodeURIComponent(url)}`)
|
||
: call('yt.expand_playlist', 'yt_expand_playlist', { url }),
|
||
// nocache=1 skips the server's cached copy and resolves fresh YouTube
|
||
// streams (used when the cached copy won't play on this device).
|
||
getStreams: (videoId, { nocache = false } = {}) => WEB
|
||
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}${nocache ? '&nocache=1' : ''}${!nocache && hevcCapable() ? '&hevc=1' : ''}`)
|
||
: call('yt.streams', 'yt_streams', { videoId }),
|
||
loadData: () => WEB
|
||
? Promise.resolve(loadDataFromStorage())
|
||
: call('store.load', 'store_load', {}),
|
||
// data is sent pre-stringified so the native side can write it verbatim.
|
||
saveData: (d) => WEB
|
||
? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d)))
|
||
: call('store.save', 'store_save', { data: JSON.stringify(d) }),
|
||
// Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig)
|
||
cacheDownload: (videoId, opts) => WEB
|
||
? opfsDownload(sanitizeId(videoId), opts)
|
||
: call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
|
||
// Download a server-edited cut of `sourceId` into the cache under a custom
|
||
// id. WEB (OPFS) only — the native shells have no ffmpeg edit pipeline.
|
||
cacheDownloadEdited: (customId, sourceId, keepParam) => WEB
|
||
? opfsDownloadEdited(sanitizeId(customId), sanitizeId(sourceId), keepParam)
|
||
: Promise.resolve({ ok: false, error: 'Editing is only available in the web app' }),
|
||
cacheStatus: (videoId) => WEB
|
||
? opfsStatus(sanitizeId(videoId))
|
||
: call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
|
||
cacheList: () => WEB
|
||
? opfsList()
|
||
: call('cache.list', 'cache_list', {}),
|
||
cacheDelete: (videoId) => WEB
|
||
? opfsDelete(sanitizeId(videoId))
|
||
: call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
|
||
cacheClear: () => WEB
|
||
? opfsClear()
|
||
: call('cache.clear', 'cache_clear', {}),
|
||
};
|
||
|
||
// Pre-resolve streams for videos the user is likely to play next (server
|
||
// /api/streams/warm). Each id is warmed at most once per 20 min per tab.
|
||
const warmedAt = new Map();
|
||
function warmStreams(ids) {
|
||
if (!WEB || navigator.onLine === false) return;
|
||
const now = Date.now();
|
||
for (const id of ids) {
|
||
if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue;
|
||
if (now - (warmedAt.get(id) || 0) < 20 * 60_000) continue;
|
||
warmedAt.set(id, now);
|
||
fetch(`/api/streams/warm?v=${encodeURIComponent(id)}`, { priority: 'low' }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// 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 ----------
|
||
const DEFAULT_SETTINGS = {
|
||
quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,
|
||
p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default
|
||
p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default
|
||
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
|
||
repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends
|
||
loopOne: false, // repeat the single current video
|
||
shuffle: false, // shuffle upcoming tracks when playing a list
|
||
theme: 'dark', // 'dark' | 'light' | 'contrast'
|
||
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
|
||
density: 'comfortable', // 'comfortable' | 'compact'
|
||
perfMode: false, // disable heavy visual effects for speed
|
||
reduceMotion: false, // disable animations
|
||
layout: 'classic', // 'classic'|'sanctuary-stage'|'liturgical'|'living-hymnal'|'broadcaster'|'contemplative'|'bento-hub'
|
||
autoBackupEnabled: false,
|
||
autoBackupIntervalDays: 7,
|
||
serviceVideo: false, // show the video panel inside service mode (lyric videos)
|
||
};
|
||
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null };
|
||
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
|
||
let searchResults = [];
|
||
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
||
// "searched and got nothing back" (keep the pane so the user sees why).
|
||
let searchAttempted = false;
|
||
// Bumped on every dispatched search so a slower, superseded /api/search
|
||
// response (or background refresh — see runSearchQuery) can't clobber a
|
||
// newer one that already landed.
|
||
let searchSeq = 0;
|
||
let channelData = { name: '', url: '', key: '', results: [], loading: false };
|
||
let queue = []; // list of video objects for autoplay
|
||
let queueIndex = -1;
|
||
let queueSource = ''; // label of what's playing ('queue','playlist:<id>',…)
|
||
let playFullMode = false; // "play in full" session: ignore resume positions,
|
||
// A-B markers, shuffle, loop and repeat — play the
|
||
// list start-to-finish, then stop.
|
||
let current = null; // { meta, qualities, audioUrl, localUrl? }
|
||
let dragSource = -1; // index of card being dragged
|
||
let listFilter = '';
|
||
let selectMode = false;
|
||
let selectedIds = new Set();
|
||
let sleepTimerRemaining = 0;
|
||
let sleepTimerTick = null;
|
||
let abA = null;
|
||
let abB = null;
|
||
let relatedVideos = [];
|
||
let relatedCollapsed = false;
|
||
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
|
||
const downloadMeta = new Map(); // id -> video object, for the Downloads page
|
||
const removing = new Set(); // video ids with an in-flight cache removal
|
||
const playlistOps = new Set(); // `${playlistId}:${videoId}` add/remove in flight
|
||
|
||
// Concurrency helper (frontend/async-guard.js) — keeps the save / playlist
|
||
// operations from being triggered twice at once for the same target.
|
||
const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive)
|
||
|| (async (set, key, fn) => {
|
||
if (set.has(key)) return undefined;
|
||
set.add(key);
|
||
try { return await fn(); } finally { set.delete(key); }
|
||
});
|
||
|
||
// ---------- 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'),
|
||
shuffleBtn: $('shuffleBtn'),
|
||
loopBtn: $('loopBtn'),
|
||
repeatBtn: $('repeatBtn'),
|
||
queueBtn: $('queueBtn'),
|
||
cards: $('cards'),
|
||
listTitle: $('listTitle'),
|
||
listActions: $('listActions'),
|
||
status: $('status'),
|
||
searchForm: $('searchForm'),
|
||
searchInput: $('searchInput'),
|
||
searchRecent: $('searchRecent'),
|
||
playlistList: $('playlistList'),
|
||
newPlaylistBtn: $('newPlaylistBtn'),
|
||
audioOnlyToggle: $('audioOnlyToggle'),
|
||
saveBtn: $('saveBtn'),
|
||
editBtn: $('editBtn'),
|
||
brokenBtn: $('brokenBtn'),
|
||
addPlaylistBtn: $('addPlaylistBtn'),
|
||
};
|
||
|
||
// ---------- Persistence ----------
|
||
function persist() {
|
||
clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400);
|
||
scheduleProfilePush();
|
||
}
|
||
|
||
// ---------- Online profile sync (WEB mode) ----------
|
||
// The profile NAME acts as the passkey: any device that knows it can load
|
||
// and update the same server-side copy of playlists/settings/etc. Sync is
|
||
// last-write-wins: every local change pushes (debounced); every app launch
|
||
// pulls when the server copy is newer than what this device last synced.
|
||
|
||
function profilePayload() {
|
||
return {
|
||
playlists: data.playlists,
|
||
history: data.history,
|
||
customVideos: data.customVideos,
|
||
pinnedPlaylists: data.pinnedPlaylists || [],
|
||
settings: data.settings,
|
||
resumePositions: data.resumePositions,
|
||
rememberPos: data.rememberPos,
|
||
playCount: data.playCount,
|
||
abMarkers: data.abMarkers,
|
||
bookmarks: data.bookmarks || {},
|
||
lyricOffsets: data.lyricOffsets || {},
|
||
stats: data.stats || null,
|
||
};
|
||
}
|
||
|
||
let profilePushTimer = null;
|
||
function scheduleProfilePush() {
|
||
if (!WEB || !data.profile || !data.profile.name) return;
|
||
clearTimeout(profilePushTimer);
|
||
profilePushTimer = setTimeout(pushProfile, 1500);
|
||
}
|
||
|
||
async function pushProfile() {
|
||
if (!WEB || !data.profile || !data.profile.name) return;
|
||
try {
|
||
const res = await fetch('/api/profile/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: data.profile.name, data: profilePayload() }),
|
||
});
|
||
const j = await res.json().catch(() => null);
|
||
if (j && j.ok) {
|
||
data.profile.syncedAt = j.updatedAt || 0;
|
||
// Record syncedAt directly — going through persist() would re-schedule
|
||
// another push forever.
|
||
API.saveData(data).catch(() => {});
|
||
}
|
||
} catch { /* offline — the next persist() retries */ }
|
||
}
|
||
|
||
// Replace the synced slice of local state with a profile's server copy.
|
||
function applyProfileData(name, payload, updatedAt) {
|
||
payload = payload || {};
|
||
if (Array.isArray(payload.playlists)) data.playlists = payload.playlists;
|
||
if (Array.isArray(payload.history)) data.history = payload.history;
|
||
if (Array.isArray(payload.customVideos)) data.customVideos = payload.customVideos;
|
||
if (Array.isArray(payload.pinnedPlaylists)) data.pinnedPlaylists = payload.pinnedPlaylists;
|
||
if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions;
|
||
if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos;
|
||
if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
|
||
if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers;
|
||
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
|
||
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
|
||
if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats;
|
||
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
|
||
data.profile = { name, syncedAt: updatedAt || 0 };
|
||
API.saveData(data).catch(() => {});
|
||
}
|
||
|
||
// ---- "syncing" indicator -------------------------------------------------
|
||
// Playlists paint from localStorage on the first frame, so the network pass
|
||
// needs to say it is still working. Counted, because several things sync at
|
||
// once and the last one to finish should be the one that hides it.
|
||
let _syncBusy = 0;
|
||
function setSyncing(on) {
|
||
_syncBusy = Math.max(0, _syncBusy + (on ? 1 : -1));
|
||
const el = document.getElementById('syncSpinner');
|
||
if (el) el.classList.toggle('hidden', _syncBusy === 0);
|
||
}
|
||
|
||
/** Runs the launch network pass without blocking the first paint. */
|
||
async function syncOnLaunch() {
|
||
setSyncing(true);
|
||
const before = playlistFingerprint();
|
||
try {
|
||
await pullProfileIfNewer();
|
||
// A delivery is addressed to a profile, so this runs once we know which
|
||
// one (if any) this device is linked to.
|
||
await checkPlaylistInbox();
|
||
} catch { /* offline — the local copy is already on screen */ }
|
||
finally { setSyncing(false); }
|
||
// Only repaint when the server actually gave us something different;
|
||
// a needless render would drop the sidebar's scroll position.
|
||
if (playlistFingerprint() !== before) {
|
||
renderSmartSidebar();
|
||
render();
|
||
}
|
||
updateProfileStatus();
|
||
}
|
||
|
||
// Cheap "did the synced slice change?" check — names, ids and lengths.
|
||
function playlistFingerprint() {
|
||
try {
|
||
return JSON.stringify((data.playlists || []).map((p) => [p.id, p.name, (p.videos || []).length]));
|
||
} catch { return String(Math.random()); }
|
||
}
|
||
|
||
// On launch: adopt the server copy when it's newer than this device's last
|
||
// sync (another device pushed since); otherwise push local state up.
|
||
async function pullProfileIfNewer() {
|
||
if (!WEB || !data.profile || !data.profile.name) return;
|
||
try {
|
||
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`);
|
||
if (res.status === 404) return; // profile gone server-side; keep local data
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) return;
|
||
if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) {
|
||
applyProfileData(j.name, j.data, j.updatedAt);
|
||
} else {
|
||
scheduleProfilePush();
|
||
}
|
||
} catch { /* offline — stay on local data */ }
|
||
}
|
||
|
||
// The full URL that links a fresh device to this profile. Opening it runs
|
||
// adoptProfileFromUrl() on the other device. Null when nothing is linked, or
|
||
// outside WEB mode where there is no origin worth sharing.
|
||
function profileShareUrl() {
|
||
if (!WEB || !data.profile || !data.profile.name) return null;
|
||
return `${location.origin}/?profile=${encodeURIComponent(data.profile.name)}`;
|
||
}
|
||
|
||
async function copyProfileLink() {
|
||
const url = profileShareUrl();
|
||
if (!url) return;
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
toast('Profile link copied ✓');
|
||
} catch {
|
||
// Clipboard blocked (insecure context / permission) — fall back to a
|
||
// selectable modal so the link is still recoverable by hand.
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `
|
||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px">Copy this link:</p>
|
||
<input id="profileLinkInput" type="text" readonly value="${escapeHtml(url)}" />`;
|
||
showModal('🔗 Profile link', body, [{ label: 'Done', primary: true, onClick: closeModal }]);
|
||
const inp = $('profileLinkInput');
|
||
inp.focus();
|
||
inp.select();
|
||
}
|
||
}
|
||
|
||
async function shareProfileLink() {
|
||
const url = profileShareUrl();
|
||
if (!url) return;
|
||
if (!navigator.share) return copyProfileLink(); // desktop / no Web Share API
|
||
try {
|
||
await navigator.share({
|
||
title: 'YT Player profile',
|
||
text: `Open my YT Player profile “${data.profile.name}”`,
|
||
url,
|
||
});
|
||
} catch {
|
||
// User dismissed the share sheet, or the target rejected it — nothing to do.
|
||
}
|
||
}
|
||
|
||
// Refresh the Settings rows and the sidebar block without a full re-render.
|
||
function updateProfileStatus() {
|
||
const linked = !!(data.profile && data.profile.name);
|
||
const url = profileShareUrl();
|
||
|
||
const el = document.getElementById('profileStatus');
|
||
if (el) el.textContent = (linked && data.profile.name) || 'Not linked';
|
||
const unlink = document.getElementById('profileUnlinkBtn');
|
||
if (unlink) unlink.style.display = linked ? '' : 'none';
|
||
|
||
// Settings → share-link row (only rendered on the settings view)
|
||
const linkRow = document.getElementById('profileLinkRow');
|
||
if (linkRow) linkRow.classList.toggle('hidden', !url);
|
||
const linkVal = document.getElementById('profileLinkValue');
|
||
if (linkVal) linkVal.textContent = url || '';
|
||
['profileCopyLinkBtn', 'profileShareBtn'].forEach((id) => {
|
||
const b = document.getElementById(id);
|
||
if (b) b.style.display = url ? '' : 'none';
|
||
});
|
||
|
||
// Sidebar footer block
|
||
const sb = document.getElementById('sidebarProfile');
|
||
if (sb) sb.classList.toggle('hidden', !url);
|
||
const sbName = document.getElementById('sidebarProfileName');
|
||
if (sbName) sbName.textContent = (linked && data.profile.name) || '';
|
||
}
|
||
|
||
// ---------- Shared playlist link (WEB mode) ----------
|
||
|
||
function showSharedPlaylistModal(name, url, code) {
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `
|
||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||
Anyone with this link can add a copy of “<strong>${escapeHtml(name)}</strong>” to their playlists.
|
||
</p>
|
||
<input id="sharedPlaylistUrlInput" type="text" readonly value="${escapeHtml(url)}" />
|
||
<div class="share-send">
|
||
<label class="share-send-label" for="shareSendName">Or send it straight to someone</label>
|
||
<div class="share-send-row">
|
||
<input id="shareSendName" type="text" autocomplete="off" autocapitalize="none"
|
||
spellcheck="false" placeholder="Their username" maxlength="40" />
|
||
<button id="shareSendBtn" class="btn" type="button">Send</button>
|
||
</div>
|
||
<div id="shareSendMsg" class="share-send-msg"></div>
|
||
</div>`;
|
||
|
||
const copyAction = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
toast('Playlist link copied ✓');
|
||
} catch {
|
||
const inp = $('sharedPlaylistUrlInput');
|
||
if (inp) { inp.focus(); inp.select(); }
|
||
}
|
||
};
|
||
|
||
// Deliver to another profile by name. The recipient is prompted on their next
|
||
// launch (there is no push channel), so this queues rather than pushes.
|
||
const sendAction = async () => {
|
||
const input = $('shareSendName');
|
||
const btn = $('shareSendBtn');
|
||
const msg = $('shareSendMsg');
|
||
if (!input || !btn || !msg) return;
|
||
const to = input.value.trim();
|
||
if (!to) { msg.className = 'share-send-msg err'; msg.textContent = 'Enter a username first.'; input.focus(); return; }
|
||
|
||
btn.disabled = true;
|
||
msg.className = 'share-send-msg';
|
||
msg.textContent = 'Sending\u2026';
|
||
try {
|
||
const res = await fetch('/api/playlist/send', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ to, from: (data.profile && data.profile.name) || '', code }),
|
||
});
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) {
|
||
msg.className = 'share-send-msg err';
|
||
msg.textContent = '\u26a0 ' + ((j && j.error) || 'Could not send');
|
||
} else {
|
||
msg.className = 'share-send-msg ok';
|
||
msg.textContent = `Sent to \u201c${to}\u201d \u2014 they'll get it the next time YT Player is open on their device.`;
|
||
input.value = '';
|
||
}
|
||
} catch {
|
||
msg.className = 'share-send-msg err';
|
||
msg.textContent = '\u26a0 Network error \u2014 could not send';
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
|
||
const actions = [
|
||
{ label: 'Done', onClick: closeModal },
|
||
{ label: 'Copy link', primary: !navigator.share, onClick: copyAction },
|
||
];
|
||
|
||
if (navigator.share) {
|
||
actions.push({
|
||
label: 'Share',
|
||
primary: true,
|
||
onClick: async () => {
|
||
try {
|
||
await navigator.share({
|
||
title: `Playlist: ${name}`,
|
||
text: `Open playlist “${name}” in YT Player`,
|
||
url,
|
||
});
|
||
} catch {
|
||
// User dismissed share sheet or target rejected
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
showModal('🔗 Share playlist', body, actions);
|
||
// showModal moves the body into #modalBody, so the send controls only exist
|
||
// in the document from here on — wire them after it, not before.
|
||
const sendBtn = $('shareSendBtn');
|
||
if (sendBtn) sendBtn.onclick = sendAction;
|
||
const sendInput = $('shareSendName');
|
||
if (sendInput) {
|
||
sendInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); sendAction(); }
|
||
});
|
||
}
|
||
setTimeout(() => {
|
||
const inp = $('sharedPlaylistUrlInput');
|
||
if (inp) { inp.focus(); inp.select(); }
|
||
}, 50);
|
||
}
|
||
|
||
async function sharePlaylist(pl) {
|
||
if (!pl || !Array.isArray(pl.videos) || !pl.videos.length) return;
|
||
try {
|
||
const res = await fetch('/api/playlist/share', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ playlist: { name: pl.name, videos: pl.videos } }),
|
||
});
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) {
|
||
toast('⚠ ' + ((j && j.error) || 'Failed to share playlist'));
|
||
return;
|
||
}
|
||
const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`;
|
||
showSharedPlaylistModal(pl.name, url, j.code);
|
||
} catch {
|
||
toast('⚠ Network error — could not share playlist');
|
||
}
|
||
}
|
||
|
||
// A shared playlist link (?list=code) arriving on this device.
|
||
// Adds the playlist to the device's playlists without touching existing data or profiles.
|
||
async function adoptSharedPlaylistFromUrl() {
|
||
if (!WEB) return;
|
||
let code = '';
|
||
try {
|
||
code = (new URLSearchParams(location.search).get('list') || '').trim();
|
||
} catch { return; }
|
||
if (!code) return;
|
||
|
||
// Strip the param FIRST so a reload can't re-trigger the prompt.
|
||
try {
|
||
const params = new URLSearchParams(location.search);
|
||
params.delete('list');
|
||
const qs = params.toString();
|
||
history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
|
||
} catch {}
|
||
|
||
await adoptSharedPlaylistByCode(code);
|
||
}
|
||
|
||
// Fetch a shared playlist by code, confirm, and add it alongside the existing
|
||
// playlists. Shared by the ?list= link path and by inbox deliveries, so both
|
||
// routes get identical dedupe, naming and confirm behaviour.
|
||
// Returns true only when the playlist was actually added.
|
||
async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) {
|
||
let shared;
|
||
try {
|
||
const res = await fetch(`/api/playlist/shared?code=${encodeURIComponent(code)}`);
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok || !j.playlist) {
|
||
toast('⚠ ' + ((j && j.error) || 'Shared playlist not found'));
|
||
return false;
|
||
}
|
||
shared = j.playlist;
|
||
} catch {
|
||
toast('⚠ Network error — could not load shared playlist');
|
||
return false;
|
||
}
|
||
|
||
// Dedupe BEFORE the prompt so the count the user agrees to is the count they get.
|
||
const seenIds = new Set();
|
||
const dedupedVideos = [];
|
||
for (const v of (Array.isArray(shared.videos) ? shared.videos : [])) {
|
||
if (!v || !v.id || seenIds.has(v.id)) continue;
|
||
seenIds.add(v.id);
|
||
dedupedVideos.push(slim(v));
|
||
}
|
||
const count = dedupedVideos.length;
|
||
if (!count) { toast('⚠ That shared playlist has no playable videos'); return false; }
|
||
|
||
// fromName comes from another user, so it is escaped like any untrusted text.
|
||
const senderLine = fromName
|
||
? `<strong>${escapeHtml(fromName)}</strong> sent you this playlist.<br />`
|
||
: '';
|
||
|
||
const ok = await new Promise((resolve) => {
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `
|
||
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
|
||
${senderLine}
|
||
Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists?
|
||
This playlist is added alongside your existing playlists and nothing is replaced.
|
||
</p>`;
|
||
showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [
|
||
{ label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
||
{ label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
||
]);
|
||
});
|
||
if (!ok) return false;
|
||
|
||
// Ensure unique name: if name already exists, suffix (2), (3)...
|
||
const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist';
|
||
let finalName = baseName;
|
||
let counter = 2;
|
||
const existingNames = new Set((data.playlists || []).map((p) => p.name));
|
||
while (existingNames.has(finalName)) {
|
||
finalName = `${baseName} (${counter})`;
|
||
counter++;
|
||
}
|
||
|
||
const pl = { id: uid(), name: finalName, videos: dedupedVideos };
|
||
if (!Array.isArray(data.playlists)) data.playlists = [];
|
||
data.playlists.push(pl);
|
||
persist();
|
||
renderSidebar();
|
||
render();
|
||
toast(`Added “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`);
|
||
return true;
|
||
}
|
||
|
||
// Playlists other users sent to this device's linked profile. Offered one at a
|
||
// time on launch; each is cleared from the server inbox once answered, whether
|
||
// the user kept it or not, so it is never offered twice.
|
||
//
|
||
// Launch alone is not enough: an installed PWA is usually resumed from the
|
||
// background rather than relaunched, so it also re-checks when it returns to
|
||
// the foreground and on a timer while visible (see startPlaylistInboxWatch).
|
||
let inboxCheckRunning = false;
|
||
let inboxLastCheck = 0;
|
||
async function checkPlaylistInbox() {
|
||
if (!WEB || inboxCheckRunning) return;
|
||
const me = data.profile && data.profile.name;
|
||
if (!me) return; // deliveries are addressed to a profile, not a device
|
||
inboxCheckRunning = true;
|
||
inboxLastCheck = Date.now();
|
||
try {
|
||
await drainPlaylistInbox(me);
|
||
} finally {
|
||
inboxCheckRunning = false;
|
||
}
|
||
}
|
||
|
||
const INBOX_POLL_MS = 2 * 60 * 1000;
|
||
const INBOX_MIN_GAP_MS = 20 * 1000;
|
||
function startPlaylistInboxWatch() {
|
||
if (!WEB) return;
|
||
const maybeCheck = () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (Date.now() - inboxLastCheck < INBOX_MIN_GAP_MS) return;
|
||
// Never pop the prompt over a dialog the user is already in.
|
||
const modal = $('modal');
|
||
if (modal && !modal.classList.contains('hidden')) return;
|
||
checkPlaylistInbox();
|
||
};
|
||
document.addEventListener('visibilitychange', maybeCheck);
|
||
window.addEventListener('focus', maybeCheck);
|
||
setInterval(maybeCheck, INBOX_POLL_MS);
|
||
}
|
||
|
||
async function drainPlaylistInbox(me) {
|
||
|
||
let items = [];
|
||
try {
|
||
const res = await fetch(`/api/playlist/inbox?name=${encodeURIComponent(me)}`);
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok || !Array.isArray(j.items)) return;
|
||
items = j.items;
|
||
} catch {
|
||
return; // offline — they stay queued for next launch
|
||
}
|
||
|
||
for (const item of items) {
|
||
if (!item || !item.code) continue;
|
||
await adoptSharedPlaylistByCode(item.code, { fromName: item.from || '' });
|
||
try {
|
||
await fetch('/api/playlist/inbox/dismiss', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: me, id: item.id }),
|
||
});
|
||
} catch { /* leave it queued; a duplicate prompt beats losing the playlist */ }
|
||
}
|
||
}
|
||
|
||
// A shared link (?profile=name) arriving on this device. Adopting replaces the
|
||
// synced slice of local state, so anything already on the device gets a
|
||
// confirm first; a link for the profile already linked here is a no-op.
|
||
async function adoptProfileFromUrl() {
|
||
if (!WEB) return;
|
||
let name = '';
|
||
try {
|
||
name = (new URLSearchParams(location.search).get('profile') || '').trim();
|
||
} catch { return; }
|
||
if (!name) return;
|
||
|
||
// Drop the param either way so a reload can't re-trigger the prompt.
|
||
try { history.replaceState(null, '', location.pathname + location.hash); } catch {}
|
||
|
||
if (data.profile && data.profile.name &&
|
||
data.profile.name.toLowerCase() === name.toLowerCase()) return;
|
||
|
||
const hasLocalData = (data.playlists && data.playlists.length) ||
|
||
(data.history && data.history.length) ||
|
||
!!(data.profile && data.profile.name);
|
||
if (hasLocalData) {
|
||
const ok = await new Promise((resolve) => {
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `
|
||
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
|
||
This link opens the online profile <strong>${escapeHtml(name)}</strong>.
|
||
Loading it replaces the playlists, history and settings on this device,
|
||
and future changes here sync to that profile.
|
||
</p>`;
|
||
showModal('⬇ Open shared profile', body, [
|
||
{ label: 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
||
{ label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
||
]);
|
||
});
|
||
if (!ok) return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`);
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) {
|
||
toast('⚠ ' + ((j && j.error) || 'Profile not found'));
|
||
return;
|
||
}
|
||
applyProfileData(j.name, j.data, j.updatedAt);
|
||
toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 });
|
||
} catch {
|
||
toast('⚠ Network error — could not open that profile');
|
||
}
|
||
}
|
||
|
||
function createProfileFlow() {
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `
|
||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||
Pick a name (3–40 characters: letters, digits, - or _).
|
||
<strong>The name is the key</strong> — anyone who knows it can load this
|
||
profile on their device, so use something hard to guess or go random.
|
||
</p>
|
||
<input id="profileNameInput" type="text" placeholder="e.g. crimson-falcon-8317" autocomplete="off" />`;
|
||
showModal('+ Create online profile', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: '🎲 Random name', onClick: () => requestCreateProfile(null) },
|
||
{ label: 'Create', primary: true, onClick: () => {
|
||
const name = ($('profileNameInput').value || '').trim();
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/.test(name)) {
|
||
toast('⚠ Name must be 3–40 characters: letters, digits, - or _');
|
||
return; // keep the modal open for another attempt
|
||
}
|
||
requestCreateProfile(name);
|
||
} },
|
||
]);
|
||
$('profileNameInput').focus();
|
||
}
|
||
|
||
// name === null → let the server generate a unique random one.
|
||
async function requestCreateProfile(name) {
|
||
try {
|
||
const res = await fetch('/api/profile/create', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, data: profilePayload() }),
|
||
});
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) {
|
||
toast('⚠ ' + ((j && j.error) || 'Could not create profile'));
|
||
return; // modal stays open — user can adjust the name
|
||
}
|
||
closeModal();
|
||
data.profile = { name: j.name, syncedAt: j.updatedAt || 0 };
|
||
persist();
|
||
updateProfileStatus();
|
||
toast(`Profile “${j.name}” created ✓ — load it by name on any device`, { duration: 4500 });
|
||
} catch {
|
||
toast('⚠ Network error — try again');
|
||
}
|
||
}
|
||
|
||
function loadProfileFlow() {
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `
|
||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||
Enter a profile name to pull its playlists, history and settings onto
|
||
this device. The synced data on this device is replaced, and future
|
||
changes here sync back to that profile.
|
||
</p>
|
||
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
|
||
showModal('⬇ Load online profile', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Load', primary: true, onClick: async () => {
|
||
const name = ($('profileNameInput').value || '').trim();
|
||
if (!name) return;
|
||
try {
|
||
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`);
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) {
|
||
toast('⚠ ' + ((j && j.error) || 'Profile not found'));
|
||
return; // keep the modal open
|
||
}
|
||
closeModal();
|
||
applyProfileData(j.name, j.data, j.updatedAt);
|
||
// Re-apply everything the loaded data drives.
|
||
applyAppearance();
|
||
updateLoopRepeatButtons();
|
||
updateQueueBadge();
|
||
els.volume.value = String(data.settings.volume ?? 1);
|
||
els.quality.value = data.settings.quality || 'auto';
|
||
els.audioOnlyToggle.checked = !!data.settings.audioOnly;
|
||
renderSidebar();
|
||
renderSmartSidebar();
|
||
render();
|
||
updateProfileStatus();
|
||
data.playlists.forEach(preloadPlaylist);
|
||
preloadPinnedPlaylists();
|
||
toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 });
|
||
} catch {
|
||
toast('⚠ Network error — try again');
|
||
}
|
||
} },
|
||
]);
|
||
$('profileNameInput').focus();
|
||
}
|
||
|
||
// ---------- Helpers ----------
|
||
// Only for values interpolated into innerHTML/attribute strings (profile names
|
||
// off a URL are attacker-controlled).
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
const ICON_PLAY = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M9 5.5v13l10-6.5z"/></svg>';
|
||
const ICON_PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/></svg>';
|
||
// Speaker icon follows the volume (muted / low / high); the level before a mute
|
||
// is remembered so un-muting returns to it.
|
||
let lastVolume = 1;
|
||
function syncMuteIcon() {
|
||
const b = els.muteBtn;
|
||
if (!b) return;
|
||
const v = parseFloat(els.volume.value);
|
||
if (v > 0) lastVolume = v;
|
||
const lvl = v === 0 ? 0 : v < 0.5 ? 1 : 2;
|
||
const waves = lvl === 0 ? '<path d="M16 9l5 6M21 9l-5 6"/>' : lvl === 1 ? '<path d="M15.5 9.5a4 4 0 0 1 0 5"/>' : '<path d="M15.5 9.5a4 4 0 0 1 0 5M18.5 6.5a8 8 0 0 1 0 11"/>';
|
||
b.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z" class="spk"/>${waves}</svg>`;
|
||
b.classList.toggle('muted', lvl === 0);
|
||
b.title = lvl === 0 ? 'Unmute' : 'Mute';
|
||
b.setAttribute('aria-label', b.title);
|
||
}
|
||
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, { duration = 2200 } = {}) {
|
||
const t = document.createElement('div');
|
||
t.className = 'toast';
|
||
t.textContent = msg;
|
||
const container = $('toastContainer');
|
||
container.appendChild(t);
|
||
// Trigger layout for animation
|
||
t.style.animation = 'none';
|
||
t.offsetHeight; // force reflow
|
||
t.style.animation = '';
|
||
|
||
// Auto-remove after duration
|
||
setTimeout(() => {
|
||
t.classList.add('toast-exit');
|
||
setTimeout(() => t.remove(), 280);
|
||
}, duration);
|
||
|
||
// Cap at 3 toasts — fade out the oldest. Removal waits for the exit
|
||
// animation, so only count toasts that aren't already leaving: the old
|
||
// `while (children.length > 3)` never removed anything synchronously and
|
||
// froze the page for good the moment a 4th toast appeared.
|
||
const live = [...container.children].filter((c) => !c.classList.contains('toast-exit'));
|
||
for (const old of live.slice(0, Math.max(0, live.length - 3))) {
|
||
old.classList.add('toast-exit');
|
||
setTimeout(() => old.remove(), 280);
|
||
}
|
||
return t;
|
||
}
|
||
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
|
||
|
||
// Apply theme / font size / density / performance settings to the document root.
|
||
// Driven entirely by data-* attributes that styles.css keys off of.
|
||
function applyAppearance() {
|
||
const s = data.settings;
|
||
const root = document.documentElement;
|
||
root.dataset.theme = s.theme || 'dark';
|
||
root.dataset.font = s.fontScale || 'normal';
|
||
root.dataset.density = s.density || 'comfortable';
|
||
root.dataset.perf = s.perfMode ? 'on' : 'off';
|
||
root.dataset.motion = s.reduceMotion ? 'reduced' : 'full';
|
||
root.dataset.layout = s.layout || 'classic';
|
||
}
|
||
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)
|
||
// ============================================================================
|
||
// An empty file (a save that never got its bytes) is not a saved video.
|
||
function isUsableCacheItem(it) {
|
||
return !!(it && it.id) && !(typeof it.size === 'number' && it.size <= 0);
|
||
}
|
||
|
||
async function refreshCachedIds() {
|
||
const next = new Set();
|
||
try {
|
||
const res = await API.cacheList();
|
||
if (res && res.ok) for (const it of res.items || []) if (isUsableCacheItem(it)) next.add(it.id);
|
||
} catch { /* shell without cache support — leave empty */ }
|
||
cachedIds.clear();
|
||
for (const id of next) cachedIds.add(id);
|
||
}
|
||
|
||
// The badges trust cachedIds, which is otherwise only read from storage at
|
||
// launch — a PWA resumed from the background would keep showing videos the
|
||
// browser has since evicted (iOS does this under storage pressure). Re-read
|
||
// the device store when the app returns to the foreground and repaint only
|
||
// what changed. Bumped on every add so a save landing mid-listing is never
|
||
// undone by the older listing.
|
||
let cacheMutations = 0;
|
||
let cacheResyncRunning = false;
|
||
async function resyncCachedIds() {
|
||
if (!WEB || !window.OPFS || !window.OPFS.isSupported() || cacheResyncRunning) return;
|
||
cacheResyncRunning = true;
|
||
try {
|
||
const before = cacheMutations;
|
||
let items;
|
||
try { items = await window.OPFS.listVideos({ strict: true }); } catch { return; }
|
||
if (before !== cacheMutations) return; // a save landed meanwhile — next time
|
||
const listed = new Set(items.filter(isUsableCacheItem).map((it) => it.id));
|
||
const changed = [];
|
||
for (const id of [...cachedIds]) {
|
||
if (!listed.has(id)) { cachedIds.delete(id); changed.push(id); }
|
||
}
|
||
for (const id of listed) {
|
||
if (!cachedIds.has(id) && !downloading.has(id)) { cachedIds.add(id); changed.push(id); }
|
||
}
|
||
if (!changed.length) return;
|
||
for (const id of changed) markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
|
||
renderSidebar();
|
||
if (view.type === 'playlist') {
|
||
const pl = data.playlists.find((p) => p.id === view.id);
|
||
const offlineBtn = $('plOfflineBtn');
|
||
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
|
||
} else if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') {
|
||
renderList();
|
||
}
|
||
} finally {
|
||
cacheResyncRunning = false;
|
||
}
|
||
}
|
||
|
||
function startCacheResyncWatch() {
|
||
if (!WEB) return;
|
||
let last = Date.now();
|
||
const maybe = () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (Date.now() - last < 5000) return;
|
||
last = Date.now();
|
||
resyncCachedIds();
|
||
};
|
||
document.addEventListener('visibilitychange', maybe);
|
||
window.addEventListener('pageshow', maybe);
|
||
}
|
||
|
||
// ---------- Offline thumbnail warm-up ----------
|
||
// Saving a video stores only its media; its artwork lives on i.ytimg.com and
|
||
// is only cached once something has actually requested it. Pull it through the
|
||
// service worker (which caches thumbnail responses) so a video saved for
|
||
// offline still has its art when the network is gone.
|
||
function warmThumb(url) {
|
||
if (!WEB || !url || !/^https?:/i.test(url)) return;
|
||
fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {});
|
||
}
|
||
|
||
// A YouTube id, or one of the server's own uploads (server/uploads.js).
|
||
const YT_ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
||
const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
|
||
const isUploadId = (id) => UPLOAD_ID_RE.test(String(id || ''));
|
||
const isMediaId = (id) => YT_ID_RE.test(String(id || '')) || isUploadId(id);
|
||
|
||
function thumbUrlFor(id, known) {
|
||
if (known && known.thumbnail) return known.thumbnail;
|
||
if (isUploadId(id)) return `/api/uploads/${id}/art`; // 404s when the file has no cover
|
||
if (String(id).startsWith('edit_')) return ''; // edited copies have no YouTube art
|
||
return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
|
||
}
|
||
|
||
// One-shot backfill on launch: everything already saved offline, plus every
|
||
// playlist video (the home screen and sidebar render those). Cached thumbs are
|
||
// served by the SW without touching the network, so only the first run costs
|
||
// anything. Bounded and lightly throttled so a big library can't burst.
|
||
const THUMB_WARM_MAX = 400;
|
||
async function warmOfflineThumbs() {
|
||
if (!WEB || navigator.onLine === false) return;
|
||
const urls = new Set();
|
||
for (const id of cachedIds) {
|
||
const u = thumbUrlFor(id, videoById(id));
|
||
if (u) urls.add(u);
|
||
}
|
||
for (const pl of data.playlists || []) {
|
||
for (const v of pl.videos || []) if (v && v.thumbnail) urls.add(v.thumbnail);
|
||
}
|
||
const list = [...urls].slice(0, THUMB_WARM_MAX);
|
||
for (let i = 0; i < list.length; i += 4) {
|
||
await Promise.all(list.slice(i, i + 4).map((u) =>
|
||
fetch(u, { mode: 'cors', credentials: 'omit' }).catch(() => {})));
|
||
}
|
||
}
|
||
|
||
// This device's record of what it holds and each file's content id
|
||
// (docs/p2p-architecture.md flow 2). The P2P client reports these.
|
||
function recordDeviceFile(id, res) {
|
||
if (!WEB || !window.DeviceDB) return;
|
||
const cid = res && window.Sha256 && window.Sha256.isHex(res.sha256) ? res.sha256 : null;
|
||
const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed';
|
||
const now = Date.now();
|
||
window.DeviceDB.putFile({ videoId: id, cid, size: (res && res.size) || 0, savedAt: now, lastCheckedAt: now, state })
|
||
.then(() => { if (window.P2PClient) window.P2PClient.changed(); })
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Download a video into the permanent offline cache. Safe to call repeatedly.
|
||
async function preload(video, { quiet = false, mux = false } = {}) {
|
||
const id = video.id;
|
||
// Custom (edited) videos have no YouTube source to (re)download — their
|
||
// media is produced once by the editor. Never route them through the normal
|
||
// cache-download path (a fake edit_… id would 404 on /api/download).
|
||
if (video.custom) return;
|
||
if (!id || cachedIds.has(id) || downloading.has(id)) return;
|
||
downloading.add(id);
|
||
downloadMeta.set(id, slim(video));
|
||
markCardCacheState(id, 'downloading');
|
||
if (view.type === 'downloads') renderList();
|
||
updateDownloadBadge();
|
||
// Reflect the busy state on the now-playing Save button immediately, so the
|
||
// UI responds the moment the (long) download begins rather than only when it
|
||
// finishes in the `finally` below.
|
||
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
|
||
if (!quiet) toast(`Saving “${video.title}” for offline…`);
|
||
try {
|
||
const res = await API.cacheDownload(id, { mux });
|
||
if (res && res.ok && res.cached) {
|
||
cachedIds.add(id);
|
||
cacheMutations++;
|
||
recordDeviceFile(id, res);
|
||
warmThumb(thumbUrlFor(id, video));
|
||
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);
|
||
downloadMeta.delete(id);
|
||
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
|
||
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
|
||
updateDownloadBadge();
|
||
if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
|
||
if (view.type === 'playlist') {
|
||
const pl = data.playlists.find((p) => p.id === view.id);
|
||
const offlineBtn = $('plOfflineBtn');
|
||
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
|
||
}
|
||
renderSidebar();
|
||
}
|
||
}
|
||
|
||
// A device copy that failed to play. Left in place it stays badged "cached"
|
||
// and fails on every tap, so drop it and save a fresh one in the background.
|
||
// An HEVC copy means canPlayType over-promised: stop asking for HEVC first so
|
||
// the re-save comes back as H.264. Edited cuts exist only on the device (no
|
||
// source to re-save from), so they are never deleted. One re-save per video
|
||
// per session keeps a copy that is bad for another reason from looping.
|
||
const resavedAfterFailure = new Set();
|
||
async function dropBrokenLocalCopy(meta) {
|
||
const id = meta && meta.id;
|
||
if (!id || meta.custom || !cachedIds.has(id)) return;
|
||
try {
|
||
if (WEB && window.OPFS && typeof window.OPFS.sniffCodec === 'function' &&
|
||
(await window.OPFS.sniffCodec(id)) === 'hevc') {
|
||
try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ }
|
||
}
|
||
} catch { /* sniffing is best-effort */ }
|
||
try { await API.cacheDelete(id); } catch { /* best-effort */ }
|
||
cachedIds.delete(id);
|
||
markCardCacheState(id, 'none');
|
||
renderSidebar();
|
||
if (view.type === 'playlist') {
|
||
const pl = data.playlists.find((p) => p.id === view.id);
|
||
const offlineBtn = $('plOfflineBtn');
|
||
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
|
||
}
|
||
if (resavedAfterFailure.has(id)) return;
|
||
resavedAfterFailure.add(id);
|
||
preload(meta, { quiet: true });
|
||
}
|
||
|
||
// Sidebar badge showing how many downloads are in flight.
|
||
function updateDownloadBadge() {
|
||
const n = downloading.size;
|
||
const badge = $('navDlCount');
|
||
if (badge) { badge.textContent = String(n); badge.classList.toggle('hidden', n === 0); }
|
||
// Mirror to bottom-nav badge
|
||
const bb = $('bnDlCount');
|
||
if (bb) { bb.textContent = String(n); bb.classList.toggle('hidden', n === 0); }
|
||
}
|
||
|
||
// Helper: count how many videos in a playlist are cached offline.
|
||
// Custom (edited) videos count as cached (produced by the editor, not downloaded).
|
||
function playlistOfflineStats(pl) {
|
||
if (!pl || !Array.isArray(pl.videos)) return { cached: 0, total: 0 };
|
||
let cached = 0;
|
||
for (const v of pl.videos) {
|
||
if (v && (v.custom || cachedIds.has(v.id))) cached++;
|
||
}
|
||
return { cached, total: pl.videos.length };
|
||
}
|
||
|
||
// 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 }));
|
||
}
|
||
|
||
// Preload every pinned playlist's videos even when autoPreload is off.
|
||
function preloadPinnedPlaylists() {
|
||
const pinnedSet = new Set(data.pinnedPlaylists || []);
|
||
if (!pinnedSet.size) return;
|
||
for (const pl of data.playlists || []) {
|
||
if (pinnedSet.has(pl.id) && Array.isArray(pl.videos)) {
|
||
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');
|
||
const thumb = c.querySelector('.thumb');
|
||
if (state === 'downloading' && !thumb.querySelector('.dl-progress')) {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'dl-progress';
|
||
bar.innerHTML = '<div class="dl-bar"></div>';
|
||
thumb.appendChild(bar);
|
||
} else if (state !== 'downloading') {
|
||
const bar = thumb.querySelector('.dl-progress');
|
||
if (bar) bar.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Video editor — cut parts out of a video and save a custom offline copy
|
||
//
|
||
// The editor works on a SOURCE video (any card / the now-playing video). The
|
||
// user marks one or more CUT ranges; everything outside those ranges survives.
|
||
// On confirm we compute the keep segments (VideoEdit.invertCuts), ask the
|
||
// server to trim+concat the source into one continuous mp4 (?edit=1&keep=…),
|
||
// store it in OPFS under a fresh custom id, and register a custom video object
|
||
// in data.customVideos so it plays offline and can be added to playlists just
|
||
// like a normal video.
|
||
// ============================================================================
|
||
|
||
// Make a stable-ish unique id for a custom cut. Not a YouTube id — the
|
||
// `edit_` prefix is how the rest of the app recognises an offline-only video.
|
||
function customVideoId(sourceId) {
|
||
return 'edit_' + sanitizeId(sourceId) + '_' + uid();
|
||
}
|
||
|
||
// Kick off the server-side edit + OPFS save for a custom video object, driving
|
||
// the same download/cache UI state (badges, toasts) as a normal save.
|
||
async function downloadEdited(customVideo) {
|
||
const id = customVideo.id;
|
||
if (!id || cachedIds.has(id) || downloading.has(id)) return;
|
||
downloading.add(id);
|
||
downloadMeta.set(id, slim(customVideo));
|
||
markCardCacheState(id, 'downloading');
|
||
if (view.type === 'downloads') renderList();
|
||
updateDownloadBadge();
|
||
toast(`Rendering “${customVideo.title}”…`);
|
||
try {
|
||
const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep);
|
||
if (res && res.ok && res.cached) {
|
||
cachedIds.add(id);
|
||
cacheMutations++;
|
||
// An edited copy has no art of its own — warm its source's thumbnail,
|
||
// which is what the Saved list falls back to.
|
||
warmThumb(thumbUrlFor(customVideo.sourceId, videoById(customVideo.sourceId)));
|
||
// Only persist the custom video once its media is actually stored, so a
|
||
// failed render never leaves a dangling entry the user can't play.
|
||
if (!(data.customVideos || []).some((v) => v.id === id)) {
|
||
data.customVideos = data.customVideos || [];
|
||
data.customVideos.push(customVideo);
|
||
persist();
|
||
}
|
||
toast(`Saved edited “${customVideo.title}” ✓`);
|
||
if (view.type === 'downloads' || view.type === 'saved') renderList();
|
||
} else {
|
||
toast('⚠ ' + ((res && res.error) || 'Could not render edited video'));
|
||
}
|
||
} catch (e) {
|
||
toast('⚠ ' + (e && e.message ? e.message : 'Editing failed'));
|
||
} finally {
|
||
downloading.delete(id);
|
||
downloadMeta.delete(id);
|
||
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
|
||
updateDownloadBadge();
|
||
if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
|
||
if (view.type === 'playlist') {
|
||
const pl = data.playlists.find((p) => p.id === view.id);
|
||
const offlineBtn = $('plOfflineBtn');
|
||
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
|
||
}
|
||
renderSidebar();
|
||
}
|
||
}
|
||
|
||
// Remove a custom (edited) video entirely: its cached media file, its cache
|
||
// membership, its registry entry, and any playlist references. Unlike a normal
|
||
// "remove from cache", the media can't be re-fetched, so this is a true delete.
|
||
async function deleteCustomVideo(id) {
|
||
try { await API.cacheDelete(id); } catch { /* best-effort */ }
|
||
cachedIds.delete(id);
|
||
data.customVideos = (data.customVideos || []).filter((v) => v.id !== id);
|
||
data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => x.id !== id); });
|
||
data.queue = (data.queue || []).filter((x) => x.id !== id);
|
||
persist();
|
||
markCardCacheState(id, 'none');
|
||
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
|
||
toast('Deleted edited video');
|
||
if (view.type === 'saved' || view.type === 'downloads' || view.type === 'playlist') renderList();
|
||
}
|
||
|
||
// Open the editor modal for a source video. `duration` seconds is needed to
|
||
// compute keep segments; we take it from the live player when the video is
|
||
// currently playing, else from the card metadata.
|
||
function openVideoEditor(source) {
|
||
if (!(WEB && window.OPFS && window.OPFS.isSupported())) {
|
||
toast('⚠ Editing needs offline storage, which this browser doesn’t support');
|
||
return;
|
||
}
|
||
// Prefer the precise live duration when editing the now-playing video.
|
||
let duration = 0;
|
||
if (current && current.meta && current.meta.id === source.id && Player.master && Player.master.duration) {
|
||
duration = Player.master.duration;
|
||
}
|
||
if (!duration) duration = Number(source.duration) || 0;
|
||
if (!duration || !isFinite(duration)) {
|
||
toast('⚠ Play the video first so its length is known, then edit');
|
||
return;
|
||
}
|
||
|
||
const cuts = []; // [{start,end}] the user is removing
|
||
let lastMarker = null; // 'A' or 'B'
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'video-editor';
|
||
body.innerHTML = `
|
||
<p class="ve-intro">Mark the parts to <strong>remove</strong>. Everything else is kept and saved as a new offline video.</p>
|
||
<div class="ve-scrubber-track">
|
||
<div class="ve-scrubber-bar"></div>
|
||
<div class="ve-marker ve-marker-a">A</div>
|
||
<div class="ve-marker ve-marker-b">B</div>
|
||
</div>
|
||
<div class="ve-add-row">
|
||
<label>From <input type="text" class="ve-from" placeholder="0:30" inputmode="numeric" /></label>
|
||
<label>To <input type="text" class="ve-to" placeholder="1:15" inputmode="numeric" /></label>
|
||
<button type="button" class="btn ve-add">Add cut</button>
|
||
</div>
|
||
<div class="ve-move-row" style="margin-top:10px">
|
||
<button type="button" class="btn ve-move">Move video to last clicked marker</button>
|
||
</div>
|
||
<div class="ve-error" hidden></div>
|
||
<div class="ve-cuts"></div>
|
||
<label class="ve-title-row">Name <input type="text" class="ve-title" maxlength="120" /></label>
|
||
<div class="ve-summary"></div>`;
|
||
|
||
const fromEl = body.querySelector('.ve-from');
|
||
const toEl = body.querySelector('.ve-to');
|
||
const addBtn = body.querySelector('.ve-add');
|
||
const moveBtn = body.querySelector('.ve-move');
|
||
const errEl = body.querySelector('.ve-error');
|
||
const cutsEl = body.querySelector('.ve-cuts');
|
||
const titleEl = body.querySelector('.ve-title');
|
||
const sumEl = body.querySelector('.ve-summary');
|
||
const trackEl = body.querySelector('.ve-scrubber-track');
|
||
const markerA = body.querySelector('.ve-marker-a');
|
||
const markerB = body.querySelector('.ve-marker-b');
|
||
|
||
titleEl.value = (source.title || 'Video') + ' (edit)';
|
||
|
||
// Update markers based on duration
|
||
function updateScrubber() {
|
||
const a = VideoEdit.parseTime(fromEl.value);
|
||
const b = VideoEdit.parseTime(toEl.value);
|
||
|
||
if (a !== null) markerA.style.left = (a / duration * 100) + '%';
|
||
if (b !== null) markerB.style.left = (b / duration * 100) + '%';
|
||
}
|
||
|
||
trackEl.onclick = (e) => {
|
||
const rect = trackEl.getBoundingClientRect();
|
||
const percent = (e.clientX - rect.left) / rect.width;
|
||
const time = Math.round(percent * duration);
|
||
|
||
if (!fromEl.value || lastMarker === 'B') {
|
||
fromEl.value = VideoEdit.fmtTime(time);
|
||
lastMarker = 'A';
|
||
} else {
|
||
toEl.value = VideoEdit.fmtTime(time);
|
||
lastMarker = 'B';
|
||
}
|
||
updateScrubber();
|
||
};
|
||
|
||
markerA.onclick = (e) => { e.stopPropagation(); lastMarker = 'A'; };
|
||
markerB.onclick = (e) => { e.stopPropagation(); lastMarker = 'B'; };
|
||
|
||
moveBtn.onclick = () => {
|
||
if (lastMarker === 'A' && fromEl.value) {
|
||
Player.seek(VideoEdit.parseTime(fromEl.value));
|
||
} else if (lastMarker === 'B' && toEl.value) {
|
||
Player.seek(VideoEdit.parseTime(toEl.value));
|
||
}
|
||
};
|
||
|
||
fromEl.oninput = updateScrubber;
|
||
toEl.oninput = updateScrubber;
|
||
|
||
function showErr(msg) {
|
||
errEl.textContent = msg;
|
||
errEl.hidden = !msg;
|
||
}
|
||
|
||
function refresh() {
|
||
cutsEl.innerHTML = '';
|
||
const sorted = cuts.slice().sort((a, b) => a.start - b.start);
|
||
sorted.forEach((cut) => {
|
||
const row = document.createElement('div');
|
||
row.className = 've-cut';
|
||
row.innerHTML = `<span>✂ ${VideoEdit.fmtTime(cut.start)} – ${VideoEdit.fmtTime(cut.end)}</span>`;
|
||
const del = document.createElement('button');
|
||
del.className = 've-cut-del';
|
||
del.type = 'button';
|
||
del.textContent = '✕';
|
||
del.title = 'Remove this cut';
|
||
del.onclick = () => {
|
||
const i = cuts.indexOf(cut);
|
||
if (i > -1) cuts.splice(i, 1);
|
||
refresh();
|
||
};
|
||
row.appendChild(del);
|
||
cutsEl.appendChild(row);
|
||
});
|
||
const keep = VideoEdit.invertCuts(cuts, duration);
|
||
const finalLen = VideoEdit.keepDuration(keep);
|
||
sumEl.innerHTML = cuts.length
|
||
? `Final length: <strong>${VideoEdit.fmtTime(finalLen)}</strong> of ${VideoEdit.fmtTime(duration)}`
|
||
: `No cuts yet — the whole ${VideoEdit.fmtTime(duration)} video would be saved.`;
|
||
}
|
||
|
||
addBtn.onclick = () => {
|
||
showErr('');
|
||
const a = VideoEdit.parseTime(fromEl.value);
|
||
const b = VideoEdit.parseTime(toEl.value);
|
||
if (a === null || b === null) { showErr('Enter valid times, e.g. 0:30 and 1:15.'); return; }
|
||
if (b <= a) { showErr('“To” must be after “From”.'); return; }
|
||
if (a >= duration) { showErr(`Times must be within the video (0 – ${VideoEdit.fmtTime(duration)}).`); return; }
|
||
cuts.push({ start: a, end: Math.min(b, duration) });
|
||
fromEl.value = '';
|
||
toEl.value = '';
|
||
fromEl.focus();
|
||
refresh();
|
||
};
|
||
|
||
refresh();
|
||
|
||
showModal('Edit & download', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{
|
||
label: 'Save edited copy', primary: true, onClick: () => {
|
||
if (!VideoEdit.hasEdits(cuts, duration)) {
|
||
showErr('Add at least one cut, or use ⬇ Save for the full video.');
|
||
return;
|
||
}
|
||
const keep = VideoEdit.invertCuts(cuts, duration);
|
||
if (!keep.length) {
|
||
showErr('That would remove the entire video — leave something to keep.');
|
||
return;
|
||
}
|
||
const custom = {
|
||
id: customVideoId(source.id),
|
||
title: (titleEl.value || '').trim() || ((source.title || 'Video') + ' (edit)'),
|
||
channel: source.channel || '',
|
||
channelId: source.channelId || '',
|
||
channelUrl: source.channelUrl || '',
|
||
thumbnail: source.thumbnail || '',
|
||
duration: Math.round(VideoEdit.keepDuration(keep)),
|
||
custom: true,
|
||
sourceId: source.id,
|
||
keep: VideoEdit.keepToParam(keep),
|
||
};
|
||
closeModal();
|
||
downloadEdited(custom);
|
||
},
|
||
},
|
||
]);
|
||
setTimeout(() => fromEl.focus(), 50);
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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,
|
||
bufferGraceTimer: null, // pending "pause audio after a stall that outlasts the grace window" timer
|
||
_bgHandoff: false, // sound temporarily moved to <audio> for background play
|
||
_wantsPlaying: false, // tracks user/app *intent* to be playing, independent
|
||
// of what the underlying <audio>/<video> elements
|
||
// report — iOS Safari (esp. PWA/standalone on iPhone)
|
||
// can silently pause a background audio element on
|
||
// screen-lock or app-switch without ever pausing the
|
||
// still-visible muted video, and without always
|
||
// firing a 'pause' event we react to. Comparing
|
||
// intent against actual element state is how we
|
||
// detect and recover from that.
|
||
|
||
get soundEl() {
|
||
return this.mode === 'dual' ? els.audio : this.master;
|
||
},
|
||
|
||
async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true, nocache = false } = {}) {
|
||
perfMark('ytp:tap');
|
||
if (!this._handoff) Transition.cancel();
|
||
// When false (auto-advance / prev), skip resuming the saved timestamp and
|
||
// start from the beginning (or the A marker, if an A-B loop is set).
|
||
this._resumeOnLoad = resume;
|
||
// When false (auto-advance / prev), don't scroll the portrait view back
|
||
// to the player — the user may be browsing another page while listening.
|
||
this._revealOnLoad = reveal;
|
||
// Monotonic load token: any await below may resolve after the user has
|
||
// already started a different video — stale loads must stand down
|
||
// instead of hijacking playback (longest window: save-before-playing).
|
||
const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
|
||
showSpinner(true);
|
||
els.placeholder.classList.add('hidden');
|
||
// Revoke any previous OPFS blob URL to free memory
|
||
if (_currentBlobUrl) {
|
||
if (window.OPFS) window.OPFS.revokeUrl(_currentBlobUrl);
|
||
else URL.revokeObjectURL(_currentBlobUrl);
|
||
_currentBlobUrl = null;
|
||
}
|
||
try {
|
||
// Play from the offline cache when available — instant and works offline.
|
||
if (!preferStream && cachedIds.has(videoObj.id)) {
|
||
let localUrl = null;
|
||
try {
|
||
if (TAURI) {
|
||
const st = await API.cacheStatus(videoObj.id);
|
||
if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
|
||
} else if (WEB && window.OPFS) {
|
||
localUrl = await window.OPFS.getFileUrl(videoObj.id);
|
||
if (localUrl) _currentBlobUrl = localUrl;
|
||
}
|
||
} 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;
|
||
}
|
||
}
|
||
|
||
// Custom (edited) videos exist ONLY in the offline cache — there is no
|
||
// YouTube stream to fall back to. If the cached file is missing (e.g.
|
||
// cleared, or synced from another device that never had the media),
|
||
// surface a clear error instead of trying to stream a fake video id.
|
||
if (videoObj.custom) {
|
||
throw new Error('This edited video isn’t available offline on this device.');
|
||
}
|
||
|
||
// Opt-in "Save before playing" (Settings → Playback, off by default):
|
||
// download the server-compiled single file into the offline cache
|
||
// first, then play the local copy — one data stream instead of the
|
||
// dual video+audio streams, and the video is kept for offline reuse.
|
||
// Any failure falls through to normal streaming playback.
|
||
if (!preferStream && data.settings.saveBeforePlay && !data.settings.audioOnly &&
|
||
WEB && window.OPFS && window.OPFS.isSupported() && !cachedIds.has(videoObj.id)) {
|
||
await preload(videoObj, { mux: true });
|
||
if (seq !== this._loadSeq) return;
|
||
if (cachedIds.has(videoObj.id)) {
|
||
const localUrl = await window.OPFS.getFileUrl(videoObj.id);
|
||
if (seq !== this._loadSeq) return;
|
||
if (localUrl) {
|
||
_currentBlobUrl = 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, { nocache });
|
||
if (seq !== this._loadSeq) return; // a newer load took over meanwhile
|
||
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);
|
||
// Show retry button in the player pane
|
||
const retry = els.playerPane.querySelector('.retry-btn');
|
||
if (retry) retry.remove();
|
||
const btn = document.createElement('button');
|
||
btn.className = 'retry-btn';
|
||
btn.textContent = '↻ Retry';
|
||
btn.addEventListener('click', () => {
|
||
els.playerPane.querySelectorAll('.retry-btn').forEach((b) => b.remove());
|
||
Player.loadVideo(videoObj, { preferStream, resume, reveal, nocache });
|
||
});
|
||
els.playerPane.appendChild(btn);
|
||
offerPeerDownload(videoObj);
|
||
}
|
||
},
|
||
|
||
// Shared UI updates once `current` is populated (cached or streamed).
|
||
afterLoad() {
|
||
addToHistory(current.meta);
|
||
Notes.load(current.meta);
|
||
Wave.load(current.meta);
|
||
Gestures.reset();
|
||
Party.afterLoad();
|
||
Remote.changed();
|
||
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();
|
||
showMiniBar();
|
||
updateServiceMode();
|
||
// Media Session API — OS media keys + lock screen
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.metadata = new MediaMetadata({
|
||
title: current.meta.title || '',
|
||
artist: current.meta.channel || '',
|
||
artwork: current.meta.thumbnail ? [{ src: current.meta.thumbnail, sizes: '480x360', type: 'image/jpeg' }] : [],
|
||
});
|
||
navigator.mediaSession.setActionHandler('play', () => Player.play());
|
||
navigator.mediaSession.setActionHandler('pause', () => Player.pause());
|
||
navigator.mediaSession.setActionHandler('previoustrack', () => playPrev());
|
||
navigator.mediaSession.setActionHandler('nexttrack', () => playNext());
|
||
navigator.mediaSession.setActionHandler('seekbackward', (d) => Player.seek(Math.max(0, Player.master.currentTime - (d.seekOffset || 10))));
|
||
navigator.mediaSession.setActionHandler('seekforward', (d) => Player.seek(Player.master.currentTime + (d.seekOffset || 10)));
|
||
}
|
||
// In portrait PWA mode, scroll the player into view so it's immediately
|
||
// visible without the user having to swipe up manually — but only for
|
||
// user-picked tracks; auto-advance must not hijack the scroll position.
|
||
if (this._revealOnLoad) scrollPlayerIntoViewPortrait();
|
||
// Restore A-B markers and load related (non-blocking)
|
||
restoreAbMarkers();
|
||
updateRememberPosUI();
|
||
loadRelated();
|
||
exitSelectMode();
|
||
// Restore saved playback position — only when this song's "remember
|
||
// position" flag is on (per-playlist copy first, global fallback), and
|
||
// never during a "play in full" session.
|
||
// On auto-advance (or prev/next) we do NOT resume the previous timestamp —
|
||
// a freshly selected track should start at the beginning, or at the A point
|
||
// when an A-B loop is set for it.
|
||
const id = current.meta.id;
|
||
if (playFullMode || this._handoff) {
|
||
// Play-in-full ignores resume positions and A markers alike; a
|
||
// crossfade/gapless handoff continues from the bridge's position.
|
||
} else if (this._resumeOnLoad && rememberPosEnabled() && data.resumePositions[id] && data.resumePositions[id] > 1) {
|
||
const saved = data.resumePositions[id];
|
||
const restore = () => {
|
||
Player.seek(saved);
|
||
toast('Resumed from ' + fmtTime(saved));
|
||
Player.master.removeEventListener('canplay', restore);
|
||
Player.master.removeEventListener('loadedmetadata', restore);
|
||
};
|
||
Player.master.addEventListener('canplay', restore, { once: true });
|
||
Player.master.addEventListener('loadedmetadata', restore, { once: true });
|
||
} else if (abA !== null && abA > 1) {
|
||
// Not resuming, but an A marker exists — start the loop from A.
|
||
const seekToA = () => {
|
||
Player.seek(abA);
|
||
Player.master.removeEventListener('canplay', seekToA);
|
||
Player.master.removeEventListener('loadedmetadata', seekToA);
|
||
};
|
||
Player.master.addEventListener('canplay', seekToA, { once: true });
|
||
Player.master.addEventListener('loadedmetadata', seekToA, { once: true });
|
||
}
|
||
},
|
||
|
||
// Ordered list of qualities to try if playback errors out. Adaptive
|
||
// (video-only + separate audio via /api/play) goes near the front: those
|
||
// streams proxy reliably, whereas progressive single-file formats (itag 18
|
||
// and friends) are increasingly SABR-gated and 403 even server-side, so they
|
||
// now go LAST. We then 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); // adaptive = sturdiest (proxied)
|
||
qs.forEach(push); // then everything, incl. progressive
|
||
this.fallbackQueue = queue;
|
||
this.fbIndex = 0;
|
||
},
|
||
onMediaError(el) {
|
||
// A cached file failed to play — fall back to live streaming.
|
||
if (current && current.localUrl) {
|
||
// An error from the PREVIOUS track's blob (revoked by the load that
|
||
// replaced it) must not condemn the new track's good copy.
|
||
if (el && el.src && el.src !== current.localUrl) return;
|
||
const meta = current.meta;
|
||
current.localUrl = null;
|
||
dropBrokenLocalCopy(meta);
|
||
toast('Saved copy won’t play here — streaming and re-saving it…', { duration: 4000 });
|
||
// Preserve the resume intent of the load that just failed.
|
||
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
|
||
return;
|
||
}
|
||
// The server's cached copy failed on this device — stream from YouTube
|
||
// instead. Never auto-reported: a decode failure can be specific to one
|
||
// device, so the ⚠ Broken button is how a user flags a bad server copy.
|
||
if (current && current.serverCached) {
|
||
const meta = current.meta;
|
||
// An HEVC copy that won't play means canPlayType over-promised on this
|
||
// device: stop asking for HEVC here (the server re-checks its copy).
|
||
if ((current.qualities || []).some((q) => q.codec === 'hevc')) {
|
||
try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ }
|
||
}
|
||
current.serverCached = false;
|
||
toast('Server copy failed — streaming from YouTube. Tap ⚠ Broken to re-download it.', { duration: 5000 });
|
||
this.loadVideo(meta, { preferStream: true, nocache: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
|
||
return;
|
||
}
|
||
// Advance to the next candidate stream; give up with a clear message at the end.
|
||
if (this.fbIndex < this.fallbackQueue.length - 1) {
|
||
this.fbIndex++;
|
||
const next = this.fallbackQueue[this.fbIndex];
|
||
toast('Playback hiccup — trying ' + next.label + '…');
|
||
this.attach(next);
|
||
} else {
|
||
showSpinner(false);
|
||
toast('⚠ This video couldn’t be played. Try another.');
|
||
}
|
||
},
|
||
|
||
attach(quality) {
|
||
const V = els.video, A = els.audio;
|
||
// An uploaded audio file has no picture track: play it like audio-only and
|
||
// show its cover art where the video would be.
|
||
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio');
|
||
|
||
// 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.master.removeEventListener('canplay', onReadyLocal); this._startPlayback(); };
|
||
this.master.addEventListener('canplay', onReadyLocal);
|
||
// Cached/offline playback is just as susceptible to a silently-paused
|
||
// background audio element as streamed playback (audio-only mode still
|
||
// routes through <audio>), so it needs the same watchdog.
|
||
this.startDrift();
|
||
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.master.removeEventListener('canplay', onReady);
|
||
this._startPlayback();
|
||
};
|
||
this.master.addEventListener('canplay', onReady);
|
||
this.startDrift();
|
||
},
|
||
|
||
// Start the freshly attached track. A crossfade/gapless handoff (see
|
||
// Transition) has the next song already sounding on a bridge <audio>; pick
|
||
// up from the bridge's position and release it once we are really playing.
|
||
_handoff: null,
|
||
_startPlayback() {
|
||
const h = this._handoff;
|
||
this._handoff = null;
|
||
if (!h) { this.play(); return; }
|
||
try { const t = h.at(); if (t > 0.05) this.seek(t); } catch { /* bridge gone */ }
|
||
this.play();
|
||
let done = false;
|
||
const release = () => {
|
||
if (done) return;
|
||
done = true;
|
||
this.master.removeEventListener('playing', release);
|
||
h.release();
|
||
};
|
||
this.master.addEventListener('playing', release);
|
||
setTimeout(release, 5000); // never leave two songs playing
|
||
},
|
||
|
||
play() {
|
||
this._wantsPlaying = true;
|
||
this.master.play().catch(() => {});
|
||
if (this.secondary) {
|
||
this.secondary.currentTime = this.master.currentTime;
|
||
this.secondary.play().catch(() => {});
|
||
}
|
||
},
|
||
pause() {
|
||
this._wantsPlaying = false;
|
||
this.clearBufferGrace();
|
||
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;
|
||
},
|
||
|
||
// Drift-correction thresholds for dual mode (muted video + synced audio).
|
||
// A hard `currentTime` jump on a *playing* <audio> element causes an
|
||
// audible pop/glitch — every earlier version of the watchdog snapped audio
|
||
// back into sync any time drift exceeded 0.3s, which is exactly what read
|
||
// as "audio stutter" (video stayed smooth because it was never touched).
|
||
// We now use a tiered response instead of one hard threshold:
|
||
// < SOFT_SYNC_THRESHOLD — inaudible, ignore (avoids constant micro-corrections)
|
||
// < HARD_SYNC_THRESHOLD — gently slew playbackRate so the audio eases back
|
||
// into sync over ~1s with no audible artifact
|
||
// >= HARD_SYNC_THRESHOLD — snap (only reached from seeks, buffer flushes,
|
||
// or background-resume, where a single correction
|
||
// is unavoidable and rare)
|
||
SOFT_SYNC_THRESHOLD: 0.08,
|
||
HARD_SYNC_THRESHOLD: 0.3,
|
||
SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync
|
||
|
||
// Shared by the 1s watchdog tick and the master 'playing' handler so both
|
||
// paths get the same no-pop behavior.
|
||
//
|
||
// AUDIO IS THE CLOCK. Corrections are applied to the muted <video>, never
|
||
// to the playing <audio>: seeking or rate-shifting the silent element is
|
||
// invisible, while the same operation on the sounding element is exactly
|
||
// the "stutter" users hear. On a phone that can't sustain the video
|
||
// bitrate, the video rebuffers in a loop — under the old master-is-clock
|
||
// scheme every one of those cycles seeked the live audio (and backgrounding
|
||
// the app, which halts video decode and this timer, made playback smooth —
|
||
// the reported iPhone symptom).
|
||
correctDrift() {
|
||
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
|
||
if (this.secondary.paused) return; // resume paths own this case
|
||
const drift = this.secondary.currentTime - this.master.currentTime;
|
||
const abs = Math.abs(drift);
|
||
if (abs >= this.HARD_SYNC_THRESHOLD) {
|
||
// Snap the muted video onto the audio clock (inaudible).
|
||
this._internalSeek = true;
|
||
this.master.currentTime = this.secondary.currentTime;
|
||
this._resyncSpeed();
|
||
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
|
||
// Video behind audio -> speed the video up; ahead -> slow it down.
|
||
const base = parseFloat(els.speed.value) || 1;
|
||
this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE : base - this.SLEW_RATE;
|
||
} else {
|
||
this._resyncSpeed();
|
||
}
|
||
},
|
||
// Restore playbackRates to the user-selected speed once drift is within
|
||
// tolerance (or after a hard snap) so a slew correction never lingers and
|
||
// overshoots.
|
||
_resyncSpeed() {
|
||
const base = parseFloat(els.speed.value) || 1;
|
||
if (this.master && this.master.playbackRate !== base) this.master.playbackRate = base;
|
||
if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
|
||
},
|
||
|
||
// Runs for every mode (not just 'dual') as a watchdog: iOS Safari — most
|
||
// visibly in PWA/standalone mode on iPhone — can silently pause the element
|
||
// actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
|
||
// the screen is locked or the app is backgrounded, without necessarily
|
||
// firing a 'pause' event we react to. The muted <video> element in 'dual'
|
||
// mode is unaffected and keeps rolling, which is why video looks fine while
|
||
// audio silently drops. Comparing `_wantsPlaying` (our intent) against the
|
||
// element's actual `.paused` state lets us detect and resume from that.
|
||
startDrift() {
|
||
this.stopDrift();
|
||
this.driftTimer = setInterval(() => {
|
||
if (!this._wantsPlaying) return;
|
||
// Dual-mode: keep the secondary audio track in sync with the master,
|
||
// via the tiered corrector above (no hard snap for small, normal drift).
|
||
this.correctDrift();
|
||
// The element that's actually producing sound in the current mode.
|
||
const sounder = this.soundEl;
|
||
if (sounder && sounder.paused) {
|
||
sounder.currentTime = this.master.currentTime;
|
||
sounder.play().catch(() => {});
|
||
}
|
||
}, 1000);
|
||
},
|
||
stopDrift() {
|
||
if (this.driftTimer) clearInterval(this.driftTimer);
|
||
this.driftTimer = null;
|
||
this.clearBufferGrace();
|
||
},
|
||
// Cancel a pending "pause synced audio after a stall" timer — used once the
|
||
// video recovers (playing/canplay), on an explicit pause, and when tearing
|
||
// down the current element (attach()/stopDrift()) so a stale timer never
|
||
// fires against a track that's already moved on.
|
||
clearBufferGrace() {
|
||
if (this.bufferGraceTimer) {
|
||
clearTimeout(this.bufferGraceTimer);
|
||
this.bufferGraceTimer = null;
|
||
}
|
||
},
|
||
// iOS suspends a backgrounded <video> outright, so in 'progressive' mode
|
||
// (where the video element IS the sound source) locking the screen kills the
|
||
// audio with nothing left playing. An <audio> element is allowed to continue,
|
||
// so hand the sound over to it on the way out and hand it back on return.
|
||
// 'dual' and 'audio' modes already play through <audio> and need no handoff.
|
||
handoffToBackgroundAudio() {
|
||
if (!this._wantsPlaying || this._bgHandoff) return;
|
||
if (this.mode !== 'progressive') return;
|
||
const src = (current && (current.localUrl || current.audioUrl)) || '';
|
||
if (!src) return; // nothing to continue with — leave playback alone
|
||
const V = els.video, A = els.audio;
|
||
try {
|
||
A.src = src;
|
||
A.currentTime = V.currentTime || 0;
|
||
A.volume = V.volume;
|
||
A.muted = V.muted;
|
||
const p = A.play();
|
||
if (p && p.catch) p.catch(() => {});
|
||
V.pause();
|
||
// Making <audio> the master keeps soundEl, the drift watchdog and the
|
||
// progress UI pointed at whatever is actually producing sound.
|
||
this.master = A;
|
||
this._bgHandoff = true;
|
||
} catch {
|
||
this._bgHandoff = false;
|
||
}
|
||
},
|
||
|
||
// Undo handoffToBackgroundAudio, putting the video back in charge at the
|
||
// position the audio reached while the app was away.
|
||
restoreFromBackgroundAudio() {
|
||
if (!this._bgHandoff) return;
|
||
const V = els.video, A = els.audio;
|
||
const t = A.currentTime || 0;
|
||
this._bgHandoff = false;
|
||
this.master = V;
|
||
try {
|
||
V.currentTime = t;
|
||
A.pause();
|
||
A.removeAttribute('src');
|
||
A.load();
|
||
if (this._wantsPlaying) {
|
||
const p = V.play();
|
||
if (p && p.catch) p.catch(() => {});
|
||
}
|
||
} catch { /* the watchdog picks up whatever is left */ }
|
||
},
|
||
|
||
// Called when the page/tab regains foreground focus (screen unlock, app
|
||
// switch back). This is the single most common trigger for iOS silently
|
||
// pausing background audio, so we react immediately here instead of
|
||
// waiting up to 1s for the next startDrift() tick.
|
||
resumeIfNeeded() {
|
||
if (!this._wantsPlaying) return;
|
||
const sounder = this.soundEl;
|
||
if (sounder && sounder.paused) {
|
||
sounder.currentTime = this.master.currentTime;
|
||
sounder.play().catch(() => {});
|
||
}
|
||
if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
|
||
this.secondary.currentTime = this.master.currentTime;
|
||
this.secondary.play().catch(() => {});
|
||
}
|
||
},
|
||
};
|
||
|
||
// Recover from iOS silently pausing background audio the moment the app
|
||
// returns to the foreground (screen unlock, switching back from another app,
|
||
// or the PWA being restored from a frozen/backgrounded state). This fires far
|
||
// faster than the 1s watchdog interval, which matters for a jarring "audio
|
||
// paused, video kept playing" UX.
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') {
|
||
Player.restoreFromBackgroundAudio();
|
||
Player.resumeIfNeeded();
|
||
if (isServiceModeOpen()) acquireWakeLock();
|
||
} else {
|
||
// Fires while the page can still start media, which is what makes the
|
||
// handoff possible at all — once iOS has fully suspended us, play() is
|
||
// rejected.
|
||
Player.handoffToBackgroundAudio();
|
||
}
|
||
});
|
||
window.addEventListener('pageshow', () => {
|
||
Player.restoreFromBackgroundAudio();
|
||
Player.resumeIfNeeded();
|
||
if (isServiceModeOpen()) acquireWakeLock();
|
||
});
|
||
window.addEventListener('focus', () => {
|
||
Player.restoreFromBackgroundAudio();
|
||
Player.resumeIfNeeded();
|
||
if (isServiceModeOpen()) acquireWakeLock();
|
||
});
|
||
|
||
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.clearBufferGrace();
|
||
// ONLY a deliberate pause stops the synced audio. iOS pauses a
|
||
// backgrounded <video> by itself on screen-lock or app-switch, and
|
||
// propagating that to the audio element is what silenced playback in
|
||
// the background — the audio is allowed to keep running there, so it
|
||
// should. Player.pause() clears _wantsPlaying before pausing the
|
||
// master, so intent is what separates the two cases.
|
||
if (!Player._wantsPlaying) Player.secondary.pause();
|
||
}
|
||
updatePlayBtn();
|
||
});
|
||
el.addEventListener('seeking', () => {
|
||
if (!masterIs(el) || !Player.secondary) return;
|
||
// Drift correction seeks the muted video onto the audio clock; mirroring
|
||
// that back onto the audio would re-create the audible snap it exists to
|
||
// avoid. Only user/programmatic seeks propagate to the audio.
|
||
if (Player._internalSeek) { Player._internalSeek = false; return; }
|
||
Player.secondary.currentTime = el.currentTime;
|
||
});
|
||
// Video re-buffering must NEVER interrupt the audio: the audio element is
|
||
// the playback clock (see correctDrift). Earlier versions paused the
|
||
// synced audio when a video stall outlasted a 250ms grace window and
|
||
// snapped its clock on recovery — on a phone that can't sustain the video
|
||
// bitrate that cycle repeats indefinitely and is heard as constant
|
||
// stuttering. Now a stalling video just shows the spinner and catches up
|
||
// to the audio clock (or gets snapped forward by the watchdog) when ready.
|
||
el.addEventListener('waiting', () => {
|
||
if (masterIs(el)) showSpinner(true);
|
||
});
|
||
el.addEventListener('playing', () => {
|
||
if (!masterIs(el)) return;
|
||
if (performance.getEntriesByName('ytp:tap').length) {
|
||
perfMeasure('ytp:tap-to-play', 'ytp:tap');
|
||
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
|
||
}
|
||
if (Array.isArray(queue) && queueIndex >= 0) warmStreams(queue.slice(queueIndex + 1, queueIndex + 3).map((x) => x && x.id));
|
||
showSpinner(false);
|
||
Player.clearBufferGrace();
|
||
if (Player.secondary && !el.paused && Player.secondary.paused) {
|
||
// Resume-only path (audio silently paused by iOS or a real stall):
|
||
// seeking a *paused* audio element is inaudible, so sync then play.
|
||
Player.secondary.currentTime = el.currentTime;
|
||
Player.secondary.play().catch(() => {});
|
||
} else if (Player.secondary && !el.paused) {
|
||
// Audio kept playing through the video stall — realign the video.
|
||
Player.correctDrift();
|
||
}
|
||
});
|
||
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
|
||
el.addEventListener('timeupdate', () => {
|
||
if (masterIs(el)) {
|
||
updateProgress();
|
||
// Persist playback position every 10s. timeupdate fires ~4×/s, so
|
||
// gate on the 10s bucket actually changing — otherwise persist()
|
||
// (full JSON.stringify + synchronous localStorage write) ran ~4
|
||
// times back-to-back within each matching second, a periodic
|
||
// main-thread stall on phones.
|
||
if (current && current.meta) {
|
||
const t = Player.master.currentTime;
|
||
const bucket = Math.floor(t / 10);
|
||
if (t > 5 && bucket !== Player._lastPersistBucket) {
|
||
Player._lastPersistBucket = bucket;
|
||
if (!playFullMode && rememberPosEnabled()) {
|
||
data.resumePositions[current.meta.id] = t;
|
||
persist();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
el.addEventListener('pause', () => {
|
||
if (masterIs(el) && current && current.meta && !playFullMode && rememberPosEnabled()) {
|
||
const t = Player.master.currentTime;
|
||
if (t > 1) {
|
||
data.resumePositions[current.meta.id] = t;
|
||
persist();
|
||
}
|
||
}
|
||
});
|
||
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
|
||
el.addEventListener('ended', () => { if (masterIs(el)) onTrackEnded(); });
|
||
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(el);
|
||
});
|
||
}
|
||
bind(V);
|
||
bind(A);
|
||
}
|
||
|
||
function updatePlayBtn() {
|
||
Remote.changed();
|
||
els.playBtn.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE;
|
||
$('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
|
||
const spBtn = $('servicePlayBtn');
|
||
if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
||
}
|
||
// "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one.
|
||
// Playback switches to live YouTube streams meanwhile (resuming where it
|
||
// was), and a device copy — most likely made from the same bad file — is
|
||
// replaced: the re-save waits on the server's refetch job.
|
||
const reportingBroken = new Set();
|
||
async function reportBroken(video) {
|
||
const id = video.id;
|
||
if (!id || video.custom || reportingBroken.has(id)) return;
|
||
reportingBroken.add(id);
|
||
updateNowPlayingActions();
|
||
try {
|
||
const res = await fetch(`/api/media/${encodeURIComponent(id)}/redownload`, { method: 'POST' });
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok || !j.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||
toast('Re-downloading on the server…');
|
||
const hadLocal = cachedIds.has(id);
|
||
if (hadLocal) {
|
||
try { await API.cacheDelete(id); } catch { /* best-effort */ }
|
||
cachedIds.delete(id);
|
||
markCardCacheState(id, 'none');
|
||
}
|
||
if (current && current.meta && current.meta.id === id) {
|
||
const at = Player.master ? Player.master.currentTime || 0 : 0;
|
||
await Player.loadVideo(current.meta, { preferStream: true, nocache: true, resume: false, reveal: false });
|
||
if (at > 1 && current && current.meta && current.meta.id === id) {
|
||
Player.master.addEventListener('loadedmetadata', () => Player.seek(at), { once: true });
|
||
}
|
||
}
|
||
if (hadLocal) preload(video);
|
||
} catch (err) {
|
||
toast('⚠ Could not report: ' + err.message);
|
||
} finally {
|
||
reportingBroken.delete(id);
|
||
updateNowPlayingActions();
|
||
}
|
||
}
|
||
|
||
// P2P availability under the title (docs/p2p-architecture.md flow 5).
|
||
// Cached 60 s per video so the frequent updateNowPlayingActions() calls
|
||
// don't hammer the server.
|
||
const availCache = new Map(); // videoId -> { at, payload }
|
||
async function renderAvailability(id) {
|
||
const el = $('npAvail');
|
||
if (!el) return;
|
||
const off = data.settings.p2pShare === false && data.settings.p2pReceive === false;
|
||
if (!WEB || !window.P2PCore || off || navigator.onLine === false || String(id).startsWith('edit_')) {
|
||
el.classList.add('hidden');
|
||
return;
|
||
}
|
||
let hit = availCache.get(id);
|
||
if (!hit || Date.now() - hit.at > 60_000) {
|
||
hit = { at: Date.now(), payload: null };
|
||
availCache.set(id, hit);
|
||
try {
|
||
const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`);
|
||
hit.payload = r.ok ? await r.json() : null;
|
||
} catch { /* offline / disabled — no line */ }
|
||
if (availCache.size > 200) availCache.delete(availCache.keys().next().value);
|
||
}
|
||
if (!current || !current.meta || current.meta.id !== id) return; // moved on meanwhile
|
||
const f = window.P2PCore.formatAvailability(hit.payload, Date.now());
|
||
if (!f) { el.classList.add('hidden'); return; }
|
||
el.textContent = f.text;
|
||
el.title = f.title;
|
||
el.classList.toggle('stale', f.stale);
|
||
el.classList.remove('hidden');
|
||
}
|
||
|
||
// Fetch a verified copy from another device (docs/p2p-architecture.md flow 6).
|
||
// Download-then-play: on success the file is saved offline like any save.
|
||
async function getFromPeers(videoObj, c, peers, onProgress) {
|
||
const id = videoObj.id;
|
||
downloading.add(id);
|
||
markCardCacheState(id, 'downloading');
|
||
updateDownloadBadge();
|
||
try {
|
||
const res = await window.P2PTransfer.download({ videoId: id, cid: c.cid, size: c.size, peers, onProgress });
|
||
if (res.ok) {
|
||
cachedIds.add(id);
|
||
cacheMutations++;
|
||
recordDeviceFile(id, { sha256: res.sha256, expectedSha: c.cid, size: res.size });
|
||
toast(`Saved "${videoObj.title || id}" from another device ✓`);
|
||
}
|
||
return res;
|
||
} finally {
|
||
downloading.delete(id);
|
||
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
|
||
updateDownloadBadge();
|
||
renderSidebar();
|
||
}
|
||
}
|
||
|
||
// Offered when a video won't load: only if some device holding it is online now.
|
||
async function offerPeerDownload(videoObj) {
|
||
if (!WEB || !window.P2PTransfer || !window.P2PCore || data.settings.p2pReceive === false) return;
|
||
const id = videoObj && videoObj.id;
|
||
if (!id || videoObj.custom || cachedIds.has(id)) return;
|
||
let payload = null;
|
||
try {
|
||
const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`);
|
||
payload = r.ok ? await r.json() : null;
|
||
} catch { return; }
|
||
const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null;
|
||
const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : [];
|
||
if (!peers.length || !els.playerPane.querySelector('.retry-btn')) return; // nobody online / user moved on
|
||
const old = els.playerPane.querySelector('.peer-btn');
|
||
if (old) old.remove();
|
||
const btn = document.createElement('button');
|
||
btn.className = 'retry-btn peer-btn';
|
||
btn.textContent = `📡 Get it from a device (${peers.length} online)`;
|
||
btn.addEventListener('click', async () => {
|
||
btn.disabled = true;
|
||
const res = await getFromPeers(videoObj, c, peers, (got, total) => {
|
||
btn.textContent = `📡 ${Math.min(99, Math.round((got / total) * 100))}%…`;
|
||
});
|
||
if (res.ok) {
|
||
els.playerPane.querySelectorAll('.retry-btn').forEach((b) => b.remove());
|
||
Player.loadVideo(videoObj);
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.textContent = '📡 Try again';
|
||
toast('⚠ ' + res.error);
|
||
}
|
||
});
|
||
els.playerPane.appendChild(btn);
|
||
}
|
||
|
||
|
||
|
||
// "More controls" sheet for the player deck. Toggles drive the original (hidden)
|
||
// buttons; speed and quality are mirrored selects that write back to the real
|
||
// ones. Rows marked `stay` keep the sheet open so A then B can be set in a row.
|
||
function openMoreControls() {
|
||
const toggles = [
|
||
['abABtn', '🅰', 'Loop start (A)', true],
|
||
['abBBtn', '🅱', 'Loop end (B)', true],
|
||
['abClearBtn', '↺', 'Clear A-B loop', true],
|
||
['rememberPosBtn', '📍', 'Remember position', true],
|
||
['sleepTimerBtn', '⏱', 'Sleep timer', false],
|
||
['transitionBtn', '↦', 'Between songs', true],
|
||
['eqBtn', '🎚', 'Equalizer', false],
|
||
['pipBtn', '⧉', 'Picture-in-picture', false],
|
||
];
|
||
const selects = [['speedSelect', '⏩', 'Speed'], ['qualitySelect', '🎞', 'Quality']];
|
||
const list = document.createElement('div');
|
||
list.className = 'more-list';
|
||
const draw = () => {
|
||
list.innerHTML = '';
|
||
for (const [id, icon, title] of selects) {
|
||
const src = $(id);
|
||
if (!src || !src.options.length) continue;
|
||
const row = document.createElement('label');
|
||
row.className = 'more-row';
|
||
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b></span>';
|
||
row.querySelector('.more-ico').textContent = icon;
|
||
row.querySelector('b').textContent = title;
|
||
const sel = document.createElement('select');
|
||
sel.className = 'more-sel';
|
||
sel.innerHTML = src.innerHTML;
|
||
sel.value = src.value;
|
||
sel.onchange = () => { src.value = sel.value; src.dispatchEvent(new Event('change', { bubbles: true })); };
|
||
row.appendChild(sel);
|
||
list.appendChild(row);
|
||
}
|
||
for (const [id, icon, title, stay] of toggles) {
|
||
const src = $(id);
|
||
if (!src || src.classList.contains('hidden') || src.hidden) continue;
|
||
const on = src.classList.contains('active') || src.classList.contains('sleep-active');
|
||
const row = document.createElement('button');
|
||
row.type = 'button';
|
||
row.className = 'more-row' + (on ? ' on' : '');
|
||
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">' + (on ? '●' : '›') + '</span>';
|
||
row.querySelector('.more-ico').textContent = icon;
|
||
row.querySelector('b').textContent = title;
|
||
row.querySelector('small').textContent = String(src.title || '').replace(/\s*\[[A-Za-z]\]\s*$/, '');
|
||
row.onclick = () => {
|
||
if (stay) { src.click(); setTimeout(draw, 60); return; }
|
||
closeModal();
|
||
setTimeout(() => src.click(), 0);
|
||
};
|
||
list.appendChild(row);
|
||
}
|
||
};
|
||
draw();
|
||
showModal('More controls', list, [{ label: 'Done', onClick: closeModal }]);
|
||
}
|
||
|
||
|
||
// "+ Add": one button, then a small choice between the queue and a playlist.
|
||
function openAddChoice() {
|
||
if (!current || !current.meta) return;
|
||
const video = current.meta;
|
||
const list = document.createElement('div');
|
||
list.className = 'more-list';
|
||
const row = (icon, title, sub, fn) => {
|
||
const r = document.createElement('button');
|
||
r.type = 'button';
|
||
r.className = 'more-row';
|
||
r.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
||
r.querySelector('.more-ico').textContent = icon;
|
||
r.querySelector('b').textContent = title;
|
||
r.querySelector('small').textContent = sub;
|
||
r.onclick = () => { closeModal(); setTimeout(fn, 0); };
|
||
list.appendChild(r);
|
||
};
|
||
row('▶', 'Add to queue', 'Play it next in this session', () => addToQueue(video));
|
||
row('☰', 'Add to playlist', 'Save it to one of your playlists', () => openCardMenu(video));
|
||
showModal('Add “' + (video.title || 'video').slice(0, 40) + '”', list, [{ label: 'Cancel', onClick: closeModal }]);
|
||
}
|
||
|
||
// "More actions" sheet: the everyday four stay on the page, the rest live here.
|
||
// Each row drives the original (hidden) button, so handlers and the hidden /
|
||
// disabled state those buttons already manage keep working.
|
||
function openMoreActions() {
|
||
const defs = [
|
||
['editBtn', '✂', 'Edit and download', 'Trim a clip, adjust quality and export'],
|
||
['shareMomentBtn', '🔗', 'Share moment', 'Link, GIF or soundbite of this exact second'],
|
||
['externalBtn', '📺', 'Open in…', 'VLC, MX Player or another app'],
|
||
['brokenBtn', '⚠', 'Report broken video', 'Won’t play, out of sync or glitchy — re-download it', true],
|
||
];
|
||
const list = document.createElement('div');
|
||
list.className = 'more-list';
|
||
for (const [id, icon, title, sub, danger] of defs) {
|
||
const src = $(id);
|
||
if (!src || src.hidden) continue;
|
||
const row = document.createElement('button');
|
||
row.type = 'button';
|
||
row.className = 'more-row' + (danger ? ' danger' : '');
|
||
row.disabled = !!src.disabled;
|
||
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
||
row.querySelector('.more-ico').textContent = icon;
|
||
row.querySelector('b').textContent = title;
|
||
row.querySelector('small').textContent = sub;
|
||
row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); };
|
||
list.appendChild(row);
|
||
}
|
||
if (!list.children.length) { toast('No more actions for this video'); return; }
|
||
showModal('More actions', list, [{ label: 'Close', onClick: closeModal }]);
|
||
}
|
||
|
||
// Reflect cache state on the now-playing Save button.
|
||
function updateNowPlayingActions() {
|
||
if (!current || !current.meta) return;
|
||
const id = current.meta.id;
|
||
renderAvailability(id);
|
||
// Custom (edited) videos are already a saved-offline cut of a source video;
|
||
// re-saving or re-editing them makes no sense, so hide those actions.
|
||
const isCustom = !!current.meta.custom;
|
||
if (els.editBtn) els.editBtn.hidden = isCustom;
|
||
if (els.saveBtn) els.saveBtn.hidden = isCustom;
|
||
// The server cache exists only behind the web PWA backend.
|
||
if (els.brokenBtn) {
|
||
els.brokenBtn.hidden = isCustom || !WEB;
|
||
els.brokenBtn.disabled = reportingBroken.has(id);
|
||
els.brokenBtn.textContent = reportingBroken.has(id) ? '⏳ Reporting…' : '⚠ Broken';
|
||
}
|
||
const btn = els.saveBtn;
|
||
if (!btn) return;
|
||
if (removing.has(id)) {
|
||
btn.textContent = '⏳ Removing…';
|
||
btn.classList.remove('done');
|
||
btn.disabled = true;
|
||
} else if (downloading.has(id)) {
|
||
btn.textContent = '⏳ Saving…';
|
||
btn.classList.remove('done');
|
||
btn.disabled = true;
|
||
} else 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 {
|
||
btn.textContent = '⬇ Save';
|
||
btn.classList.remove('done');
|
||
btn.disabled = false;
|
||
btn.title = 'Save this video for offline playback';
|
||
}
|
||
}
|
||
// Smallest font (px) the sung line may be fitted to in service mode; longer lines wrap instead.
|
||
const SERVICE_LYRIC_MIN_PX = 30;
|
||
|
||
// ============================================================================
|
||
// Shared: size each lyric row (.sl-line) to the largest font that fits ONE row
|
||
// ============================================================================
|
||
// Used by service-mode lyrics and the presenter view. Measured with a hidden
|
||
// real .sl-line (same font rules) in two passes: the display font has an
|
||
// optical-size axis, so glyphs get relatively WIDER as the size drops and a
|
||
// single measurement at 100px over-fits on small screens. Sets --fit per row;
|
||
// with `uniform` every row gets the smallest fit, so all lines share one size.
|
||
function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform = false } = {}) {
|
||
if (!list) return;
|
||
const rows = [...list.querySelectorAll('.sl-line')];
|
||
if (!rows.length || !list.clientWidth) return;
|
||
const width = list.clientWidth * 0.92 * 0.97; // 4% side padding + slack
|
||
const cap = Math.max(30, Math.min(list.clientHeight * maxShare, maxPx));
|
||
const clamp = (px) => Math.max(minPx, Math.min(cap, px));
|
||
const probe = document.createElement('div');
|
||
probe.className = 'sl-line';
|
||
probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap;width:auto;padding:0;font-size:100px;transition:none';
|
||
list.appendChild(probe);
|
||
const widthAt = (px) => { probe.style.fontSize = px + 'px'; return probe.getBoundingClientRect().width; };
|
||
const fits = [];
|
||
for (const el of rows) {
|
||
probe.textContent = el.textContent;
|
||
const w100 = widthAt(100);
|
||
let px = cap;
|
||
if (w100) {
|
||
const est = clamp((width / w100) * 100);
|
||
const wEst = widthAt(est);
|
||
px = Math.floor(clamp(wEst ? est * (width / wEst) : est));
|
||
}
|
||
el.style.setProperty('--fit', px + 'px');
|
||
fits.push(px);
|
||
}
|
||
probe.remove();
|
||
if (uniform) {
|
||
const px = Math.min(...fits);
|
||
rows.forEach((el) => el.style.setProperty('--fit', px + 'px'));
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Lyrics · chapters · bookmarks
|
||
// ============================================================================
|
||
// Lyrics and chapters are shared per video by every user (server/notes.js
|
||
// keeps each save as a revision). Bookmarks and the sync offset are personal
|
||
// and ride along with the profile sync (data.bookmarks / data.lyricOffsets).
|
||
// The last copy seen of each video's shared notes is kept in localStorage so
|
||
// saved songs still show their lyrics offline.
|
||
const Notes = (() => {
|
||
const LC = window.LyricsCore;
|
||
const panel = $('notesPanel');
|
||
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {} };
|
||
if (!panel || !LC) return noop;
|
||
|
||
const CACHE_KEY = 'ytpNotesCache';
|
||
const CACHE_MAX = 200;
|
||
const st = {
|
||
videoId: null, lyrics: null, chapters: null, tab: 'lyrics',
|
||
activeIdx: -1, lastUserScroll: 0, sync: null, loadSeq: 0,
|
||
statusMsg: '', sectionText: '', lastDur: 0, chipKey: '', addLabel: '',
|
||
};
|
||
|
||
// ---- small helpers ----
|
||
const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || '');
|
||
const profileName = () => (data.profile && data.profile.name) || '';
|
||
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []);
|
||
const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
|
||
const bookmarks = () => {
|
||
if (!st.videoId || !data.bookmarks) return [];
|
||
return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : [];
|
||
};
|
||
const docOffset = () => (st.lyrics && Number(st.lyrics.data.offset)) || 0;
|
||
const personalOffset = () => (st.videoId && data.lyricOffsets && Number(data.lyricOffsets[st.videoId])) || 0;
|
||
const offset = () => docOffset() + personalOffset();
|
||
const now = () => (Player.master && Player.master.currentTime) || 0;
|
||
const round2 = (n) => Math.round(n * 100) / 100;
|
||
|
||
function readCache() {
|
||
try { return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch { return {}; }
|
||
}
|
||
function writeCache(id, entry) {
|
||
try {
|
||
const c = readCache();
|
||
c[id] = { ...(c[id] || {}), ...entry, at: Date.now() };
|
||
const ids = Object.keys(c);
|
||
if (ids.length > CACHE_MAX) {
|
||
ids.sort((a, b) => (c[a].at || 0) - (c[b].at || 0))
|
||
.slice(0, ids.length - CACHE_MAX).forEach((k) => delete c[k]);
|
||
}
|
||
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
|
||
} catch { /* storage full or blocked — lyrics just won't be offline */ }
|
||
}
|
||
|
||
function requireProfile() {
|
||
if (profileName()) return true;
|
||
toast('Link an online profile (Settings → Online profile) to edit shared lyrics and chapters.', { duration: 5000 });
|
||
return false;
|
||
}
|
||
|
||
function setStatus(msg) { st.statusMsg = msg || ''; renderStatus(); }
|
||
function renderStatus() {
|
||
const el = $('notesStatus');
|
||
if (el) el.textContent = st.statusMsg || st.sectionText || '';
|
||
}
|
||
|
||
// ---- loading ----
|
||
async function load(meta) {
|
||
if (st.sync && meta && meta.id === st.videoId) return; // a fallback reload mid-sync
|
||
endSync();
|
||
if (!supported(meta)) {
|
||
st.videoId = null;
|
||
st.lyrics = null;
|
||
panel.classList.add('hidden');
|
||
renderSeekMarkers();
|
||
renderService(true);
|
||
renderStage(true);
|
||
return;
|
||
}
|
||
const id = meta.id;
|
||
const seq = ++st.loadSeq;
|
||
st.videoId = id;
|
||
$('tsResults').innerHTML = '';
|
||
$('tsInfo').textContent = '';
|
||
delete $('tsSource').dataset.touched;
|
||
$('tsSource').value = 'lyrics';
|
||
st.activeIdx = -1;
|
||
st.lastDur = 0;
|
||
const cached = readCache()[id];
|
||
st.lyrics = cached ? cached.lyrics || null : null;
|
||
st.chapters = cached ? cached.chapters || null : null;
|
||
panel.classList.remove('hidden');
|
||
setStatus(cached ? '' : 'Loading…');
|
||
renderAll();
|
||
try {
|
||
const res = await fetch(`/api/notes/${encodeURIComponent(id)}`);
|
||
const j = await res.json();
|
||
if (seq !== st.loadSeq) return;
|
||
if (!j || !j.ok) throw new Error((j && j.error) || 'failed');
|
||
st.lyrics = j.lyrics;
|
||
st.chapters = j.chapters;
|
||
writeCache(id, { lyrics: j.lyrics, chapters: j.chapters });
|
||
setStatus('');
|
||
if (!st.sync) renderAll();
|
||
} catch {
|
||
if (seq !== st.loadSeq) return;
|
||
setStatus(cached ? 'Offline copy' : 'Offline');
|
||
}
|
||
}
|
||
|
||
function applyNote(id, kind, note) {
|
||
writeCache(id, { [kind]: note });
|
||
if (id !== st.videoId) return;
|
||
st[kind] = note;
|
||
renderAll();
|
||
}
|
||
|
||
// ---- rendering ----
|
||
function renderAll() {
|
||
renderLyrics();
|
||
renderService(true);
|
||
renderStage(true);
|
||
renderMarkers();
|
||
renderSeekMarkers();
|
||
renderOffset();
|
||
}
|
||
|
||
function renderLyrics() {
|
||
const list = $('lyricsList');
|
||
const tagsEl = $('lyricsTags');
|
||
const ls = lines();
|
||
const tags = (st.lyrics && st.lyrics.data && st.lyrics.data.tags) || [];
|
||
tagsEl.innerHTML = tags.map((t) => `<span class="lyr-tag">${escapeHtml(t)}</span>`).join('');
|
||
tagsEl.classList.toggle('hidden', !tags.length);
|
||
$('lyricsSyncBtn').disabled = !ls.length || !!st.sync;
|
||
$('lyricsHistoryBtn').disabled = !st.lyrics;
|
||
$('lyricsEditBtn').disabled = !!st.sync;
|
||
st.activeIdx = -1;
|
||
if (!ls.length) {
|
||
list.innerHTML = `
|
||
<div class="notes-empty">
|
||
<div>No lyrics yet for this song.</div>
|
||
<div class="notes-empty-actions">
|
||
<button class="np-btn" type="button" data-act="web">🌐 Find lyrics online</button>
|
||
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
|
||
${isUploadId(st.videoId) ? '' : '<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>'}
|
||
</div>
|
||
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
|
||
</div>`;
|
||
st.sectionText = '';
|
||
renderStatus();
|
||
return;
|
||
}
|
||
list.innerHTML = ls.map((l, i) => {
|
||
const time = l.t === null || l.t === undefined ? '' : LC.fmtClock(l.t);
|
||
if (l.kind === 'section') {
|
||
return `<div class="lyr-section" data-i="${i}">${escapeHtml(l.text)}${time ? `<span class="lyr-section-time"> · ${time}</span>` : ''}</div>`;
|
||
}
|
||
if (l.kind === 'cue') {
|
||
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
|
||
}
|
||
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${escapeHtml(l.text)}</span></div>`;
|
||
}).join('');
|
||
if (st.sync) renderSyncPointer();
|
||
else tick(now(), true);
|
||
paintFlags();
|
||
loadFlags();
|
||
setPanelFlagMode(sv.panelFlag && flagsEnabled());
|
||
}
|
||
|
||
function renderOffset() {
|
||
const p = personalOffset();
|
||
$('lyrOffVal').textContent = `${p > 0 ? '+' : ''}${p.toFixed(1)}s`;
|
||
$('lyrOffVal').classList.toggle('nonzero', p !== 0);
|
||
const auto = data.settings.lyricsAutoscroll !== false;
|
||
$('lyrAutoscroll').checked = auto;
|
||
}
|
||
|
||
function markerItems() {
|
||
const out = [];
|
||
chapters().forEach((c, i) => out.push({ ...c, kind: 'chapter', i }));
|
||
bookmarks().forEach((b, i) => out.push({ ...b, kind: 'bookmark', i }));
|
||
return out.sort((a, b) => a.t - b.t);
|
||
}
|
||
|
||
function renderMarkers() {
|
||
const chs = chapters();
|
||
const bms = bookmarks();
|
||
const count = chs.length + bms.length;
|
||
$('markersCount').textContent = count ? String(count) : '';
|
||
$('chaptersHistoryBtn').disabled = !st.chapters;
|
||
|
||
const chips = markerItems();
|
||
$('markerChips').innerHTML = chips.map((m) => `
|
||
<button class="marker-chip ${m.kind}" type="button" data-t="${m.t}" title="${escapeHtml(m.note || m.title)}">
|
||
<span class="marker-chip-time">${LC.fmtClock(m.t)}</span> ${m.kind === 'bookmark' ? '📌 ' : ''}${escapeHtml(m.title)}
|
||
</button>`).join('');
|
||
$('markerChips').classList.toggle('hidden', !chips.length);
|
||
|
||
const row = (m, kind, i) => `
|
||
<div class="marker-row" data-kind="${kind}" data-i="${i}">
|
||
<button class="mk-time" type="button" data-act="jump" title="Jump here">${LC.fmtClock(m.t)}</button>
|
||
<div class="mk-body">
|
||
<div class="mk-title">${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}</div>
|
||
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
|
||
</div>
|
||
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
|
||
<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
|
||
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>
|
||
</div>`;
|
||
$('chapterList').innerHTML = chs.length
|
||
? chs.map((c, i) => row(c, 'chapter', i)).join('')
|
||
: '<div class="notes-empty small">No chapters yet — tap + Chapter while the song plays.</div>';
|
||
$('bookmarkList').innerHTML = bms.length
|
||
? bms.map((b, i) => row(b, 'bookmark', i)).join('')
|
||
: '<div class="notes-empty small">No bookmarks — tap 📌 or press P.</div>';
|
||
st.chipKey = '';
|
||
}
|
||
|
||
function renderSeekMarkers() {
|
||
const host = $('seekMarkers');
|
||
if (!host) return;
|
||
const dur = (Player.master && Player.master.duration) || (current && current.meta && current.meta.duration) || 0;
|
||
st.lastDur = dur;
|
||
if (!st.videoId || !dur || !isFinite(dur)) { host.innerHTML = ''; return; }
|
||
host.innerHTML = markerItems()
|
||
.filter((m) => m.t <= dur)
|
||
.map((m) => `<span class="seek-tick ${m.kind}" style="left:${((m.t / dur) * 100).toFixed(3)}%"></span>`)
|
||
.join('');
|
||
}
|
||
|
||
// ---- playback tick (timeupdate) ----
|
||
function scrollToRow(list, el) {
|
||
const top = el.offsetTop - list.clientHeight / 2 + el.clientHeight / 2;
|
||
list.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||
}
|
||
|
||
function tick(cur, force = false) {
|
||
tickService(cur, force);
|
||
tickStage(cur, force);
|
||
if (!st.videoId || panel.classList.contains('hidden')) return;
|
||
const dur = (Player.master && Player.master.duration) || 0;
|
||
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
|
||
|
||
// "+ Chapter at 2:41" follows the playhead.
|
||
const label = `+ Chapter at ${LC.fmtClock(cur)}`;
|
||
if (label !== st.addLabel) { st.addLabel = label; $('addChapterBtn').textContent = label; }
|
||
|
||
// Active chip.
|
||
const active = LC.activeMarker(markerItems(), cur);
|
||
const key = active ? `${active.kind}:${active.i}` : '';
|
||
if (key !== st.chipKey) {
|
||
st.chipKey = key;
|
||
$('markerChips').querySelectorAll('.marker-chip.active').forEach((el) => el.classList.remove('active'));
|
||
if (active) {
|
||
const chip = $('markerChips').querySelector(`.marker-chip[data-t="${active.t}"]`);
|
||
if (chip) chip.classList.add('active');
|
||
}
|
||
}
|
||
|
||
if (st.sync) return;
|
||
const ls = lines();
|
||
if (!ls.length) return;
|
||
const off = offset();
|
||
const idx = LC.activeIndex(ls, cur, off);
|
||
const list = $('lyricsList');
|
||
if (idx !== st.activeIdx || force) {
|
||
const prev = list.querySelector('.lyr-line.active');
|
||
if (prev) prev.classList.remove('active');
|
||
const el = idx >= 0 ? list.querySelector(`[data-i="${idx}"]`) : null;
|
||
if (el) {
|
||
el.classList.add('active');
|
||
const auto = data.settings.lyricsAutoscroll !== false;
|
||
if (auto && st.tab === 'lyrics' && Date.now() - st.lastUserScroll > 4000) scrollToRow(list, el);
|
||
}
|
||
st.activeIdx = idx;
|
||
const sec = LC.activeSection(ls, cur, off);
|
||
st.sectionText = sec ? `${sec.text} · now` : '';
|
||
renderStatus();
|
||
}
|
||
// Band cues count down over their last minute.
|
||
list.querySelectorAll('.lyr-cue').forEach((el) => {
|
||
const l = ls[Number(el.dataset.i)];
|
||
const inEl = el.querySelector('.lyr-cue-in');
|
||
if (!l || l.t === null || l.t === undefined || !inEl) return;
|
||
const left = l.t + off - cur;
|
||
const txt = left > 0 && left <= 60 ? `in ${LC.fmtClock(Math.ceil(left))}` : '';
|
||
if (inEl.textContent !== txt) inEl.textContent = txt;
|
||
el.classList.toggle('soon', !!txt);
|
||
});
|
||
}
|
||
|
||
// ---- saving shared notes ----
|
||
async function putNote(id, kind, payload, baseRev) {
|
||
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ data: payload, baseRev: baseRev || 0, profile: profileName() }),
|
||
});
|
||
const j = await res.json().catch(() => null);
|
||
if (res.status === 409 && j && j.conflict) return { conflict: true, current: j.current };
|
||
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
|
||
return j;
|
||
}
|
||
|
||
function askConflict(kind, current) {
|
||
return new Promise((resolve) => {
|
||
const body = document.createElement('div');
|
||
const who = current && current.updatedBy ? escapeHtml(current.updatedBy) : 'Someone';
|
||
body.innerHTML = `<p class="modal-text">${who} saved a newer version of these ${kind} while you were editing.
|
||
Keep yours (theirs stays in the history) or load theirs?</p>`;
|
||
showModal('Newer version on the server', body, [
|
||
{ label: 'Load theirs', onClick: () => { closeModal(); resolve(false); } },
|
||
{ label: 'Keep mine', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
||
]);
|
||
});
|
||
}
|
||
|
||
// 'saved' | 'theirs' | 'failed'
|
||
async function saveShared(kind, payload, baseRev) {
|
||
const id = st.videoId;
|
||
if (!id || !requireProfile()) return 'failed';
|
||
try {
|
||
let r = await putNote(id, kind, payload, baseRev);
|
||
if (r.conflict) {
|
||
if (!(await askConflict(kind, r.current))) {
|
||
applyNote(id, kind, r.current);
|
||
return 'theirs';
|
||
}
|
||
r = await putNote(id, kind, payload, r.current ? r.current.rev : 0);
|
||
if (r.conflict) throw new Error('someone saved again — try once more');
|
||
}
|
||
applyNote(id, kind, {
|
||
data: r.data, rev: r.rev, source: 'user', updatedBy: profileName(),
|
||
updatedAt: Math.floor(Date.now() / 1000),
|
||
});
|
||
toast(`Saved ✓ — shared with everyone`);
|
||
return 'saved';
|
||
} catch (err) {
|
||
toast('⚠ Could not save: ' + err.message, { duration: 4000 });
|
||
return 'failed';
|
||
}
|
||
}
|
||
|
||
// ---- lyrics editor ----
|
||
async function captionsText() {
|
||
const id = st.videoId;
|
||
if (!id) return null;
|
||
toast('Fetching YouTube captions…');
|
||
try {
|
||
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/captions`);
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
|
||
toast(`Imported ${j.lines.length} lines from ${j.auto ? 'auto-generated ' : ''}captions (${j.lang}) — check the words before saving.`, { duration: 5000 });
|
||
return LC.toText({ lines: j.lines.map((l) => ({ ...l, kind: 'line' })), tags: [] });
|
||
} catch (err) {
|
||
toast('⚠ ' + err.message, { duration: 4000 });
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function openEditor(prefill, { importCaptions = false, note = '' } = {}) {
|
||
if (!st.videoId || !requireProfile()) return;
|
||
const baseRev = st.lyrics ? st.lyrics.rev : 0;
|
||
const body = document.createElement('div');
|
||
body.className = 'lyr-editor';
|
||
body.innerHTML = `
|
||
<p class="lyr-editor-help">One lyric line per line. Optional: <code>[1:23.45]</code> times a line
|
||
(or time them later with Tap-sync), <code># Chorus</code> is a section, <code>! Drums in</code> a band cue,
|
||
<code>@ Key G, Capo 2, 70 BPM</code> adds tags. Pasted .lrc files work as-is.</p>
|
||
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
|
||
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
|
||
<div class="lyr-editor-tools">
|
||
<button type="button" class="np-btn" data-act="captions">⤓ Import YouTube captions</button>
|
||
<button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button>
|
||
</div>`;
|
||
const ta = body.querySelector('textarea');
|
||
ta.value = prefill != null ? prefill : (st.lyrics ? LC.toText(st.lyrics.data) : '');
|
||
ta.addEventListener('keydown', (e) => e.stopPropagation());
|
||
const runCaptions = async () => {
|
||
const text = await captionsText();
|
||
if (text != null) ta.value = text;
|
||
};
|
||
body.querySelector('[data-act="captions"]').addEventListener('click', runCaptions);
|
||
body.querySelector('[data-act="stamp"]').addEventListener('click', () => {
|
||
const pos = ta.selectionStart;
|
||
const start = ta.value.lastIndexOf('\n', pos - 1) + 1;
|
||
const endNl = ta.value.indexOf('\n', start);
|
||
const end = endNl < 0 ? ta.value.length : endNl;
|
||
const lineText = ta.value.slice(start, end).replace(/^(\[[^\]]*\]\s*)+/, '');
|
||
const stamp = `[${LC.fmtStamp(Math.max(0, now() - docOffset()))}] `;
|
||
ta.value = ta.value.slice(0, start) + stamp + lineText + ta.value.slice(end);
|
||
// Move to the next line so repeated taps walk down the sheet.
|
||
const next = ta.value.indexOf('\n', start);
|
||
const caret = next < 0 ? ta.value.length : next + 1;
|
||
ta.focus();
|
||
ta.setSelectionRange(caret, caret);
|
||
});
|
||
showModal(st.lyrics ? 'Edit lyrics' : 'Add lyrics', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Save for everyone', primary: true, onClick: async () => {
|
||
const text = ta.value;
|
||
const doc = LC.fromText(text);
|
||
const payload = {
|
||
lines: doc.lines,
|
||
tags: doc.tags,
|
||
offset: doc.offset !== null ? doc.offset : docOffset(),
|
||
};
|
||
closeModal();
|
||
const r = await saveShared('lyrics', payload, baseRev);
|
||
if (r === 'failed') openEditor(text, { note: 'Not saved yet — your text is still here.' });
|
||
} },
|
||
]);
|
||
if (importCaptions) runCaptions();
|
||
else setTimeout(() => ta.focus(), 50);
|
||
}
|
||
|
||
// Look the song up on LRCLIB (published, often synced lyrics) and save it.
|
||
async function fetchWebLyrics(overwrite = false) {
|
||
const id = st.videoId;
|
||
if (!id || !requireProfile()) return;
|
||
setStatus('Looking up lyrics online…');
|
||
try {
|
||
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ profile: profileName(), overwrite }),
|
||
});
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; }
|
||
toast(`🌐 ${j.lines} ${j.synced ? 'timed' : 'untimed'} lines from LRCLIB (${j.match.artist} – ${j.match.track})`, { duration: 5000 });
|
||
setStatus('');
|
||
load(current && current.meta ? current.meta : { id });
|
||
} catch (err) {
|
||
toast('⚠ ' + err.message);
|
||
setStatus('');
|
||
}
|
||
}
|
||
|
||
// ---- tap-sync ----
|
||
function startSync() {
|
||
if (!st.videoId || !requireProfile()) return;
|
||
const src = (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || [];
|
||
if (!src.length) return;
|
||
st.sync = {
|
||
lines: src.map((l) => ({ ...l })),
|
||
ptr: 0,
|
||
history: [],
|
||
baseRev: st.lyrics.rev,
|
||
videoId: st.videoId,
|
||
};
|
||
panel.classList.add('syncing');
|
||
$('lyricsSyncBar').classList.remove('hidden');
|
||
st.tab = 'lyrics';
|
||
showTab('lyrics');
|
||
renderLyrics();
|
||
toast('Play the song · tap Stamp line (or Enter) as each line starts', { duration: 4000 });
|
||
}
|
||
|
||
function renderSyncPointer() {
|
||
const s = st.sync;
|
||
if (!s) return;
|
||
const list = $('lyricsList');
|
||
list.querySelectorAll('.sync-ptr').forEach((el) => el.classList.remove('sync-ptr'));
|
||
const el = list.querySelector(`[data-i="${s.ptr}"]`);
|
||
if (el) { el.classList.add('sync-ptr'); scrollToRow(list, el); }
|
||
const total = s.lines.length;
|
||
const next = s.lines[s.ptr];
|
||
$('lyricsSyncHint').textContent = next
|
||
? `${s.ptr + 1}/${total} · next: “${next.text.slice(0, 60)}”`
|
||
: `All ${total} lines stamped — save when ready.`;
|
||
$('lyricsUndoBtn').disabled = !s.history.length;
|
||
}
|
||
|
||
function stamp() {
|
||
const s = st.sync;
|
||
if (!s || s.ptr >= s.lines.length) return;
|
||
s.history.push({ i: s.ptr, prev: s.lines[s.ptr].t });
|
||
s.lines[s.ptr].t = round2(Math.max(0, now() - docOffset()));
|
||
s.ptr++;
|
||
renderLyrics();
|
||
}
|
||
|
||
function undoStamp() {
|
||
const s = st.sync;
|
||
const h = s && s.history.pop();
|
||
if (!h) return;
|
||
s.lines[h.i].t = h.prev;
|
||
s.ptr = h.i;
|
||
renderLyrics();
|
||
}
|
||
|
||
function endSync() {
|
||
if (!st.sync) return;
|
||
st.sync = null;
|
||
panel.classList.remove('syncing');
|
||
$('lyricsSyncBar').classList.add('hidden');
|
||
renderLyrics();
|
||
}
|
||
|
||
async function finishSync() {
|
||
const s = st.sync;
|
||
if (!s) return;
|
||
const payload = { ...(st.lyrics ? st.lyrics.data : {}), lines: s.lines };
|
||
const r = await saveShared('lyrics', payload, s.baseRev);
|
||
if (r !== 'failed') endSync();
|
||
}
|
||
|
||
function handleSyncKey(e) {
|
||
if (!st.sync) return false;
|
||
if (e.key === 'Enter' || e.key === 't') { e.preventDefault(); stamp(); return true; }
|
||
if (e.key === 'Backspace') { e.preventDefault(); undoStamp(); return true; }
|
||
if (e.key === 'Escape') { e.preventDefault(); endSync(); return true; }
|
||
return false;
|
||
}
|
||
|
||
// ---- history (both kinds) ----
|
||
async function openHistory(kind) {
|
||
const id = st.videoId;
|
||
if (!id) return;
|
||
let revs = [];
|
||
try {
|
||
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs`)).json();
|
||
if (!j.ok) throw new Error(j.error || 'failed');
|
||
revs = j.revs;
|
||
} catch (err) {
|
||
toast('⚠ Could not load history: ' + err.message);
|
||
return;
|
||
}
|
||
const live = st[kind] ? st[kind].rev : 0;
|
||
const body = document.createElement('div');
|
||
body.className = 'notes-history';
|
||
body.innerHTML = revs.length ? revs.map((r) => `
|
||
<div class="hist-row">
|
||
<div class="hist-main">
|
||
<span class="hist-rev">v${r.rev}</span>
|
||
<span class="hist-who">${escapeHtml(r.updatedBy || 'unknown')}</span>
|
||
<span class="hist-src">${escapeHtml(r.source)}</span>
|
||
<span class="hist-when">${new Date(r.createdAt * 1000).toLocaleString()}</span>
|
||
</div>
|
||
${r.rev === live ? '<span class="hist-live">current</span>' : `<button class="np-btn" type="button" data-rev="${r.rev}">Restore</button>`}
|
||
</div>`).join('') : '<div class="notes-empty small">No history yet.</div>';
|
||
body.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-rev]');
|
||
if (!btn) return;
|
||
try {
|
||
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs/${btn.dataset.rev}`)).json();
|
||
if (!j.ok) throw new Error(j.error || 'failed');
|
||
closeModal();
|
||
await saveShared(kind, j.data, live);
|
||
} catch (err) {
|
||
toast('⚠ Could not restore: ' + err.message);
|
||
}
|
||
});
|
||
showModal(kind === 'lyrics' ? '🕘 Lyrics history' : '🕘 Chapters history', body, [
|
||
{ label: 'Close', primary: true, onClick: closeModal },
|
||
]);
|
||
}
|
||
|
||
// ---- chapters & bookmarks ----
|
||
// longNote: a multi-line personal note (study notes) instead of the short
|
||
// one-line note shared chapters are limited to.
|
||
function markerForm(title, init, { longNote = false, titleOptional = false } = {}) {
|
||
return new Promise((resolve) => {
|
||
const body = document.createElement('div');
|
||
body.className = 'marker-form';
|
||
body.innerHTML = `
|
||
<label>Time <input type="text" class="mf-time" inputmode="decimal" placeholder="m:ss" /></label>
|
||
<label>${titleOptional ? 'Label (optional)' : 'Title'} <input type="text" class="mf-title" maxlength="100" placeholder="${titleOptional ? 'e.g. Key idea' : 'e.g. Key change → D'}" /></label>
|
||
<label>Note ${longNote
|
||
? '<textarea class="mf-note" maxlength="2000" rows="6" placeholder="Write your note… (Ctrl/⌘+Enter saves)"></textarea>'
|
||
: '<input type="text" class="mf-note" maxlength="200" placeholder="optional" />'}</label>`;
|
||
const $t = body.querySelector('.mf-time');
|
||
const $ti = body.querySelector('.mf-title');
|
||
const $n = body.querySelector('.mf-note');
|
||
$t.value = LC.fmtClock(init.t);
|
||
$ti.value = init.title || '';
|
||
$n.value = init.note || '';
|
||
const submit = () => {
|
||
const t = LC.parseStamp($t.value);
|
||
const ttl = $ti.value.trim();
|
||
if (t === null) { toast('Time must look like 1:23'); return; }
|
||
const note = $n.value.trim();
|
||
const label = ttl || (titleOptional ? (note.split('\n')[0].slice(0, 60) || 'Note') : '');
|
||
if (!label) { toast('Give it a title'); return; }
|
||
closeModal();
|
||
resolve({ t, title: label, note });
|
||
};
|
||
body.addEventListener('keydown', (e) => {
|
||
e.stopPropagation();
|
||
if (e.key !== 'Enter') return;
|
||
if (e.target.tagName === 'TEXTAREA' && !(e.ctrlKey || e.metaKey)) return; // newline
|
||
e.preventDefault();
|
||
submit();
|
||
});
|
||
showModal(title, body, [
|
||
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
|
||
{ label: 'Save', primary: true, onClick: submit },
|
||
]);
|
||
setTimeout(() => (longNote && titleOptional ? $n : $ti).focus(), 50);
|
||
});
|
||
}
|
||
|
||
async function addChapter() {
|
||
if (!st.videoId || !requireProfile()) return;
|
||
const baseRev = st.chapters ? st.chapters.rev : 0;
|
||
const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' });
|
||
if (!v) return;
|
||
await saveShared('chapters', { items: [...chapters(), v] }, baseRev);
|
||
}
|
||
|
||
async function editMarker(kind, i) {
|
||
const list = kind === 'chapter' ? chapters() : bookmarks();
|
||
const item = list[i];
|
||
if (!item) return;
|
||
if (kind === 'chapter' && !requireProfile()) return;
|
||
const baseRev = st.chapters ? st.chapters.rev : 0;
|
||
const v = await markerForm(kind === 'chapter' ? '✎ Edit chapter' : '✎ Edit note / bookmark', item,
|
||
kind === 'chapter' ? {} : { longNote: true, titleOptional: true });
|
||
if (!v) return;
|
||
const next = list.slice();
|
||
next[i] = v;
|
||
if (kind === 'chapter') await saveShared('chapters', { items: next }, baseRev);
|
||
else setBookmarks(next);
|
||
}
|
||
|
||
async function deleteMarker(kind, i) {
|
||
if (kind === 'chapter') {
|
||
if (!requireProfile()) return;
|
||
const next = chapters().filter((_, j) => j !== i);
|
||
await saveShared('chapters', { items: next }, st.chapters ? st.chapters.rev : 0);
|
||
} else {
|
||
setBookmarks(bookmarks().filter((_, j) => j !== i));
|
||
toast('Bookmark removed');
|
||
}
|
||
}
|
||
|
||
function setBookmarks(list) {
|
||
if (!st.videoId) return;
|
||
if (!data.bookmarks) data.bookmarks = {};
|
||
// Each entry carries its video's title/channel so the 📝 Notes page can
|
||
// list notes for videos that are no longer in history.
|
||
const meta = current && current.meta && current.meta.id === st.videoId ? current.meta : null;
|
||
if (meta) list = list.map((b) => ({ ...b, vt: b.vt || meta.title || '', vc: b.vc || meta.channel || '' }));
|
||
const sorted = list.slice().sort((a, b) => a.t - b.t);
|
||
if (sorted.length) data.bookmarks[st.videoId] = sorted;
|
||
else delete data.bookmarks[st.videoId];
|
||
persist();
|
||
renderMarkers();
|
||
renderSeekMarkers();
|
||
}
|
||
|
||
function addBookmark() {
|
||
if (!st.videoId) { toast('Bookmarks need a YouTube video'); return; }
|
||
const t = round2(now());
|
||
const n = bookmarks().length + 1;
|
||
setBookmarks([...bookmarks(), { t, title: `Bookmark ${n}`, note: '' }]);
|
||
toast(`📌 Bookmarked ${LC.fmtClock(t)} — rename it under Chapters & bookmarks`);
|
||
}
|
||
|
||
async function addNote() {
|
||
if (!st.videoId) { toast('Notes need a YouTube video'); return; }
|
||
const v = await markerForm('📝 Note at this moment', { t: round2(now()), title: '', note: '' }, { longNote: true, titleOptional: true });
|
||
if (!v) return;
|
||
setBookmarks([...bookmarks(), { ...v, kind: 'note' }]);
|
||
toast(`📝 Note saved at ${LC.fmtClock(v.t)}`);
|
||
}
|
||
|
||
function openExport() {
|
||
const chs = chapters();
|
||
const bms = bookmarks();
|
||
if (!chs.length && !bms.length) { toast('Nothing to export yet'); return; }
|
||
const title = (current && current.meta && current.meta.title) || st.videoId;
|
||
const build = (withBm) => LC.toYouTubeTimestamps(withBm ? [...chs, ...bms] : chs);
|
||
const body = document.createElement('div');
|
||
body.className = 'marker-export';
|
||
body.innerHTML = `
|
||
<p class="modal-text">YouTube timestamp format — paste into a description, setlist or chat.</p>
|
||
<label class="mx-inc"><input type="checkbox" class="mx-bm" ${chs.length ? '' : 'checked'} /> Include my bookmarks</label>
|
||
<textarea class="mx-text" readonly spellcheck="false"></textarea>`;
|
||
const ta = body.querySelector('textarea');
|
||
const inc = body.querySelector('.mx-bm');
|
||
const refresh = () => { ta.value = build(inc.checked); };
|
||
inc.addEventListener('change', refresh);
|
||
refresh();
|
||
showModal('⤓ Export chapters', body, [
|
||
{ label: 'Download .txt', onClick: () => {
|
||
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `${String(title).replace(/[^\w\- ]+/g, '').trim().slice(0, 60) || st.videoId}-chapters.txt`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||
} },
|
||
{ label: 'Copy', primary: true, onClick: async () => {
|
||
try { await navigator.clipboard.writeText(ta.value); toast('Copied ✓'); }
|
||
catch { ta.select(); toast('Select-all done — copy with Ctrl/⌘+C'); }
|
||
} },
|
||
]);
|
||
}
|
||
|
||
// ---- service mode: lyrics only ----
|
||
// The sung line sits in the middle of the screen at the largest size that
|
||
// still fits on ONE row (measured per line with a canvas), highlighted; its
|
||
// neighbours shrink and fade with distance. Auto-scroll keeps it centred.
|
||
const sv = { key: '', idx: -2, timer: null, flags: new Map(), flagsKey: '', flagMode: false, panelFlag: false, pressed: 0 };
|
||
const flagsEnabled = () => data.settings.lyricFlags !== false && !!window.FlagUI;
|
||
const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics;
|
||
const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false;
|
||
const sungLines = () => lines().map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
|
||
|
||
// Plain (untimed) lyrics have no sung line to spotlight, so every line gets
|
||
// the same size — the largest at which the longest line still fits one row.
|
||
const plainLyrics = () => { const l = sungLines(); return l.length > 0 && l.every((x) => x.t === null || x.t === undefined); };
|
||
function applyFits() {
|
||
const list = $('serviceLyricsList');
|
||
const plain = plainLyrics();
|
||
list.classList.toggle('plain', plain);
|
||
fitLyricLines(list, { uniform: plain, minPx: SERVICE_LYRIC_MIN_PX });
|
||
}
|
||
|
||
function renderServiceButtons() {
|
||
const on = !!data.settings.serviceLyrics;
|
||
const lb = $('serviceLyricsBtn');
|
||
lb.setAttribute('aria-pressed', String(on));
|
||
const fb = $('serviceFlagBtn');
|
||
if (fb) {
|
||
const show = on && flagsEnabled();
|
||
fb.classList.toggle('hidden', !show);
|
||
if (!show) sv.flagMode = false;
|
||
fb.setAttribute('aria-pressed', String(sv.flagMode));
|
||
$('serviceFlagHint').classList.toggle('hidden', !(show && sv.flagMode));
|
||
$('serviceLyrics').classList.toggle('flagging', show && sv.flagMode);
|
||
}
|
||
const ab = $('serviceAutoscrollBtn');
|
||
ab.classList.toggle('hidden', !on);
|
||
ab.setAttribute('aria-pressed', String(serviceAuto()));
|
||
}
|
||
|
||
function renderService(force = false) {
|
||
renderServiceButtons();
|
||
const on = serviceOn();
|
||
$('serviceMode').classList.toggle('lyrics-on', on);
|
||
$('serviceLyrics').classList.toggle('hidden', !on);
|
||
if (!on) { sv.key = ''; return; }
|
||
const list = $('serviceLyricsList');
|
||
const items = sungLines();
|
||
const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`;
|
||
if (key === sv.key && !force) return;
|
||
sv.key = key;
|
||
sv.idx = -2;
|
||
// Half-height spacers let the first and last line reach the centre.
|
||
const pad = Math.round(list.clientHeight / 2);
|
||
list.style.paddingTop = pad + 'px';
|
||
list.style.paddingBottom = pad + 'px';
|
||
if (!items.length) {
|
||
const title = (current && current.meta && current.meta.title) || '';
|
||
list.innerHTML = `<div class="sl-empty">${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `<small>${escapeHtml(title)}</small>` : ''}</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = items.map((l) =>
|
||
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
|
||
paintFlags();
|
||
loadFlags();
|
||
applyFits();
|
||
tickService(now(), true);
|
||
// One more fit once the view has settled (fonts, first layout).
|
||
clearTimeout(sv.refit);
|
||
sv.refit = setTimeout(() => { if (serviceOn()) applyFits(); }, 900);
|
||
}
|
||
|
||
// ---- reporting wrong lines (per-profile setting: settings.lyricFlags) ----
|
||
function paintFlags() {
|
||
document.querySelectorAll('#serviceLyricsList .sl-line, #lyricsList .lyr-line').forEach((el) => {
|
||
const l = lines()[Number(el.dataset.i)];
|
||
const f = flagsEnabled() && l ? sv.flags.get(l.text) : null;
|
||
el.classList.toggle('flagged', !!f);
|
||
el.classList.toggle('mine', !!(f && f.mine));
|
||
if (f) el.dataset.n = f.count > 1 ? String(f.count) : ''; else delete el.dataset.n;
|
||
});
|
||
}
|
||
async function loadFlags() {
|
||
if (!flagsEnabled() || !st.videoId || !st.lyrics) return;
|
||
const key = `${st.videoId}|${st.lyrics.rev}|${profileName()}`;
|
||
if (key === sv.flagsKey) return;
|
||
sv.flagsKey = key;
|
||
const vid = st.videoId;
|
||
const got = await FlagUI.list(vid, profileName());
|
||
if (vid !== st.videoId) return; // moved on while the request was out
|
||
sv.flags = new Map(got.map((f) => [f.text, f]));
|
||
paintFlags();
|
||
}
|
||
async function openFlag(row) {
|
||
const l = row && lines()[Number(row.dataset.i)];
|
||
if (!l || !st.videoId) return;
|
||
if (!requireProfile()) return;
|
||
const vid = st.videoId;
|
||
const res = await FlagUI.open({ videoId: vid, text: l.text, existing: sv.flags.get(l.text), profile: profileName() });
|
||
if (!res) return;
|
||
sv.flagsKey = ''; // force a refresh from the server (counts, ids)
|
||
if (vid === st.videoId) await loadFlags();
|
||
toast(res.action === 'withdrawn' ? 'Report withdrawn' : 'Thanks — reported ✓');
|
||
if (res.action === 'saved') { sv.flagMode = false; renderServiceButtons(); setPanelFlagMode(false); }
|
||
}
|
||
|
||
function centreOn(list, el) {
|
||
const go = () => list.scrollTo({ top: Math.max(0, el.offsetTop - list.clientHeight / 2 + el.offsetHeight / 2), behavior: 'smooth' });
|
||
go();
|
||
// Font sizes animate, so re-centre once the line has grown.
|
||
clearTimeout(sv.timer);
|
||
sv.timer = setTimeout(go, 380);
|
||
}
|
||
|
||
function tickService(cur, force = false) {
|
||
if (!serviceOn()) return;
|
||
const list = $('serviceLyricsList');
|
||
const idx = LC.activeIndex(lines(), cur, offset());
|
||
if (idx === sv.idx && !force) return;
|
||
sv.idx = idx;
|
||
const rows = [...list.querySelectorAll('.sl-line')];
|
||
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
|
||
rows.forEach((el, k) => {
|
||
const d = pos < 0 ? 99 : Math.abs(k - pos);
|
||
el.classList.toggle('active', d === 0);
|
||
el.classList.toggle('near', d === 1);
|
||
el.classList.toggle('past', pos >= 0 && k < pos);
|
||
});
|
||
if (serviceAuto() && rows.length) centreOn(list, pos >= 0 ? rows[pos] : rows[0]);
|
||
}
|
||
|
||
function serviceRefresh() { renderService(true); }
|
||
|
||
// ---- Lyrics in place of the video (watch party / sing-along) ------------
|
||
// Same machinery as service mode, but inside the player stage, so playback
|
||
// and party sync carry on untouched: the <video> is only hidden.
|
||
const stg = { key: '', idx: -2, timer: null, refit: null };
|
||
const stageOn = () => !!data.settings.stageLyrics;
|
||
const stageAuto = () => data.settings.stageLyricsAutoscroll !== false;
|
||
|
||
function renderStageButtons() {
|
||
const a = $('stageLyricsAuto');
|
||
if (!a) return;
|
||
a.textContent = stageAuto() ? '⇅ Auto-scroll on' : '⇅ Auto-scroll off';
|
||
a.classList.toggle('on', stageAuto());
|
||
a.setAttribute('aria-pressed', String(stageAuto()));
|
||
}
|
||
|
||
function renderStage(force = false) {
|
||
const box = $('stageLyrics');
|
||
if (!box) return;
|
||
const stage = document.querySelector('.player-stage');
|
||
const on = stageOn();
|
||
box.classList.toggle('hidden', !on);
|
||
if (stage) stage.classList.toggle('lyrics-stage', on);
|
||
if (typeof Party !== 'undefined' && Party.renderLyricsToggle) Party.renderLyricsToggle();
|
||
if (!on) { stg.key = ''; return; }
|
||
renderStageButtons();
|
||
const list = $('stageLyricsList');
|
||
const items = sungLines();
|
||
const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`;
|
||
if (key === stg.key && !force) return;
|
||
stg.key = key;
|
||
stg.idx = -2;
|
||
const pad = Math.round(list.clientHeight / 2);
|
||
list.style.paddingTop = pad + 'px';
|
||
list.style.paddingBottom = pad + 'px';
|
||
if (!items.length) {
|
||
const title = (current && current.meta && current.meta.title) || '';
|
||
list.innerHTML = `<div class="sl-empty">${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `<small>${escapeHtml(title)}</small>` : ''}</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = items.map((l) =>
|
||
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
|
||
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
|
||
tickStage(now(), true);
|
||
clearTimeout(stg.refit);
|
||
stg.refit = setTimeout(() => { if (stageOn()) fitLyricLines(list, { maxShare: 0.15, maxPx: 96 }); }, 900);
|
||
}
|
||
|
||
function tickStage(cur, force = false) {
|
||
if (!stageOn()) return;
|
||
const list = $('stageLyricsList');
|
||
if (!list) return;
|
||
const idx = LC.activeIndex(lines(), cur, offset());
|
||
if (idx === stg.idx && !force) return;
|
||
stg.idx = idx;
|
||
const rows = [...list.querySelectorAll('.sl-line')];
|
||
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
|
||
rows.forEach((el, k) => {
|
||
const d = pos < 0 ? 99 : Math.abs(k - pos);
|
||
el.classList.toggle('active', d === 0);
|
||
el.classList.toggle('near', d === 1);
|
||
el.classList.toggle('past', pos >= 0 && k < pos);
|
||
});
|
||
if (stageAuto() && rows.length) {
|
||
const target = pos >= 0 ? rows[pos] : rows[0];
|
||
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
|
||
go();
|
||
clearTimeout(stg.timer);
|
||
stg.timer = setTimeout(go, 380);
|
||
}
|
||
}
|
||
|
||
function setStageLyrics(on) {
|
||
data.settings.stageLyrics = !!on;
|
||
persist();
|
||
renderStage(true);
|
||
}
|
||
|
||
if ($('stageLyricsOff')) {
|
||
$('stageLyricsOff').addEventListener('click', () => setStageLyrics(false));
|
||
$('stageLyricsAuto').addEventListener('click', () => {
|
||
data.settings.stageLyricsAutoscroll = !stageAuto();
|
||
persist();
|
||
renderStageButtons();
|
||
if (stageAuto()) tickStage(now(), true);
|
||
});
|
||
// Tapping a line seeks, exactly as in service mode. A party guest seeking
|
||
// is handled by Party (it either drives the host or goes "off sync").
|
||
$('stageLyricsList').addEventListener('click', (e) => {
|
||
const row = e.target.closest('.sl-line');
|
||
const l = row && lines()[Number(row.dataset.i)];
|
||
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
|
||
});
|
||
window.addEventListener('resize', () => { if (stageOn()) renderStage(true); });
|
||
}
|
||
|
||
$('serviceLyricsBtn').addEventListener('click', () => {
|
||
data.settings.serviceLyrics = !data.settings.serviceLyrics;
|
||
// Lyrics fill the screen; the inline video panel would squeeze them.
|
||
if (data.settings.serviceLyrics && data.settings.serviceVideo) {
|
||
data.settings.serviceVideo = false;
|
||
updateServiceVideoUI();
|
||
}
|
||
persist();
|
||
renderService(true);
|
||
});
|
||
$('serviceAutoscrollBtn').addEventListener('click', () => {
|
||
data.settings.serviceLyricsAutoscroll = !serviceAuto();
|
||
persist();
|
||
renderServiceButtons();
|
||
if (serviceAuto()) tickService(now(), true);
|
||
});
|
||
$('serviceFlagBtn').addEventListener('click', () => {
|
||
sv.flagMode = !sv.flagMode;
|
||
renderServiceButtons();
|
||
});
|
||
{
|
||
// Tap = seek (or report, in report mode). Holding a line reports it too, so
|
||
// it works without switching modes.
|
||
const list = $('serviceLyricsList');
|
||
let timer = null, sx = 0, sy = 0;
|
||
const cancel = () => { clearTimeout(timer); timer = null; };
|
||
list.addEventListener('pointerdown', (e) => {
|
||
if (!flagsEnabled() || !e.target.closest('.sl-line')) return;
|
||
sx = e.clientX; sy = e.clientY;
|
||
const row = e.target.closest('.sl-line');
|
||
cancel();
|
||
timer = setTimeout(() => { timer = null; sv.pressed = Date.now(); if (navigator.vibrate) navigator.vibrate(12); openFlag(row); }, 550);
|
||
});
|
||
list.addEventListener('pointermove', (e) => { if (timer && Math.hypot(e.clientX - sx, e.clientY - sy) > 8) cancel(); });
|
||
['pointerup', 'pointercancel', 'pointerleave', 'scroll'].forEach((t) => list.addEventListener(t, cancel, { passive: true }));
|
||
list.addEventListener('contextmenu', (e) => { if (flagsEnabled()) e.preventDefault(); });
|
||
list.addEventListener('click', (e) => {
|
||
if (Date.now() - sv.pressed < 700) return; // that was the long-press
|
||
const row = e.target.closest('.sl-line');
|
||
if (!row) return;
|
||
if (sv.flagMode) { openFlag(row); return; }
|
||
const l = lines()[Number(row.dataset.i)];
|
||
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
|
||
});
|
||
}
|
||
window.addEventListener('resize', () => { if (serviceOn()) renderService(); });
|
||
// Sizes are measured with the web font; a measurement taken while it was
|
||
// still loading used the narrower fallback, so re-fit once fonts land.
|
||
if (document.fonts) {
|
||
document.fonts.ready.then(() => { if (serviceOn()) applyFits(); });
|
||
document.fonts.addEventListener('loadingdone', () => { if (serviceOn()) applyFits(); });
|
||
}
|
||
|
||
// ---- transcript search ----
|
||
// Lyrics (shared) or the video's YouTube captions, fetched on demand and
|
||
// kept per video. Empty query = the whole transcript, to read along.
|
||
const ts = { captions: new Map(), loading: null };
|
||
async function captionLines(id) {
|
||
if (ts.captions.has(id)) return ts.captions.get(id);
|
||
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/captions`);
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
|
||
const v = { lines: j.lines.map((l) => ({ ...l, kind: 'line' })), label: `YouTube ${j.auto ? 'auto-captions' : 'subtitles'} (${j.lang})` };
|
||
ts.captions.set(id, v);
|
||
if (ts.captions.size > 30) ts.captions.delete(ts.captions.keys().next().value);
|
||
return v;
|
||
}
|
||
async function runSearch() {
|
||
if (!st.videoId) return;
|
||
const q = $('tsQuery').value.trim().toLowerCase();
|
||
const srcSel = $('tsSource');
|
||
const haveLyrics = !!(st.lyrics && st.lyrics.data && st.lyrics.data.lines.length);
|
||
const ytOnly = !isUploadId(st.videoId);
|
||
srcSel.querySelector('option[value="captions"]').disabled = !ytOnly;
|
||
if (!ytOnly) srcSel.value = 'lyrics';
|
||
else if (!haveLyrics && srcSel.value === 'lyrics' && !srcSel.dataset.touched) srcSel.value = 'captions';
|
||
const info = $('tsInfo');
|
||
const out = $('tsResults');
|
||
let src, label, shift = 0;
|
||
if (srcSel.value === 'lyrics') {
|
||
src = haveLyrics ? st.lyrics.data.lines.filter((l) => l.kind === 'line') : [];
|
||
label = haveLyrics ? 'Shared lyrics' : 'No lyrics for this video — switch to YouTube transcript';
|
||
shift = offset();
|
||
} else {
|
||
const id = st.videoId;
|
||
info.textContent = 'Loading the YouTube transcript…';
|
||
try {
|
||
const r = await captionLines(id);
|
||
if (id !== st.videoId) return;
|
||
src = r.lines;
|
||
label = r.label;
|
||
} catch (err) {
|
||
info.textContent = `⚠ ${err.message}`;
|
||
out.innerHTML = '';
|
||
return;
|
||
}
|
||
}
|
||
const esc = (t) => escapeHtml(t);
|
||
const hits = src.filter((l) => l.t !== null && l.t !== undefined && (!q || l.text.toLowerCase().includes(q))).slice(0, 400);
|
||
info.textContent = `${label} · ${q ? `${hits.length} match${hits.length === 1 ? '' : 'es'}` : `${hits.length} lines`}`;
|
||
const mark = (text) => {
|
||
if (!q) return esc(text);
|
||
const i = text.toLowerCase().indexOf(q);
|
||
return i < 0 ? esc(text) : `${esc(text.slice(0, i))}<mark>${esc(text.slice(i, i + q.length))}</mark>${esc(text.slice(i + q.length))}`;
|
||
};
|
||
out.innerHTML = hits.map((l) => `<button class="ts-hit" type="button" data-t="${l.t + shift}"><span class="ts-time">${LC.fmtClock(l.t + shift)}</span><span>${mark(l.text)}</span></button>`).join('')
|
||
|| `<div class="notes-empty small">${q ? 'No matches.' : 'Nothing to show.'}</div>`;
|
||
}
|
||
let tsTimer = null;
|
||
$('tsQuery').addEventListener('input', () => { clearTimeout(tsTimer); tsTimer = setTimeout(runSearch, 150); });
|
||
$('tsQuery').addEventListener('keydown', (e) => e.stopPropagation());
|
||
$('tsSource').addEventListener('change', (e) => { e.target.dataset.touched = '1'; runSearch(); });
|
||
$('tsResults').addEventListener('click', (e) => {
|
||
const b = e.target.closest('.ts-hit');
|
||
if (b) { Player.seek(Math.max(0, Number(b.dataset.t))); if (Player.master.paused) Player.play(); }
|
||
});
|
||
|
||
// ---- tabs + events ----
|
||
function showTab(tab) {
|
||
st.tab = tab;
|
||
panel.querySelectorAll('.notes-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
|
||
$('lyricsPane').classList.toggle('hidden', tab !== 'lyrics');
|
||
$('markersPane').classList.toggle('hidden', tab !== 'markers');
|
||
$('searchPane').classList.toggle('hidden', tab !== 'search');
|
||
if (tab === 'search') runSearch();
|
||
if (tab === 'lyrics') tick(now(), true);
|
||
}
|
||
|
||
panel.querySelectorAll('.notes-tab').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
|
||
$('lyricsEditBtn').addEventListener('click', () => openEditor(null));
|
||
$('lyricsSyncBtn').addEventListener('click', startSync);
|
||
$('lyricsHistoryBtn').addEventListener('click', () => openHistory('lyrics'));
|
||
$('chaptersHistoryBtn').addEventListener('click', () => openHistory('chapters'));
|
||
$('lyricsStampBtn').addEventListener('click', (e) => { e.currentTarget.blur(); stamp(); });
|
||
$('lyricsUndoBtn').addEventListener('click', undoStamp);
|
||
$('lyricsSyncCancel').addEventListener('click', endSync);
|
||
$('lyricsSyncDone').addEventListener('click', finishSync);
|
||
const nudge = (d) => {
|
||
if (!st.videoId) return;
|
||
if (!data.lyricOffsets) data.lyricOffsets = {};
|
||
const v = Math.round((personalOffset() + d) * 10) / 10;
|
||
if (v) data.lyricOffsets[st.videoId] = v;
|
||
else delete data.lyricOffsets[st.videoId];
|
||
persist();
|
||
renderOffset();
|
||
tick(now(), true);
|
||
};
|
||
function setPanelFlagMode(on) {
|
||
sv.panelFlag = !!on;
|
||
const b = $('lyricsFlagBtn');
|
||
if (b) {
|
||
b.setAttribute('aria-pressed', String(sv.panelFlag));
|
||
b.classList.toggle('hidden', !flagsEnabled());
|
||
b.querySelector('span').textContent = sv.panelFlag ? 'Tap the line that is wrong…' : 'Report a wrong line';
|
||
}
|
||
$('lyricsList').classList.toggle('flagging', sv.panelFlag);
|
||
}
|
||
if ($('lyricsFlagBtn')) $('lyricsFlagBtn').addEventListener('click', () => setPanelFlagMode(!sv.panelFlag));
|
||
{
|
||
// Hold a lyric line to report it without switching modes.
|
||
const pl = $('lyricsList');
|
||
let timer = null, sx = 0, sy = 0;
|
||
const cancel = () => { clearTimeout(timer); timer = null; };
|
||
pl.addEventListener('pointerdown', (e) => {
|
||
const row = e.target.closest('.lyr-line');
|
||
if (!row || !flagsEnabled() || st.sync) return;
|
||
sx = e.clientX; sy = e.clientY; cancel();
|
||
timer = setTimeout(() => { timer = null; sv.pressed = Date.now(); if (navigator.vibrate) navigator.vibrate(12); openFlag(row); }, 550);
|
||
});
|
||
pl.addEventListener('pointermove', (e) => { if (timer && Math.hypot(e.clientX - sx, e.clientY - sy) > 8) cancel(); });
|
||
['pointerup', 'pointercancel', 'pointerleave', 'scroll'].forEach((t) => pl.addEventListener(t, cancel, { passive: true }));
|
||
}
|
||
$('lyrOffMinus').addEventListener('click', () => nudge(-0.5));
|
||
$('lyrOffPlus').addEventListener('click', () => nudge(0.5));
|
||
$('lyrAutoscroll').addEventListener('change', (e) => {
|
||
data.settings.lyricsAutoscroll = e.target.checked;
|
||
persist();
|
||
if (e.target.checked) { st.lastUserScroll = 0; tick(now(), true); }
|
||
});
|
||
const list = $('lyricsList');
|
||
['wheel', 'touchstart', 'touchmove'].forEach((ev) =>
|
||
list.addEventListener(ev, () => { st.lastUserScroll = Date.now(); }, { passive: true }));
|
||
list.addEventListener('click', (e) => {
|
||
const act = e.target.closest('[data-act]');
|
||
if (act) {
|
||
if (act.dataset.act === 'paste') openEditor('');
|
||
else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true });
|
||
else if (act.dataset.act === 'web') fetchWebLyrics();
|
||
return;
|
||
}
|
||
const row = e.target.closest('[data-i]');
|
||
if (!row) return;
|
||
if (Date.now() - sv.pressed < 700) return; // that was the long-press
|
||
const i = Number(row.dataset.i);
|
||
if (st.sync) { st.sync.ptr = i; renderSyncPointer(); return; }
|
||
if (sv.panelFlag && row.classList.contains('lyr-line')) { openFlag(row); return; }
|
||
const l = lines()[i];
|
||
if (l && l.t !== null && l.t !== undefined) {
|
||
st.lastUserScroll = 0;
|
||
Player.seek(Math.max(0, l.t + offset()));
|
||
}
|
||
});
|
||
$('markerChips').addEventListener('click', (e) => {
|
||
const chip = e.target.closest('.marker-chip');
|
||
if (chip) Player.seek(Number(chip.dataset.t));
|
||
});
|
||
$('addChapterBtn').addEventListener('click', addChapter);
|
||
$('addBookmarkBtn').addEventListener('click', addBookmark);
|
||
$('addNoteBtn').addEventListener('click', addNote);
|
||
$('exportMarkersBtn').addEventListener('click', openExport);
|
||
$('markersPane').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.marker-row [data-act]');
|
||
if (!btn) return;
|
||
const row = btn.closest('.marker-row');
|
||
const kind = row.dataset.kind;
|
||
const i = Number(row.dataset.i);
|
||
const item = (kind === 'chapter' ? chapters() : bookmarks())[i];
|
||
if (btn.dataset.act === 'jump' && item) Player.seek(item.t);
|
||
else if (btn.dataset.act === 'edit') editMarker(kind, i);
|
||
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
|
||
});
|
||
|
||
return {
|
||
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
|
||
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
|
||
};
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Phone remote (server/remote.js relays both ends over /ws/remote)
|
||
// ============================================================================
|
||
// Host = this tab plays to the TV / speakers and obeys a paired phone.
|
||
// Remote = this phone drives a host. Hosting survives reloads (the secret and
|
||
// "enabled" flag live in localStorage), so a TV PC that restarts its browser
|
||
// keeps its phones paired.
|
||
const Remote = (() => {
|
||
const noop = { boot() {}, changed() {} };
|
||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
||
|
||
const HOST_KEY = 'ytpRemoteHost';
|
||
const PAIR_KEY = 'ytpRemotePair';
|
||
const load = (k) => { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } };
|
||
const save = (k, v) => { try { if (v) localStorage.setItem(k, JSON.stringify(v)); else localStorage.removeItem(k); } catch { /* storage blocked */ } };
|
||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
|
||
const newSecret = () => {
|
||
const b = new Uint8Array(32);
|
||
crypto.getRandomValues(b);
|
||
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||
};
|
||
const guessName = (fallback) => {
|
||
const ua = navigator.userAgent || '';
|
||
if (/iPhone/.test(ua)) return 'iPhone';
|
||
if (/iPad/.test(ua)) return 'iPad';
|
||
if (/Android/.test(ua)) return 'Android phone';
|
||
if (/Windows/.test(ua)) return 'Windows PC';
|
||
if (/Mac/.test(ua)) return 'Mac';
|
||
return fallback;
|
||
};
|
||
const clock = (t) => (window.LyricsCore ? LyricsCore.fmtClock(t) : fmtTime(t));
|
||
|
||
// ==========================================================================
|
||
// Host (the screen being controlled)
|
||
// ==========================================================================
|
||
const host = { ws: null, remotes: [], code: '', codeExpires: 0, retryMs: 2000, timer: null, lastState: '', lastQueue: '', replaced: false, pending: false };
|
||
|
||
function hostCfg() {
|
||
let cfg = load(HOST_KEY);
|
||
if (!cfg || !cfg.secret) cfg = { secret: newSecret(), name: guessName('Desktop'), enabled: false };
|
||
return cfg;
|
||
}
|
||
|
||
function hostConnect() {
|
||
const cfg = hostCfg();
|
||
if (!cfg.enabled) return;
|
||
try { if (host.ws) host.ws.close(); } catch { /* already closed */ }
|
||
const ws = new WebSocket(wsUrl({ role: 'host', secret: cfg.secret, name: cfg.name }));
|
||
host.ws = ws;
|
||
host.replaced = false;
|
||
ws.onopen = () => { host.retryMs = 2000; };
|
||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onHostMessage(m); };
|
||
ws.onclose = (e) => {
|
||
if (host.ws !== ws) return;
|
||
host.ws = null;
|
||
host.remotes = [];
|
||
renderChip();
|
||
renderHostModal();
|
||
if (e.code === 4000) { host.replaced = true; renderHostModal(); return; } // another tab hosts now
|
||
if (hostCfg().enabled) {
|
||
clearTimeout(host.timer);
|
||
host.timer = setTimeout(hostConnect, host.retryMs);
|
||
host.retryMs = Math.min(host.retryMs * 2, 30000);
|
||
}
|
||
};
|
||
}
|
||
|
||
const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); };
|
||
|
||
function onHostMessage(m) {
|
||
if (m.type === 'hello') {
|
||
host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || [];
|
||
host.lastState = ''; host.lastQueue = '';
|
||
pushState(); pushQueue();
|
||
} else if (m.type === 'code') {
|
||
host.code = m.code; host.codeExpires = m.codeExpires;
|
||
} else if (m.type === 'remotes') {
|
||
host.remotes = m.remotes || [];
|
||
if (m.joined) { toast(`📱 ${m.joined} connected as a remote`); host.lastState = ''; host.lastQueue = ''; pushState(); pushQueue(); }
|
||
if (m.left) toast(`📱 ${m.left} disconnected`);
|
||
} else if (m.type === 'ping') {
|
||
hostSend({ type: 'pong', rid: m.rid, t: m.t });
|
||
return;
|
||
} else if (m.type === 'cmd') {
|
||
runCommand(m);
|
||
return;
|
||
}
|
||
renderChip();
|
||
renderHostModal();
|
||
}
|
||
|
||
function stateNow() {
|
||
const meta = current && current.meta;
|
||
const M = Player.master;
|
||
return {
|
||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null,
|
||
cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0,
|
||
dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0,
|
||
paused: !M || M.paused,
|
||
volume: data.settings.volume ?? 1,
|
||
service: isServiceModeOpen(),
|
||
sleep: sleepTimerRemaining || 0,
|
||
hostName: hostCfg().name,
|
||
// For the presenter screen: this screen's personal lyric offset for the
|
||
// song and the playback speed (to extrapolate between updates).
|
||
off: meta && data.lyricOffsets ? Number(data.lyricOffsets[meta.id]) || 0 : 0,
|
||
rate: (M && M.playbackRate) || 1,
|
||
};
|
||
}
|
||
|
||
function pushState(force = false) {
|
||
if (!host.ws || !host.remotes.length) return;
|
||
const st = stateNow();
|
||
const key = JSON.stringify({ ...st, cur: Math.floor(st.cur) });
|
||
if (!force && key === host.lastState) return;
|
||
host.lastState = key;
|
||
hostSend({ type: 'state', state: { ...st, at: Date.now() } });
|
||
}
|
||
|
||
function pushQueue() {
|
||
if (!host.ws || !host.remotes.length) return;
|
||
const items = queue.slice(0, 200).map((v) => ({
|
||
id: v.id, title: v.title || '', channel: v.channel || '', duration: v.duration || 0, thumbnail: v.thumbnail || '',
|
||
}));
|
||
const key = queueIndex + '|' + items.map((v) => v.id).join(',');
|
||
if (key === host.lastQueue) return;
|
||
host.lastQueue = key;
|
||
hostSend({ type: 'queue', queue: { items, idx: queueIndex } });
|
||
}
|
||
|
||
// Coalesce bursts (play event + renderUpNext + afterLoad) into one push.
|
||
function changed() {
|
||
if (!host.ws || host.pending) return;
|
||
host.pending = true;
|
||
setTimeout(() => { host.pending = false; pushState(); pushQueue(); }, 50);
|
||
}
|
||
|
||
const validIdx = (i) => Number.isInteger(i) && i > queueIndex && i < queue.length;
|
||
|
||
function runCommand(m) {
|
||
const who = m.from || 'Phone';
|
||
switch (m.cmd) {
|
||
case 'toggle': Player.toggle(); break;
|
||
case 'play': Player.play(); break;
|
||
case 'pause': Player.pause(); break;
|
||
case 'next': playNext(); break;
|
||
case 'prev': playPrev(); break;
|
||
case 'seek': {
|
||
const t = Number(m.t);
|
||
if (isFinite(t) && t >= 0) Player.seek(t);
|
||
break;
|
||
}
|
||
case 'volume': {
|
||
const v = Math.max(0, Math.min(1, Number(m.v)));
|
||
if (!isFinite(v)) break;
|
||
data.settings.volume = v;
|
||
els.volume.value = String(v);
|
||
Player.applyVolume();
|
||
syncMuteIcon();
|
||
persist();
|
||
break;
|
||
}
|
||
case 'jump': {
|
||
const i = Number(m.i);
|
||
if (Number.isInteger(i) && i >= 0 && i < queue.length) { queueIndex = i - 1; playNext(); }
|
||
break;
|
||
}
|
||
case 'remove': {
|
||
const i = Number(m.i);
|
||
if (validIdx(i)) { queue.splice(i, 1); renderUpNext(); }
|
||
break;
|
||
}
|
||
case 'move': {
|
||
const from = Number(m.from), to = Number(m.to);
|
||
if (validIdx(from) && validIdx(to) && from !== to) {
|
||
const [v] = queue.splice(from, 1);
|
||
queue.splice(to, 0, v);
|
||
renderUpNext();
|
||
}
|
||
break;
|
||
}
|
||
case 'add': {
|
||
const v = m.video;
|
||
if (!v || typeof v.id !== 'string' || !isMediaId(v.id)) break;
|
||
const item = slim({ ...v, title: String(v.title || v.id).slice(0, 300), channel: String(v.channel || '').slice(0, 200) });
|
||
if (!current || !queue.length) { playFromList([item], 0, 'remote'); }
|
||
else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); }
|
||
toast(`📱 ${who} added “${item.title}”`);
|
||
break;
|
||
}
|
||
case 'sleep': {
|
||
const min = Number(m.min) || 0;
|
||
if (min > 0) startSleepTimer(Math.min(min, 240)); else cancelSleepTimer();
|
||
break;
|
||
}
|
||
case 'service':
|
||
if (m.on) enterServiceMode(); else exitServiceMode();
|
||
break;
|
||
default: return;
|
||
}
|
||
changed();
|
||
}
|
||
|
||
function startHosting() {
|
||
const cfg = hostCfg();
|
||
cfg.enabled = true;
|
||
save(HOST_KEY, cfg);
|
||
hostConnect();
|
||
renderHostModal();
|
||
}
|
||
|
||
function stopHosting() {
|
||
const cfg = hostCfg();
|
||
cfg.enabled = false;
|
||
save(HOST_KEY, cfg);
|
||
clearTimeout(host.timer);
|
||
const ws = host.ws;
|
||
host.ws = null;
|
||
host.remotes = [];
|
||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||
renderChip();
|
||
renderHostModal();
|
||
}
|
||
|
||
function unpairAll() {
|
||
hostSend({ type: 'unpair-all' });
|
||
const cfg = hostCfg();
|
||
cfg.secret = newSecret(); // every phone's token was derived from the old one
|
||
save(HOST_KEY, cfg);
|
||
setTimeout(hostConnect, 300);
|
||
toast('All phones unpaired');
|
||
}
|
||
|
||
function renderChip() {
|
||
const chip = $('remoteChip');
|
||
if (!chip) return;
|
||
const n = host.remotes.length;
|
||
chip.classList.toggle('hidden', !host.ws || !n);
|
||
if (n) {
|
||
$('remoteChipText').textContent = n === 1
|
||
? `Remote connected — ${host.remotes[0].name}`
|
||
: `${n} remotes connected`;
|
||
}
|
||
}
|
||
|
||
let hostModalOpen = false;
|
||
function renderHostModal() {
|
||
if (!hostModalOpen) return;
|
||
const body = $('modalBody') && $('modalBody').querySelector('.remote-host');
|
||
if (!body) { hostModalOpen = false; return; }
|
||
const cfg = hostCfg();
|
||
const live = !!host.ws;
|
||
const mins = host.codeExpires ? Math.max(0, Math.round((host.codeExpires - Date.now()) / 60000)) : 0;
|
||
body.querySelector('.rh-body').innerHTML = !cfg.enabled ? `
|
||
<p class="modal-text">Let a phone drive this screen — play/pause, seek, volume, the queue and service mode —
|
||
while this computer plays to the TV or speakers. Pairing uses a one-time 6-digit code; nothing to sign in to.</p>`
|
||
: host.replaced ? `<p class="modal-text">Another tab on this computer took over as the remote-controlled screen.</p>`
|
||
: !live ? `<p class="modal-text">Connecting to the server…</p>`
|
||
: `
|
||
<div class="rh-pair">
|
||
<img class="rh-qr" alt="QR code to pair a phone" src="/api/remote/qr/${host.code}" />
|
||
<div>
|
||
<div class="rh-label">Pairing code</div>
|
||
<div class="rh-code">${host.code.slice(0, 3)} ${host.code.slice(3)}</div>
|
||
<div class="rh-sub">Valid ${mins} more min · one use</div>
|
||
<div class="rh-sub">On the phone: open this app → 📱 Remote → <b>Use this phone as a remote</b>, or scan the QR code.</div>
|
||
</div>
|
||
</div>
|
||
<div class="rh-label">Presenter / projector screen</div>
|
||
<div class="rh-present">
|
||
<img class="rh-qr rh-qr-sm" alt="QR code for the presenter screen" src="/api/remote/qr/${host.code}?kind=present" />
|
||
<div>
|
||
<div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open
|
||
<code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div>
|
||
<button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button>
|
||
</div>
|
||
</div>
|
||
<div class="rh-label">Connected phones & screens</div>
|
||
${host.remotes.length ? host.remotes.map((r) => `
|
||
<div class="rh-remote"><span>📱 ${escapeHtml(r.name)}</span><button class="mk-btn" type="button" data-kick="${escapeHtml(r.rid)}" title="Disconnect">✕</button></div>`).join('')
|
||
: '<div class="rh-sub">None yet.</div>'}`;
|
||
const nameIn = body.querySelector('.rh-name');
|
||
if (document.activeElement !== nameIn) nameIn.value = cfg.name;
|
||
body.querySelector('[data-act="start"]').classList.toggle('hidden', cfg.enabled && !host.replaced);
|
||
body.querySelector('[data-act="stop"]').classList.toggle('hidden', !cfg.enabled);
|
||
body.querySelector('[data-act="code"]').classList.toggle('hidden', !live);
|
||
body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live);
|
||
}
|
||
|
||
function openHostModal() {
|
||
const body = document.createElement('div');
|
||
body.className = 'remote-host';
|
||
body.innerHTML = `
|
||
<div class="rh-body"></div>
|
||
<label class="rh-name-row">This screen's name <input class="rh-name" maxlength="40" /></label>
|
||
<div class="rh-buttons">
|
||
<button class="np-btn" type="button" data-act="start">▶ Start — control from a phone</button>
|
||
<button class="np-btn" type="button" data-act="code">↻ New code</button>
|
||
<button class="np-btn" type="button" data-act="unpair">Unpair all phones</button>
|
||
<button class="np-btn" type="button" data-act="stop">Stop</button>
|
||
</div>`;
|
||
body.addEventListener('click', (e) => {
|
||
const kick = e.target.closest('[data-kick]');
|
||
if (kick) { hostSend({ type: 'kick', rid: kick.dataset.kick }); return; }
|
||
const act = e.target.closest('[data-act]');
|
||
if (!act) return;
|
||
if (act.dataset.act === 'start') startHosting();
|
||
else if (act.dataset.act === 'stop') stopHosting();
|
||
else if (act.dataset.act === 'code') hostSend({ type: 'new-code' });
|
||
else if (act.dataset.act === 'unpair') unpairAll();
|
||
else if (act.dataset.act === 'present') {
|
||
// A second window on this computer (drag it to the projector display).
|
||
window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720');
|
||
}
|
||
});
|
||
body.querySelector('.rh-name').addEventListener('change', (e) => {
|
||
const cfg = hostCfg();
|
||
cfg.name = e.target.value.trim().slice(0, 40) || guessName('Desktop');
|
||
save(HOST_KEY, cfg);
|
||
if (cfg.enabled) hostConnect(); // the server learns names on connect
|
||
});
|
||
showModal('📱 Control this screen from a phone', body, [
|
||
{ label: 'Done', primary: true, onClick: () => { hostModalOpen = false; closeModal(); } },
|
||
]);
|
||
hostModalOpen = true;
|
||
renderHostModal();
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Remote (this phone drives a host)
|
||
// ==========================================================================
|
||
const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, latency: null, online: false, seeking: false, volTimer: null, drag: null };
|
||
|
||
const rcSend = (m) => { if (rc.ws && rc.ws.readyState === 1) rc.ws.send(JSON.stringify(m)); };
|
||
const cmd = (name, args = {}) => rcSend({ type: 'cmd', cmd: name, ...args });
|
||
|
||
function setStatus(text, ok = false) {
|
||
$('rvStatus').textContent = text;
|
||
$('rvStatus').classList.toggle('ok', ok);
|
||
}
|
||
|
||
function showPairForm(msg = '') {
|
||
$('rvPair').classList.remove('hidden');
|
||
$('rvControl').classList.add('hidden');
|
||
$('rvHostName').textContent = 'Remote';
|
||
setStatus('Not paired');
|
||
$('rvPairMsg').textContent = msg;
|
||
if (!$('rvName').value) $('rvName').value = (load(PAIR_KEY) || {}).name || guessName('Phone');
|
||
}
|
||
|
||
function rcConnect() {
|
||
const p = load(PAIR_KEY);
|
||
if (!p || !rc.open) return;
|
||
clearTimeout(rc.retry);
|
||
try { if (rc.ws) rc.ws.close(); } catch { /* gone */ }
|
||
$('rvPair').classList.add('hidden');
|
||
$('rvControl').classList.remove('hidden');
|
||
$('rvHostName').textContent = p.hostName || 'Screen';
|
||
setStatus('Connecting…');
|
||
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: p.name || guessName('Phone') }));
|
||
rc.ws = ws;
|
||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onRemoteMessage(m); };
|
||
ws.onclose = (e) => {
|
||
if (rc.ws !== ws) return;
|
||
rc.ws = null;
|
||
rc.online = false;
|
||
clearInterval(rc.ping);
|
||
if (e.code === 4001) { save(PAIR_KEY, null); showPairForm('This phone was unpaired by the screen — pair again.'); return; }
|
||
if (!rc.open) return;
|
||
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
|
||
rc.retry = setTimeout(rcConnect, 4000);
|
||
};
|
||
}
|
||
|
||
function onRemoteMessage(m) {
|
||
if (m.type === 'hello') {
|
||
rc.online = true;
|
||
$('rvHostName').textContent = m.hostName || 'Screen';
|
||
if (m.state) { rc.state = m.state; rc.stateAt = Date.now(); }
|
||
if (m.queue) rc.queue = m.queue;
|
||
setStatus('Synced', true);
|
||
clearInterval(rc.ping);
|
||
const ping = () => rcSend({ type: 'ping', t: Date.now() });
|
||
ping();
|
||
rc.ping = setInterval(ping, 5000);
|
||
renderRemote(); renderQueue();
|
||
} else if (m.type === 'state') {
|
||
rc.state = m.state; rc.stateAt = Date.now();
|
||
renderRemote();
|
||
} else if (m.type === 'queue') {
|
||
rc.queue = m.queue || { items: [], idx: -1 };
|
||
renderQueue();
|
||
} else if (m.type === 'pong') {
|
||
rc.latency = Date.now() - m.t;
|
||
if (rc.online) setStatus(`Synced · ${rc.latency} ms`, true);
|
||
} else if (m.type === 'host-offline') {
|
||
rc.online = false;
|
||
setStatus('Screen offline — waiting…');
|
||
}
|
||
}
|
||
|
||
function liveCur() {
|
||
const s = rc.state;
|
||
if (!s) return 0;
|
||
const cur = s.paused ? s.cur : s.cur + (Date.now() - rc.stateAt) / 1000;
|
||
return Math.min(cur, s.dur || cur);
|
||
}
|
||
|
||
function renderRemote() {
|
||
const s = rc.state;
|
||
const v = s && s.v;
|
||
$('rvTitle').textContent = v ? v.title : 'Nothing playing';
|
||
$('rvChannel').textContent = v ? v.channel : '';
|
||
const thumb = $('rvThumb');
|
||
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
|
||
else thumb.classList.add('hidden');
|
||
$('rvToggle').textContent = s && !s.paused ? '⏸' : '▶';
|
||
$('rvDur').textContent = clock((s && s.dur) || 0);
|
||
if (!rc.volTimer && document.activeElement !== $('rvVolume')) {
|
||
const pct = Math.round(((s && s.volume) ?? 1) * 100);
|
||
$('rvVolume').value = String(pct);
|
||
$('rvVolVal').textContent = pct + '%';
|
||
}
|
||
const sleepMin = s && s.sleep ? Math.ceil(s.sleep / 60) : 0;
|
||
$('rvSleep').textContent = sleepMin ? `⏱ ${sleepMin} min` : '⏱ Sleep';
|
||
$('rvSleep').classList.toggle('on', !!sleepMin);
|
||
$('rvService').classList.toggle('on', !!(s && s.service));
|
||
renderSeek();
|
||
}
|
||
|
||
function renderSeek() {
|
||
if (rc.seeking || !rc.open) return;
|
||
const s = rc.state;
|
||
const cur = liveCur();
|
||
$('rvCur').textContent = clock(cur);
|
||
$('rvSeek').value = s && s.dur ? String(Math.round((cur / s.dur) * 1000)) : '0';
|
||
}
|
||
|
||
function renderQueue() {
|
||
const q = rc.queue || { items: [], idx: -1 };
|
||
const up = q.items.map((v, i) => ({ ...v, i })).filter((v) => v.i > q.idx);
|
||
$('rvQueueCount').textContent = up.length ? `${up.length} song${up.length === 1 ? '' : 's'}` : '';
|
||
const list = $('rvQueue');
|
||
list.innerHTML = up.length ? up.map((v) => `
|
||
<div class="rv-row" data-i="${v.i}">
|
||
<span class="rv-handle" aria-label="Drag to reorder">⠿</span>
|
||
<div class="rv-row-text">
|
||
<div class="rv-row-title">${escapeHtml(v.title)}</div>
|
||
<div class="rv-row-sub">${escapeHtml(v.channel)}</div>
|
||
</div>
|
||
<span class="rv-row-dur">${v.duration ? clock(v.duration) : ''}</span>
|
||
<button class="rv-row-x" type="button" data-rm="${v.i}" aria-label="Remove">✕</button>
|
||
</div>`).join('') : '<div class="rv-empty">Nothing queued after this song.</div>';
|
||
}
|
||
|
||
// ---- drag to reorder (pointer events, works for touch + mouse) ----
|
||
function onDragStart(e) {
|
||
const handle = e.target.closest('.rv-handle');
|
||
if (!handle) return;
|
||
const row = handle.closest('.rv-row');
|
||
e.preventDefault();
|
||
const rows = [...$('rvQueue').querySelectorAll('.rv-row')];
|
||
rc.drag = { row, rows, startY: e.clientY, from: Number(row.dataset.i), to: Number(row.dataset.i), h: row.offsetHeight };
|
||
row.classList.add('dragging');
|
||
row.setPointerCapture(e.pointerId);
|
||
row.addEventListener('pointermove', onDragMove);
|
||
row.addEventListener('pointerup', onDragEnd, { once: true });
|
||
row.addEventListener('pointercancel', onDragEnd, { once: true });
|
||
}
|
||
function onDragMove(e) {
|
||
const d = rc.drag;
|
||
if (!d) return;
|
||
const dy = e.clientY - d.startY;
|
||
d.row.style.transform = `translateY(${dy}px)`;
|
||
const pos = d.rows.indexOf(d.row);
|
||
const target = Math.max(0, Math.min(d.rows.length - 1, pos + Math.round(dy / d.h)));
|
||
d.to = Number(d.rows[target].dataset.i);
|
||
}
|
||
function onDragEnd() {
|
||
const d = rc.drag;
|
||
rc.drag = null;
|
||
if (!d) return;
|
||
d.row.removeEventListener('pointermove', onDragMove);
|
||
d.row.classList.remove('dragging');
|
||
d.row.style.transform = '';
|
||
if (d.to !== d.from) {
|
||
// Optimistic: reorder locally now, the host's queue push confirms it.
|
||
const items = rc.queue.items.slice();
|
||
const [v] = items.splice(d.from, 1);
|
||
items.splice(d.to, 0, v);
|
||
rc.queue = { ...rc.queue, items };
|
||
renderQueue();
|
||
cmd('move', { from: d.from, to: d.to });
|
||
}
|
||
}
|
||
|
||
// ---- add to queue: search sheet ----
|
||
function openAddSheet() {
|
||
const body = document.createElement('div');
|
||
body.className = 'rv-add';
|
||
body.innerHTML = `
|
||
<form class="rv-add-form"><input class="rv-add-q" type="search" placeholder="Search YouTube…" /><button class="np-btn" type="submit">Search</button></form>
|
||
<div class="rv-add-results"></div>`;
|
||
const results = body.querySelector('.rv-add-results');
|
||
let found = [];
|
||
body.querySelector('form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const q = body.querySelector('.rv-add-q').value.trim();
|
||
if (!q) return;
|
||
results.innerHTML = '<div class="rv-empty">Searching…</div>';
|
||
try {
|
||
const res = await API.search(q);
|
||
found = (res && res.ok && res.results) || [];
|
||
} catch { found = []; }
|
||
results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => `
|
||
<button class="rv-add-item" type="button" data-k="${i}">
|
||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
|
||
<span><b>${escapeHtml(v.title || '')}</b><small>${escapeHtml(v.channel || '')}${v.duration ? ' · ' + clock(v.duration) : ''}</small></span>
|
||
</button>`).join('') : '<div class="rv-empty">No results.</div>';
|
||
});
|
||
results.addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-k]');
|
||
if (!b) return;
|
||
const v = found[Number(b.dataset.k)];
|
||
if (!v) return;
|
||
cmd('add', { video: { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail } });
|
||
b.classList.add('added');
|
||
toast(`Added “${v.title}”`);
|
||
});
|
||
showModal('+ Add to the screen’s queue', body, [{ label: 'Done', primary: true, onClick: closeModal }]);
|
||
setTimeout(() => body.querySelector('.rv-add-q').focus(), 50);
|
||
}
|
||
|
||
async function doPair(codeRaw) {
|
||
const code = String(codeRaw || '').replace(/\D/g, '');
|
||
const name = $('rvName').value.trim() || guessName('Phone');
|
||
if (code.length !== 6) { $('rvPairMsg').textContent = 'Enter the 6-digit code from the screen.'; return; }
|
||
$('rvPairMsg').textContent = 'Pairing…';
|
||
try {
|
||
const res = await fetch('/api/remote/pair', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name }),
|
||
});
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) { $('rvPairMsg').textContent = '⚠ ' + ((j && j.error) || `HTTP ${res.status}`); return; }
|
||
save(PAIR_KEY, { roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName, name: j.name });
|
||
$('rvCode').value = '';
|
||
rcConnect();
|
||
} catch {
|
||
$('rvPairMsg').textContent = '⚠ Network error — try again';
|
||
}
|
||
}
|
||
|
||
function openRemoteView({ code = '' } = {}) {
|
||
rc.open = true;
|
||
// This phone is the remote now — don't also play here.
|
||
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
||
$('remoteView').classList.remove('hidden');
|
||
document.body.classList.add('remote-open');
|
||
if (code) { save(PAIR_KEY, null); showPairForm(); $('rvCode').value = code; doPair(code); return; }
|
||
if (load(PAIR_KEY)) rcConnect(); else showPairForm();
|
||
}
|
||
|
||
function closeRemoteView() {
|
||
rc.open = false;
|
||
clearTimeout(rc.retry);
|
||
clearInterval(rc.ping);
|
||
const ws = rc.ws;
|
||
rc.ws = null;
|
||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||
$('remoteView').classList.add('hidden');
|
||
document.body.classList.remove('remote-open');
|
||
}
|
||
|
||
function openChooser() {
|
||
const hosting = hostCfg().enabled;
|
||
const paired = load(PAIR_KEY);
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `<p class="modal-text">Play on a computer hooked to the TV or speakers, and drive it from your phone.</p>`;
|
||
showModal('📱 Remote', body, [
|
||
{ label: paired ? `Control “${paired.hostName}”` : 'Use this phone as a remote', onClick: () => { closeModal(); openRemoteView(); } },
|
||
{ label: hosting ? 'This screen: remote on' : 'Control this screen from a phone', primary: true, onClick: () => { closeModal(); openHostModal(); } },
|
||
]);
|
||
}
|
||
|
||
function bindUi() {
|
||
$('navRemoteBtn').addEventListener('click', () => {
|
||
if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
|
||
openChooser();
|
||
});
|
||
$('remoteChip').addEventListener('click', openHostModal);
|
||
$('rvClose').addEventListener('click', closeRemoteView);
|
||
$('rvMenu').addEventListener('click', () => {
|
||
const p = load(PAIR_KEY);
|
||
showModal('Remote options', document.createTextNode(p ? `Paired with “${p.hostName}”.` : 'Not paired.'), [
|
||
{ label: 'Close', onClick: closeModal },
|
||
...(p ? [{ label: 'Unpair this phone', danger: true, onClick: () => { closeModal(); save(PAIR_KEY, null); try { rc.ws && rc.ws.close(); } catch { /* gone */ } showPairForm('Unpaired.'); } }] : []),
|
||
]);
|
||
});
|
||
$('rvPairBtn').addEventListener('click', () => doPair($('rvCode').value));
|
||
$('rvCode').addEventListener('keydown', (e) => { if (e.key === 'Enter') doPair($('rvCode').value); });
|
||
$('rvToggle').addEventListener('click', () => {
|
||
cmd('toggle');
|
||
if (rc.state) { rc.state.cur = liveCur(); rc.stateAt = Date.now(); rc.state.paused = !rc.state.paused; renderRemote(); }
|
||
});
|
||
$('rvPrev').addEventListener('click', () => cmd('prev'));
|
||
$('rvNext').addEventListener('click', () => cmd('next'));
|
||
const seek = $('rvSeek');
|
||
seek.addEventListener('input', () => {
|
||
rc.seeking = true;
|
||
const dur = (rc.state && rc.state.dur) || 0;
|
||
$('rvCur').textContent = clock((Number(seek.value) / 1000) * dur);
|
||
});
|
||
seek.addEventListener('change', () => {
|
||
const dur = (rc.state && rc.state.dur) || 0;
|
||
const t = (Number(seek.value) / 1000) * dur;
|
||
cmd('seek', { t });
|
||
if (rc.state) { rc.state.cur = t; rc.stateAt = Date.now(); }
|
||
rc.seeking = false;
|
||
});
|
||
$('rvVolume').addEventListener('input', (e) => {
|
||
const pct = Number(e.target.value);
|
||
$('rvVolVal').textContent = pct + '%';
|
||
clearTimeout(rc.volTimer);
|
||
rc.volTimer = setTimeout(() => { rc.volTimer = null; cmd('volume', { v: pct / 100 }); }, 120);
|
||
});
|
||
$('rvQueue').addEventListener('pointerdown', onDragStart);
|
||
$('rvQueue').addEventListener('click', (e) => {
|
||
const rm = e.target.closest('[data-rm]');
|
||
if (rm) { cmd('remove', { i: Number(rm.dataset.rm) }); return; }
|
||
if (e.target.closest('.rv-handle')) return;
|
||
const row = e.target.closest('.rv-row');
|
||
if (row) cmd('jump', { i: Number(row.dataset.i) });
|
||
});
|
||
$('rvAdd').addEventListener('click', openAddSheet);
|
||
$('rvSleep').addEventListener('click', () => {
|
||
const options = [0, 15, 30, 45, 60, 90];
|
||
const curMin = rc.state && rc.state.sleep ? Math.ceil(rc.state.sleep / 60) : 0;
|
||
const next = options.find((o) => o > curMin) ?? 0;
|
||
cmd('sleep', { min: next });
|
||
toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off');
|
||
});
|
||
$('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) }));
|
||
setInterval(() => { if (rc.open) renderSeek(); }, 500);
|
||
// Hosts keep phones current once a second while anything plays.
|
||
setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000);
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible' && rc.open && !rc.ws) rcConnect();
|
||
});
|
||
}
|
||
|
||
function boot() {
|
||
bindUi();
|
||
if (hostCfg().enabled) hostConnect();
|
||
// A scanned QR / shared link: /?pair=482913
|
||
let code = '';
|
||
try { code = (new URLSearchParams(location.search).get('pair') || '').replace(/\D/g, ''); } catch { /* no URL */ }
|
||
if (code) {
|
||
try {
|
||
const u = new URL(location.href);
|
||
u.searchParams.delete('pair');
|
||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||
} catch { /* leave the URL */ }
|
||
openRemoteView({ code });
|
||
}
|
||
}
|
||
|
||
return { boot, changed };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Playlist transitions: normal · gapless · crossfade
|
||
// ============================================================================
|
||
// A hidden bridge <audio> preloads the NEXT song's audio ~25 s before the end.
|
||
// Gapless starts it the instant the current song ends; crossfade starts it
|
||
// `crossfadeSec` early and ramps the two volumes. Then the queue advances as
|
||
// usual and the main player, once loaded, picks up at the bridge's position
|
||
// (Player._startPlayback) and the bridge is released — so the switch never
|
||
// has the load-and-buffer silence of a normal advance.
|
||
// iOS ignores element.volume, so there crossfade degrades to gapless.
|
||
const TRANSITION_MODES = ['off', 'gapless', 'crossfade'];
|
||
const Transition = (() => {
|
||
const bridge = new Audio();
|
||
bridge.preload = 'auto';
|
||
bridge.setAttribute('playsinline', '');
|
||
const st = { forId: null, nextId: null, src: '', ready: false, phase: 'idle', ramp: null, gapTimer: null, blob: null, preparing: false };
|
||
|
||
const volumeWorks = (() => {
|
||
try { const a = new Audio(); a.volume = 0.5; return Math.abs(a.volume - 0.5) < 0.01; } catch { return false; }
|
||
})();
|
||
const mode = () => {
|
||
const m = data.settings.transition || 'off';
|
||
return m === 'crossfade' && !volumeWorks ? 'gapless' : m;
|
||
};
|
||
const fadeSec = () => Math.max(2, Math.min(12, Number(data.settings.crossfadeSec) || 6));
|
||
|
||
function nextIndex() {
|
||
if (queueIndex >= 0 && queueIndex < queue.length - 1) return queueIndex + 1;
|
||
if (!playFullMode && data.settings.repeatMode === 'all' && queue.length > 1) return 0;
|
||
return -1;
|
||
}
|
||
|
||
function eligible() {
|
||
if (mode() === 'off' || !current || !current.meta) return false;
|
||
if (!(queueSource.startsWith('playlist:') || queueSource === 'queue')) return false;
|
||
if (data.settings.loopOne && !playFullMode) return false;
|
||
if (abA !== null || abB !== null) return false;
|
||
if (sleepAtTrackEnd) return false; // the sleep timer stops after this song
|
||
if (Party.guestActive()) return false; // party guests follow the host
|
||
if (Player.master.paused || Player._handoff) return false;
|
||
return nextIndex() >= 0;
|
||
}
|
||
|
||
function clearBridge() {
|
||
clearInterval(st.ramp);
|
||
clearTimeout(st.gapTimer);
|
||
st.ramp = null;
|
||
st.gapTimer = null;
|
||
try { bridge.pause(); } catch { /* nothing loaded */ }
|
||
bridge.removeAttribute('src');
|
||
try { bridge.load(); } catch { /* fine */ }
|
||
if (st.blob) { try { if (window.OPFS) window.OPFS.revokeUrl(st.blob); else URL.revokeObjectURL(st.blob); } catch { /* gone */ } st.blob = null; }
|
||
st.forId = st.nextId = null;
|
||
st.src = '';
|
||
st.ready = false;
|
||
st.preparing = false;
|
||
}
|
||
|
||
function cancel() {
|
||
if (st.phase === 'handoff') return; // the new track is taking over
|
||
if (st.phase === 'fading') Player.applyVolume();
|
||
st.phase = 'idle';
|
||
clearBridge();
|
||
}
|
||
|
||
// Audio for the next song: the offline copy if this device has one, else
|
||
// the same stream resolution the player uses (server copy or proxy).
|
||
async function audioUrlFor(v) {
|
||
if (v.custom || cachedIds.has(v.id)) {
|
||
if (WEB && window.OPFS) {
|
||
const u = await window.OPFS.getFileUrl(v.id);
|
||
if (u) { st.blob = u; return u; }
|
||
}
|
||
if (v.custom) return '';
|
||
}
|
||
const res = await API.getStreams(v.id, {});
|
||
if (!res || !res.ok || !res.data) return '';
|
||
const d = res.data;
|
||
if (d.audioUrl) return d.audioUrl;
|
||
const prog = (d.qualities || []).find((q) => q.hasAudio);
|
||
return prog ? prog.url : '';
|
||
}
|
||
|
||
async function prepare() {
|
||
const i = nextIndex();
|
||
const v = queue[i];
|
||
if (!v || st.preparing) return;
|
||
st.preparing = true;
|
||
st.forId = current.meta.id;
|
||
st.nextId = v.id;
|
||
try {
|
||
const url = await audioUrlFor(v);
|
||
if (!url || st.forId !== (current && current.meta && current.meta.id)) { st.preparing = false; return; }
|
||
st.src = url;
|
||
bridge.src = url;
|
||
bridge.volume = 0;
|
||
bridge.muted = false;
|
||
bridge.playbackRate = parseFloat(els.speed.value) || 1;
|
||
bridge.load();
|
||
bridge.addEventListener('canplay', () => { st.ready = true; }, { once: true });
|
||
} catch { /* stays not ready → a normal advance happens */ }
|
||
st.preparing = false;
|
||
}
|
||
|
||
function startBridge(volume) {
|
||
try { bridge.currentTime = 0; } catch { /* not seekable yet */ }
|
||
bridge.volume = volume;
|
||
return bridge.play().catch(() => false);
|
||
}
|
||
|
||
// Advance the queue with the bridge carrying the sound.
|
||
function handoff() {
|
||
clearInterval(st.ramp);
|
||
st.ramp = null;
|
||
st.phase = 'handoff';
|
||
const h = {
|
||
at: () => bridge.currentTime || 0,
|
||
release: () => {
|
||
if (Player._handoff === h) Player._handoff = null;
|
||
st.phase = 'idle';
|
||
// A short fade-out hides any overlap while the main element settles.
|
||
const v0 = bridge.volume;
|
||
let k = 0;
|
||
const t = setInterval(() => {
|
||
k++;
|
||
try { bridge.volume = Math.max(0, v0 * (1 - k / 4)); } catch { /* fine */ }
|
||
if (k >= 4) { clearInterval(t); clearBridge(); }
|
||
}, 40);
|
||
},
|
||
};
|
||
Player._handoff = h;
|
||
// Safety: if the next track never starts, don't leave the bridge playing.
|
||
setTimeout(() => { if (Player._handoff === h) h.release(); }, 20000);
|
||
Player.soundEl.volume = data.settings.volume ?? 1;
|
||
if (!advanceQueue()) { Player._handoff = null; st.phase = 'idle'; clearBridge(); }
|
||
}
|
||
|
||
function startCrossfade(remaining) {
|
||
st.phase = 'fading';
|
||
const target = data.settings.volume ?? 1;
|
||
const total = Math.max(0.5, remaining);
|
||
const t0 = performance.now();
|
||
startBridge(0);
|
||
st.ramp = setInterval(() => {
|
||
const p = Math.min(1, (performance.now() - t0) / 1000 / total);
|
||
try { bridge.volume = target * p; } catch { /* fine */ }
|
||
try { Player.soundEl.volume = target * (1 - p); } catch { /* fine */ }
|
||
if (p >= 1 || Player.master.ended) handoff();
|
||
}, 50);
|
||
}
|
||
|
||
function startGapless(remaining) {
|
||
st.phase = 'gap-armed';
|
||
const rate = Player.master.playbackRate || 1;
|
||
st.gapTimer = setTimeout(() => {
|
||
startBridge(data.settings.volume ?? 1);
|
||
Player.master.pause();
|
||
handoff();
|
||
}, Math.max(0, (remaining / rate) * 1000 - 40));
|
||
}
|
||
|
||
// Called on every timeupdate (~4×/s) from updateProgress.
|
||
function tick(cur, dur) {
|
||
if (st.phase === 'handoff') return;
|
||
// The final timeupdate arrives with the element already paused by its own
|
||
// end — that is the crossfade finishing, not the user pausing.
|
||
if (st.phase === 'fading' && (Player.master.ended || (dur && cur >= dur - 0.25))) { handoff(); return; }
|
||
if (!eligible() || !dur || !isFinite(dur)) {
|
||
if (st.phase === 'fading' || st.phase === 'gap-armed') cancel();
|
||
else if (st.forId && (!current || st.forId !== current.meta.id)) cancel();
|
||
return;
|
||
}
|
||
if (st.forId && st.forId !== current.meta.id) clearBridge();
|
||
const remaining = dur - cur;
|
||
const lead = mode() === 'crossfade' ? fadeSec() : 0;
|
||
if (!st.forId && remaining < lead + 25) prepare();
|
||
if (st.phase !== 'idle' || !st.ready) return;
|
||
if (mode() === 'crossfade' && remaining <= lead) startCrossfade(remaining);
|
||
else if (mode() === 'gapless' && remaining <= 0.6) startGapless(remaining);
|
||
}
|
||
|
||
function label(m = mode()) {
|
||
return m === 'crossfade' ? `Crossfade (${fadeSec()} s)` : m === 'gapless' ? 'Gapless' : 'Normal';
|
||
}
|
||
|
||
function renderButton() {
|
||
const btn = $('transitionBtn');
|
||
if (!btn) return;
|
||
const m = data.settings.transition || 'off';
|
||
btn.textContent = m === 'crossfade' ? '⤨' : m === 'gapless' ? '⇥' : '↦';
|
||
btn.classList.toggle('active', m !== 'off');
|
||
btn.title = `Between songs in a playlist: ${label(m === 'crossfade' && !volumeWorks ? 'gapless' : m)} — click to change`;
|
||
}
|
||
|
||
function cycle() {
|
||
const cur = data.settings.transition || 'off';
|
||
const next = TRANSITION_MODES[(TRANSITION_MODES.indexOf(cur) + 1) % TRANSITION_MODES.length];
|
||
data.settings.transition = next;
|
||
persist();
|
||
cancel();
|
||
renderButton();
|
||
const text = next === 'crossfade'
|
||
? (volumeWorks ? `⤨ Playback mode: Crossfade — songs in a playlist blend over ${fadeSec()} s` : '⤨ Crossfade needs volume control this device lacks — playing gapless instead')
|
||
: next === 'gapless' ? '⇥ Playback mode: Gapless — the next song starts the instant this one ends'
|
||
: '↦ Playback mode: Normal — a short pause while the next song loads';
|
||
toast(text, { duration: 6000 });
|
||
}
|
||
|
||
// Read-only snapshot for diagnostics and tests.
|
||
const state = () => ({ phase: st.phase, ready: st.ready, nextId: st.nextId, bridgeT: bridge.currentTime, bridgePaused: bridge.paused, bridgeVol: bridge.volume });
|
||
// The song ended: during a crossfade the fade timer can lose the race with
|
||
// 'ended' (the fade is timed to finish exactly then) — hand off now instead
|
||
// of letting a normal advance restart the next song from 0 and kill the
|
||
// bridge. Returns true when a transition owns this end.
|
||
function onEnded() {
|
||
if (st.phase === 'fading') { handoff(); return true; }
|
||
return st.phase === 'handoff' || st.phase === 'gap-armed';
|
||
}
|
||
return { tick, cancel, cycle, renderButton, busy: () => st.phase === 'handoff' || st.phase === 'gap-armed', onEnded, mode, label, state };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Waveform seek bar (audio-only playback)
|
||
// ============================================================================
|
||
// Loudness envelope from GET /api/media/:id/peaks (server-cached songs only),
|
||
// drawn as bars behind the (now transparent) range input, so tap/drag/keyboard
|
||
// seeking are the same control as before. Offline, envelopes seen earlier come
|
||
// from localStorage.
|
||
const Wave = (() => {
|
||
const wrap = document.querySelector('.seek-wrap');
|
||
const canvas = $('waveCanvas');
|
||
if (!wrap || !canvas) return { load() {}, draw() {}, refresh() {} };
|
||
const ctx = canvas.getContext('2d');
|
||
const CACHE_KEY = 'ytpPeaks';
|
||
const st = { id: null, peaks: null, seq: 0, hoverX: null, lastKey: '' };
|
||
|
||
const readCache = () => { try { return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch { return {}; } };
|
||
function writeCache(id, peaks) {
|
||
try {
|
||
const c = readCache();
|
||
c[id] = { p: peaks.map((v) => String.fromCharCode(48 + Math.round(v * 0.74))).join(''), at: Date.now() };
|
||
const ids = Object.keys(c);
|
||
if (ids.length > 80) ids.sort((a, b) => c[a].at - c[b].at).slice(0, ids.length - 80).forEach((k) => delete c[k]);
|
||
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
|
||
} catch { /* storage full */ }
|
||
}
|
||
const fromCache = (id) => {
|
||
const e = readCache()[id];
|
||
return e && e.p ? [...e.p].map((ch) => (ch.charCodeAt(0) - 48) / 0.74) : null;
|
||
};
|
||
|
||
const active = () => !!(st.peaks && data.settings.audioOnly && current && current.meta && current.meta.id === st.id);
|
||
|
||
async function load(meta) {
|
||
const seq = ++st.seq;
|
||
st.id = meta && meta.id;
|
||
st.peaks = null;
|
||
refresh();
|
||
if (!WEB || !st.id || meta.custom || !isMediaId(st.id)) return;
|
||
const cached = fromCache(st.id);
|
||
if (cached) { st.peaks = cached; refresh(); }
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
const res = await fetch(`/api/media/${encodeURIComponent(st.id)}/peaks`);
|
||
if (seq !== st.seq) return;
|
||
if (res.status === 503) { await new Promise((r) => setTimeout(r, 1500)); continue; }
|
||
const j = await res.json();
|
||
if (!j.ok || !Array.isArray(j.peaks)) return; // not cached on the server → plain seek bar
|
||
st.peaks = j.peaks;
|
||
writeCache(st.id, j.peaks);
|
||
refresh();
|
||
return;
|
||
} catch { return; } // offline — keep the cached envelope if any
|
||
}
|
||
}
|
||
|
||
function loudest() {
|
||
if (!st.peaks) return null;
|
||
// Loudest ~5 s window, not a single spike.
|
||
const n = st.peaks.length, w = Math.max(1, Math.round(n / 60));
|
||
let best = -1, at = 0;
|
||
for (let i = 0; i + w <= n; i++) {
|
||
let s = 0;
|
||
for (let k = i; k < i + w; k++) s += st.peaks[k];
|
||
if (s > best) { best = s; at = i; }
|
||
}
|
||
return at / n;
|
||
}
|
||
|
||
function refresh() {
|
||
const on = active();
|
||
wrap.classList.toggle('wave-on', on);
|
||
$('waveLoud').classList.toggle('hidden', !on);
|
||
st.lastKey = '';
|
||
if (on) draw();
|
||
}
|
||
|
||
function draw() {
|
||
if (!active()) return;
|
||
const dur = Player.master.duration || (current.meta && current.meta.duration) || 0;
|
||
const cur = Player.master.currentTime || 0;
|
||
const p = dur ? Math.min(1, cur / dur) : 0;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||
const key = `${w}x${h}|${Math.round(p * 1000)}|${st.hoverX}`;
|
||
if (key === st.lastKey) return;
|
||
st.lastKey = key;
|
||
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
|
||
canvas.width = Math.round(w * dpr);
|
||
canvas.height = Math.round(h * dpr);
|
||
}
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, w, h);
|
||
const cs = getComputedStyle(document.documentElement);
|
||
const played = cs.getPropertyValue('--accent').trim() || '#ff4b32';
|
||
const rest = cs.getPropertyValue('--line').trim() || '#333';
|
||
const hover = cs.getPropertyValue('--text-dim').trim() || '#777';
|
||
const barW = 3, gap = 2;
|
||
const bars = Math.max(20, Math.floor(w / (barW + gap)));
|
||
const n = st.peaks.length;
|
||
for (let b = 0; b < bars; b++) {
|
||
const a = Math.floor((b / bars) * n), z = Math.max(a + 1, Math.floor(((b + 1) / bars) * n));
|
||
let v = 0;
|
||
for (let k = a; k < z; k++) v = Math.max(v, st.peaks[k]);
|
||
const bh = Math.max(2, (v / 100) * (h - 4));
|
||
const x = b * (barW + gap);
|
||
const frac = (b + 0.5) / bars;
|
||
ctx.fillStyle = frac <= p ? played : (st.hoverX !== null && x <= st.hoverX ? hover : rest);
|
||
ctx.fillRect(x, (h - bh) / 2, barW, bh);
|
||
}
|
||
// Playhead
|
||
ctx.fillStyle = cs.getPropertyValue('--text').trim() || '#fff';
|
||
ctx.fillRect(Math.round(p * w) - 1, 0, 2, h);
|
||
}
|
||
|
||
// Hover tooltip with the time under the pointer.
|
||
const tip = $('waveTip');
|
||
wrap.addEventListener('pointermove', (e) => {
|
||
if (!active()) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const x = Math.max(0, Math.min(r.width, e.clientX - r.left));
|
||
st.hoverX = x;
|
||
const dur = Player.master.duration || current.meta.duration || 0;
|
||
tip.textContent = fmtTime((x / r.width) * dur);
|
||
tip.style.left = `${x}px`;
|
||
tip.classList.remove('hidden');
|
||
draw();
|
||
});
|
||
wrap.addEventListener('pointerleave', () => { st.hoverX = null; tip.classList.add('hidden'); draw(); });
|
||
$('waveLoud').addEventListener('click', () => {
|
||
const f = loudest();
|
||
const dur = Player.master.duration || current.meta.duration || 0;
|
||
if (f !== null && dur) Player.seek(f * dur);
|
||
});
|
||
window.addEventListener('resize', () => { st.lastKey = ''; draw(); });
|
||
|
||
return { load, draw, refresh };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Listening stats — tracker (data.stats, see frontend/stats-core.js)
|
||
// ============================================================================
|
||
// Counts seconds actually heard (media-time deltas while playing, so seeks and
|
||
// pauses don't count) and one play per load once 30 s have been heard; a
|
||
// looped song that restarts counts again. Written every 60 s, on each counted
|
||
// play, and when the page is hidden, so profile sync isn't spammed.
|
||
const StatsTrack = (() => {
|
||
const st = { id: null, meta: null, lastT: null, heard: 0, counted: false, pending: 0 };
|
||
function commit(play) {
|
||
if (!st.meta || (!play && st.pending < 1)) return;
|
||
data.stats = StatsCore.record(data.stats, st.meta, st.pending, play);
|
||
st.pending = 0;
|
||
persist();
|
||
}
|
||
function tick(cur) {
|
||
const meta = current && current.meta;
|
||
if (!meta || !meta.id || !window.StatsCore) return;
|
||
if (meta.id !== st.id) {
|
||
commit(false);
|
||
st.id = meta.id;
|
||
st.meta = { id: meta.id, title: meta.title, channel: meta.channel };
|
||
st.lastT = null; st.heard = 0; st.counted = false;
|
||
}
|
||
if (Player.master.paused) { st.lastT = null; return; }
|
||
if (st.lastT !== null) {
|
||
const d = cur - st.lastT;
|
||
if (d > 0 && d < 3) {
|
||
const real = d / (Player.master.playbackRate || 1);
|
||
st.pending += real;
|
||
st.heard += real;
|
||
} else if (d < -5 && cur < 5) {
|
||
st.heard = 0; st.counted = false; // looped back to the start
|
||
}
|
||
}
|
||
st.lastT = cur;
|
||
if (!st.counted && st.heard >= 30) { st.counted = true; commit(true); }
|
||
else if (st.pending >= 60) commit(false);
|
||
}
|
||
const flush = () => commit(false);
|
||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(); });
|
||
window.addEventListener('pagehide', flush);
|
||
return { tick, flush };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Stats view
|
||
// ============================================================================
|
||
function renderStats() {
|
||
StatsTrack.flush();
|
||
els.listTitle.textContent = 'Stats';
|
||
els.listActions.innerHTML = '';
|
||
els.status.classList.add('hidden');
|
||
const c = els.cards;
|
||
const S = StatsCore.summarize(data.stats);
|
||
if (!S.totalPlays && S.totalSec < 60) {
|
||
c.innerHTML = `
|
||
<div class="empty-state stats-empty">
|
||
<div class="empty-icon">📊</div>
|
||
<h3 class="empty-title">No listening stats yet</h3>
|
||
<p class="empty-desc">Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const hrs = (sec) => StatsCore.fmtHours(sec);
|
||
const delta = S.thisMonthSec - S.lastMonthSec;
|
||
const deltaTxt = S.lastMonthSec || S.thisMonthSec
|
||
? `${delta >= 0 ? '▲ +' : '▼ −'}${(Math.abs(delta) / 3600).toFixed(1)}h vs last month`
|
||
: '';
|
||
const maxArtist = Math.max(1, ...S.topArtists.map((a) => a.plays));
|
||
const spark = (vals) => {
|
||
const w = 96, h = 26, max = Math.max(1, ...vals);
|
||
const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - 3 - (v / max) * (h - 6)}`).join(' ');
|
||
return `<svg class="st-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" /></svg>`;
|
||
};
|
||
const heatMax = Math.max(1, ...S.heat.flat().map((x) => x.minutes));
|
||
const level = (m) => (m <= 0 ? 0 : Math.min(4, 1 + Math.floor((m / heatMax) * 3.999)));
|
||
const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||
// Month label over the first week column of each month.
|
||
const months = S.heat.map((col, w) => {
|
||
const d = new Date(`${col[0].date}T12:00:00`);
|
||
const prev = w ? new Date(`${S.heat[w - 1][0].date}T12:00:00`) : null;
|
||
return !prev || prev.getMonth() !== d.getMonth() ? d.toLocaleDateString(undefined, { month: 'short' }) : '';
|
||
});
|
||
const playlistsWith = data.playlists.length;
|
||
|
||
c.innerHTML = `
|
||
<div class="stats">
|
||
<div class="st-tiles">
|
||
<div class="st-tile">
|
||
<div class="st-label">⏱ Listening this month</div>
|
||
<div class="st-big">${hrs(S.thisMonthSec)}</div>
|
||
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
||
</div>
|
||
<div class="st-tile">
|
||
<div class="st-label">▶ Track plays</div>
|
||
<div class="st-big">${S.totalPlays}</div>
|
||
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
|
||
</div>
|
||
<div class="st-tile">
|
||
<div class="st-label">🔥 Practice streak</div>
|
||
<div class="st-big">${S.streak.current}-day</div>
|
||
<div class="st-sub">${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="st-grid">
|
||
<section class="st-panel">
|
||
<div class="st-panel-head"><span>🎤 Top artists</span><small>plays this month</small></div>
|
||
${S.topArtists.length ? S.topArtists.map((a) => `
|
||
<div class="st-bar-row" data-tip="${escapeHtml(a.name)} — ${a.plays} play${a.plays === 1 ? '' : 's'} this month">
|
||
<span class="st-bar-name">${escapeHtml(a.name)}</span>
|
||
<span class="st-bar-track"><span class="st-bar" style="width:${Math.max(3, (a.plays / maxArtist) * 100)}%"></span></span>
|
||
<span class="st-bar-val">${a.plays}</span>
|
||
</div>`).join('') : '<div class="st-none">No plays this month yet.</div>'}
|
||
</section>
|
||
<section class="st-panel">
|
||
<div class="st-panel-head"><span>🎵 Top songs</span><small>last 30 days</small></div>
|
||
${S.topSongs.length ? S.topSongs.map((t, i) => `
|
||
<div class="st-song" data-tip="${escapeHtml(t.title)} — ${t.plays} play${t.plays === 1 ? '' : 's'} in 30 days" data-id="${escapeHtml(t.id)}">
|
||
<span class="st-rank">${String(i + 1).padStart(2, '0')}</span>
|
||
<span class="st-song-text"><b>${escapeHtml(t.title)}</b><small>${escapeHtml(t.channel)}</small></span>
|
||
${spark(t.spark)}
|
||
<span class="st-bar-val">${t.plays}</span>
|
||
</div>`).join('') : '<div class="st-none">No plays in the last 30 days.</div>'}
|
||
</section>
|
||
</div>
|
||
|
||
<section class="st-panel">
|
||
<div class="st-panel-head"><span>🗓 Listening habits</span><small>last 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}</small></div>
|
||
<div class="st-heat-wrap">
|
||
<div class="st-heat-days"><span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span>Sun</span></div>
|
||
<div class="st-heat-main">
|
||
<div class="st-heat-months">${months.map((m) => `<span>${m}</span>`).join('')}</div>
|
||
<div class="st-heat">${S.heat.map((col) => col.map((x) => x.future
|
||
? '<span class="st-cell future"></span>'
|
||
: `<span class="st-cell l${level(x.minutes)}" data-tip="${fmtDay(x.date)} · ${x.minutes} min"></span>`).join('')).join('')}</div>
|
||
</div>
|
||
</div>
|
||
<div class="st-legend"><span>Less</span>${[0, 1, 2, 3, 4].map((l) => `<span class="st-cell l${l}"></span>`).join('')}<span>More</span></div>
|
||
<details class="st-table"><summary>Show as table</summary>
|
||
<table><thead><tr><th>Day</th><th>Minutes</th></tr></thead><tbody>
|
||
${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => `<tr><td>${fmtDay(x.date)}</td><td>${x.minutes}</td></tr>`).join('') || '<tr><td colspan="2">No listening in this window.</td></tr>'}
|
||
</tbody></table>
|
||
</details>
|
||
</section>
|
||
<div id="statsTip" class="st-tip hidden"></div>
|
||
</div>`;
|
||
|
||
// One tooltip for every mark (bars, songs, heat cells).
|
||
const root = c.querySelector('.stats');
|
||
const tip = $('statsTip');
|
||
root.addEventListener('pointermove', (e) => {
|
||
const el = e.target.closest('[data-tip]');
|
||
if (!el) { tip.classList.add('hidden'); return; }
|
||
tip.textContent = el.dataset.tip;
|
||
tip.classList.remove('hidden');
|
||
const r = root.getBoundingClientRect();
|
||
const half = tip.offsetWidth / 2 + 4; // keep the whole tip inside the panel
|
||
tip.style.left = `${Math.min(r.width - half, Math.max(half, e.clientX - r.left))}px`;
|
||
tip.style.top = `${e.clientY - r.top - 14}px`;
|
||
});
|
||
root.addEventListener('pointerleave', () => tip.classList.add('hidden'));
|
||
root.querySelectorAll('.st-song').forEach((row) => row.addEventListener('click', () => {
|
||
const v = data.history.find((x) => x.id === row.dataset.id) || { id: row.dataset.id, title: row.querySelector('b').textContent };
|
||
Player.loadVideo(v);
|
||
}));
|
||
}
|
||
|
||
// ============================================================================
|
||
// Presenter / projector view (/?present=<code>)
|
||
// ============================================================================
|
||
// A lyrics-only display for a projector or TV that follows the screen actually
|
||
// playing. It pairs through the phone-remote relay (server/remote.js) exactly
|
||
// like a phone, but only listens: the host's state messages carry the song id,
|
||
// position, speed and lyric offset, and this view extrapolates the position
|
||
// between updates. Lyrics come from the shared /api/notes store.
|
||
const Presenter = (() => {
|
||
const noop = { boot() {}, active: () => false };
|
||
const view = $('presenterView');
|
||
if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
|
||
const KEY = 'ytpPresenterPair';
|
||
const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
|
||
const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } };
|
||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
|
||
const list = $('pvList');
|
||
const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
|
||
|
||
function setStatus(t) { $('pvStatus').textContent = t; }
|
||
|
||
function show() {
|
||
view.classList.remove('hidden');
|
||
document.body.classList.add('presenter-open');
|
||
// The app underneath may have focused its search box; keys belong here now.
|
||
try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
|
||
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
||
keepAwake();
|
||
bumpIdle();
|
||
}
|
||
|
||
async function keepAwake() {
|
||
try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
|
||
}
|
||
|
||
function showPairForm(msg = '') {
|
||
$('pvPair').classList.remove('hidden');
|
||
$('pvMsg').textContent = msg;
|
||
setStatus('Not paired');
|
||
}
|
||
|
||
async function pair(code) {
|
||
code = String(code || '').replace(/\D/g, '');
|
||
if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
|
||
$('pvMsg').textContent = 'Connecting…';
|
||
try {
|
||
const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '🖥 Presenter' }) });
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) { showPairForm('⚠ ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
|
||
save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
|
||
$('pvPair').classList.add('hidden');
|
||
connect();
|
||
} catch {
|
||
showPairForm('⚠ Network error — try again');
|
||
}
|
||
}
|
||
|
||
function connect() {
|
||
const p = load();
|
||
if (!p) { showPairForm(); return; }
|
||
clearTimeout(st.retry);
|
||
try { if (st.ws) st.ws.close(); } catch { /* gone */ }
|
||
setStatus('Connecting…');
|
||
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: '🖥 Presenter' }));
|
||
st.ws = ws;
|
||
ws.onmessage = (e) => {
|
||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||
if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
|
||
else if (m.type === 'state') onState(m.state);
|
||
else if (m.type === 'host-offline') setStatus(`${st.hostName || 'The screen'} is offline — waiting…`);
|
||
};
|
||
ws.onclose = (e) => {
|
||
if (st.ws !== ws) return;
|
||
st.ws = null;
|
||
if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
|
||
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
|
||
st.retry = setTimeout(connect, 3000);
|
||
};
|
||
}
|
||
|
||
function onState(s) {
|
||
st.state = s;
|
||
st.stateAt = Date.now();
|
||
const v = s && s.v;
|
||
setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
|
||
const id = v && v.id;
|
||
if (id !== st.vid) loadSong(id, v);
|
||
}
|
||
|
||
async function loadSong(id, v) {
|
||
const seq = ++st.seq;
|
||
st.vid = id;
|
||
st.all = [];
|
||
st.docOff = 0;
|
||
render(v);
|
||
if (!id || !isMediaId(id)) return;
|
||
let notes = st.notes.get(id);
|
||
if (!notes) {
|
||
try {
|
||
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
|
||
notes = j && j.ok ? j : null;
|
||
if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
|
||
} catch { notes = null; }
|
||
}
|
||
if (seq !== st.seq) return;
|
||
const doc = notes && notes.lyrics && notes.lyrics.data;
|
||
st.all = (doc && doc.lines) || [];
|
||
st.docOff = (doc && Number(doc.offset)) || 0;
|
||
render(v);
|
||
}
|
||
|
||
function render(v) {
|
||
const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
|
||
const title = $('pvTitle');
|
||
if (!sung.length) {
|
||
list.innerHTML = '';
|
||
title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `<small>${escapeHtml(v.channel)}</small>` : ''}` : '';
|
||
title.classList.toggle('hidden', !v);
|
||
return;
|
||
}
|
||
title.classList.add('hidden');
|
||
const pad = Math.round(list.clientHeight / 2);
|
||
list.style.paddingTop = pad + 'px';
|
||
list.style.paddingBottom = pad + 'px';
|
||
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
|
||
refit();
|
||
st.idx = -2;
|
||
tick(true);
|
||
setTimeout(refit, 900); // once more after fonts/layout settle
|
||
}
|
||
|
||
function refit() {
|
||
fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
|
||
}
|
||
|
||
function liveTime() {
|
||
const s = st.state;
|
||
if (!s) return 0;
|
||
return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
|
||
}
|
||
|
||
function tick(force = false) {
|
||
if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
|
||
const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
|
||
if (idx === st.idx && !force) return;
|
||
st.idx = idx;
|
||
const rows = [...list.querySelectorAll('.sl-line')];
|
||
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
|
||
rows.forEach((el, k) => {
|
||
const d = pos < 0 ? 99 : Math.abs(k - pos);
|
||
el.classList.toggle('active', d === 0);
|
||
el.classList.toggle('near', d === 1);
|
||
el.classList.toggle('past', pos >= 0 && k < pos);
|
||
});
|
||
const target = pos >= 0 ? rows[pos] : rows[0];
|
||
if (target) {
|
||
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
|
||
go();
|
||
setTimeout(go, 380);
|
||
}
|
||
}
|
||
|
||
function toggleFullscreen() {
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||
else view.requestFullscreen?.().catch(() => {});
|
||
}
|
||
|
||
// The status bar and cursor hide after 3 s without mouse movement.
|
||
function bumpIdle() {
|
||
view.classList.remove('idle');
|
||
clearTimeout(st.idleTimer);
|
||
st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
|
||
}
|
||
|
||
function boot() {
|
||
let param = null;
|
||
try { param = new URLSearchParams(location.search).get('present'); } catch { /* no URL */ }
|
||
if (param === null) return;
|
||
try {
|
||
const u = new URL(location.href);
|
||
u.searchParams.delete('present');
|
||
history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
|
||
} catch { /* keep */ }
|
||
show();
|
||
const code = String(param).replace(/\D/g, '');
|
||
if (code.length === 6) pair(code);
|
||
else if (load()) connect();
|
||
else showPairForm();
|
||
setInterval(() => tick(), 200);
|
||
window.addEventListener('resize', () => { refit(); tick(true); });
|
||
if (document.fonts) {
|
||
document.fonts.ready.then(refit);
|
||
document.fonts.addEventListener('loadingdone', refit);
|
||
}
|
||
view.addEventListener('mousemove', bumpIdle);
|
||
view.addEventListener('dblclick', toggleFullscreen);
|
||
$('pvFull').addEventListener('click', toggleFullscreen);
|
||
$('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
|
||
$('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
|
||
document.addEventListener('keydown', (e) => {
|
||
if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
|
||
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
|
||
else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
|
||
}, true);
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
|
||
});
|
||
}
|
||
|
||
return { boot, active: () => !view.classList.contains('hidden') };
|
||
})();
|
||
|
||
// Open a video and jump to `t` seconds once it can play (deep links, notes).
|
||
async function playVideoAt(v, t = 0) {
|
||
if (current && current.meta && current.meta.id === v.id && Player.master) {
|
||
Player.seek(t);
|
||
if (Player.master.paused) Player.play();
|
||
return;
|
||
}
|
||
await Player.loadVideo(v, { resume: false });
|
||
if (t > 0 && Player.master) {
|
||
const seekOnce = () => { Player.master.removeEventListener('canplay', seekOnce); Player.seek(t); toast(`Jumped to ${LyricsCore.fmtClock(t)}`); };
|
||
if (Player.master.readyState >= 3) seekOnce();
|
||
else Player.master.addEventListener('canplay', seekOnce);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 📁 Library — video/audio uploaded to this server
|
||
// ============================================================================
|
||
async function renderLibrary() {
|
||
els.listTitle.textContent = 'Library';
|
||
els.listActions.innerHTML = '';
|
||
els.status.classList.add('hidden');
|
||
els.cards.innerHTML = '<div class="sm-busy">Loading the server library…</div>';
|
||
let list = [];
|
||
try {
|
||
const j = await (await fetch('/api/uploads?limit=200')).json();
|
||
list = (j && j.ok && j.uploads) || [];
|
||
} catch {
|
||
els.cards.innerHTML = '<div class="sm-err">⚠ Could not reach the server</div>';
|
||
return;
|
||
}
|
||
if (view.type !== 'library') return;
|
||
if (!list.length) {
|
||
els.cards.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-icon">📁</div>
|
||
<h3 class="empty-title">Nothing uploaded yet</h3>
|
||
<p class="empty-desc">An admin can upload videos and audio at <b>/admin</b>; they show up here and in search, with their cover art and any embedded lyrics.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const cards = list.map((u) => ({
|
||
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', duration: u.duration,
|
||
thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', kind: u.kind, upload: true,
|
||
}));
|
||
els.cards.innerHTML = '';
|
||
cards.forEach((v, i) => els.cards.appendChild(renderCard(v, i, cards)));
|
||
}
|
||
|
||
// ============================================================================
|
||
// 📝 Notes view — every video's personal notes & bookmarks
|
||
// ============================================================================
|
||
function renderNotesView() {
|
||
els.listTitle.textContent = 'Notes';
|
||
els.status.classList.add('hidden');
|
||
const groups = Object.entries(data.bookmarks || {})
|
||
.filter(([, list]) => Array.isArray(list) && list.length)
|
||
.map(([id, list]) => {
|
||
const h = data.history.find((x) => x.id === id);
|
||
const first = list.find((b) => b.vt) || {};
|
||
return { id, title: first.vt || (h && h.title) || id, channel: first.vc || (h && h.channel) || '', list };
|
||
});
|
||
// Most recently played first (history order), the rest after.
|
||
const order = new Map(data.history.map((v, i) => [v.id, i]));
|
||
groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9));
|
||
els.listActions.innerHTML = groups.length ? '<button class="np-btn" id="notesExportBtn" type="button">⤓ Export Markdown</button>' : '';
|
||
const c = els.cards;
|
||
if (!groups.length) {
|
||
c.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-icon">📝</div>
|
||
<h3 class="empty-title">No notes yet</h3>
|
||
<p class="empty-desc">While a video plays, open <b>Chapters & bookmarks</b> → <b>📝 Note</b> (or press N) to write a note tied to that exact moment. They sync with your online profile.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const total = groups.reduce((n, g) => n + g.list.length, 0);
|
||
c.innerHTML = `
|
||
<div class="notes-view">
|
||
<input class="nv-filter" type="search" placeholder="Filter ${total} notes & bookmarks…" />
|
||
${groups.map((g) => `
|
||
<section class="nv-group" data-id="${escapeHtml(g.id)}">
|
||
<div class="nv-head"><b>${escapeHtml(g.title)}</b><small>${escapeHtml(g.channel)} · ${g.list.length}</small></div>
|
||
${g.list.map((b) => `
|
||
<button class="nv-item" type="button" data-t="${b.t}" data-q="${escapeHtml(`${b.title} ${b.note || ''} ${g.title}`.toLowerCase())}">
|
||
<span class="nv-time">${LyricsCore.fmtClock(b.t)}</span>
|
||
<span class="nv-body"><span class="nv-title">${b.kind === 'note' ? '📝 ' : '📌 '}${escapeHtml(b.title)}</span>${b.note ? `<span class="nv-note">${escapeHtml(b.note)}</span>` : ''}</span>
|
||
</button>`).join('')}
|
||
</section>`).join('')}
|
||
</div>`;
|
||
c.querySelector('.nv-filter').addEventListener('input', (e) => {
|
||
const q = e.target.value.trim().toLowerCase();
|
||
c.querySelectorAll('.nv-group').forEach((g) => {
|
||
let any = false;
|
||
g.querySelectorAll('.nv-item').forEach((it) => { const hit = !q || it.dataset.q.includes(q); it.classList.toggle('hidden', !hit); any = any || hit; });
|
||
g.classList.toggle('hidden', !any);
|
||
});
|
||
});
|
||
c.querySelector('.nv-filter').addEventListener('keydown', (e) => e.stopPropagation());
|
||
c.querySelectorAll('.nv-item').forEach((it) => it.addEventListener('click', () => {
|
||
const g = groups.find((x) => x.id === it.closest('.nv-group').dataset.id);
|
||
playVideoAt({ id: g.id, title: g.title, channel: g.channel }, Number(it.dataset.t));
|
||
}));
|
||
const ex = $('notesExportBtn');
|
||
if (ex) ex.addEventListener('click', () => {
|
||
const md = ['# YT Player notes', `_Exported ${new Date().toLocaleString()}_`, ''];
|
||
for (const g of groups) {
|
||
md.push(`## ${g.title}`, `${g.channel ? g.channel + ' · ' : ''}https://youtu.be/${g.id}`, '');
|
||
for (const b of g.list) {
|
||
md.push(`- **[${LyricsCore.fmtClock(b.t)}](${location.origin}/?v=${g.id}&t=${Math.floor(b.t)})** ${b.title}`);
|
||
if (b.note && b.note !== b.title) md.push(...b.note.split('\n').map((l) => ` ${l}`));
|
||
}
|
||
md.push('');
|
||
}
|
||
const blob = new Blob([md.join('\n')], { type: 'text/markdown' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `ytplayer-notes-${new Date().toISOString().slice(0, 10)}.md`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Timestamp sharing — deep links (/?v=ID&t=SEC) and GIFs of a moment
|
||
// ============================================================================
|
||
const Share = (() => {
|
||
const appLink = (id, t) => `${location.origin}/?v=${encodeURIComponent(id)}${t >= 1 ? `&t=${Math.floor(t)}` : ''}`;
|
||
const ytLink = (id, t) => `https://youtu.be/${encodeURIComponent(id)}${t >= 1 ? `?t=${Math.floor(t)}` : ''}`;
|
||
|
||
async function copy(text, label) {
|
||
try { await navigator.clipboard.writeText(text); toast(`${label} copied ✓`); }
|
||
catch { toast('Copy blocked — long-press the link to copy it'); }
|
||
}
|
||
|
||
function open() {
|
||
const meta = current && current.meta;
|
||
if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
|
||
const t0 = Math.floor(Player.master.currentTime || 0);
|
||
const body = document.createElement('div');
|
||
body.className = 'share-moment';
|
||
body.innerHTML = `
|
||
<label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label>
|
||
<div class="sm-links">
|
||
<div class="sm-link"><span>This app</span><code class="sm-app"></code><button class="np-btn" type="button" data-act="app">Copy</button></div>
|
||
${isUploadId(meta.id) ? '' : '<div class="sm-link"><span>YouTube</span><code class="sm-yt"></code><button class="np-btn" type="button" data-act="yt">Copy</button></div>'}
|
||
</div>
|
||
<div class="sm-gif">
|
||
<div class="sm-gif-controls">
|
||
<label>GIF length <select class="sm-len"><option value="2">2 s</option><option value="3" selected>3 s</option><option value="5">5 s</option></select></label>
|
||
<button class="np-btn" type="button" data-act="gif">🎞 Make GIF</button>
|
||
</div>
|
||
<div class="sm-gif-out"></div>
|
||
</div>
|
||
<div class="sm-gif sm-bite">
|
||
<div class="sm-gif-controls">
|
||
<label>🔔 Soundbite <input class="sm-b-start" inputmode="decimal" /></label>
|
||
<label>to <input class="sm-b-end" inputmode="decimal" /></label>
|
||
<select class="sm-b-fmt"><option value="mp3">MP3</option><option value="m4r">iPhone ringtone (.m4r)</option></select>
|
||
<button class="np-btn" type="button" data-act="bite">Make</button>
|
||
</div>
|
||
<div class="sm-bite-out"></div>
|
||
</div>`;
|
||
const timeIn = body.querySelector('.sm-time');
|
||
const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; };
|
||
const refresh = () => {
|
||
body.querySelector('.sm-app').textContent = appLink(meta.id, cur());
|
||
const yt = body.querySelector('.sm-yt');
|
||
if (yt) yt.textContent = ytLink(meta.id, cur());
|
||
};
|
||
timeIn.addEventListener('input', refresh);
|
||
timeIn.addEventListener('keydown', (e) => e.stopPropagation());
|
||
refresh();
|
||
// Soundbite range: the A-B loop when one is set, else the next 20 s.
|
||
const bStart = abA !== null ? abA : t0;
|
||
const bEnd = abB !== null && abB > bStart ? abB : bStart + 20;
|
||
body.querySelector('.sm-b-start').value = LyricsCore.fmtClock(bStart);
|
||
body.querySelector('.sm-b-end').value = LyricsCore.fmtClock(bEnd);
|
||
body.querySelectorAll('.sm-bite input').forEach((i) => i.addEventListener('keydown', (e) => e.stopPropagation()));
|
||
let biteBlob = null;
|
||
let gifBlob = null;
|
||
body.addEventListener('click', async (e) => {
|
||
const act = e.target.closest('[data-act]');
|
||
if (!act) return;
|
||
if (act.dataset.act === 'app') copy(appLink(meta.id, cur()), 'App link');
|
||
else if (act.dataset.act === 'yt') copy(ytLink(meta.id, cur()), 'YouTube link');
|
||
else if (act.dataset.act === 'gif') {
|
||
const out = body.querySelector('.sm-gif-out');
|
||
out.innerHTML = '<div class="sm-busy">Making the GIF…</div>';
|
||
act.disabled = true;
|
||
try {
|
||
const d = body.querySelector('.sm-len').value;
|
||
const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/gif?t=${cur()}&d=${d}&w=480`);
|
||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
|
||
gifBlob = await res.blob();
|
||
const url = URL.createObjectURL(gifBlob);
|
||
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
|
||
out.innerHTML = `<img class="sm-preview" alt="GIF preview" src="${url}" />
|
||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
||
${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`;
|
||
out.dataset.name = name;
|
||
} catch (err) {
|
||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||
} finally {
|
||
act.disabled = false;
|
||
}
|
||
} else if (act.dataset.act === 'bite') {
|
||
const out = body.querySelector('.sm-bite-out');
|
||
const a = LyricsCore.parseStamp(body.querySelector('.sm-b-start').value);
|
||
const b = LyricsCore.parseStamp(body.querySelector('.sm-b-end').value);
|
||
const fmt = body.querySelector('.sm-b-fmt').value;
|
||
const max = fmt === 'm4r' ? 40 : 60;
|
||
if (a === null || b === null || b <= a) { out.innerHTML = '<div class="sm-err">⚠ Times must look like 1:23, end after start</div>'; return; }
|
||
if (b - a > max) { out.innerHTML = `<div class="sm-err">⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}</div>`; return; }
|
||
out.innerHTML = '<div class="sm-busy">Cutting the soundbite…</div>';
|
||
act.disabled = true;
|
||
try {
|
||
const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/clip?start=${a}&end=${b}&fmt=${fmt}`);
|
||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
|
||
biteBlob = await res.blob();
|
||
const url = URL.createObjectURL(biteBlob);
|
||
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(a)}s.${fmt}`;
|
||
out.dataset.name = name;
|
||
out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||
out.innerHTML = `<audio controls src="${url}" class="sm-audio"></audio>
|
||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
||
${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div>
|
||
${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`;
|
||
} catch (err) {
|
||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||
} finally {
|
||
act.disabled = false;
|
||
}
|
||
} else if (act.dataset.act === 'sharebite' && biteBlob) {
|
||
const o = body.querySelector('.sm-bite-out');
|
||
try { await navigator.share({ files: [new File([biteBlob], o.dataset.name, { type: o.dataset.type })], title: meta.title }); } catch { /* dismissed */ }
|
||
} else if (act.dataset.act === 'sharegif' && gifBlob) {
|
||
const name = body.querySelector('.sm-gif-out').dataset.name;
|
||
try { await navigator.share({ files: [new File([gifBlob], name, { type: 'image/gif' })], title: meta.title }); } catch { /* dismissed */ }
|
||
}
|
||
});
|
||
const actions = [{ label: 'Done', onClick: closeModal }];
|
||
if (navigator.share) {
|
||
actions.push({ label: '↗ Share link', primary: true, onClick: async () => {
|
||
try { await navigator.share({ title: meta.title, text: `${meta.title} @ ${LyricsCore.fmtClock(cur())}`, url: appLink(meta.id, cur()) }); } catch { /* dismissed */ }
|
||
} });
|
||
} else {
|
||
actions.push({ label: 'Copy app link', primary: true, onClick: () => copy(appLink(meta.id, cur()), 'App link') });
|
||
}
|
||
showModal('🔗 Share this moment', body, actions);
|
||
}
|
||
|
||
// /?v=<id>&t=<sec> — open that video at that moment.
|
||
async function bootFromUrl() {
|
||
let id = '', t = 0;
|
||
try {
|
||
const q = new URLSearchParams(location.search);
|
||
id = (q.get('v') || '').trim();
|
||
t = Math.max(0, parseFloat(q.get('t')) || 0);
|
||
} catch { return; }
|
||
if (!isMediaId(id)) return;
|
||
try {
|
||
const u = new URL(location.href);
|
||
u.searchParams.delete('v');
|
||
u.searchParams.delete('t');
|
||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||
} catch { /* keep */ }
|
||
await playVideoAt(data.history.find((x) => x.id === id) || { id, title: '' }, t);
|
||
}
|
||
|
||
return { open, bootFromUrl };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Picture-in-picture
|
||
// ============================================================================
|
||
// PiP floats the <video>. In dual mode the sound is on a separate <audio>, so
|
||
// the PiP window's own play/pause is mirrored onto the whole player.
|
||
const PiP = (() => {
|
||
const V = els.video;
|
||
const supported = () => (document.pictureInPictureEnabled && !V.disablePictureInPicture) || typeof V.webkitSetPresentationMode === 'function';
|
||
const inPip = () => document.pictureInPictureElement === V || V.webkitPresentationMode === 'picture-in-picture';
|
||
function render() {
|
||
const btn = $('pipBtn');
|
||
if (!btn) return;
|
||
btn.classList.toggle('hidden', !supported());
|
||
btn.classList.toggle('active', inPip());
|
||
}
|
||
async function toggle() {
|
||
if (!supported()) { toast('This browser has no picture-in-picture'); return; }
|
||
if (!current) { toast('Play a video first'); return; }
|
||
if (Player.mode === 'audio') { toast('Picture-in-picture needs video — turn off audio-only mode'); return; }
|
||
try {
|
||
if (inPip()) {
|
||
if (document.pictureInPictureElement) await document.exitPictureInPicture();
|
||
else V.webkitSetPresentationMode('inline');
|
||
} else if (V.requestPictureInPicture) {
|
||
await V.requestPictureInPicture();
|
||
} else {
|
||
V.webkitSetPresentationMode('picture-in-picture');
|
||
}
|
||
} catch (e) {
|
||
toast('⚠ Picture-in-picture: ' + e.message);
|
||
}
|
||
render();
|
||
}
|
||
V.addEventListener('pause', () => {
|
||
if (inPip() && Player.mode === 'dual' && Player._wantsPlaying && !V.ended) Player.pause();
|
||
});
|
||
V.addEventListener('play', () => {
|
||
if (inPip() && Player.mode === 'dual' && !Player._wantsPlaying) Player.play();
|
||
});
|
||
['enterpictureinpicture', 'leavepictureinpicture', 'webkitpresentationmodechanged'].forEach((ev) => V.addEventListener(ev, render));
|
||
try { navigator.mediaSession && navigator.mediaSession.setActionHandler('enterpictureinpicture', toggle); } catch { /* unsupported action */ }
|
||
return { toggle, render };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Equalizer — 5-band graphic EQ (Web Audio)
|
||
// ============================================================================
|
||
// Media elements are routed through BiquadFilters only once a non-flat curve
|
||
// is chosen (createMediaElementSource can't be undone; afterwards "Flat"
|
||
// simply zeroes the gains). Disabled on iOS: Safari suspends Web Audio when
|
||
// the screen locks, which would silence background playback.
|
||
const EQ_BANDS = [
|
||
{ f: 60, type: 'lowshelf', label: '60' },
|
||
{ f: 250, type: 'peaking', label: '250' },
|
||
{ f: 1000, type: 'peaking', label: '1k' },
|
||
{ f: 4000, type: 'peaking', label: '4k' },
|
||
{ f: 12000, type: 'highshelf', label: '12k' },
|
||
];
|
||
const EQ_PRESETS = {
|
||
flat: { name: 'Flat', g: [0, 0, 0, 0, 0] },
|
||
bass: { name: 'Bass boost', g: [7, 4, 0, 0, 0] },
|
||
vocal: { name: 'Vocal', g: [-2, -1, 3, 4, 1] },
|
||
classical: { name: 'Classical', g: [4, 2, -1, 1, 3] },
|
||
treble: { name: 'Treble boost', g: [0, 0, 0, 3, 6] },
|
||
};
|
||
const EQ = (() => {
|
||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
const st = { ctx: null, filters: null, sources: new Map() };
|
||
const available = () => !!AC && !isIOS;
|
||
const gains = () => {
|
||
const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g;
|
||
return EQ_BANDS.map((_, i) => Math.max(-12, Math.min(12, Number(g[i]) || 0)));
|
||
};
|
||
const isFlat = () => gains().every((v) => v === 0);
|
||
|
||
function build() {
|
||
if (st.ctx) return true;
|
||
try {
|
||
st.ctx = new AC();
|
||
st.filters = EQ_BANDS.map((b) => {
|
||
const f = st.ctx.createBiquadFilter();
|
||
f.type = b.type;
|
||
f.frequency.value = b.f;
|
||
f.Q.value = 1.0;
|
||
return f;
|
||
});
|
||
for (let i = 0; i < st.filters.length - 1; i++) st.filters[i].connect(st.filters[i + 1]);
|
||
st.filters[st.filters.length - 1].connect(st.ctx.destination);
|
||
return true;
|
||
} catch { st.ctx = null; return false; }
|
||
}
|
||
|
||
function attach(el) {
|
||
if (!el || st.sources.has(el)) return;
|
||
try {
|
||
const src = st.ctx.createMediaElementSource(el);
|
||
src.connect(st.filters[0]);
|
||
st.sources.set(el, src);
|
||
} catch { /* already connected elsewhere */ }
|
||
}
|
||
|
||
// Apply the stored curve; wires the graph up the first time it's non-flat.
|
||
function apply() {
|
||
if (!available()) return;
|
||
if (!st.ctx && isFlat()) return;
|
||
if (!build()) return;
|
||
attach(els.video);
|
||
attach(els.audio);
|
||
const g = gains();
|
||
st.filters.forEach((f, i) => { f.gain.value = g[i]; });
|
||
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
|
||
renderButton();
|
||
}
|
||
|
||
function renderButton() {
|
||
const btn = $('eqBtn');
|
||
if (!btn) return;
|
||
btn.classList.toggle('active', !isFlat());
|
||
const p = data.settings.eq && data.settings.eq.preset;
|
||
btn.title = `Equalizer: ${p && EQ_PRESETS[p] ? EQ_PRESETS[p].name : isFlat() ? 'Flat' : 'Custom'} (E)`;
|
||
}
|
||
|
||
function set(preset, g) {
|
||
data.settings.eq = { preset, g };
|
||
persist();
|
||
apply();
|
||
renderButton();
|
||
}
|
||
|
||
function open() {
|
||
const body = document.createElement('div');
|
||
body.className = 'eq-panel';
|
||
if (!available()) {
|
||
body.innerHTML = `<p class="modal-text">${isIOS
|
||
? 'The equalizer isn’t available on iPhone and iPad: iOS stops Web Audio when the screen locks, which would cut background playback.'
|
||
: 'This browser has no Web Audio support.'}</p>`;
|
||
showModal('🎚 Equalizer', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||
return;
|
||
}
|
||
const draw = () => {
|
||
const g = gains();
|
||
const cur = data.settings.eq && data.settings.eq.preset;
|
||
body.innerHTML = `
|
||
<div class="eq-presets">${Object.entries(EQ_PRESETS).map(([k, p]) => `<button type="button" class="sp-chip${cur === k || (!cur && k === 'flat' && isFlat()) ? ' on' : ''}" data-preset="${k}">${p.name}</button>`).join('')}</div>
|
||
<div class="eq-bands">${EQ_BANDS.map((b, i) => `
|
||
<label class="eq-band">
|
||
<span class="eq-val">${g[i] > 0 ? '+' : ''}${g[i]} dB</span>
|
||
<input type="range" min="-12" max="12" step="1" value="${g[i]}" data-band="${i}" aria-label="${b.label} Hz" />
|
||
<span class="eq-f">${b.label}</span>
|
||
</label>`).join('')}</div>
|
||
<p class="sp-note">Changes apply instantly. Hz bands from bass (left) to treble (right).</p>`;
|
||
};
|
||
draw();
|
||
body.addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-preset]');
|
||
if (!b) return;
|
||
set(b.dataset.preset, EQ_PRESETS[b.dataset.preset].g.slice());
|
||
draw();
|
||
toast(`🎚 Equalizer: ${EQ_PRESETS[b.dataset.preset].name}`);
|
||
});
|
||
body.addEventListener('input', (e) => {
|
||
const r = e.target.closest('[data-band]');
|
||
if (!r) return;
|
||
const g = gains();
|
||
g[Number(r.dataset.band)] = Number(r.value);
|
||
set('custom', g);
|
||
r.parentElement.querySelector('.eq-val').textContent = `${g[r.dataset.band] > 0 ? '+' : ''}${g[r.dataset.band]} dB`;
|
||
body.querySelectorAll('[data-preset]').forEach((x) => x.classList.remove('on'));
|
||
});
|
||
showModal('🎚 Equalizer', body, [{ label: 'Done', primary: true, onClick: closeModal }]);
|
||
}
|
||
|
||
// A context created before any user gesture starts suspended.
|
||
document.addEventListener('pointerdown', () => { if (st.ctx && st.ctx.state === 'suspended') st.ctx.resume().catch(() => {}); }, true);
|
||
return { open, apply, renderButton, available, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null }) };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Touch gestures on the video
|
||
// ============================================================================
|
||
// Double-tap: left third −10 s, right third +10 s, centre play/pause (or reset
|
||
// zoom when zoomed). Vertical swipe (fullscreen only, so inline the page still
|
||
// scrolls): right half = volume, left half = brightness (a CSS filter — web
|
||
// pages can't set the screen's backlight). Pinch = zoom 1–3× with two-finger pan.
|
||
const Gestures = (() => {
|
||
const stage = document.querySelector('.player-stage');
|
||
const hud = $('gestureHud');
|
||
if (!stage || !hud) return { reset() {} };
|
||
const V = els.video;
|
||
const pts = new Map();
|
||
const st = { lastTap: 0, lastX: 0, tapTimer: null, swipe: null, pinch: null, zoom: 1, panX: 0, panY: 0, bright: 1, hudTimer: null, moved: false };
|
||
const isFs = () => !!(document.fullscreenElement || document.webkitFullscreenElement) || document.body.classList.contains('landscape-fs');
|
||
|
||
function showHud(html) {
|
||
hud.innerHTML = html;
|
||
hud.classList.remove('hidden');
|
||
clearTimeout(st.hudTimer);
|
||
st.hudTimer = setTimeout(() => hud.classList.add('hidden'), 700);
|
||
}
|
||
const bar = (label, frac) => `<div class="gh-label">${label}</div><div class="gh-bar"><span style="width:${Math.round(frac * 100)}%"></span></div>`;
|
||
|
||
function applyView() {
|
||
V.style.transform = st.zoom > 1.001 ? `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})` : '';
|
||
V.style.filter = Math.abs(st.bright - 1) > 0.01 ? `brightness(${st.bright})` : '';
|
||
}
|
||
function reset() { st.zoom = 1; st.panX = st.panY = 0; applyView(); }
|
||
|
||
function seekBy(d, side) {
|
||
if (!current) return;
|
||
Player.seek(Math.max(0, Math.min((Player.master.duration || 1e9) - 0.5, Player.master.currentTime + d)));
|
||
showHud(`<div class="gh-seek ${side}">${d < 0 ? '⟲' : '⟳'} ${Math.abs(d)} s</div>`);
|
||
}
|
||
|
||
function onTap(e) {
|
||
const r = stage.getBoundingClientRect();
|
||
const x = (e.clientX - r.left) / r.width;
|
||
const now = performance.now();
|
||
if (now - st.lastTap < 320 && Math.abs(e.clientX - st.lastX) < 60) {
|
||
st.lastTap = 0;
|
||
if (x < 1 / 3) seekBy(-10, 'left');
|
||
else if (x > 2 / 3) seekBy(10, 'right');
|
||
else if (st.zoom > 1.001) { reset(); showHud('<div class="gh-label">Zoom reset</div>'); }
|
||
else if (current) { Player.toggle(); updatePlayBtn(); showHud(`<div class="gh-seek">${Player.master.paused ? '⏸' : '▶'}</div>`); }
|
||
return;
|
||
}
|
||
st.lastTap = now;
|
||
st.lastX = e.clientX;
|
||
}
|
||
|
||
stage.addEventListener('pointerdown', (e) => {
|
||
if (e.pointerType !== 'touch') return;
|
||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
st.moved = false;
|
||
if (pts.size === 2) {
|
||
const [a, b] = [...pts.values()];
|
||
st.pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), zoom: st.zoom, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, panX: st.panX, panY: st.panY };
|
||
st.swipe = null;
|
||
} else if (pts.size === 1 && isFs()) {
|
||
const r = stage.getBoundingClientRect();
|
||
const right = (e.clientX - r.left) / r.width > 0.5;
|
||
st.swipe = { y0: e.clientY, right, v0: data.settings.volume ?? 1, b0: st.bright, h: r.height, active: false };
|
||
}
|
||
});
|
||
|
||
stage.addEventListener('pointermove', (e) => {
|
||
if (!pts.has(e.pointerId)) return;
|
||
const p = pts.get(e.pointerId);
|
||
if (Math.hypot(e.clientX - p.x, e.clientY - p.y) > 10) st.moved = true;
|
||
pts.set(e.pointerId, { ...p, cx: e.clientX, cy: e.clientY });
|
||
if (st.pinch && pts.size >= 2) {
|
||
const [a, b] = [...pts.values()].map((q) => ({ x: q.cx ?? q.x, y: q.cy ?? q.y }));
|
||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||
st.zoom = Math.max(1, Math.min(3, st.pinch.zoom * (d / (st.pinch.d || 1))));
|
||
const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
|
||
const r = stage.getBoundingClientRect();
|
||
const maxX = (r.width * (st.zoom - 1)) / 2, maxY = (r.height * (st.zoom - 1)) / 2;
|
||
st.panX = Math.max(-maxX, Math.min(maxX, st.pinch.panX + (cx - st.pinch.cx)));
|
||
st.panY = Math.max(-maxY, Math.min(maxY, st.pinch.panY + (cy - st.pinch.cy)));
|
||
applyView();
|
||
showHud(`<div class="gh-label">Zoom ${st.zoom.toFixed(1)}×</div>`);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
const sw = st.swipe;
|
||
if (!sw) return;
|
||
const dy = sw.y0 - e.clientY;
|
||
if (!sw.active && Math.abs(dy) < 16) return;
|
||
sw.active = true;
|
||
const delta = dy / (sw.h * 0.8);
|
||
if (sw.right) {
|
||
const v = Math.max(0, Math.min(1, sw.v0 + delta));
|
||
data.settings.volume = Math.round(v * 100) / 100;
|
||
els.volume.value = String(data.settings.volume);
|
||
Player.applyVolume();
|
||
syncMuteIcon();
|
||
showHud(bar(`🔊 Volume ${Math.round(v * 100)}%`, v));
|
||
} else {
|
||
st.bright = Math.max(0.3, Math.min(1.6, sw.b0 + delta));
|
||
applyView();
|
||
showHud(bar(`☀ Brightness ${Math.round(st.bright * 100)}%`, (st.bright - 0.3) / 1.3));
|
||
}
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
|
||
const end = (e) => {
|
||
if (!pts.has(e.pointerId)) return;
|
||
const wasPinch = !!st.pinch;
|
||
pts.delete(e.pointerId);
|
||
if (pts.size < 2) st.pinch = null;
|
||
if (st.swipe && st.swipe.active && pts.size === 0) persist();
|
||
if (pts.size === 0) st.swipe = null;
|
||
if (!wasPinch && !st.moved && e.type === 'pointerup') onTap(e);
|
||
if (st.zoom < 1.05) reset();
|
||
};
|
||
stage.addEventListener('pointerup', end);
|
||
stage.addEventListener('pointercancel', end);
|
||
return { reset, state: () => ({ zoom: st.zoom, bright: st.bright }) };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// Open in an external player (VLC, MX Player, …)
|
||
// ============================================================================
|
||
// Hands over the server-cached mp4 (/api/media/<id>, a public, Range-capable
|
||
// URL with audio+video in one file — the in-app split streams are useless to
|
||
// other players). Android: intent: links; iOS: VLC's x-callback scheme;
|
||
// desktop: vlc:// or an .m3u playlist file.
|
||
/* ============================================================================
|
||
* SectionRail — floating jump-to-card rail for phones.
|
||
*
|
||
* The player page is a tall stack of cards on a phone (player, now playing,
|
||
* lyrics, related, the list). The rail gives each visible card an icon:
|
||
* • it follows the scroll and highlights whichever card you are looking at,
|
||
* • tapping an icon scrolls to that card,
|
||
* • tapping the icon of the card you are ALREADY on folds/unfolds it
|
||
* (the "double tap" of the request — the first tap takes you there, the
|
||
* second one collapses it).
|
||
* A folded card keeps its header, so it stays a landmark you can scroll to
|
||
* and reopen. Fold state is per device and rides in settings.
|
||
* ========================================================================== */
|
||
const SectionRail = (() => {
|
||
const SECTIONS = [
|
||
{ id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' },
|
||
{ id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true },
|
||
{ id: 'notes', sel: '#notesPanel', icon: '📝', label: 'Lyrics', fold: true },
|
||
{ id: 'related', sel: '#relatedPanel', icon: '🔗', label: 'Related', fold: true },
|
||
{ id: 'list', sel: '#listPane', icon: '📋', label: 'List' },
|
||
];
|
||
const st = { active: '', btns: new Map(), labelTimer: null, raf: 0, bottom: -1 };
|
||
const rail = () => document.getElementById('sectionRail');
|
||
const folds = () => (data.settings.foldedCards ||= {});
|
||
const isFolded = (id) => !!folds()[id];
|
||
const elOf = (s) => document.querySelector(s.sel);
|
||
// Only cards that are actually on screen belong in the rail.
|
||
const visible = () => SECTIONS.filter((s) => {
|
||
const el = elOf(s);
|
||
return el && !el.classList.contains('hidden') && el.offsetParent !== null;
|
||
});
|
||
|
||
function applyFold(sec) {
|
||
const el = elOf(sec);
|
||
if (!el || !sec.fold) return;
|
||
el.classList.toggle('folded', isFolded(sec.id));
|
||
if (sec.id === 'notes') {
|
||
const b = document.getElementById('notesFoldBtn');
|
||
if (b) b.setAttribute('aria-expanded', String(!isFolded(sec.id)));
|
||
}
|
||
// The Related card already had its own +/− button; keep the two in step.
|
||
if (sec.id === 'related') {
|
||
relatedCollapsed = isFolded(sec.id);
|
||
const rl = document.getElementById('relatedList');
|
||
const rb = document.getElementById('relatedToggleBtn');
|
||
if (rl) rl.classList.toggle('hidden', relatedCollapsed);
|
||
if (rb) rb.textContent = relatedCollapsed ? '+' : '−';
|
||
}
|
||
}
|
||
|
||
function setFolded(id, on) {
|
||
const sec = SECTIONS.find((x) => x.id === id);
|
||
if (!sec || !sec.fold) return;
|
||
folds()[id] = !!on;
|
||
persist();
|
||
applyFold(sec);
|
||
paint(); // NOT render() — folding a card must not rebuild the list
|
||
}
|
||
|
||
function flashLabel(btn) {
|
||
clearTimeout(st.labelTimer);
|
||
[...st.btns.values()].forEach((b) => b.classList.remove('show-label'));
|
||
btn.classList.add('show-label');
|
||
st.labelTimer = setTimeout(() => btn.classList.remove('show-label'), 1400);
|
||
}
|
||
|
||
function onTap(sec, btn) {
|
||
const el = elOf(sec);
|
||
if (!el) return;
|
||
flashLabel(btn);
|
||
if (st.active === sec.id && sec.fold) {
|
||
// Already here: this is the second tap — fold or unfold the card.
|
||
setFolded(sec.id, !isFolded(sec.id));
|
||
if (isFolded(sec.id)) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
return;
|
||
}
|
||
st.active = sec.id; // claim it now so the next tap folds
|
||
paint();
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
function build() {
|
||
const r = rail();
|
||
if (!r) return;
|
||
const items = visible();
|
||
// A rail with one entry is noise; so is one on the empty home screen.
|
||
if (items.length < 2) { r.classList.add('hidden'); r.innerHTML = ''; st.btns.clear(); return; }
|
||
const key = items.map((s) => s.id).join(',');
|
||
if (r.dataset.key !== key) {
|
||
r.dataset.key = key;
|
||
r.innerHTML = '';
|
||
st.btns.clear();
|
||
for (const sec of items) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'sec-rail-btn';
|
||
btn.dataset.sec = sec.id;
|
||
btn.setAttribute('aria-label', sec.label);
|
||
btn.innerHTML = `<span aria-hidden="true">${sec.icon}</span><span class="sec-rail-label">${escapeHtml(sec.label)}</span>`;
|
||
btn.addEventListener('click', () => onTap(sec, btn));
|
||
r.appendChild(btn);
|
||
st.btns.set(sec.id, btn);
|
||
}
|
||
}
|
||
r.classList.remove('hidden');
|
||
placeRail();
|
||
paint();
|
||
}
|
||
|
||
// The page does NOT scroll the document: `.player-pane` is the scroller in
|
||
// the browser, but the installed PWA in portrait scrolls `.body` instead
|
||
// (`.player-pane` goes overflow:visible there). So find the real scroller
|
||
// rather than assuming one.
|
||
function scrollerOf(el) {
|
||
for (let e = el && el.parentElement; e; e = e.parentElement) {
|
||
const cs = getComputedStyle(e);
|
||
if (/(auto|scroll)/.test(cs.overflowY) && e.scrollHeight > e.clientHeight + 4) return e;
|
||
}
|
||
return document.scrollingElement || document.documentElement;
|
||
}
|
||
|
||
// The visible box to measure against — the scroller's own rect, or the
|
||
// window when the document itself scrolls.
|
||
function viewBox() {
|
||
const first = elOf(visible()[0] || SECTIONS[0]);
|
||
const sc = scrollerOf(first);
|
||
if (!sc || sc === document.scrollingElement || sc === document.documentElement) {
|
||
return { top: 0, height: window.innerHeight };
|
||
}
|
||
const r = sc.getBoundingClientRect();
|
||
return { top: r.top, height: r.height };
|
||
}
|
||
|
||
// Which card is the reader looking at? The one whose top is closest to a
|
||
// line a third of the way down the visible box — steadier than "first
|
||
// visible", which flickers between two cards at a boundary.
|
||
function currentId() {
|
||
const items = visible();
|
||
if (!items.length) return '';
|
||
const box = viewBox();
|
||
const line = box.top + box.height / 3;
|
||
let best = '', bestD = Infinity;
|
||
let near = items[0].id, nearD = Infinity; // fallback: closest card overall
|
||
for (const sec of items) {
|
||
const el = elOf(sec);
|
||
if (!el) continue;
|
||
const r = el.getBoundingClientRect();
|
||
const d = Math.abs(r.top - line);
|
||
// Distance to the visible band, for the fallback below.
|
||
const outside = Math.max(box.top - r.bottom, r.top - (box.top + box.height), 0);
|
||
if (outside < nearD || (outside === nearD && d < bestD)) { nearD = outside; near = sec.id; }
|
||
if (r.bottom < box.top || r.top > box.top + box.height) continue;
|
||
if (d < bestD) { bestD = d; best = sec.id; }
|
||
}
|
||
// Scrolled past the last card (or a card is taller than the viewport and
|
||
// its top has gone by): never leave the rail with nothing highlighted.
|
||
return best || near;
|
||
}
|
||
|
||
// The mini player and the bottom nav are both position:fixed bottom:0, and
|
||
// either can be absent (nothing playing / landscape). Measure whatever is
|
||
// actually pinned down there and park the rail above it, rather than
|
||
// guessing an offset that is wrong on the next phone.
|
||
function placeRail() {
|
||
const r = rail();
|
||
let occupied = 0;
|
||
for (const id of ['bottomNav', 'miniBar']) {
|
||
const el = document.getElementById(id);
|
||
if (!el || el.classList.contains('hidden')) continue;
|
||
const box = el.getBoundingClientRect();
|
||
if (!box.height) continue;
|
||
if (getComputedStyle(el).position !== 'fixed') continue;
|
||
// Count any fixed bar sitting in the lower half. It must NOT be required
|
||
// to touch the viewport bottom: on an iPhone XR the bottom nav ends
|
||
// above the home-indicator gap, so a "bottom >= innerHeight" test missed
|
||
// it entirely and the rail was parked underneath it.
|
||
if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
|
||
}
|
||
// 14px was too tight: the mini player is a floating CARD with a shadow and
|
||
// rounded corners, so its visual edge reads higher than its box.
|
||
const want = Math.round(occupied + 24);
|
||
if (st.bottom !== want) {
|
||
st.bottom = want;
|
||
if (r) r.style.setProperty('--rail-bottom', want + 'px');
|
||
// Published for anything else that must sit above the bottom bars —
|
||
// the watch-party panel's leave button was hidden behind them.
|
||
document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
|
||
}
|
||
}
|
||
|
||
function paint() {
|
||
for (const [id, btn] of st.btns) {
|
||
btn.classList.toggle('active', id === st.active);
|
||
btn.classList.toggle('folded', isFolded(id));
|
||
}
|
||
}
|
||
|
||
function onScroll() {
|
||
if (st.raf) return;
|
||
st.raf = requestAnimationFrame(() => {
|
||
st.raf = 0;
|
||
// Panels appear and disappear outside render() too (the Related card
|
||
// shows once a video's related list arrives), and a card with no icon
|
||
// would leave the rail with nothing highlighted. build() is a no-op
|
||
// unless the set of visible cards actually changed.
|
||
build();
|
||
placeRail();
|
||
const id = currentId();
|
||
if (id && id !== st.active) { st.active = id; paint(); }
|
||
});
|
||
}
|
||
|
||
function boot() {
|
||
const fb = document.getElementById('notesFoldBtn');
|
||
if (fb) fb.addEventListener('click', () => setFolded('notes', !isFolded('notes')));
|
||
SECTIONS.forEach(applyFold);
|
||
// Scroll events from an element do NOT bubble, so a window listener never
|
||
// fires here. A capture-phase listener on document catches them all, and
|
||
// keeps working when the scroller changes between browser and installed
|
||
// PWA (different elements scroll in each).
|
||
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
||
window.addEventListener('resize', () => { build(); placeRail(); onScroll(); });
|
||
// The mini player appears AFTER the scroll that reveals it, so a scroll
|
||
// handler alone measures the layout as it was a moment ago and parks the
|
||
// rail underneath it. Watch the two bottom bars for real.
|
||
const watch = ['bottomNav', 'miniBar'].map((id) => document.getElementById(id)).filter(Boolean);
|
||
if (watch.length && typeof MutationObserver !== 'undefined') {
|
||
// The mini bar slides in over 0.25s, so a measurement taken the instant
|
||
// it appears catches it mid-animation and lands the rail a few pixels
|
||
// too low. Measure now and again once it has settled.
|
||
const mo = new MutationObserver(() => { placeRail(); setTimeout(placeRail, 320); });
|
||
watch.forEach((el) => mo.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
|
||
}
|
||
if (watch.length && typeof ResizeObserver !== 'undefined') {
|
||
const ro = new ResizeObserver(() => placeRail());
|
||
watch.forEach((el) => ro.observe(el));
|
||
}
|
||
build();
|
||
onScroll();
|
||
}
|
||
|
||
// Called from render(): cards appear and disappear as videos load.
|
||
function refresh() {
|
||
SECTIONS.forEach(applyFold);
|
||
build();
|
||
placeRail();
|
||
onScroll();
|
||
}
|
||
|
||
return { boot, refresh, setFolded, isFolded };
|
||
})();
|
||
|
||
const External = (() => {
|
||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||
const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`;
|
||
async function cached(id) {
|
||
if (isUploadId(id)) return true; // uploads are always a single file on the server
|
||
try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; }
|
||
}
|
||
const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`;
|
||
function m3u(items) {
|
||
return ['#EXTM3U', ...items.flatMap((v) => [`#EXTINF:${Math.round(v.duration || -1)},${(v.title || v.id).replace(/[\r\n]/g, ' ')}`, streamUrl(v.id)])].join('\n') + '\n';
|
||
}
|
||
function download(name, text) {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([text], { type: 'audio/x-mpegurl' }));
|
||
a.download = name;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||
}
|
||
|
||
async function open() {
|
||
const meta = current && current.meta;
|
||
if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
|
||
const body = document.createElement('div');
|
||
body.className = 'ext-panel';
|
||
body.innerHTML = '<div class="sm-busy">Checking the server copy…</div>';
|
||
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||
const ok = await cached(meta.id);
|
||
if (!ok) {
|
||
body.innerHTML = `<p class="modal-text">The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ⬇ Save), then try again.</p>`;
|
||
return;
|
||
}
|
||
const url = streamUrl(meta.id);
|
||
const title = meta.title || meta.id;
|
||
const inList = queue.length > 1 && queueIndex >= 0;
|
||
body.innerHTML = `
|
||
<div class="sm-link"><span>Stream</span><code>${escapeHtml(url)}</code><button class="np-btn" type="button" data-act="copy">Copy</button></div>
|
||
<div class="ext-buttons">
|
||
${isAndroid ? `
|
||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">▶ VLC</a>
|
||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">▶ MX Player</a>
|
||
<a class="np-btn" href="${escapeHtml(intent(url, title))}">▶ Open with…</a>`
|
||
: isIOS ? `
|
||
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">▶ VLC</a>
|
||
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">▶ Infuse</a>`
|
||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">▶ VLC</a>`}
|
||
<button class="np-btn" type="button" data-act="m3u">⤓ .m3u (this video)</button>
|
||
${inList ? '<button class="np-btn" type="button" data-act="m3u-list">⤓ .m3u (whole list)</button>' : ''}
|
||
</div>
|
||
<p class="sp-note">${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
|
||
: isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'
|
||
: 'Desktop VLC may not register vlc:// links — the .m3u file opens in VLC, mpv, IINA or any media player.'}</p>`;
|
||
body.addEventListener('click', async (e) => {
|
||
const act = e.target.closest('[data-act]');
|
||
if (!act) return;
|
||
if (act.dataset.act === 'copy') {
|
||
try { await navigator.clipboard.writeText(url); toast('Stream URL copied ✓'); } catch { toast('Copy blocked'); }
|
||
} else if (act.dataset.act === 'm3u') {
|
||
download(`${title.replace(/[^\w\- ]+/g, '').trim().slice(0, 50) || meta.id}.m3u`, m3u([meta]));
|
||
} else if (act.dataset.act === 'm3u-list') {
|
||
act.disabled = true;
|
||
const items = queue.slice(Math.max(0, queueIndex));
|
||
const flags = await Promise.all(items.map((v) => (v.custom ? false : cached(v.id))));
|
||
const ready = items.filter((_, i) => flags[i]);
|
||
download('ytplayer-queue.m3u', m3u(ready));
|
||
toast(`Playlist with ${ready.length} of ${items.length} songs${ready.length < items.length ? ' (the rest aren’t cached on the server yet)' : ''}`, { duration: 5000 });
|
||
act.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
return { open, m3u, intent };
|
||
})();
|
||
|
||
// ============================================================================
|
||
// 🎉 Watch party — synced playback + chat + voice (server/party.js relays)
|
||
// ============================================================================
|
||
// The host's player drives; guests follow it. Guests extrapolate the host's
|
||
// position from the server-stamped state (clock offset measured at join),
|
||
// seek when > 1.5 s off and mirror play/pause. A guest acting on their own
|
||
// player either sends a command (when the host allows control) or goes
|
||
// "off sync" until they tap Re-sync. Voice chat is a WebRTC mesh between
|
||
// members who switch it on (STUN only — works on most home/mobile networks).
|
||
const Party = (() => {
|
||
const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
|
||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
||
const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName';
|
||
const ls = {
|
||
get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
|
||
set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
|
||
};
|
||
let pid = ls.get(PID_KEY);
|
||
if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
|
||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
|
||
|
||
const st = {
|
||
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
||
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
||
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||
minimized: false,
|
||
};
|
||
const inParty = () => !!st.role;
|
||
const isHost = () => st.role === 'host';
|
||
const guestActive = () => st.role === 'guest' && !st.detached;
|
||
const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); };
|
||
const serverNow = () => Date.now() + st.offset;
|
||
const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
|
||
|
||
// ---------- connection ----------
|
||
function connect() {
|
||
clearTimeout(st.retry);
|
||
const q = { role: st.role, pid, name: myName() || 'Guest' };
|
||
if (st.code) q.code = st.code;
|
||
if (isHost() && st.secret) q.secret = st.secret;
|
||
const ws = new WebSocket(wsUrl(q));
|
||
st.ws = ws;
|
||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
|
||
ws.onclose = (e) => {
|
||
if (st.ws !== ws) return;
|
||
st.ws = null;
|
||
if (!inParty()) return;
|
||
if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
|
||
const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.';
|
||
leave(false);
|
||
toast(`🎉 ${why}`, { duration: 5000 });
|
||
return;
|
||
}
|
||
setStatus('Reconnecting…');
|
||
st.retry = setTimeout(connect, 3000);
|
||
};
|
||
ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
|
||
}
|
||
|
||
function onMessage(m) {
|
||
if (m.type === 'hello') {
|
||
st.code = m.code;
|
||
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
|
||
st.offset = (m.now || Date.now()) - Date.now();
|
||
st.members = m.members || [];
|
||
st.chat = m.chat || [];
|
||
st.allowControl = !!m.allowControl;
|
||
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
||
render();
|
||
if (isHost()) pushState(true);
|
||
else if (m.state) applyState(m.state);
|
||
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
|
||
} else if (m.type === 'state') {
|
||
st.state = m.state;
|
||
if (st.role === 'guest') applyState(m.state);
|
||
} else if (m.type === 'members') {
|
||
st.members = m.members || [];
|
||
if (m.joined) sysMsg(`${m.joined} joined`);
|
||
if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
|
||
render();
|
||
syncVoicePeers();
|
||
} else if (m.type === 'chat') {
|
||
st.chat.push(m.msg);
|
||
if (st.chat.length > 100) st.chat.shift();
|
||
renderChat();
|
||
if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`);
|
||
} else if (m.type === 'settings') {
|
||
st.allowControl = !!m.allowControl;
|
||
render();
|
||
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
|
||
} else if (m.type === 'cmd' && isHost()) {
|
||
runCommand(m);
|
||
} else if (m.type === 'rtc') {
|
||
onRtc(m.from, m.data);
|
||
} else if (m.type === 'ended') {
|
||
toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 });
|
||
}
|
||
}
|
||
|
||
// ---------- host: broadcast ----------
|
||
function stateNow() {
|
||
const meta = current && current.meta;
|
||
const M = Player.master;
|
||
return {
|
||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
|
||
cur: M ? M.currentTime || 0 : 0,
|
||
paused: !M || M.paused,
|
||
rate: (M && M.playbackRate) || 1,
|
||
};
|
||
}
|
||
function pushState(force = false) {
|
||
if (!isHost() || !st.ws) return;
|
||
const s = stateNow();
|
||
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
|
||
if (!force && key === st.lastPush) return;
|
||
st.lastPush = key;
|
||
send({ type: 'state', state: s });
|
||
}
|
||
function runCommand(m) {
|
||
switch (m.cmd) {
|
||
case 'toggle': Player.toggle(); break;
|
||
case 'play': Player.play(); break;
|
||
case 'pause': Player.pause(); break;
|
||
case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break;
|
||
case 'next': playNext(); break;
|
||
case 'prev': playPrev(); break;
|
||
default: return;
|
||
}
|
||
toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`);
|
||
setTimeout(() => pushState(true), 200);
|
||
}
|
||
|
||
// ---------- guest: follow ----------
|
||
const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
|
||
function targetTime(s) {
|
||
if (!s) return 0;
|
||
return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
|
||
}
|
||
async function applyState(s) {
|
||
st.state = s;
|
||
if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
|
||
const target = targetTime(s);
|
||
if (!current || !current.meta || current.meta.id !== s.v.id) {
|
||
quiet(6000);
|
||
await playVideoAt({ ...s.v }, target);
|
||
return;
|
||
}
|
||
const M = Player.master;
|
||
if (!M) return;
|
||
if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); }
|
||
if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); }
|
||
if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
|
||
else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
|
||
}
|
||
// A guest's own actions: forwarded when allowed, else they go off sync.
|
||
function onLocalAction(kind) {
|
||
if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return;
|
||
if (st.allowControl) {
|
||
const M = Player.master;
|
||
if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
|
||
else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' });
|
||
return;
|
||
}
|
||
st.detached = true;
|
||
setStatus('Off sync — you paused or skipped on your own');
|
||
render();
|
||
}
|
||
// playNext/playPrev guard: guests don't change songs themselves.
|
||
function intercept(cmd) {
|
||
if (st.role !== 'guest' || st.detached) return false;
|
||
if (st.allowControl) send({ type: 'cmd', cmd });
|
||
else toast('🎉 The host picks the songs in a watch party');
|
||
return true;
|
||
}
|
||
function resync() {
|
||
st.detached = false;
|
||
setStatus('Synced with the host');
|
||
render();
|
||
applyState(st.state);
|
||
}
|
||
|
||
// ---------- voice (WebRTC mesh) ----------
|
||
const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
|
||
function peer(other) {
|
||
let pc = st.voice.pcs.get(other);
|
||
if (pc) return pc;
|
||
pc = new RTCPeerConnection({ iceServers: ICE });
|
||
st.voice.pcs.set(other, pc);
|
||
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream));
|
||
pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); };
|
||
pc.ontrack = (e) => {
|
||
let a = st.voice.audios.get(other);
|
||
if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); }
|
||
a.srcObject = e.streams[0];
|
||
a.play().catch(() => {});
|
||
};
|
||
pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); };
|
||
return pc;
|
||
}
|
||
function dropPeer(other) {
|
||
const pc = st.voice.pcs.get(other);
|
||
if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); }
|
||
const a = st.voice.audios.get(other);
|
||
if (a) { a.srcObject = null; st.voice.audios.delete(other); }
|
||
}
|
||
async function offerTo(other) {
|
||
const pc = peer(other);
|
||
const offer = await pc.createOffer();
|
||
await pc.setLocalDescription(offer);
|
||
send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } });
|
||
}
|
||
async function onRtc(from, d) {
|
||
if (!st.voice.on || !d) return;
|
||
try {
|
||
if (d.sdp) {
|
||
const pc = peer(from);
|
||
await pc.setRemoteDescription(d.sdp);
|
||
if (d.sdp.type === 'offer') {
|
||
const ans = await pc.createAnswer();
|
||
await pc.setLocalDescription(ans);
|
||
send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } });
|
||
}
|
||
} else if (d.ice) {
|
||
await peer(from).addIceCandidate(d.ice);
|
||
}
|
||
} catch { /* a glare/ICE hiccup — the next members update retries */ }
|
||
}
|
||
// The member with the smaller pid makes the offer, so each pair connects once.
|
||
function connectVoicePeers() {
|
||
if (!st.voice.on) return;
|
||
for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {});
|
||
}
|
||
function syncVoicePeers() {
|
||
const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid));
|
||
for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other);
|
||
connectVoicePeers();
|
||
}
|
||
async function toggleVoice() {
|
||
if (st.voice.on) {
|
||
st.voice.on = false;
|
||
for (const other of [...st.voice.pcs.keys()]) dropPeer(other);
|
||
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop());
|
||
st.voice.stream = null;
|
||
send({ type: 'voice', on: false });
|
||
render();
|
||
return;
|
||
}
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; }
|
||
try {
|
||
st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
||
} catch (e) {
|
||
toast('🎤 Microphone blocked: ' + (e.message || e.name));
|
||
return;
|
||
}
|
||
st.voice.on = true;
|
||
st.voice.muted = false;
|
||
send({ type: 'voice', on: true });
|
||
toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 });
|
||
render();
|
||
connectVoicePeers();
|
||
}
|
||
function toggleMute() {
|
||
if (!st.voice.stream) return;
|
||
st.voice.muted = !st.voice.muted;
|
||
st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; });
|
||
render();
|
||
}
|
||
|
||
// ---------- lifecycle ----------
|
||
function start() {
|
||
st.role = 'host';
|
||
st.code = ''; st.secret = ''; st.chat = []; st.members = [];
|
||
ls.set(HOST_KEY, null);
|
||
showPanel();
|
||
setStatus('Starting…');
|
||
connect();
|
||
beginTimers();
|
||
}
|
||
function join(code, name) {
|
||
code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||
if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
|
||
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||
st.role = 'guest';
|
||
st.code = code; st.detached = false; st.chat = []; st.members = [];
|
||
showPanel();
|
||
setStatus('Joining…');
|
||
connect();
|
||
beginTimers();
|
||
}
|
||
function beginTimers() {
|
||
clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||
st.pushTimer = setInterval(() => pushState(), 3000);
|
||
st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500);
|
||
}
|
||
function leave(sendEnd = true) {
|
||
if (isHost() && sendEnd) send({ type: 'end' });
|
||
if (st.voice.on) toggleVoice();
|
||
const ws = st.ws;
|
||
st.ws = null;
|
||
st.role = null; st.code = ''; st.secret = '';
|
||
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||
ls.set(HOST_KEY, null);
|
||
$('partyPanel').classList.add('hidden');
|
||
render();
|
||
}
|
||
|
||
// ---------- UI ----------
|
||
// Lyrics-instead-of-video is a per-device view: each guest chooses it for
|
||
// themselves, and the host's playback is untouched (the video keeps playing
|
||
// behind the lyrics, so sync and audio carry on).
|
||
const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
|
||
function renderLyricsToggle() {
|
||
const b = document.querySelector('#partyPanel .pt-lyrics');
|
||
if (!b) return;
|
||
b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
|
||
b.classList.toggle('on', lyricsOn());
|
||
}
|
||
function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
|
||
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
|
||
function link() { return `${location.origin}/?party=${st.code}`; }
|
||
function showPanel() { $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
|
||
|
||
function render() {
|
||
const panel = $('partyPanel');
|
||
const chip = $('navPartyBtn');
|
||
if (chip) chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party';
|
||
if (!panel || panel.classList.contains('hidden')) return;
|
||
const nameOk = !!myName();
|
||
if (!inParty()) {
|
||
panel.querySelector('.pt-body').innerHTML = `
|
||
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
||
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
||
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||
<div class="pt-or">or join one</div>
|
||
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
||
$('ptTitle').textContent = '🎉 Watch party';
|
||
setStatus('');
|
||
return;
|
||
}
|
||
$('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
|
||
const s = st.state && st.state.v;
|
||
panel.querySelector('.pt-body').innerHTML = `
|
||
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
||
${!isHost() && s ? `<div class="pt-now">▶ ${escapeHtml(s.title)}</div>` : ''}
|
||
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
||
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||
<div class="pt-voice">
|
||
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
|
||
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
|
||
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
|
||
</div>
|
||
<div class="pt-chat" id="ptChat"></div>
|
||
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
|
||
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
||
renderChat();
|
||
}
|
||
function renderChat() {
|
||
const box = $('ptChat');
|
||
if (!box) return;
|
||
box.innerHTML = st.chat.map((c) => c.sys
|
||
? `<div class="pt-sys">${escapeHtml(c.text)}</div>`
|
||
: `<div class="pt-line${c.pid === pid ? ' me' : ''}"><b>${escapeHtml(c.from)}</b> ${escapeHtml(c.text)}</div>`).join('');
|
||
box.scrollTop = box.scrollHeight;
|
||
}
|
||
|
||
function bindUi() {
|
||
const panel = $('partyPanel');
|
||
$('navPartyBtn').addEventListener('click', () => {
|
||
if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
|
||
if (panel.classList.contains('hidden')) showPanel();
|
||
else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); }
|
||
else if (!inParty()) panel.classList.add('hidden');
|
||
else { st.minimized = true; panel.classList.add('min'); }
|
||
});
|
||
$('ptMin').addEventListener('click', () => {
|
||
if (!inParty()) { panel.classList.add('hidden'); return; }
|
||
st.minimized = !st.minimized;
|
||
panel.classList.toggle('min', st.minimized);
|
||
});
|
||
panel.addEventListener('keydown', (e) => e.stopPropagation());
|
||
panel.addEventListener('click', async (e) => {
|
||
const a = e.target.closest('[data-act]');
|
||
if (!a || a.tagName === 'INPUT') return;
|
||
const act = a.dataset.act;
|
||
const nameIn = panel.querySelector('.pt-name');
|
||
const name = nameIn ? nameIn.value.trim() : '';
|
||
if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
|
||
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||
if (act === 'start') start();
|
||
else if (act === 'join') join(panel.querySelector('.pt-code').value);
|
||
else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
|
||
else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
|
||
else if (act === 'resync') resync();
|
||
else if (act === 'voice') toggleVoice();
|
||
else if (act === 'mute') toggleMute();
|
||
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
|
||
else if (act === 'leave') leave(true);
|
||
});
|
||
panel.addEventListener('change', (e) => {
|
||
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
|
||
});
|
||
panel.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const input = panel.querySelector('.pt-msg');
|
||
const text = input.value.trim();
|
||
if (text) { send({ type: 'chat', text }); input.value = ''; }
|
||
});
|
||
// Host: push on every player change; guests: detect their own actions.
|
||
['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
|
||
[els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => {
|
||
if (el !== Player.master) return;
|
||
if (isHost()) pushState(true);
|
||
else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev);
|
||
}));
|
||
});
|
||
}
|
||
|
||
function boot() {
|
||
bindUi();
|
||
render();
|
||
// Resume hosting after a reload.
|
||
try {
|
||
const h = JSON.parse(ls.get(HOST_KEY) || 'null');
|
||
if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); }
|
||
} catch { /* none */ }
|
||
let code = '';
|
||
try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ }
|
||
if (code) {
|
||
try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
|
||
if (myName()) join(code);
|
||
else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); }
|
||
}
|
||
}
|
||
|
||
return {
|
||
boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
|
||
state: () => ({
|
||
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
||
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
||
remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length,
|
||
chat: st.chat.length, allowControl: st.allowControl,
|
||
}),
|
||
_join: join, _start: start,
|
||
};
|
||
})();
|
||
|
||
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);
|
||
updateMiniBar();
|
||
updateServiceProgress(cur, dur);
|
||
Notes.tick(cur);
|
||
Transition.tick(cur, dur);
|
||
if (sleepAtTrackEnd && dur) applySleepFade(dur - cur);
|
||
StatsTrack.tick(cur);
|
||
Wave.draw();
|
||
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only
|
||
// when "loop current" is on; otherwise B is the effective end of the track and
|
||
// we advance per the repeat settings (next track, or wrap when "repeat list").
|
||
if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB) {
|
||
if (data.settings.loopOne) {
|
||
Player.seek(abA);
|
||
} else if (!advanceQueue()) {
|
||
// Nothing to advance to (repeat off, last track) — stop at B like a real end.
|
||
Player.pause();
|
||
Player.seek(abA);
|
||
updatePlayBtn();
|
||
$('upnext').classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Mini now-playing bar
|
||
// ============================================================================
|
||
function showMiniBar() {
|
||
if (!current || !current.meta) return;
|
||
$('miniTitle').textContent = current.meta.title;
|
||
const miniCh = $('miniChannel');
|
||
miniCh.textContent = current.meta.channel || '';
|
||
miniCh.classList.toggle('link', !!channelKeyOf(current.meta));
|
||
$('miniBar').classList.remove('hidden');
|
||
updateMiniBar();
|
||
}
|
||
function updateMiniBar() {
|
||
if (!current || !current.meta) return;
|
||
const cur = Player.master.currentTime || 0;
|
||
const dur = Player.master.duration || 0;
|
||
$('miniTime').textContent = fmtTime(cur);
|
||
if (dur > 0) {
|
||
$('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%';
|
||
}
|
||
}
|
||
function hideMiniBar() {
|
||
$('miniBar').classList.add('hidden');
|
||
}
|
||
|
||
// ============================================================================
|
||
// History
|
||
// ============================================================================
|
||
function addToHistory(meta) {
|
||
data.playCount = data.playCount || {};
|
||
data.playCount[meta.id] = (data.playCount[meta.id] || 0) + 1;
|
||
data.history = data.history.filter((v) => v.id !== meta.id);
|
||
data.history.unshift({
|
||
id: meta.id, title: meta.title, channel: meta.channel,
|
||
channelId: meta.channelId || '', channelUrl: meta.channelUrl || '',
|
||
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, source = '', { playFull = false } = {}) {
|
||
queue = list;
|
||
queueIndex = index;
|
||
queueSource = source;
|
||
playFullMode = playFull;
|
||
unshuffledQueue = null;
|
||
// Play-in-full keeps the list order — a service set plays as arranged.
|
||
if (data.settings.shuffle && !playFull) applyShuffle();
|
||
Player.loadVideo(queue[queueIndex]);
|
||
renderUpNext();
|
||
}
|
||
|
||
// ---------- Shuffle ----------
|
||
// Shuffles only the UPCOMING part of the live queue (Fisher-Yates); the
|
||
// already-played prefix and the current track stay where they are. The
|
||
// pre-shuffle order is kept so turning shuffle off restores it.
|
||
let unshuffledQueue = null;
|
||
|
||
function applyShuffle() {
|
||
if (queue.length <= queueIndex + 2) return; // nothing meaningful to shuffle
|
||
if (!unshuffledQueue) unshuffledQueue = queue.slice();
|
||
const upcoming = queue.slice(queueIndex + 1);
|
||
for (let i = upcoming.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
|
||
}
|
||
queue = queue.slice(0, queueIndex + 1).concat(upcoming);
|
||
}
|
||
|
||
function toggleShuffle({ quiet = false } = {}) {
|
||
data.settings.shuffle = !data.settings.shuffle;
|
||
if (data.settings.shuffle) {
|
||
applyShuffle();
|
||
} else if (unshuffledQueue) {
|
||
const playingId = current && current.meta && current.meta.id;
|
||
queue = unshuffledQueue;
|
||
unshuffledQueue = null;
|
||
const idx = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
|
||
if (idx >= 0) queueIndex = idx;
|
||
}
|
||
persist();
|
||
updateLoopRepeatButtons();
|
||
renderUpNext();
|
||
if (!quiet) toast(data.settings.shuffle ? 'Shuffle on' : 'Shuffle off');
|
||
}
|
||
|
||
// ---------- Temporary queue ----------
|
||
function updateQueueBadge() {
|
||
const count = data.queue.length;
|
||
const b = $('navQueueCount');
|
||
if (b) { b.textContent = String(count); b.classList.toggle('hidden', count === 0); }
|
||
// Mirror to bottom-nav badge
|
||
const bb = $('bnQueueCount');
|
||
if (bb) { bb.textContent = String(count); bb.classList.toggle('hidden', count === 0); }
|
||
}
|
||
function addToQueue(video, { quiet = false } = {}) {
|
||
if (!video || !video.id) return;
|
||
if (data.queue.some((v) => v.id === video.id)) { if (!quiet) toast('Already in queue'); return; }
|
||
data.queue.push(slim(video));
|
||
persist();
|
||
updateQueueBadge();
|
||
if (view.type === 'queue') renderList();
|
||
if (!quiet) toast('Added to queue');
|
||
}
|
||
function removeFromQueue(id) {
|
||
data.queue = data.queue.filter((v) => v.id !== id);
|
||
// Keep an in-progress queue playback in sync if it was sourced from the queue.
|
||
if (queueSource === 'queue') {
|
||
const playingId = current && current.meta && current.meta.id;
|
||
queue = data.queue.slice();
|
||
queueIndex = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
|
||
renderUpNext();
|
||
}
|
||
persist();
|
||
updateQueueBadge();
|
||
if (view.type === 'queue') renderList();
|
||
}
|
||
function clearQueue() {
|
||
data.queue = [];
|
||
persist();
|
||
updateQueueBadge();
|
||
if (view.type === 'queue') renderList();
|
||
toast('Queue cleared');
|
||
}
|
||
function playQueue(index = 0) {
|
||
if (!data.queue.length) return;
|
||
playFromList(data.queue.slice(), index, 'queue');
|
||
}
|
||
|
||
function renderUpNext() {
|
||
updateServiceUpNext();
|
||
Remote.changed();
|
||
const upcoming = queue.slice(queueIndex + 1);
|
||
if (!upcoming.length) { $('upnext').classList.add('hidden'); return; }
|
||
$('upnext').classList.remove('hidden');
|
||
$('upnextCount').textContent = String(upcoming.length);
|
||
const list = $('upnextList');
|
||
list.innerHTML = '';
|
||
upcoming.forEach((v) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'upnext-item';
|
||
item.innerHTML = `
|
||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
|
||
<div class="upnext-info">
|
||
<div class="upnext-title"></div>
|
||
<div class="upnext-channel"></div>
|
||
</div>`;
|
||
item.querySelector('.upnext-title').textContent = v.title;
|
||
const upChEl = item.querySelector('.upnext-channel');
|
||
upChEl.textContent = v.channel || '';
|
||
if (channelKeyOf(v)) {
|
||
upChEl.classList.add('link');
|
||
upChEl.title = 'View channel';
|
||
upChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
|
||
}
|
||
item.addEventListener('click', () => {
|
||
const idx = queue.findIndex((x) => x.id === v.id);
|
||
if (idx >= 0) { queueIndex = idx - 1; playNext(); }
|
||
});
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
// Advance to the next track. Wraps to the start when "repeat list" is on.
|
||
function advanceQueue() {
|
||
if (queueIndex >= 0 && queueIndex < queue.length - 1) {
|
||
queueIndex++;
|
||
} else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) {
|
||
queueIndex = 0;
|
||
} else {
|
||
return false;
|
||
}
|
||
// Advancing to a new track always starts from the beginning (or its A point),
|
||
// never the previous resume timestamp — and never scrolls the user away
|
||
// from whatever page they are browsing while listening.
|
||
Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
|
||
renderUpNext();
|
||
return true;
|
||
}
|
||
function playNext() {
|
||
if (Party.intercept('next')) return;
|
||
if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); }
|
||
}
|
||
// Fired when a track finishes on its own — honors single-video loop first.
|
||
function onTrackEnded() {
|
||
if (Transition.onEnded()) return; // crossfade/gapless owns this end
|
||
if (sleepAtTrackEnd) { finishSleep(); return; } // sleep timer: "end of this song"
|
||
if (Party.guestActive()) return; // the host's player decides what's next
|
||
if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; }
|
||
if (!advanceQueue()) {
|
||
if (playFullMode) { playFullMode = false; toast('Finished playing in full'); }
|
||
updatePlayBtn();
|
||
$('upnext').classList.add('hidden');
|
||
}
|
||
}
|
||
function toggleLoopOne() {
|
||
data.settings.loopOne = !data.settings.loopOne;
|
||
persist();
|
||
updateLoopRepeatButtons();
|
||
toast(data.settings.loopOne ? 'Looping current video' : 'Loop off');
|
||
}
|
||
function toggleRepeat() {
|
||
data.settings.repeatMode = data.settings.repeatMode === 'all' ? 'off' : 'all';
|
||
persist();
|
||
updateLoopRepeatButtons();
|
||
toast(data.settings.repeatMode === 'all' ? 'Repeating list' : 'Repeat off');
|
||
}
|
||
// One button for shuffle / repeat list / loop one. Cycle:
|
||
// off → repeat list → shuffle → shuffle + repeat → loop this video → off.
|
||
const PLAY_MODES = [
|
||
{ name: 'In order', sub: 'Plays the list once, then stops' },
|
||
{ name: 'Repeat list', sub: 'Starts over when the list ends' },
|
||
{ name: 'Shuffle', sub: 'Random order' },
|
||
{ name: 'Shuffle + repeat', sub: 'Random order, forever' },
|
||
{ name: 'Loop this video', sub: 'Plays the same video again and again' },
|
||
];
|
||
const MODE_ICONS = [
|
||
'<path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3"/>',
|
||
'<path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3"/>',
|
||
'<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/>',
|
||
'<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/>',
|
||
'<path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3M11 10.2l1.6-.9v5.4"/>',
|
||
];
|
||
function playModeIndex() {
|
||
const st = data.settings;
|
||
if (st.loopOne) return 4;
|
||
const rep = st.repeatMode === 'all';
|
||
return st.shuffle ? (rep ? 3 : 2) : (rep ? 1 : 0);
|
||
}
|
||
function cyclePlayMode() {
|
||
const next = (playModeIndex() + 1) % PLAY_MODES.length;
|
||
const wantShuffle = next === 2 || next === 3;
|
||
data.settings.loopOne = next === 4;
|
||
data.settings.repeatMode = next === 1 || next === 3 ? 'all' : 'off';
|
||
if (!!data.settings.shuffle !== wantShuffle) toggleShuffle({ quiet: true });
|
||
persist();
|
||
updateLoopRepeatButtons();
|
||
toast(PLAY_MODES[next].name, { duration: 1800 });
|
||
}
|
||
function updateLoopRepeatButtons() {
|
||
if (els.loopBtn) els.loopBtn.classList.toggle('active', !!data.settings.loopOne);
|
||
if (els.repeatBtn) els.repeatBtn.classList.toggle('active', data.settings.repeatMode === 'all');
|
||
if (els.shuffleBtn) els.shuffleBtn.classList.toggle('active', !!data.settings.shuffle);
|
||
const mb = $('modeBtn');
|
||
if (mb) {
|
||
const i = playModeIndex();
|
||
mb.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${MODE_ICONS[i]}</svg>`;
|
||
mb.classList.toggle('active', i > 0);
|
||
mb.dataset.badge = i === 3 ? '∞' : '';
|
||
mb.title = `${PLAY_MODES[i].name} — ${PLAY_MODES[i].sub}. Tap to change.`;
|
||
mb.setAttribute('aria-label', 'Playback mode: ' + PLAY_MODES[i].name);
|
||
}
|
||
}
|
||
function playPrev() {
|
||
if (Party.intercept('prev')) return;
|
||
if (Player.master.currentTime > 3) { Player.seek(0); return; }
|
||
if (queueIndex > 0) {
|
||
queueIndex--;
|
||
Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
|
||
renderUpNext();
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Service mode (set-list view) with screen wake lock
|
||
// ============================================================================
|
||
let serviceWakeLock = null;
|
||
|
||
function isServiceModeOpen() {
|
||
const el = $('serviceMode');
|
||
return !!(el && !el.classList.contains('hidden'));
|
||
}
|
||
|
||
async function acquireWakeLock() {
|
||
if (!isServiceModeOpen()) return;
|
||
if (!('wakeLock' in navigator) || typeof navigator.wakeLock.request !== 'function') {
|
||
serviceWakeLock = null;
|
||
updateWakeStateUI();
|
||
return;
|
||
}
|
||
try {
|
||
if (serviceWakeLock && !serviceWakeLock.released) {
|
||
try { await serviceWakeLock.release(); } catch {}
|
||
}
|
||
const lock = await navigator.wakeLock.request('screen');
|
||
serviceWakeLock = lock;
|
||
lock.addEventListener('release', () => {
|
||
if (serviceWakeLock === lock) {
|
||
serviceWakeLock = null;
|
||
updateWakeStateUI();
|
||
}
|
||
});
|
||
} catch {
|
||
serviceWakeLock = null;
|
||
}
|
||
updateWakeStateUI();
|
||
}
|
||
|
||
async function releaseWakeLock() {
|
||
if (serviceWakeLock) {
|
||
try {
|
||
if (!serviceWakeLock.released) {
|
||
await serviceWakeLock.release();
|
||
}
|
||
} catch {}
|
||
serviceWakeLock = null;
|
||
}
|
||
updateWakeStateUI();
|
||
}
|
||
|
||
function updateWakeStateUI() {
|
||
const el = $('serviceWakeState');
|
||
if (!el) return;
|
||
const isAwake = !!(serviceWakeLock && !serviceWakeLock.released);
|
||
el.textContent = isAwake ? 'Screen stays awake' : 'Screen may sleep';
|
||
el.classList.toggle('awake', isAwake);
|
||
}
|
||
|
||
function updateServiceUpNext() {
|
||
const upNextEl = $('serviceUpNext');
|
||
if (!upNextEl) return;
|
||
const upcoming = (queue && queueIndex >= 0) ? queue.slice(queueIndex + 1) : [];
|
||
if (upcoming.length > 0) {
|
||
upNextEl.textContent = 'Up next: ' + (upcoming[0].title || 'Untitled');
|
||
} else {
|
||
upNextEl.textContent = 'Last song';
|
||
}
|
||
}
|
||
|
||
function updateServiceProgress(cur, dur) {
|
||
const curEl = $('serviceCurTime');
|
||
const durEl = $('serviceDurTime');
|
||
if (curEl) curEl.textContent = fmtTime(cur || 0);
|
||
if (durEl) durEl.textContent = fmtTime(dur || 0);
|
||
const fill = $('serviceProgressFill');
|
||
if (fill) fill.style.width = (dur > 0 ? Math.min(100, Math.max(0, (cur || 0) / dur * 100)) : 0).toFixed(1) + '%';
|
||
}
|
||
|
||
function updateServiceMode() {
|
||
if (!isServiceModeOpen()) return;
|
||
const meta = current ? current.meta : null;
|
||
const titleEl = $('serviceTitle');
|
||
const chanEl = $('serviceChannel');
|
||
if (titleEl) titleEl.textContent = meta ? (meta.title || '') : 'No track playing';
|
||
if (chanEl) chanEl.textContent = meta ? (meta.channel || '') : '';
|
||
|
||
// Current track note (Section 5): read entry.notes, degrade gracefully if absent
|
||
const currentEntry = (queue && queueIndex >= 0 && queueIndex < queue.length)
|
||
? queue[queueIndex]
|
||
: (currentPlaylistEntry() || meta);
|
||
const note = (currentEntry && currentEntry.notes)
|
||
? String(currentEntry.notes).trim()
|
||
: ((meta && meta.notes) ? String(meta.notes).trim() : '');
|
||
const noteEl = $('serviceNote');
|
||
if (noteEl) {
|
||
if (note) {
|
||
noteEl.textContent = note;
|
||
noteEl.classList.remove('hidden');
|
||
} else {
|
||
noteEl.textContent = '';
|
||
noteEl.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
updateServiceUpNext();
|
||
|
||
const cur = Player.master ? (Player.master.currentTime || 0) : 0;
|
||
const dur = Player.master ? (Player.master.duration || (meta ? meta.duration : 0) || 0) : 0;
|
||
updateServiceProgress(cur, dur);
|
||
|
||
const spBtn = $('servicePlayBtn');
|
||
if (spBtn && Player.master) {
|
||
spBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
||
}
|
||
|
||
updateWakeStateUI();
|
||
}
|
||
|
||
// ---------- Service mode video panel ----------
|
||
// Lyric videos carry the words in the picture, so service mode can show the
|
||
// video instead of hiding it. The <video> element is NEVER reparented — moving
|
||
// a playing video breaks playback on iOS (and drops the stream on some
|
||
// Androids). Instead .player-stage is pinned over a placeholder slot inside the
|
||
// overlay with inline fixed coordinates, which are cleared when it closes.
|
||
function serviceVideoOn() {
|
||
return !!data.settings.serviceVideo && !data.settings.audioOnly;
|
||
}
|
||
|
||
// The overlay normally sits at body level with z-index 320, and nothing inside
|
||
// .app can paint above it: .app is `position: relative; z-index: 1`, which makes
|
||
// it a stacking context, so a raised z-index on .player-stage is capped by
|
||
// .app's own layer. (On iOS the backdrop-filter on .main compounds this by also
|
||
// making .main the containing block for fixed descendants.) An earlier attempt
|
||
// pinned .player-stage over a slot in the overlay; the geometry was exact but
|
||
// the video stayed buried behind it.
|
||
//
|
||
// So to show the video we move the OVERLAY into the player pane and render it
|
||
// inline beneath the stage, instead of trying to lift the video out. Only divs
|
||
// and buttons move — the <video> element never does, because reparenting a
|
||
// playing video breaks playback on iOS.
|
||
function applyServiceVideoLayout() {
|
||
const overlay = $('serviceMode');
|
||
const pane = els.playerPane;
|
||
if (!overlay || !pane) return;
|
||
const on = isServiceModeOpen() && serviceVideoOn();
|
||
const host = on ? pane : document.body;
|
||
if (overlay.parentNode !== host) host.appendChild(overlay);
|
||
document.body.classList.toggle('service-video', on);
|
||
}
|
||
|
||
function updateServiceVideoUI() {
|
||
applyServiceVideoLayout();
|
||
const btn = $('serviceVideoBtn');
|
||
if (btn) {
|
||
btn.setAttribute('aria-pressed', serviceVideoOn() ? 'true' : 'false');
|
||
}
|
||
}
|
||
|
||
function toggleServiceVideo() {
|
||
if (data.settings.audioOnly) {
|
||
toast('Audio-only mode is on — turn it off in Settings to show video');
|
||
return;
|
||
}
|
||
data.settings.serviceVideo = !data.settings.serviceVideo;
|
||
// Video and lyrics-only share the screen; turning one on turns the other off.
|
||
if (data.settings.serviceVideo && data.settings.serviceLyrics) data.settings.serviceLyrics = false;
|
||
persist();
|
||
updateServiceVideoUI();
|
||
Notes.serviceRefresh();
|
||
}
|
||
|
||
function enterServiceMode(pl) {
|
||
if (pl && pl.videos && pl.videos.length) {
|
||
const source = 'playlist:' + pl.id;
|
||
const playingId = (current && current.meta) ? current.meta.id : null;
|
||
if (queueSource === source && playingId) {
|
||
// Already running this set — service mode is only a view, so keep the
|
||
// live queue, its position, shuffle order and playback exactly as they
|
||
// are instead of jumping back to the first song.
|
||
} else {
|
||
// Not on this set: open on the playing song if it happens to be in the
|
||
// playlist, otherwise start from the top.
|
||
const idx = playingId ? pl.videos.findIndex((v) => v && v.id === playingId) : -1;
|
||
playFromList(pl.videos, idx >= 0 ? idx : 0, source);
|
||
}
|
||
}
|
||
const el = $('serviceMode');
|
||
if (el) el.classList.remove('hidden');
|
||
// Hides the mini-bar / bottom-nav / sidebar for as long as the overlay is up
|
||
// (see .service-open in styles.css).
|
||
document.body.classList.add('service-open');
|
||
updateServiceVideoUI();
|
||
updateServiceMode();
|
||
Notes.serviceRefresh();
|
||
acquireWakeLock();
|
||
}
|
||
|
||
function exitServiceMode() {
|
||
const el = $('serviceMode');
|
||
if (el) el.classList.add('hidden');
|
||
document.body.classList.remove('service-open');
|
||
// Runs after the overlay is hidden, so this clears the pinned video styles
|
||
// and returns .player-stage to the player pane.
|
||
updateServiceVideoUI();
|
||
releaseWakeLock();
|
||
}
|
||
|
||
// ============================================================================
|
||
// Channel view — list a channel's uploads with quick actions
|
||
// ============================================================================
|
||
async function openChannel(channelKey, displayName) {
|
||
if (!channelKey) { toast('No channel info for this video'); return; }
|
||
view = { type: 'channel' };
|
||
channelData = { name: displayName || 'Channel', url: channelKey, key: channelKey, results: [], loading: true };
|
||
render();
|
||
try {
|
||
const res = await API.getChannel(channelKey);
|
||
if (view.type !== 'channel' || channelData.key !== channelKey) return; // navigated away
|
||
if (!res || !res.ok) throw new Error(res?.error || 'Could not load channel');
|
||
channelData.results = res.results || [];
|
||
channelData.name = res.channel || displayName || 'Channel';
|
||
channelData.url = res.channelUrl || channelKey;
|
||
channelData.loading = false;
|
||
renderList();
|
||
} catch (err) {
|
||
channelData.loading = false;
|
||
if (view.type === 'channel') {
|
||
els.cards.innerHTML = '';
|
||
els.status.classList.remove('hidden');
|
||
els.status.textContent = '⚠ ' + err.message;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The identifier we hand the backend to look a channel up (URL preferred).
|
||
function channelKeyOf(v) {
|
||
return (v && (v.channelUrl || v.channelId)) || '';
|
||
}
|
||
|
||
// ============================================================================
|
||
// Rendering
|
||
// ============================================================================
|
||
function renderSidebar() {
|
||
els.playlistList.innerHTML = '';
|
||
for (const pl of data.playlists) {
|
||
const stats = playlistOfflineStats(pl);
|
||
let offlineBadge = '';
|
||
if (stats.total > 0) {
|
||
if (stats.cached === stats.total) {
|
||
offlineBadge = `<span class="pl-offline-badge all" title="Available offline">⬇</span>`;
|
||
} else if (stats.cached > 0) {
|
||
offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||
}
|
||
}
|
||
const item = document.createElement('div');
|
||
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
|
||
item.innerHTML = `<span class="pl-name"></span>${offlineBadge}<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);
|
||
});
|
||
// Keep bottom-nav in sync with sidebar nav active state
|
||
document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
|
||
b.classList.toggle('active', b.dataset.view === view.type);
|
||
});
|
||
updateQueueBadge();
|
||
updateDownloadBadge();
|
||
renderHomePlaylists();
|
||
}
|
||
|
||
// The empty player pane (nothing playing yet) shows the branding hero. Once
|
||
// this device has playlists, they take that space instead — the hero stays as
|
||
// the fallback so a first-time visitor still gets the landing.
|
||
// Driven from renderSidebar() so every playlist mutation refreshes both.
|
||
function renderHomePlaylists() {
|
||
const wrap = document.getElementById('homePlaylists');
|
||
const hero = document.querySelector('#playerPlaceholder .hero');
|
||
const grid = document.getElementById('homePlGrid');
|
||
if (!wrap || !hero || !grid) return;
|
||
|
||
const lists = data.playlists || [];
|
||
wrap.classList.toggle('hidden', !lists.length);
|
||
hero.classList.toggle('hidden', !!lists.length);
|
||
// The hero is centered art; the grid wants the full box, so the placeholder
|
||
// switches from centering to stretching (and tightens its padding), and the
|
||
// stage drops its 16:9 lock so the grid gets the pane's real height.
|
||
if (els.placeholder) els.placeholder.classList.toggle('showing-playlists', !!lists.length);
|
||
if (els.playerPane) els.playerPane.classList.toggle('home-playlists', !!lists.length);
|
||
if (!lists.length) { grid.innerHTML = ''; return; }
|
||
|
||
grid.innerHTML = '';
|
||
for (const pl of lists) {
|
||
const count = pl.videos.length;
|
||
const thumb = (pl.videos.find((v) => v && v.thumbnail) || {}).thumbnail || '';
|
||
const stats = playlistOfflineStats(pl);
|
||
let offlineHtml = '';
|
||
if (stats.total > 0) {
|
||
if (stats.cached === stats.total) {
|
||
offlineHtml = `<span class="home-pl-offline all" title="Available offline">⬇</span>`;
|
||
} else if (stats.cached > 0) {
|
||
offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||
}
|
||
}
|
||
const card = document.createElement('button');
|
||
card.className = 'home-pl-card';
|
||
card.type = 'button';
|
||
card.innerHTML = `
|
||
<span class="home-pl-thumb">
|
||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" />`
|
||
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
||
${offlineHtml}
|
||
<span class="home-pl-play" title="Play all">▶</span>
|
||
</span>
|
||
<span class="home-pl-name"></span>
|
||
<span class="home-pl-count">${count} video${count === 1 ? '' : 's'}</span>`;
|
||
card.querySelector('.home-pl-name').textContent = pl.name;
|
||
card.title = pl.name;
|
||
card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||
card.querySelector('.home-pl-play').addEventListener('click', (e) => {
|
||
e.stopPropagation(); // the ▶ badge plays; the rest of the card opens the list
|
||
if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id);
|
||
else toast('That playlist is empty');
|
||
});
|
||
grid.appendChild(card);
|
||
}
|
||
// Measure after layout — scrollHeight is meaningless until the cards lay out.
|
||
requestAnimationFrame(updateHomeGridScrollState);
|
||
}
|
||
|
||
// With enough playlists the grid still has to scroll. Flag when it genuinely
|
||
// overflows (and whether it's at the end) so the CSS fades the bottom edge
|
||
// instead of slicing the last row against a hard border.
|
||
function updateHomeGridScrollState() {
|
||
const grid = document.getElementById('homePlGrid');
|
||
if (!grid) return;
|
||
const overflows = grid.scrollHeight > grid.clientHeight + 1;
|
||
grid.classList.toggle('is-scrollable', overflows);
|
||
grid.classList.toggle('at-end', !overflows ||
|
||
grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2);
|
||
}
|
||
|
||
function currentList() {
|
||
if (view.type === 'search') return searchResults;
|
||
if (view.type === 'history') return data.history;
|
||
if (view.type === 'queue') return data.queue;
|
||
if (view.type === 'channel') return channelData.results;
|
||
if (view.type === 'smart') return getSmartList(view.smartType);
|
||
if (view.type === 'playlist') {
|
||
const pl = data.playlists.find((p) => p.id === view.id);
|
||
return pl ? pl.videos : [];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function showSearchSkeletons() {
|
||
// A search is in flight — the pane is about to have results, so reveal it
|
||
// now rather than popping in only once they land.
|
||
setListPaneHidden(false);
|
||
els.status.classList.add('hidden');
|
||
els.cards.innerHTML = '';
|
||
for (let i = 0; i < 8; i++) {
|
||
const s = document.createElement('div');
|
||
s.className = 'card skeleton-card';
|
||
s.innerHTML = `
|
||
<div class="thumb skeleton-shimmer"></div>
|
||
<div class="card-info">
|
||
<div class="skeleton-line skeleton-shimmer" style="width:85%"></div>
|
||
<div class="skeleton-line skeleton-shimmer" style="width:55%;margin-top:8px"></div>
|
||
</div>
|
||
<div class="skeleton-dot skeleton-shimmer"></div>`;
|
||
els.cards.appendChild(s);
|
||
}
|
||
}
|
||
|
||
// Before the first search the list pane has nothing to say but "Search for
|
||
// something to begin" — hide it outright so the playlist board (or the hero)
|
||
// owns the full width on a fresh launch. Any search or view change brings it
|
||
// back. Must run before the early returns below, which skip the rest.
|
||
function setListPaneHidden(hidden) {
|
||
const pane = document.getElementById('listPane');
|
||
if (pane) pane.classList.toggle('hidden', hidden);
|
||
}
|
||
|
||
function renderList() {
|
||
setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted);
|
||
if (view.type === 'settings') { renderSettings(); return; }
|
||
if (view.type === 'saved') { renderSaved(); return; }
|
||
if (view.type === 'p2p') { renderP2pList(); return; }
|
||
if (view.type === 'downloads') { renderDownloads(); return; }
|
||
if (view.type === 'stats') { renderStats(); return; }
|
||
if (view.type === 'notes') { renderNotesView(); return; }
|
||
if (view.type === 'library') { renderLibrary(); return; }
|
||
|
||
// Filter bar — show for filterable views
|
||
const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart'];
|
||
const showFilter = filterableViews.includes(view.type);
|
||
$('listFilterBar').classList.toggle('hidden', !showFilter);
|
||
$('batchBar').classList.toggle('hidden', !selectMode);
|
||
if (selectMode) $('batchCount').textContent = `${selectedIds.size} selected`;
|
||
|
||
// Build the list, applying filter if active
|
||
let list = currentList();
|
||
if (listFilter && showFilter) {
|
||
const q = listFilter.toLowerCase();
|
||
list = list.filter((v) => (v.title || '').toLowerCase().includes(q) || (v.channel || '').toLowerCase().includes(q));
|
||
}
|
||
|
||
els.cards.innerHTML = '';
|
||
els.cards.classList.toggle('select-mode', selectMode);
|
||
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 === 'queue') {
|
||
els.listTitle.textContent = 'Queue';
|
||
if (list.length) {
|
||
const playAll = document.createElement('button');
|
||
playAll.textContent = '▶ Play queue';
|
||
playAll.onclick = () => playQueue(0);
|
||
const clear = document.createElement('button');
|
||
clear.textContent = 'Clear';
|
||
clear.onclick = clearQueue;
|
||
els.listActions.append(playAll, clear);
|
||
}
|
||
} else if (view.type === 'smart') {
|
||
const sp = SMART_PLAYLISTS.find((s) => s.id === view.smartType);
|
||
els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
|
||
if (list.length) {
|
||
const playAll = document.createElement('button');
|
||
playAll.textContent = '▶ Play all';
|
||
playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
|
||
const queueAll = document.createElement('button');
|
||
queueAll.textContent = '+ Queue all';
|
||
queueAll.onclick = () => { list.forEach((v) => addToQueue(v, { quiet: true })); toast('Added to queue'); };
|
||
els.listActions.append(playAll, queueAll);
|
||
}
|
||
} else if (view.type === 'channel') {
|
||
els.listTitle.textContent = channelData.name || 'Channel';
|
||
if (list.length) {
|
||
const playAll = document.createElement('button');
|
||
playAll.textContent = '▶ Play all';
|
||
playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
|
||
const queueAll = document.createElement('button');
|
||
queueAll.textContent = '+ Queue all';
|
||
queueAll.onclick = () => { channelData.results.forEach((v) => addToQueue(v, { quiet: true })); toast('Added channel to queue'); };
|
||
els.listActions.append(playAll, queueAll);
|
||
}
|
||
} 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.className = 'la-primary';
|
||
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
|
||
const playFull = document.createElement('button');
|
||
playFull.textContent = '▶ Play in full';
|
||
playFull.className = 'la-main';
|
||
playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one';
|
||
playFull.onclick = () => {
|
||
if (!pl.videos.length) return;
|
||
playFromList(pl.videos, 0, 'playlist:' + pl.id, { playFull: true });
|
||
toast('Playing in full — stops after the last video');
|
||
};
|
||
const serviceBtn = document.createElement('button');
|
||
serviceBtn.textContent = '🎤 Service mode';
|
||
serviceBtn.className = 'la-main';
|
||
serviceBtn.title = 'Set-list view with screen wake lock';
|
||
serviceBtn.onclick = () => {
|
||
if (!pl.videos || !pl.videos.length) { toast('Playlist is empty'); return; }
|
||
enterServiceMode(pl);
|
||
};
|
||
const queueAll = document.createElement('button');
|
||
queueAll.textContent = '+ Queue';
|
||
queueAll.className = 'la-main';
|
||
queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); };
|
||
const rename = document.createElement('button');
|
||
rename.textContent = 'Rename';
|
||
rename.onclick = () => renamePlaylist(pl);
|
||
const del = document.createElement('button');
|
||
del.textContent = 'Delete';
|
||
del.onclick = () => deletePlaylist(pl);
|
||
|
||
const offlineBtn = document.createElement('button');
|
||
offlineBtn.id = 'plOfflineBtn';
|
||
updatePlaylistOfflineButton(offlineBtn, pl);
|
||
offlineBtn.onclick = () => makePlaylistOffline(pl);
|
||
|
||
const pinBtn = document.createElement('button');
|
||
pinBtn.id = 'plPinBtn';
|
||
const isPinned = (data.pinnedPlaylists || []).includes(pl.id);
|
||
pinBtn.textContent = isPinned ? '📌 Pinned' : '📌 Keep offline';
|
||
pinBtn.classList.toggle('pinned', isPinned);
|
||
pinBtn.title = isPinned ? 'Pinned for offline keep — click to unpin' : 'Pin to keep all videos in this playlist available offline';
|
||
pinBtn.onclick = () => togglePinPlaylist(pl);
|
||
|
||
const buttons = [playAll, playFull, serviceBtn, queueAll, offlineBtn, pinBtn];
|
||
if (pl.videos && pl.videos.length >= 1) {
|
||
const share = document.createElement('button');
|
||
share.textContent = '🔗 Share';
|
||
share.onclick = () => sharePlaylist(pl);
|
||
buttons.push(share);
|
||
}
|
||
buttons.push(rename, del);
|
||
els.listActions.append(...buttons);
|
||
}
|
||
}
|
||
|
||
// Select button for batch-operable views
|
||
const batchViews = ['history', 'playlist', 'queue', 'smart'];
|
||
if (batchViews.includes(view.type) && list.length) {
|
||
const selBtn = document.createElement('button');
|
||
selBtn.textContent = selectMode ? '✓ Selecting' : 'Select';
|
||
selBtn.style.fontWeight = selectMode ? '700' : '';
|
||
selBtn.onclick = toggleSelectMode;
|
||
els.listActions.appendChild(selBtn);
|
||
}
|
||
|
||
// Channel still loading — show skeletons.
|
||
if (view.type === 'channel' && channelData.loading && !list.length) {
|
||
showSearchSkeletons();
|
||
return;
|
||
}
|
||
|
||
if (!list.length) {
|
||
els.status.classList.add('hidden');
|
||
els.cards.innerHTML = '';
|
||
const empty = document.createElement('div');
|
||
empty.className = 'empty-state';
|
||
|
||
if (view.type === 'search') {
|
||
// Landing (hero or playlist board) lives in the player pane. Before the
|
||
// first search this pane is hidden outright; after one that came back
|
||
// empty it stays visible so the result is legible.
|
||
els.status.classList.remove('hidden');
|
||
els.status.textContent = searchAttempted
|
||
? 'No results for that search.'
|
||
: 'Search for something to begin.';
|
||
return;
|
||
}
|
||
|
||
if (view.type === 'history') {
|
||
empty.innerHTML = `
|
||
<div class="empty-icon">🕘</div>
|
||
<h3 class="empty-title">Nothing watched yet</h3>
|
||
<p class="empty-desc">Your viewing history shows up here once you start playing videos.</p>
|
||
`;
|
||
addBrowseCta(empty, '🔍 Search videos');
|
||
} else if (view.type === 'queue') {
|
||
empty.innerHTML = `
|
||
<div class="empty-icon">▶</div>
|
||
<h3 class="empty-title">Your queue is empty</h3>
|
||
<p class="empty-desc">Use <strong>+ Queue</strong> on any video to line it up. The queue is temporary and plays in order.</p>
|
||
`;
|
||
addBrowseCta(empty, '🔍 Find something to play');
|
||
} else if (view.type === 'channel') {
|
||
empty.innerHTML = `
|
||
<div class="empty-icon">📺</div>
|
||
<h3 class="empty-title">No videos found</h3>
|
||
<p class="empty-desc">This channel didn't return any uploads.</p>
|
||
`;
|
||
addBrowseCta(empty, '🔍 Back to search');
|
||
} else {
|
||
// Playlist view — empty
|
||
empty.innerHTML = `
|
||
<div class="empty-icon">🎵</div>
|
||
<h3 class="empty-title">This playlist is empty</h3>
|
||
<p class="empty-desc">Add videos from search results or use the <strong>+ Playlist</strong> button while playing.</p>
|
||
`;
|
||
addBrowseCta(empty, '🔍 Browse videos');
|
||
}
|
||
|
||
els.cards.appendChild(empty);
|
||
return;
|
||
}
|
||
els.status.classList.add('hidden');
|
||
|
||
list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list)));
|
||
markPlayingCard();
|
||
}
|
||
|
||
function addBrowseCta(empty, label) {
|
||
const cta = document.createElement('button');
|
||
cta.className = 'empty-cta';
|
||
cta.textContent = label;
|
||
cta.addEventListener('click', () => { view = { type: 'search' }; render(); });
|
||
empty.appendChild(cta);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Saved videos page — every offline-cached file with sizes + totals
|
||
// ============================================================================
|
||
|
||
// ============================================================================
|
||
// Save to this device (gallery / Files / Downloads) — docs: export.js
|
||
// ============================================================================
|
||
// Sources, best first: the copy already saved in this browser; the server's copy
|
||
// (direct download on desktop, via the saved copy on phones so the share sheet
|
||
// can put it in Photos); another device that holds it; finally a normal save
|
||
// through the server. Buttons appear only when the file is ready so each one
|
||
// runs inside its own tap (the share sheet needs a fresh user gesture).
|
||
async function exportToDevice(video) {
|
||
if (!video || !video.id || !window.DeviceExport) return;
|
||
const id = video.id;
|
||
const title = video.title || videoTitleById(id) || id;
|
||
const box = document.createElement('div');
|
||
box.className = 'export-box';
|
||
box.innerHTML = '<div class="export-status" role="status"></div><div class="export-bar"><i></i></div><div class="export-actions"></div>';
|
||
const status = box.querySelector('.export-status');
|
||
const bar = box.querySelector('.export-bar');
|
||
const fill = bar.firstChild;
|
||
const actions = box.querySelector('.export-actions');
|
||
const say = (t) => { status.textContent = t; };
|
||
const pct = (p) => { bar.classList.remove('busy'); fill.style.width = Math.max(0, Math.min(100, p)) + '%'; };
|
||
const busy = () => { bar.classList.add('busy'); fill.style.width = ''; };
|
||
const button = (label, primary, fn) => {
|
||
const b = document.createElement('button');
|
||
b.className = 'btn' + (primary ? ' primary' : '');
|
||
b.textContent = label;
|
||
b.onclick = fn;
|
||
actions.appendChild(b);
|
||
return b;
|
||
};
|
||
let open = true;
|
||
showModal('Save to this device', box, [{ label: 'Close', onClick: () => { open = false; closeModal(); } }]);
|
||
|
||
const DE = window.DeviceExport;
|
||
const done = (msg) => { open = false; closeModal(); toast(msg, { duration: 4500 }); };
|
||
const ready = (file) => {
|
||
const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1];
|
||
const name = DE.fileName(title, ext, id);
|
||
pct(100);
|
||
say(`${name} · ${fmtBytes(file.size)}`);
|
||
actions.innerHTML = '';
|
||
if (DE.canShareFile(file)) {
|
||
button('Save to Photos / Files', true, async () => {
|
||
try { if (await DE.share(file, name, title) === 'shared') done('Shared ✓ — choose “Save Video” or “Save to Files”'); }
|
||
catch (e) { say('Could not open the share sheet: ' + (e && e.message ? e.message : e)); }
|
||
});
|
||
}
|
||
if (DE.canPickLocation()) {
|
||
button('Choose where to save…', !DE.canShareFile(file), async () => {
|
||
try { if (await DE.pick(file, name) === 'saved') done('Saved to your device ✓'); }
|
||
catch (e) { say('Could not save: ' + (e && e.message ? e.message : e)); }
|
||
});
|
||
}
|
||
button('Download file', !DE.canShareFile(file) && !DE.canPickLocation(), () => {
|
||
DE.download(file, name);
|
||
done('Download started — see your Downloads folder');
|
||
});
|
||
};
|
||
const localFile = async () => (cachedIds.has(id) && window.OPFS ? window.OPFS.getFileObject(id) : null);
|
||
|
||
try {
|
||
say('Looking for the video…');
|
||
busy();
|
||
let file = await localFile();
|
||
if (file) return ready(file);
|
||
|
||
// Is it on the server? (a 1-byte ranged probe; nothing is downloaded)
|
||
let serverUrl = '';
|
||
if (WEB && !video.custom) {
|
||
const url = `/api/export/${encodeURIComponent(id)}?name=${encodeURIComponent(title)}${hevcCapable() ? '&hevc=1' : ''}`;
|
||
const r = await fetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null);
|
||
if (r && (r.status === 206 || r.ok)) serverUrl = url;
|
||
}
|
||
const phone = DE.canShareFile(new File([], 'x.mp4', { type: 'video/mp4' }));
|
||
if (serverUrl && !phone) {
|
||
say('The server has this video. It downloads straight to your Downloads folder.');
|
||
pct(100);
|
||
actions.innerHTML = '';
|
||
button('Download file', true, () => { DE.download(serverUrl, DE.fileName(title, 'mp4', id)); done('Download started — see your Downloads folder'); });
|
||
return;
|
||
}
|
||
|
||
// Other devices, only when the server can't help.
|
||
if (!serverUrl && WEB && window.P2PTransfer && window.P2PCore && data.settings.p2pReceive !== false && !video.custom) {
|
||
let payload = null;
|
||
try { const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`); payload = r.ok ? await r.json() : null; } catch { /* offline */ }
|
||
const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null;
|
||
const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : [];
|
||
if (peers.length) {
|
||
say(`Getting it from another device (${peers.length} online)…`);
|
||
pct(0);
|
||
const res = await getFromPeers(video, c, peers, (got, total) => { if (open) { pct((got / total) * 100); say(`Getting it from another device… ${Math.round((got / total) * 100)}%`); } });
|
||
if (res.ok) { file = await localFile(); if (file) return ready(file); }
|
||
say('That device could not send it — saving through the server instead…');
|
||
}
|
||
}
|
||
|
||
// Normal save (server copy or YouTube via the server), then export it.
|
||
say(serverUrl ? 'Copying it to this device…' : 'Saving the video first — long videos take a while…');
|
||
busy();
|
||
await preload(video, { quiet: true });
|
||
file = await localFile();
|
||
if (!file) { say('Could not get this video. Check your connection and try again.'); pct(0); return; }
|
||
ready(file);
|
||
} catch (e) {
|
||
say('Something went wrong: ' + (e && e.message ? e.message : e));
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// "On other devices" — videos held by sharing devices (P2P), as a playlist
|
||
// ============================================================================
|
||
async function renderP2pList() {
|
||
els.listTitle.textContent = '📡 On other devices';
|
||
els.listActions.innerHTML = '';
|
||
els.status.classList.add('hidden');
|
||
const c = els.cards;
|
||
c.innerHTML = '<div class="status">Looking for videos on other devices…</div>';
|
||
let res = null;
|
||
try { const r = await fetch('/api/p2p/available?limit=100'); res = r.ok ? await r.json() : null; } catch { /* offline */ }
|
||
if (view.type !== 'p2p') return;
|
||
c.innerHTML = '';
|
||
if (!res || !res.ok) {
|
||
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Not available</h3><p class="empty-desc">Peer-to-peer sharing is off on this server, or you are offline.</p></div>';
|
||
return;
|
||
}
|
||
const items = res.items || [];
|
||
const list = items.map((it) => ({
|
||
id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '',
|
||
duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`,
|
||
}));
|
||
const refresh = document.createElement('button');
|
||
refresh.textContent = '↻';
|
||
refresh.className = 'la-icon';
|
||
refresh.title = 'Refresh';
|
||
refresh.setAttribute('aria-label', 'Refresh');
|
||
refresh.onclick = () => renderP2pList();
|
||
const playAll = document.createElement('button');
|
||
playAll.textContent = '▶ Play all';
|
||
playAll.className = 'la-primary la-inline';
|
||
playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
|
||
els.listActions.append(playAll, refresh);
|
||
if (!items.length) {
|
||
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Nothing shared yet</h3><p class="empty-desc">Videos that other devices have saved and verified show up here. Save a video and keep sharing on, and yours will appear for everyone else.</p></div>';
|
||
return;
|
||
}
|
||
const online = items.filter((i) => i.online).length;
|
||
const sum = document.createElement('div');
|
||
sum.className = 'saved-summary';
|
||
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now</span>`;
|
||
c.appendChild(sum);
|
||
items.forEach((it, idx) => {
|
||
const v = list[idx];
|
||
const row = document.createElement('div');
|
||
row.className = 'card saved-card p2p-card';
|
||
row.dataset.id = it.id;
|
||
const state = it.online ? `<b class="p2p-on">● ${it.online} online</b>` : '<span class="p2p-off">○ none online</span>';
|
||
row.innerHTML = `
|
||
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
|
||
<div class="card-info">
|
||
<div class="card-title"></div>
|
||
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
|
||
</div>
|
||
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
|
||
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
|
||
row.querySelector('.card-title').textContent = v.title;
|
||
row.addEventListener('click', (e) => {
|
||
const act = e.target.closest('[data-act]');
|
||
if (act) { e.stopPropagation(); if (act.dataset.act === 'export') exportToDevice(v); else openCardMenu(v); return; }
|
||
playFromList(list, idx, 'p2p');
|
||
});
|
||
c.appendChild(row);
|
||
});
|
||
markPlayingCard();
|
||
}
|
||
|
||
async function renderSaved() {
|
||
els.listTitle.textContent = 'Saved videos';
|
||
els.listActions.innerHTML = '';
|
||
els.status.classList.add('hidden');
|
||
const c = els.cards;
|
||
c.innerHTML = '<div class="status">Loading saved videos…</div>';
|
||
|
||
let res;
|
||
try { res = await API.cacheList(); } catch { res = null; }
|
||
if (view.type !== 'saved') return; // navigated away
|
||
|
||
if (!res || !res.ok) {
|
||
c.innerHTML = '';
|
||
const empty = document.createElement('div');
|
||
empty.className = 'empty-state';
|
||
empty.innerHTML = `
|
||
<div class="empty-icon">💾</div>
|
||
<h3 class="empty-title">Offline cache unavailable</h3>
|
||
<p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
|
||
c.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const items = (res.items || []).slice().sort((a, b) => b.size - a.size);
|
||
const total = res.total || 0;
|
||
|
||
if (!items.length) {
|
||
c.innerHTML = '';
|
||
const empty = document.createElement('div');
|
||
empty.className = 'empty-state';
|
||
empty.innerHTML = `
|
||
<div class="empty-icon">💾</div>
|
||
<h3 class="empty-title">Nothing saved yet</h3>
|
||
<p class="empty-desc">Use <strong>⬇ Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`;
|
||
addBrowseCta(empty, '🔍 Find videos');
|
||
c.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
// Header actions: total + clear all.
|
||
const clearAll = document.createElement('button');
|
||
clearAll.textContent = 'Clear all';
|
||
clearAll.onclick = () => {
|
||
showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Clear all', danger: true, onClick: async () => {
|
||
try { await API.cacheClear(); } catch {}
|
||
cachedIds.clear();
|
||
// Custom cuts live only in the cache — clearing it destroys their media,
|
||
// so drop their registry entries and playlist references too.
|
||
const customIds = new Set((data.customVideos || []).map((v) => v.id));
|
||
if (customIds.size) {
|
||
data.customVideos = [];
|
||
data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => !customIds.has(x.id)); });
|
||
data.queue = (data.queue || []).filter((x) => !customIds.has(x.id));
|
||
persist();
|
||
}
|
||
closeModal();
|
||
toast('Cache cleared');
|
||
if (current) updateNowPlayingActions();
|
||
renderSaved();
|
||
} },
|
||
]);
|
||
};
|
||
els.listActions.appendChild(clearAll);
|
||
|
||
c.innerHTML = '';
|
||
const summary = document.createElement('div');
|
||
summary.className = 'saved-summary';
|
||
summary.innerHTML = `<span class="saved-total">${fmtBytes(total)}</span><span class="saved-sub">${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
|
||
c.appendChild(summary);
|
||
|
||
items.forEach((it) => {
|
||
const known = videoById(it.id);
|
||
// A custom (edited) id has no YouTube thumbnail; fall back to its source's
|
||
// thumbnail when we know it, else a neutral placeholder.
|
||
const isCustom = (known && known.custom) || String(it.id).startsWith('edit_');
|
||
const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`;
|
||
const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb };
|
||
const row = document.createElement('div');
|
||
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
|
||
row.dataset.id = it.id;
|
||
row.innerHTML = `
|
||
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, null) || '')}" alt=""
|
||
onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div>
|
||
<div class="card-info">
|
||
<div class="card-title"></div>
|
||
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
|
||
</div>
|
||
<button class="card-act" data-act="export" title="Save to gallery" aria-label="Save to gallery">⤓</button>
|
||
<button class="card-del" title="Delete saved file">✕</button>`;
|
||
row.querySelector('.card-title').textContent = v.title || it.id;
|
||
row.addEventListener('click', (e) => {
|
||
if (e.target.closest('[data-act="export"]')) { e.stopPropagation(); exportToDevice(v); return; }
|
||
if (e.target.closest('.card-del')) return;
|
||
playFromList([v], 0, 'saved');
|
||
});
|
||
row.querySelector('.card-del').addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
if (isCustom) { await deleteCustomVideo(it.id); renderSaved(); return; }
|
||
try { await API.cacheDelete(it.id); } catch {}
|
||
cachedIds.delete(it.id);
|
||
if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions();
|
||
markCardCacheState(it.id, 'none');
|
||
toast('Removed from cache');
|
||
renderSaved();
|
||
});
|
||
c.appendChild(row);
|
||
});
|
||
markPlayingCard();
|
||
}
|
||
|
||
// ============================================================================
|
||
// Downloads page — videos currently being saved
|
||
// ============================================================================
|
||
function renderDownloads() {
|
||
els.listTitle.textContent = 'Downloads';
|
||
els.listActions.innerHTML = '';
|
||
els.status.classList.add('hidden');
|
||
const c = els.cards;
|
||
c.innerHTML = '';
|
||
|
||
const active = [...downloadMeta.values()];
|
||
if (!active.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'empty-state';
|
||
empty.innerHTML = `
|
||
<div class="empty-icon">⬇</div>
|
||
<h3 class="empty-title">No active downloads</h3>
|
||
<p class="empty-desc">Saves in progress show here with live status. Finished videos land in <strong>Saved</strong>.</p>`;
|
||
const cta = document.createElement('button');
|
||
cta.className = 'empty-cta';
|
||
cta.textContent = '💾 View saved videos';
|
||
cta.addEventListener('click', () => { view = { type: 'saved' }; render(); });
|
||
empty.appendChild(cta);
|
||
c.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const note = document.createElement('div');
|
||
note.className = 'saved-summary';
|
||
note.innerHTML = `<span class="saved-total">${active.length}</span><span class="saved-sub">download${active.length === 1 ? '' : 's'} in progress</span>`;
|
||
c.appendChild(note);
|
||
|
||
active.forEach((v) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'card downloading';
|
||
row.dataset.id = v.id;
|
||
row.innerHTML = `
|
||
<div class="thumb">
|
||
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||
<div class="dl-progress"><div class="dl-bar"></div></div>
|
||
</div>
|
||
<div class="card-info">
|
||
<div class="card-title"></div>
|
||
<div class="card-channel">⏳ Saving for offline…</div>
|
||
</div>`;
|
||
row.querySelector('.card-title').textContent = v.title || v.id;
|
||
c.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Settings page
|
||
// ============================================================================
|
||
function videoById(id) {
|
||
for (const pl of data.playlists) {
|
||
const v = pl.videos.find((x) => x.id === id);
|
||
if (v) return v;
|
||
}
|
||
const q = data.queue.find((x) => x.id === id);
|
||
if (q) return q;
|
||
const h = data.history.find((x) => x.id === id);
|
||
if (h) return h;
|
||
// Custom (edited) videos live only in data.customVideos — their media is in
|
||
// the offline cache, never on YouTube, so nothing else references them.
|
||
const cv = (data.customVideos || []).find((x) => x.id === id);
|
||
if (cv) return cv;
|
||
return null;
|
||
}
|
||
function videoTitleById(id) {
|
||
const v = videoById(id);
|
||
return v ? v.title : 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('');
|
||
|
||
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
|
||
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
|
||
`</select>`;
|
||
|
||
wrap.innerHTML = `
|
||
<div class="set-group">
|
||
<div class="set-group-title">Appearance & accessibility</div>
|
||
<label class="set-row">
|
||
<span>Theme</span>
|
||
${sel('setTheme', data.settings.theme, [['dark', 'Dark'], ['light', 'Light'], ['contrast', 'High contrast']])}
|
||
</label>
|
||
<label class="set-row">
|
||
<span>Layout theme</span>
|
||
${sel('setLayout', data.settings.layout || 'classic', [['classic', 'Classic (default)'], ['sanctuary-stage', 'Sanctuary Stage'], ['liturgical', 'Liturgical Chronicle'], ['living-hymnal', 'Living Hymnal'], ['broadcaster', 'Sunday Broadcaster'], ['contemplative', 'Contemplative'], ['bento-hub', 'Bento Hub']])}
|
||
</label>
|
||
<label class="set-row">
|
||
<span>Font size</span>
|
||
${sel('setFont', data.settings.fontScale, [['small', 'Small'], ['normal', 'Normal'], ['large', 'Large'], ['xl', 'Extra large']])}
|
||
</label>
|
||
<label class="set-row">
|
||
<span>Layout density</span>
|
||
${sel('setDensity', data.settings.density, [['comfortable', 'Comfortable'], ['compact', 'Compact']])}
|
||
</label>
|
||
<label class="set-row">
|
||
<span>
|
||
Performance mode
|
||
<small>Turns off heavy visual effects (grain, blur, glows) for a faster, lighter UI.</small>
|
||
</span>
|
||
<input id="setPerf" type="checkbox" ${data.settings.perfMode ? 'checked' : ''} />
|
||
</label>
|
||
<label class="set-row">
|
||
<span>
|
||
Reduce motion
|
||
<small>Disables animations and transitions.</small>
|
||
</span>
|
||
<input id="setMotion" type="checkbox" ${data.settings.reduceMotion ? 'checked' : ''} />
|
||
</label>
|
||
</div>
|
||
|
||
<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>Repeat list when finished</span>
|
||
<input id="setRepeat" type="checkbox" ${data.settings.repeatMode === 'all' ? 'checked' : ''} />
|
||
</label>
|
||
<label class="set-row">
|
||
<span>Loop the current video</span>
|
||
<input id="setLoopOne" type="checkbox" ${data.settings.loopOne ? 'checked' : ''} />
|
||
</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>
|
||
<label class="set-row">
|
||
<span>
|
||
Save before playing
|
||
<small>Downloads the whole video to this device first (the server compiles video + audio into one file), then plays the local copy. One stream instead of two, but playback starts after the download finishes.</small>
|
||
</span>
|
||
<input id="setSaveBeforePlay" type="checkbox" ${data.settings.saveBeforePlay ? 'checked' : ''} />
|
||
</label>
|
||
<label class="set-row">
|
||
<span>
|
||
Report wrong lyric lines
|
||
<small>Service mode → Lyrics shows a Report button, and holding a line reports it. Saved with your online profile, so it follows you between devices.</small>
|
||
</span>
|
||
<input id="setLyricFlags" type="checkbox" ${data.settings.lyricFlags !== false ? 'checked' : ''} />
|
||
</label>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">Sharing (peer-to-peer)</div>
|
||
<label class="set-row">
|
||
<span>
|
||
Share my saved videos
|
||
<small>Other devices can download videos you saved when YouTube and the server can't provide them. Files are verified by their hash. On by default.</small>
|
||
</span>
|
||
<input id="setP2pShare" type="checkbox" ${data.settings.p2pShare !== false ? 'checked' : ''} />
|
||
</label>
|
||
<label class="set-row">
|
||
<span>
|
||
Get videos from other devices
|
||
<small>When a video is gone from YouTube and the server, download a verified copy from a device that has it. On by default.</small>
|
||
</span>
|
||
<input id="setP2pReceive" type="checkbox" ${data.settings.p2pReceive !== false ? 'checked' : ''} />
|
||
</label>
|
||
<div class="set-row">
|
||
<span>This device<small id="p2pDeviceInfo">…</small></span>
|
||
<span id="p2pHoldingCount" class="set-stat">…</span>
|
||
</div>
|
||
<div class="set-row hidden" id="p2pContributeRow">
|
||
<span>
|
||
Saved videos the server can't verify yet
|
||
<small>Uploading lets the server check them (hash + media validation) so other devices can get them. Uses your upload bandwidth.</small>
|
||
</span>
|
||
<button id="p2pContributeBtn" class="btn">Verify & share</button>
|
||
</div>
|
||
</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>
|
||
<label class="set-row">
|
||
<span>
|
||
Cache size limit
|
||
<small>Auto-evict oldest files when cache exceeds this.</small>
|
||
</span>
|
||
<select id="cacheCapSelect" class="set-select">
|
||
<option value="0">No limit</option>
|
||
<option value="1073741824">1 GB</option>
|
||
<option value="2147483648">2 GB</option>
|
||
<option value="5368709120">5 GB</option>
|
||
<option value="10737418240">10 GB</option>
|
||
</select>
|
||
</label>
|
||
<div class="set-actions">
|
||
<button id="clearCacheBtn" class="btn danger">Clear all cached videos</button>
|
||
</div>
|
||
<div id="cacheList" class="cache-list"></div>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">Playlists</div>
|
||
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
|
||
<button id="importYtBtn" class="btn">⬇ Import from YouTube</button>
|
||
<button id="exportBtn" class="btn">Export playlists</button>
|
||
<button id="importBtn" class="btn">Import playlists</button>
|
||
<input id="fileInput" type="file" accept=".json" style="display:none" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">Backup & restore</div>
|
||
<label class="set-row">
|
||
<span>
|
||
Auto-backup
|
||
<small>Export a full backup JSON on a schedule.</small>
|
||
</span>
|
||
<input id="setAutoBackup" type="checkbox" ${data.settings.autoBackupEnabled ? 'checked' : ''} />
|
||
</label>
|
||
<label class="set-row">
|
||
<span>Backup interval</span>
|
||
${sel('setBackupInterval', String(data.settings.autoBackupIntervalDays || 7), [['1','Daily'],['7','Weekly'],['30','Monthly']])}
|
||
</label>
|
||
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
|
||
<button id="backupNowBtn" class="btn">Export full backup now</button>
|
||
<button id="importBackupBtn" class="btn">Import backup</button>
|
||
<input id="backupFileInput" type="file" accept=".json" style="display:none" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">Reset</div>
|
||
<div class="set-row">
|
||
<span>
|
||
Force refresh UI
|
||
<small>Re-fetches the app shell and assets, bypassing the browser cache, then reloads.</small>
|
||
</span>
|
||
<div class="set-actions" style="margin:0">
|
||
<button id="refreshUiBtn" class="btn">Refresh UI</button>
|
||
</div>
|
||
</div>
|
||
<div class="set-row">
|
||
<span>
|
||
Reset app
|
||
<small>Clears all local storage, unregisters the service worker, and deletes every cache. You will need to reload after.</small>
|
||
</span>
|
||
<div class="set-actions" style="margin:0">
|
||
<button id="resetAppBtn" class="btn danger">Reset app</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">Online profile — sync between devices</div>
|
||
<div class="set-row">
|
||
<span>
|
||
Linked profile
|
||
<small>Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one.</small>
|
||
</span>
|
||
<span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
|
||
</div>
|
||
<div class="set-row${profileShareUrl() ? '' : ' hidden'}" id="profileLinkRow">
|
||
<span>
|
||
Profile link
|
||
<small>Open this URL on another device to load the profile there. Anyone with the link gets full access to this data — share it only with your own devices or people you trust.</small>
|
||
</span>
|
||
<span id="profileLinkValue" class="set-stat" style="word-break:break-all;text-align:right">${escapeHtml(profileShareUrl() || '')}</span>
|
||
</div>
|
||
<div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<button id="profileCreateBtn" class="btn">Create profile</button>
|
||
<button id="profileLoadBtn" class="btn">Load profile</button>
|
||
<button id="profileCopyLinkBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>⧉ Copy link</button>
|
||
<button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button>
|
||
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">About</div>
|
||
<div class="set-row">
|
||
<span>
|
||
Version
|
||
<small>App version and current build.</small>
|
||
</span>
|
||
<div class="set-actions" style="margin:0">
|
||
<span id="aboutVersion" class="about-version">v${APP_VERSION}</span>
|
||
</div>
|
||
</div>
|
||
<div class="set-row">
|
||
<span>
|
||
Build time
|
||
<small>When the running server build was produced — compare across devices to confirm you're on the latest version.</small>
|
||
</span>
|
||
<span id="aboutBuildTime" class="about-version">…</span>
|
||
</div>
|
||
</div>`;
|
||
|
||
c.appendChild(wrap);
|
||
|
||
// ---- Wire appearance / accessibility ----
|
||
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
|
||
$('setLayout').addEventListener('change', (e) => { data.settings.layout = e.target.value; applyAppearance(); persist(); });
|
||
$('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
|
||
$('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
|
||
$('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); });
|
||
$('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });
|
||
$('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
|
||
$('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
|
||
|
||
// ---- 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();
|
||
syncMuteIcon();
|
||
persist();
|
||
});
|
||
$('setAudioOnly').addEventListener('change', (e) => {
|
||
data.settings.audioOnly = e.target.checked;
|
||
els.audioOnlyToggle.checked = e.target.checked;
|
||
persist();
|
||
});
|
||
$('setSaveBeforePlay').addEventListener('change', (e) => {
|
||
data.settings.saveBeforePlay = e.target.checked;
|
||
persist();
|
||
});
|
||
$('setLyricFlags').addEventListener('change', (e) => {
|
||
data.settings.lyricFlags = e.target.checked;
|
||
persist();
|
||
Notes.refreshFlags();
|
||
});
|
||
$('setAutoPreload').addEventListener('change', (e) => {
|
||
data.settings.autoPreload = e.target.checked;
|
||
persist();
|
||
if (e.target.checked) data.playlists.forEach(preloadPlaylist);
|
||
});
|
||
$('setP2pShare').addEventListener('change', (e) => {
|
||
data.settings.p2pShare = e.target.checked;
|
||
persist();
|
||
if (window.P2PClient) window.P2PClient.sync(); // share off → withdraws every holding
|
||
});
|
||
$('setP2pReceive').addEventListener('change', (e) => {
|
||
data.settings.p2pReceive = e.target.checked;
|
||
persist();
|
||
if (window.P2PClient) window.P2PClient.sync();
|
||
});
|
||
(async () => {
|
||
const info = $('p2pDeviceInfo');
|
||
const cnt = $('p2pHoldingCount');
|
||
if (!info || !cnt) return;
|
||
const P = window.P2PClient;
|
||
const d = P && P.device();
|
||
info.textContent = !d ? 'Not registered yet'
|
||
: P.isConnected() ? `Online · peer ${P.peer()}` : 'Registered · not connected';
|
||
const files = window.DeviceDB ? await window.DeviceDB.listFiles() : [];
|
||
cnt.textContent = `${files.filter((f) => f.state === 'verified').length} verified / ${files.length} saved`;
|
||
const unknown = window.P2PClient ? await window.P2PClient.unknownVideos() : [];
|
||
const row = $('p2pContributeRow');
|
||
const btn = $('p2pContributeBtn');
|
||
if (row && btn && unknown.length) {
|
||
row.classList.remove('hidden');
|
||
btn.textContent = `Verify & share ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`;
|
||
btn.onclick = async () => {
|
||
btn.disabled = true;
|
||
let ok = 0;
|
||
for (let i = 0; i < unknown.length; i++) {
|
||
const v = videoById(unknown[i]) || {};
|
||
btn.textContent = `Uploading ${i + 1} of ${unknown.length}…`;
|
||
const r = await window.P2PClient.contribute(unknown[i], { title: v.title || '', channel: v.channel || '' });
|
||
if (r && r.ok) ok++;
|
||
}
|
||
toast(`Verified ${ok} of ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`);
|
||
btn.disabled = false;
|
||
row.classList.add('hidden');
|
||
};
|
||
}
|
||
})();
|
||
|
||
// ---- 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';
|
||
}
|
||
|
||
// ---- Cache cap ----
|
||
const capSel = $('cacheCapSelect');
|
||
capSel.value = String(data.settings.cacheCap || 0);
|
||
capSel.addEventListener('change', (e) => {
|
||
data.settings.cacheCap = parseInt(e.target.value) || 0;
|
||
persist();
|
||
});
|
||
|
||
// ---- Export / Import playlists ----
|
||
$('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
|
||
$('exportBtn').addEventListener('click', exportPlaylists);
|
||
$('importBtn').addEventListener('click', () => $('fileInput').click());
|
||
$('fileInput').addEventListener('change', importPlaylists);
|
||
|
||
// ---- Backup ----
|
||
$('setAutoBackup').addEventListener('change', (e) => { data.settings.autoBackupEnabled = e.target.checked; persist(); });
|
||
$('setBackupInterval').addEventListener('change', (e) => { data.settings.autoBackupIntervalDays = parseInt(e.target.value) || 7; persist(); });
|
||
$('backupNowBtn').addEventListener('click', () => doAutoBackup());
|
||
$('importBackupBtn').addEventListener('click', () => $('backupFileInput').click());
|
||
$('backupFileInput').addEventListener('change', importBackup);
|
||
|
||
// ---- Online profile ----
|
||
$('profileCreateBtn').addEventListener('click', createProfileFlow);
|
||
$('profileLoadBtn').addEventListener('click', loadProfileFlow);
|
||
$('profileCopyLinkBtn').addEventListener('click', copyProfileLink);
|
||
$('profileShareBtn').addEventListener('click', shareProfileLink);
|
||
$('profileUnlinkBtn').addEventListener('click', () => {
|
||
data.profile = null;
|
||
persist();
|
||
updateProfileStatus();
|
||
toast('Profile unlinked — this device stops syncing (server copy is kept)');
|
||
});
|
||
|
||
// ---- About: append server build tag + build time (WEB mode only) ----
|
||
if (WEB) {
|
||
try {
|
||
const res = await fetch('/api/version', { cache: 'no-store' });
|
||
if (res.ok && view.type === 'settings') {
|
||
const v = await res.json();
|
||
const el = $('aboutVersion');
|
||
if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`;
|
||
const bt = $('aboutBuildTime');
|
||
if (bt) {
|
||
const t = v.buildTime ? new Date(v.buildTime) : null;
|
||
if (t && !isNaN(t)) {
|
||
// Shown in GMT+8 regardless of device/server timezone, plus build age.
|
||
const gmt8 = new Date(t.getTime() + 8 * 3600e3).toISOString().replace('T', ' ').slice(0, 16);
|
||
const s = Math.max(0, Math.floor((Date.now() - t.getTime()) / 1000));
|
||
const ago = s < 60 ? `${s} second${s === 1 ? '' : 's'} ago`
|
||
: s < 3600 ? `${Math.floor(s / 60)} minute${Math.floor(s / 60) === 1 ? '' : 's'} ago`
|
||
: s < 86400 ? `${Math.floor(s / 3600)} hour${Math.floor(s / 3600) === 1 ? '' : 's'} ago`
|
||
: `${Math.floor(s / 86400)} day${Math.floor(s / 86400) === 1 ? '' : 's'} ago`;
|
||
bt.textContent = `${gmt8} +08 · built ${ago}`;
|
||
} else {
|
||
bt.textContent = 'unknown';
|
||
}
|
||
}
|
||
}
|
||
} catch { /* offline — leave the static version in place */ }
|
||
}
|
||
|
||
// ---- Reset ----
|
||
$('refreshUiBtn').addEventListener('click', async () => {
|
||
const btn = $('refreshUiBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Refreshing…';
|
||
// hardReloadUI() deletes all caches and unregisters the SW so the
|
||
// next page load always fetches fresh assets from the network.
|
||
await hardReloadUI();
|
||
});
|
||
|
||
$('resetAppBtn').addEventListener('click', () => {
|
||
showModal(
|
||
'Reset app?',
|
||
(() => { const p = document.createElement('p'); p.textContent = 'This clears all local data and caches. The page will reload.'; return p; })(),
|
||
[
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{
|
||
label: 'Reset', danger: true, onClick: async () => {
|
||
closeModal();
|
||
try {
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
if ('serviceWorker' in navigator) {
|
||
const regs = await navigator.serviceWorker.getRegistrations();
|
||
await Promise.all(regs.map((r) => r.unregister()));
|
||
}
|
||
if ('caches' in window) {
|
||
const keys = await caches.keys();
|
||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||
}
|
||
} catch { /* best effort — reload regardless */ }
|
||
window.location.href = '/';
|
||
},
|
||
},
|
||
],
|
||
);
|
||
});
|
||
}
|
||
|
||
function renderCard(v, index, list) {
|
||
const card = document.createElement('div');
|
||
const isCached = cachedIds.has(v.id);
|
||
const isDownloading = downloading.has(v.id);
|
||
card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
|
||
card.dataset.id = v.id;
|
||
card.innerHTML = `
|
||
<div class="batch-check"></div>
|
||
<div class="thumb">
|
||
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
|
||
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
||
</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;
|
||
const chEl = card.querySelector('.card-channel');
|
||
chEl.textContent = v.channel || '';
|
||
if (channelKeyOf(v)) {
|
||
chEl.classList.add('link');
|
||
chEl.title = 'View channel';
|
||
chEl.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
openChannel(channelKeyOf(v), v.channel);
|
||
});
|
||
}
|
||
|
||
// Playlist view: note under title (if present) and note edit button (Section 5)
|
||
if (view.type === 'playlist') {
|
||
const hasNote = !!(v.notes && v.notes.trim());
|
||
if (hasNote) {
|
||
const noteEl = document.createElement('div');
|
||
noteEl.className = 'card-note';
|
||
noteEl.textContent = v.notes.trim();
|
||
noteEl.title = v.notes.trim();
|
||
const titleEl = card.querySelector('.card-title');
|
||
if (titleEl) titleEl.after(noteEl);
|
||
}
|
||
|
||
const noteBtn = document.createElement('button');
|
||
noteBtn.type = 'button';
|
||
noteBtn.className = 'card-note-btn' + (hasNote ? ' has-note' : '');
|
||
noteBtn.title = hasNote ? 'Edit note' : 'Add note';
|
||
noteBtn.textContent = '✎';
|
||
noteBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
openSongNoteModal(v);
|
||
});
|
||
const menuBtn = card.querySelector('.card-menu');
|
||
card.insertBefore(noteBtn, menuBtn);
|
||
}
|
||
|
||
card.addEventListener('pointerdown', () => warmStreams([v.id]), { passive: true });
|
||
card.addEventListener('click', (e) => {
|
||
if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-note-btn') || e.target.closest('.card-channel.link')) return;
|
||
if (selectMode) { toggleSelectCard(v.id); return; }
|
||
playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type);
|
||
});
|
||
card.querySelector('.card-menu').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
openCardMenu(v);
|
||
});
|
||
|
||
// Per-item delete in playlist, queue, history, and smart views.
|
||
if (view.type === 'playlist' || view.type === 'queue' || view.type === 'history' || view.type === 'smart') {
|
||
const del = document.createElement('button');
|
||
del.className = 'card-del';
|
||
del.title = view.type === 'queue' ? 'Remove from queue'
|
||
: (view.type === 'history' || view.type === 'smart') ? 'Remove from history'
|
||
: 'Remove from playlist';
|
||
del.textContent = '✕';
|
||
del.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (view.type === 'queue') { removeFromQueue(v.id); return; }
|
||
if (view.type === 'history' || view.type === 'smart') {
|
||
data.history = data.history.filter((x) => x.id !== v.id);
|
||
persist();
|
||
renderList();
|
||
return;
|
||
}
|
||
const pl = data.playlists.find((p) => p.id === view.id);
|
||
if (pl) { pl.videos = pl.videos.filter((x) => x.id !== v.id); persist(); render(); toast('Removed from playlist'); }
|
||
});
|
||
card.appendChild(del);
|
||
}
|
||
|
||
// Drag-to-reorder in playlist and queue views
|
||
if (view.type === 'playlist' || view.type === 'queue') {
|
||
card.draggable = true;
|
||
card.addEventListener('dragstart', () => {
|
||
card.classList.add('dragging');
|
||
dragSource = index;
|
||
});
|
||
card.addEventListener('dragend', () => {
|
||
card.classList.remove('dragging');
|
||
});
|
||
card.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
card.classList.add('drag-over');
|
||
});
|
||
card.addEventListener('dragleave', () => {
|
||
card.classList.remove('drag-over');
|
||
});
|
||
card.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
card.classList.remove('drag-over');
|
||
const from = dragSource;
|
||
const to = index;
|
||
if (from === to || from < 0) return;
|
||
const arr = view.type === 'queue'
|
||
? data.queue
|
||
: (data.playlists.find((p) => p.id === view.id) || {}).videos;
|
||
if (!arr) return;
|
||
const [moved] = arr.splice(from, 1);
|
||
arr.splice(to, 0, moved);
|
||
if (view.type === 'queue' && queueSource === 'queue') {
|
||
const playingId = current && current.meta && current.meta.id;
|
||
queue = data.queue.slice();
|
||
queueIndex = playingId ? queue.findIndex((x) => x.id === playingId) : queueIndex;
|
||
renderUpNext();
|
||
}
|
||
persist();
|
||
renderList();
|
||
});
|
||
}
|
||
|
||
return card;
|
||
}
|
||
|
||
function markPlayingCard() {
|
||
document.querySelectorAll('.card').forEach((c) => {
|
||
c.classList.toggle('playing', current && c.dataset.id === current.meta.id);
|
||
});
|
||
}
|
||
|
||
let _lastViewKey = null;
|
||
function render() {
|
||
listFilter = '';
|
||
const fi = $('listFilterInput');
|
||
if (fi) fi.value = '';
|
||
// On mobile, navigating (nav item / playlist / search) dismisses the drawer.
|
||
if (typeof closeSidebar === 'function') closeSidebar();
|
||
exitSelectMode();
|
||
renderSidebar();
|
||
renderSmartSidebar();
|
||
renderList();
|
||
// Portrait PWA: reveal the freshly selected view. Only on actual view
|
||
// changes — same-view re-renders (deletes, modal confirms, profile load)
|
||
// must not hijack the scroll position. Skipped on the very first render.
|
||
const viewKey = [view.type, view.id, view.smartType].join('|');
|
||
if (_lastViewKey !== null && viewKey !== _lastViewKey) scrollListIntoViewPortrait();
|
||
_lastViewKey = viewKey;
|
||
// Cards come and go with the view, so the rail is rebuilt from what is
|
||
// actually on screen.
|
||
SectionRail.refresh();
|
||
}
|
||
|
||
// ============================================================================
|
||
// Playlists
|
||
// ============================================================================
|
||
function exportPlaylists() {
|
||
const json = JSON.stringify(data.playlists, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
toast('Playlists exported');
|
||
}
|
||
function importPlaylists(e) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
try {
|
||
const imported = JSON.parse(ev.target.result);
|
||
if (!Array.isArray(imported)) throw new Error('Invalid format');
|
||
// Merge: skip duplicates by id, add new ones
|
||
const existingIds = new Set(data.playlists.map((p) => p.id));
|
||
for (const pl of imported) {
|
||
if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
|
||
data.playlists.push(pl);
|
||
existingIds.add(pl.id);
|
||
}
|
||
}
|
||
persist();
|
||
renderSidebar();
|
||
toast(`Imported ${imported.length} playlist(s)`);
|
||
} catch (err) {
|
||
toast('⚠ Failed to import: ' + err.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = ''; // allow re-import of same file
|
||
}
|
||
function openImportYouTubeModal() {
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `
|
||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||
Enter a YouTube playlist URL to import its videos into a new playlist.
|
||
</p>
|
||
<input id="ytPlaylistUrlInput" type="text" placeholder="https://www.youtube.com/playlist?list=..." autocomplete="off" />
|
||
<div id="ytImportStatus" class="hidden" style="margin-top:10px;color:var(--text-dim);font-size:13px">
|
||
Fetching playlist from YouTube…
|
||
</div>
|
||
`;
|
||
|
||
async function handleImport() {
|
||
const input = $('ytPlaylistUrlInput');
|
||
if (!input) return;
|
||
const url = input.value.trim();
|
||
if (!url) {
|
||
toast('⚠ Please enter a YouTube URL');
|
||
input.focus();
|
||
return;
|
||
}
|
||
|
||
const modalActions = $('modalActions');
|
||
const importBtn = modalActions?.querySelector('button.primary');
|
||
const cancelBtn = modalActions?.querySelector('button:not(.primary)');
|
||
const statusEl = $('ytImportStatus');
|
||
|
||
if (importBtn) {
|
||
importBtn.disabled = true;
|
||
importBtn.textContent = 'Importing…';
|
||
}
|
||
if (cancelBtn) cancelBtn.disabled = true;
|
||
input.disabled = true;
|
||
if (statusEl) statusEl.classList.remove('hidden');
|
||
|
||
try {
|
||
const res = await fetch(`/api/playlist/expand?url=${encodeURIComponent(url)}`);
|
||
const j = await res.json().catch(() => null);
|
||
if (!j || !j.ok) {
|
||
throw new Error((j && j.error) || `Failed to fetch playlist (HTTP ${res.status})`);
|
||
}
|
||
|
||
const entries = Array.isArray(j.entries) ? j.entries : [];
|
||
if (!entries.length) {
|
||
throw new Error('No videos found in that playlist');
|
||
}
|
||
|
||
const rawTitle = (j.title || '').trim();
|
||
const playlistName = rawTitle || 'Imported playlist';
|
||
|
||
// Dedupe up front so the count offered is the count actually created —
|
||
// YouTube playlists routinely repeat a video.
|
||
const seenIds = new Set();
|
||
const dedupedVideos = [];
|
||
for (const v of entries) {
|
||
if (!v || !v.id || seenIds.has(v.id)) continue;
|
||
seenIds.add(v.id);
|
||
dedupedVideos.push(slim(v));
|
||
}
|
||
const count = dedupedVideos.length;
|
||
if (!count) throw new Error('No usable videos in that playlist');
|
||
|
||
const successBody = document.createElement('div');
|
||
successBody.innerHTML = `
|
||
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
|
||
Found ${count} video${count === 1 ? '' : 's'} in “<strong>${escapeHtml(playlistName)}</strong>”${j.truncated ? ' <small style="color:var(--text-dim)">(capped at 200)</small>' : ''}.
|
||
</p>
|
||
`;
|
||
|
||
showModal('⬇ Import from YouTube', successBody, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{
|
||
label: 'Create playlist',
|
||
primary: true,
|
||
onClick: () => {
|
||
closeModal();
|
||
const baseName = playlistName;
|
||
let finalName = baseName;
|
||
let counter = 2;
|
||
const existingNames = new Set((data.playlists || []).map((p) => p.name));
|
||
while (existingNames.has(finalName)) {
|
||
finalName = `${baseName} (${counter})`;
|
||
counter++;
|
||
}
|
||
|
||
const pl = { id: uid(), name: finalName, videos: dedupedVideos };
|
||
if (!Array.isArray(data.playlists)) data.playlists = [];
|
||
data.playlists.push(pl);
|
||
persist();
|
||
renderSidebar();
|
||
render();
|
||
toast(`Created “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`);
|
||
},
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
toast('⚠ ' + (err.message || 'Failed to import playlist'));
|
||
if (importBtn) {
|
||
importBtn.disabled = false;
|
||
importBtn.textContent = 'Import';
|
||
}
|
||
if (cancelBtn) cancelBtn.disabled = false;
|
||
input.disabled = false;
|
||
if (statusEl) statusEl.classList.add('hidden');
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
showModal('⬇ Import from YouTube', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Import', primary: true, onClick: handleImport },
|
||
]);
|
||
|
||
setTimeout(() => {
|
||
const input = $('ytPlaylistUrlInput');
|
||
if (input) {
|
||
input.focus();
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
handleImport();
|
||
}
|
||
});
|
||
}
|
||
}, 50);
|
||
}
|
||
function openCardMenu(video) {
|
||
const inPlaylistView = view.type === 'playlist';
|
||
const body = document.createElement('div');
|
||
body.className = 'modal-list';
|
||
|
||
// Quick: add to queue
|
||
const queueBtn = document.createElement('button');
|
||
queueBtn.textContent = '▶ Add to queue';
|
||
queueBtn.onclick = () => { addToQueue(video); closeModal(); };
|
||
body.appendChild(queueBtn);
|
||
|
||
const isCustom = !!video.custom;
|
||
|
||
const exportBtn = document.createElement('button');
|
||
exportBtn.textContent = '🖼 Save to gallery';
|
||
exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder';
|
||
exportBtn.onclick = () => { closeModal(); exportToDevice(video); };
|
||
body.appendChild(exportBtn);
|
||
|
||
// Quick: save / remove offline. For a custom (edited) video there's no
|
||
// source to re-download, so "remove" deletes the edit entirely.
|
||
const saveBtn = document.createElement('button');
|
||
const isSaved = cachedIds.has(video.id);
|
||
if (isCustom) {
|
||
saveBtn.textContent = '🗑 Delete edited video';
|
||
saveBtn.className = 'danger';
|
||
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
|
||
} else {
|
||
saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
|
||
saveBtn.onclick = async () => {
|
||
closeModal();
|
||
if (cachedIds.has(video.id)) {
|
||
try { await API.cacheDelete(video.id); } catch {}
|
||
cachedIds.delete(video.id);
|
||
markCardCacheState(video.id, 'none');
|
||
if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions();
|
||
toast('Removed from offline cache');
|
||
} else {
|
||
preload(video);
|
||
}
|
||
};
|
||
}
|
||
body.appendChild(saveBtn);
|
||
|
||
// Quick: edit & download (only for real source videos — a custom cut can't
|
||
// be re-cut server-side because its media lives only in the browser cache).
|
||
if (!isCustom) {
|
||
const editBtn = document.createElement('button');
|
||
editBtn.textContent = '✂ Edit & download';
|
||
editBtn.onclick = () => { closeModal(); openVideoEditor(video); };
|
||
body.appendChild(editBtn);
|
||
}
|
||
|
||
const divider = document.createElement('div');
|
||
divider.className = 'modal-divider';
|
||
divider.textContent = 'Playlists';
|
||
body.appendChild(divider);
|
||
|
||
data.playlists.forEach((pl) => {
|
||
const btn = document.createElement('button');
|
||
btn.textContent = (pl.videos.some((x) => x.id === video.id) ? '✓ ' : '+ ') + pl.name;
|
||
btn.onclick = () => {
|
||
const key = pl.id + ':' + video.id;
|
||
// Guard against rapid double-clicks: the op is keyed by playlist+video,
|
||
// so a second click while the first is in flight is ignored (prevents the
|
||
// same video being pushed twice / state corruption).
|
||
runExclusive(playlistOps, key, async () => {
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ ' + pl.name;
|
||
// Recompute membership now (not from a flag captured at menu-open),
|
||
// so the decision reflects current state.
|
||
const has = pl.videos.some((x) => x.id === video.id);
|
||
try {
|
||
if (has) {
|
||
pl.videos = pl.videos.filter((x) => x.id !== video.id);
|
||
} else {
|
||
pl.videos.push(slim(video));
|
||
// Auto-cache for offline playback (fire-and-forget; its own guard
|
||
// prevents duplicate downloads).
|
||
preload(video);
|
||
}
|
||
persist();
|
||
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
|
||
} catch {
|
||
toast('⚠ Could not update playlist');
|
||
}
|
||
closeModal();
|
||
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) {
|
||
const s = {
|
||
id: v.id, title: v.title, channel: v.channel,
|
||
channelId: v.channelId || '', channelUrl: v.channelUrl || '',
|
||
duration: v.duration, thumbnail: v.thumbnail,
|
||
};
|
||
// Preserve the markers that make a custom (edited) video self-contained, so
|
||
// a slimmed copy sitting in a playlist still knows it's an offline-only cut.
|
||
if (v.custom) {
|
||
s.custom = true;
|
||
s.sourceId = v.sourceId || '';
|
||
if (v.keep) s.keep = v.keep;
|
||
}
|
||
return s;
|
||
}
|
||
|
||
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 openSongNoteModal(entry) {
|
||
const body = document.createElement('div');
|
||
body.className = 'song-note-modal';
|
||
|
||
if (entry.title) {
|
||
const sub = document.createElement('div');
|
||
sub.className = 'modal-subhead';
|
||
sub.textContent = entry.title;
|
||
body.appendChild(sub);
|
||
}
|
||
|
||
const ta = document.createElement('textarea');
|
||
ta.placeholder = 'Key, capo, arrangement cues…';
|
||
ta.value = entry.notes || '';
|
||
ta.maxLength = 500;
|
||
ta.rows = 4;
|
||
|
||
const count = document.createElement('div');
|
||
count.className = 'modal-char-count';
|
||
const updateCount = () => {
|
||
count.textContent = `${ta.value.length} / 500`;
|
||
};
|
||
ta.addEventListener('input', updateCount);
|
||
updateCount();
|
||
|
||
body.append(ta, count);
|
||
|
||
const save = () => {
|
||
const text = (ta.value || '').trim().slice(0, 500);
|
||
if (text) {
|
||
entry.notes = text;
|
||
} else {
|
||
delete entry.notes;
|
||
}
|
||
|
||
// Keep live playing metadata in sync if this song is playing from this playlist
|
||
if (current && current.meta && current.meta.id === entry.id) {
|
||
if (queueSource && queueSource.startsWith('playlist:')) {
|
||
if (text) current.meta.notes = text;
|
||
else delete current.meta.notes;
|
||
updateServiceMode();
|
||
}
|
||
}
|
||
|
||
persist();
|
||
closeModal();
|
||
renderList();
|
||
toast(text ? 'Note saved' : 'Note cleared');
|
||
};
|
||
|
||
ta.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
save();
|
||
}
|
||
});
|
||
|
||
showModal('Song note', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Save', primary: true, onClick: save },
|
||
]);
|
||
|
||
setTimeout(() => {
|
||
ta.focus();
|
||
ta.select();
|
||
}, 50);
|
||
}
|
||
|
||
function updatePlaylistOfflineButton(btn, pl) {
|
||
if (!btn || !pl) return;
|
||
const stats = playlistOfflineStats(pl);
|
||
const isDownloading = (pl.videos || []).some((v) => v && downloading.has(v.id));
|
||
if (stats.total > 0 && stats.cached === stats.total) {
|
||
btn.textContent = '✓ Available offline';
|
||
btn.disabled = true;
|
||
btn.classList.add('disabled');
|
||
btn.title = 'All videos in this playlist are available offline';
|
||
} else if (isDownloading) {
|
||
btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
|
||
btn.disabled = true;
|
||
btn.classList.add('disabled');
|
||
btn.title = `${stats.cached} of ${stats.total} videos saved offline`;
|
||
} else {
|
||
btn.textContent = '⬇ Make available offline';
|
||
btn.disabled = stats.total === 0;
|
||
btn.classList.toggle('disabled', stats.total === 0);
|
||
btn.title = stats.total === 0 ? 'Playlist is empty' : 'Download all videos for offline playback';
|
||
}
|
||
}
|
||
|
||
async function makePlaylistOffline(pl) {
|
||
if (!pl || !Array.isArray(pl.videos) || !pl.videos.length) return;
|
||
const uncollected = pl.videos.filter((v) => v && !v.custom && !cachedIds.has(v.id));
|
||
if (!uncollected.length) return;
|
||
|
||
const btn = $('plOfflineBtn');
|
||
if (btn) {
|
||
const stats = playlistOfflineStats(pl);
|
||
btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
|
||
btn.disabled = true;
|
||
btn.classList.add('disabled');
|
||
}
|
||
|
||
toast(`Saving ${uncollected.length} video${uncollected.length === 1 ? '' : 's'} for offline…`);
|
||
|
||
const promises = uncollected.map(async (v) => {
|
||
await preload(v, { quiet: true });
|
||
const b = $('plOfflineBtn');
|
||
if (b && view.type === 'playlist' && view.id === pl.id) {
|
||
updatePlaylistOfflineButton(b, pl);
|
||
}
|
||
renderSidebar();
|
||
});
|
||
await Promise.all(promises);
|
||
|
||
const finalBtn = $('plOfflineBtn');
|
||
if (finalBtn && view.type === 'playlist' && view.id === pl.id) {
|
||
updatePlaylistOfflineButton(finalBtn, pl);
|
||
}
|
||
renderSidebar();
|
||
}
|
||
|
||
function togglePinPlaylist(pl) {
|
||
if (!pl) return;
|
||
data.pinnedPlaylists = data.pinnedPlaylists || [];
|
||
const isPinned = data.pinnedPlaylists.includes(pl.id);
|
||
if (isPinned) {
|
||
data.pinnedPlaylists = data.pinnedPlaylists.filter((id) => id !== pl.id);
|
||
persist();
|
||
toast(`Unpinned “${pl.name}”`);
|
||
} else {
|
||
data.pinnedPlaylists.push(pl.id);
|
||
persist();
|
||
(pl.videos || []).forEach((v) => preload(v, { quiet: true }));
|
||
toast(`Pinned “${pl.name}” — keeping offline`);
|
||
}
|
||
const pinBtn = $('plPinBtn');
|
||
if (pinBtn) {
|
||
const nowPinned = data.pinnedPlaylists.includes(pl.id);
|
||
pinBtn.textContent = nowPinned ? '📌 Pinned' : '📌 Keep offline';
|
||
pinBtn.classList.toggle('pinned', nowPinned);
|
||
pinBtn.title = nowPinned ? 'Pinned for offline keep — click to unpin' : 'Pin to keep all videos in this playlist available offline';
|
||
}
|
||
const offlineBtn = $('plOfflineBtn');
|
||
if (offlineBtn) updatePlaylistOfflineButton(offlineBtn, pl);
|
||
renderSidebar();
|
||
}
|
||
|
||
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);
|
||
data.pinnedPlaylists = (data.pinnedPlaylists || []).filter((id) => id !== pl.id);
|
||
persist();
|
||
closeModal();
|
||
view = { type: 'search' };
|
||
render();
|
||
},
|
||
},
|
||
]);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Keyboard shortcut help overlay
|
||
// ============================================================================
|
||
function toggleShortcutHelp() {
|
||
const overlay = $('shortcutHelp');
|
||
overlay.classList.toggle('hidden');
|
||
}
|
||
|
||
function wireShortcutHelp() {
|
||
$('shortcutClose').addEventListener('click', () => $('shortcutHelp').classList.add('hidden'));
|
||
$('shortcutHelp').addEventListener('click', (e) => {
|
||
if (e.target.id === 'shortcutHelp') $('shortcutHelp').classList.add('hidden');
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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'); }
|
||
|
||
// Tapping the dimmed backdrop dismisses the modal (same as Cancel/Later).
|
||
// Guarantees a modal can never permanently swallow every tap on the page —
|
||
// the full-screen backdrop sits above the bottom nav, so a modal the user
|
||
// doesn't notice (e.g. the update prompt popping under their thumb) used to
|
||
// read as "all navigation buttons stopped working".
|
||
$('modal').addEventListener('click', (e) => {
|
||
if (e.target === $('modal')) closeModal();
|
||
});
|
||
|
||
// ============================================================================
|
||
// Portrait PWA detection & behavioral layer
|
||
// Mirrors the CSS media query: (display-mode: standalone) and (orientation: portrait)
|
||
// The JS layer adds:
|
||
// • A `portrait-pwa` class on .app for any JS-driven conditional logic.
|
||
// • Auto-close the sidebar when rotating into portrait-standalone mode.
|
||
// • Auto-scroll the player into view when a video starts in portrait mode.
|
||
// ============================================================================
|
||
const _portraitPwaMQ = window.matchMedia
|
||
? window.matchMedia('(display-mode: standalone) and (orientation: portrait)')
|
||
: { matches: false, addEventListener: () => {} };
|
||
|
||
function isPortraitPWA() {
|
||
return _portraitPwaMQ.matches;
|
||
}
|
||
|
||
function applyPortraitPwaClass() {
|
||
const app = document.querySelector('.app');
|
||
if (!app) return;
|
||
if (isPortraitPWA()) {
|
||
app.classList.add('portrait-pwa');
|
||
} else {
|
||
app.classList.remove('portrait-pwa');
|
||
}
|
||
}
|
||
|
||
// In portrait mode, .body is the single scroll container (player pane on top,
|
||
// list pane below). Scrolling it to 0 brings the player fully into view.
|
||
function scrollPlayerIntoViewPortrait() {
|
||
if (!isPortraitPWA()) return;
|
||
const body = document.querySelector('.body');
|
||
if (body) {
|
||
body.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
}
|
||
|
||
// Bring the list pane to the top of the portrait scroll container so a newly
|
||
// rendered view is actually on-screen. While a track is playing, the player
|
||
// pane above it is taller than the whole viewport — without this scroll the
|
||
// fresh view sits invisibly below the fold and every nav tap looks dead
|
||
// (the mobile "can't navigate while playing" bug).
|
||
function scrollListIntoViewPortrait() {
|
||
if (!isPortraitPWA()) return;
|
||
const body = document.querySelector('.body');
|
||
const listPane = document.querySelector('.list-pane');
|
||
// offsetParent is null while the pane is hidden (pre-first-search) — its
|
||
// rect would be all zeros and scroll the body to a bogus offset.
|
||
if (!body || !listPane || !listPane.offsetParent) return;
|
||
const top = body.scrollTop + listPane.getBoundingClientRect().top - body.getBoundingClientRect().top;
|
||
body.scrollTo({ top, behavior: 'smooth' });
|
||
}
|
||
|
||
function setupPortraitPwaWatcher() {
|
||
applyPortraitPwaClass();
|
||
_portraitPwaMQ.addEventListener('change', () => {
|
||
applyPortraitPwaClass();
|
||
// Rotating into portrait-standalone: close any open sidebar drawer so the
|
||
// full-width layout isn't obscured.
|
||
if (isPortraitPWA()) closeSidebar();
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Layout-viewport anchor guard (iOS Safari / standalone PWA)
|
||
//
|
||
// The app is a fixed-viewport layout: body is overflow:hidden and only inner
|
||
// panes scroll, so the document itself must always sit at scroll position 0.
|
||
// iOS WebKit can still scroll the *layout viewport* behind our back — exiting
|
||
// native video fullscreen (webkitEnterFullscreen is the only fullscreen path
|
||
// on iPhone, and iOS enters it by itself when the phone rotates while a video
|
||
// plays), the on-screen keyboard revealing a focused input, or any
|
||
// scrollIntoView() walking up into <html>. Once that happens, fixed elements
|
||
// (bottom nav, mini-bar) are still *drawn* in place but their hit-testing
|
||
// regions are offset by the stray scroll amount, so taps on the nav buttons
|
||
// silently do nothing — and because the body isn't user-scrollable there is
|
||
// no gesture that can undo it. That is the "nav buttons stop working after
|
||
// playing a video" iPhone bug. Snap the document back to 0 whenever it ends
|
||
// up scrolled; skipped while an input is focused so we never fight the
|
||
// keyboard auto-scroll, then re-anchored once focus leaves the field.
|
||
function setupViewportAnchorGuard() {
|
||
const editing = () => {
|
||
const el = document.activeElement;
|
||
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
|
||
};
|
||
const reanchor = () => {
|
||
if (editing()) return;
|
||
const doc = document.scrollingElement || document.documentElement;
|
||
if (window.scrollY || doc.scrollTop) window.scrollTo(0, 0);
|
||
};
|
||
window.addEventListener('scroll', reanchor);
|
||
document.addEventListener('focusout', () => setTimeout(reanchor, 50));
|
||
// Exiting native video fullscreen is the most reliable reproducer of the
|
||
// stray-scroll state; the scroll event alone doesn't always fire for it.
|
||
els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50));
|
||
// iOS can offset the layout viewport WITHOUT firing a window scroll event
|
||
// (keyboard dismissal animations, rotation mid-playback, in-app browser
|
||
// chrome changes). visualViewport does report those; hook it when present.
|
||
if (window.visualViewport) {
|
||
window.visualViewport.addEventListener('scroll', () => setTimeout(reanchor, 50));
|
||
window.visualViewport.addEventListener('resize', () => setTimeout(reanchor, 50));
|
||
}
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') setTimeout(reanchor, 50);
|
||
});
|
||
// Last-resort safety net for offsets none of the events above report:
|
||
// a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
|
||
// so "nav taps silently do nothing" can never persist longer than a beat.
|
||
setInterval(reanchor, 2000);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Events
|
||
// ============================================================================
|
||
// ============================================================================
|
||
// Responsive sidebar drawer (mobile)
|
||
// ============================================================================
|
||
function openSidebar() {
|
||
const app = document.querySelector('.app');
|
||
if (!app) return;
|
||
app.classList.add('sidebar-open');
|
||
const t = $('sidebarToggle');
|
||
if (t) t.setAttribute('aria-expanded', 'true');
|
||
}
|
||
function closeSidebar() {
|
||
const app = document.querySelector('.app');
|
||
if (!app) return;
|
||
app.classList.remove('sidebar-open');
|
||
const t = $('sidebarToggle');
|
||
if (t) t.setAttribute('aria-expanded', 'false');
|
||
}
|
||
function setupSidebarDrawer() {
|
||
const toggle = $('sidebarToggle');
|
||
const backdrop = $('sidebarBackdrop');
|
||
if (toggle) {
|
||
toggle.addEventListener('click', () => {
|
||
const app = document.querySelector('.app');
|
||
if (app && app.classList.contains('sidebar-open')) closeSidebar();
|
||
else openSidebar();
|
||
});
|
||
}
|
||
if (backdrop) backdrop.addEventListener('click', closeSidebar);
|
||
}
|
||
|
||
function wireUI() {
|
||
setupSidebarDrawer();
|
||
|
||
// ---- Sidebar: linked-profile share block (static markup, wired once) ----
|
||
$('sidebarProfileCopy').addEventListener('click', copyProfileLink);
|
||
$('sidebarProfileShare').addEventListener('click', shareProfileLink);
|
||
|
||
// ---- Empty-home playlist grid ----
|
||
$('homePlNewBtn').addEventListener('click', () => newPlaylist(null));
|
||
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
|
||
window.addEventListener('resize', updateHomeGridScrollState);
|
||
|
||
// Runs a search, optionally painting a cached result set immediately
|
||
// (`instant`, from RecentSearches.getResults) before the network reply
|
||
// lands — the perceived-speed win for re-running a recent search.
|
||
// `searchSeq` guards against a slower response overwriting a newer one
|
||
// (the instant paint above can itself be "in flight" behind a background
|
||
// refresh when the user searches something else right after).
|
||
async function runSearchQuery(q, { instant = null } = {}) {
|
||
q = (q || '').trim();
|
||
if (!q) return;
|
||
const mySeq = ++searchSeq;
|
||
perfMark('ytp:search-start');
|
||
RecentSearches.add(q);
|
||
hideRecentSearches();
|
||
view = { type: 'search' };
|
||
if (instant) {
|
||
searchResults = instant.results;
|
||
searchAttempted = true;
|
||
render();
|
||
} else {
|
||
render();
|
||
showSearchSkeletons();
|
||
}
|
||
// Even when already on the search view (no view change for render() to
|
||
// detect), a new query means the user wants to see the results.
|
||
scrollListIntoViewPortrait();
|
||
try {
|
||
const res = await API.search(q);
|
||
if (mySeq !== searchSeq) return; // superseded by a newer search
|
||
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
|
||
const results = res.results || [];
|
||
searchResults = results;
|
||
searchAttempted = true;
|
||
renderList();
|
||
RecentSearches.cacheResults(q, results);
|
||
warmStreams(results.slice(0, 3).map((r) => r.id));
|
||
perfMeasure('ytp:search', 'ytp:search-start');
|
||
} catch (err) {
|
||
if (mySeq !== searchSeq) return;
|
||
if (instant) return; // keep showing the cached results; fail silently
|
||
els.cards.innerHTML = '';
|
||
els.status.classList.remove('hidden');
|
||
els.status.textContent = '⚠ ' + err.message;
|
||
}
|
||
}
|
||
|
||
els.searchForm.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
runSearchQuery(els.searchInput.value);
|
||
});
|
||
|
||
// ---- Recent searches dropdown (device-local; see RecentSearches above) ----
|
||
function renderRecentSearches() {
|
||
const list = RecentSearches.list();
|
||
if (!list.length) { els.searchRecent.innerHTML = ''; return; }
|
||
const cachedQueries = RecentSearches.cachedQueries();
|
||
els.searchRecent.innerHTML = list.map((q) => {
|
||
const isCached = cachedQueries.has(q.trim().toLowerCase());
|
||
return `
|
||
<div class="search-recent-row${isCached ? ' is-cached' : ''}" data-q="${escapeHtml(q)}" role="option">
|
||
<span class="sr-icon" title="${isCached ? 'Loads instantly, then refreshes' : 'Recent search'}">${isCached ? '⚡' : '🕐'}</span><span class="sr-text">${escapeHtml(q)}</span>
|
||
<button type="button" class="sr-remove" data-remove="${escapeHtml(q)}" aria-label="Remove ‘${escapeHtml(q)}’ from recent searches">✕</button>
|
||
</div>`;
|
||
}).join('') + '<button type="button" class="search-recent-clear" data-clear-all>Clear recent searches</button>';
|
||
}
|
||
function showRecentSearches() {
|
||
renderRecentSearches();
|
||
if (!RecentSearches.list().length) return;
|
||
els.searchRecent.classList.remove('hidden');
|
||
}
|
||
function hideRecentSearches() { els.searchRecent.classList.add('hidden'); }
|
||
els.searchInput.addEventListener('focus', showRecentSearches);
|
||
els.searchInput.addEventListener('input', () => {
|
||
if (els.searchInput.value.trim()) hideRecentSearches(); else showRecentSearches();
|
||
});
|
||
els.searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideRecentSearches(); });
|
||
// A click on a row/remove/clear fires before the input's blur-driven hide
|
||
// would run, so use mousedown (fires first) rather than losing the target.
|
||
els.searchRecent.addEventListener('mousedown', (e) => {
|
||
const removeBtn = e.target.closest('[data-remove]');
|
||
if (removeBtn) {
|
||
e.preventDefault();
|
||
RecentSearches.remove(removeBtn.dataset.remove);
|
||
showRecentSearches();
|
||
return;
|
||
}
|
||
if (e.target.closest('[data-clear-all]')) {
|
||
e.preventDefault();
|
||
RecentSearches.clear();
|
||
hideRecentSearches();
|
||
return;
|
||
}
|
||
const row = e.target.closest('.search-recent-row');
|
||
if (row) {
|
||
e.preventDefault();
|
||
const q = row.dataset.q;
|
||
els.searchInput.value = q;
|
||
runSearchQuery(q, { instant: RecentSearches.getResults(q) });
|
||
}
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!els.searchForm.contains(e.target)) hideRecentSearches();
|
||
});
|
||
|
||
document.querySelectorAll('.nav-item').forEach((b) => {
|
||
if (!b.dataset.view) return; // e.g. 📱 Remote opens a panel, not a view
|
||
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
|
||
});
|
||
|
||
// Bottom-nav (portrait PWA) — same view switching as sidebar nav
|
||
document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
|
||
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
|
||
});
|
||
|
||
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
|
||
|
||
// Landing-hero quick-search chips — rotate periodically
|
||
const CHIP_SETS = [
|
||
[{ q: 'lofi hip hop radio', label: 'lofi beats' }, { q: 'live news', label: 'live news' }, { q: 'relaxing music', label: 'relaxing music' }, { q: 'podcast highlights', label: 'podcasts' }],
|
||
[{ q: 'ambient jazz', label: 'ambient jazz' }, { q: 'tech talk coding', label: 'tech talks' }, { q: 'street food', label: 'street food' }, { q: 'travel vlog', label: 'travel vlogs' }],
|
||
[{ q: 'synthwave mix', label: 'synthwave' }, { q: 'asmr rain', label: 'rain sounds' }, { q: 'documentary', label: 'documentaries' }, { q: 'workout music', label: 'workout' }],
|
||
[{ q: '60s rock classics', label: 'classic rock' }, { q: 'nature sounds', label: 'nature' }, { q: 'data science', label: 'data science' }, { q: 'city walks', label: 'city walks' }],
|
||
];
|
||
const chipsEl = document.querySelector('.hero-suggests');
|
||
let chipRotation = 0;
|
||
function rotateChips() {
|
||
chipRotation = (chipRotation + 1) % CHIP_SETS.length;
|
||
const set = CHIP_SETS[chipRotation];
|
||
const buttons = chipsEl.querySelectorAll('.chip');
|
||
buttons.forEach((btn, i) => {
|
||
if (i < set.length) {
|
||
btn.dataset.q = set[i].q;
|
||
btn.textContent = set[i].label;
|
||
}
|
||
});
|
||
}
|
||
setInterval(rotateChips, 8000);
|
||
|
||
document.querySelectorAll('.chip').forEach((c) => {
|
||
c.addEventListener('click', () => {
|
||
els.searchInput.value = c.dataset.q || c.textContent.trim();
|
||
els.searchForm.requestSubmit();
|
||
});
|
||
});
|
||
|
||
// Sleep timer — click cycles through off/15/30/45/60/90 min
|
||
$('sleepTimerBtn').addEventListener('click', openSleepTimer);
|
||
$('sleepCurtain').addEventListener('click', () => $('sleepCurtain').classList.add('hidden'));
|
||
$('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); });
|
||
$('transitionBtn').addEventListener('click', () => Transition.cycle());
|
||
$('shareMomentBtn').addEventListener('click', () => Share.open());
|
||
$('externalBtn').addEventListener('click', () => External.open());
|
||
$('pipBtn').addEventListener('click', () => PiP.toggle());
|
||
$('eqBtn').addEventListener('click', () => EQ.open());
|
||
|
||
// A-B markers
|
||
$('abABtn').addEventListener('click', setAbA);
|
||
$('abBBtn').addEventListener('click', setAbB);
|
||
$('abClearBtn').addEventListener('click', clearAb);
|
||
$('rememberPosBtn').addEventListener('click', toggleRememberPos);
|
||
|
||
// Related panel collapse toggle
|
||
$('relatedToggleBtn').addEventListener('click', () => {
|
||
// One source of truth: SectionRail owns fold state and applies it here
|
||
// (it sets relatedCollapsed, the list class and this button's label).
|
||
SectionRail.setFolded('related', !relatedCollapsed);
|
||
});
|
||
|
||
// List filter
|
||
$('listFilterInput').addEventListener('input', (e) => {
|
||
listFilter = e.target.value.toLowerCase();
|
||
renderList();
|
||
});
|
||
|
||
// Batch bar actions
|
||
$('batchCancelBtn').addEventListener('click', () => { exitSelectMode(); renderList(); });
|
||
$('batchQueueBtn').addEventListener('click', () => {
|
||
const all = currentList();
|
||
selectedIds.forEach((id) => {
|
||
const v = all.find((x) => x.id === id) || videoById(id);
|
||
if (v) addToQueue(v, { quiet: true });
|
||
});
|
||
toast(`Added ${selectedIds.size} to queue`);
|
||
exitSelectMode(); renderList();
|
||
});
|
||
$('batchDeleteBtn').addEventListener('click', () => {
|
||
const count = selectedIds.size;
|
||
showModal(`Delete ${count} item${count === 1 ? '' : 's'}?`, document.createTextNode('This cannot be undone.'), [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Delete', danger: true, onClick: () => {
|
||
if (view.type === 'history' || view.type === 'smart') {
|
||
data.history = data.history.filter((v) => !selectedIds.has(v.id));
|
||
} else if (view.type === 'playlist') {
|
||
const pl = data.playlists.find((p) => p.id === view.id);
|
||
if (pl) pl.videos = pl.videos.filter((v) => !selectedIds.has(v.id));
|
||
} else if (view.type === 'queue') {
|
||
selectedIds.forEach((id) => removeFromQueue(id));
|
||
}
|
||
persist(); closeModal(); exitSelectMode(); render();
|
||
toast(`Deleted ${count} item${count === 1 ? '' : 's'}`);
|
||
}},
|
||
]);
|
||
});
|
||
$('batchAddPlaylistBtn').addEventListener('click', () => {
|
||
const all = currentList();
|
||
const videos = [...selectedIds].map((id) => all.find((v) => v.id === id) || videoById(id)).filter(Boolean);
|
||
const body = document.createElement('div');
|
||
body.className = 'modal-list';
|
||
data.playlists.forEach((pl) => {
|
||
const btn = document.createElement('button');
|
||
btn.textContent = '+ ' + pl.name;
|
||
btn.onclick = () => {
|
||
videos.forEach((v) => { if (!pl.videos.some((x) => x.id === v.id)) { pl.videos.push(slim(v)); preload(v, { quiet: true }); } });
|
||
persist(); closeModal(); exitSelectMode();
|
||
toast(`Added ${videos.length} to ${pl.name}`);
|
||
};
|
||
body.appendChild(btn);
|
||
});
|
||
const nb = document.createElement('button');
|
||
nb.textContent = '+ New playlist…';
|
||
nb.onclick = () => { closeModal(); newPlaylistWithVideos(videos); exitSelectMode(); };
|
||
body.appendChild(nb);
|
||
showModal('Add to playlist', body, [{ label: 'Close', onClick: closeModal }]);
|
||
});
|
||
|
||
// Now-playing: Save (preload) + Add to playlist
|
||
els.saveBtn.addEventListener('click', async () => {
|
||
if (!current || !current.meta) return;
|
||
const id = current.meta.id;
|
||
// Ignore clicks while either direction is already in flight for this video.
|
||
if (downloading.has(id) || removing.has(id)) return;
|
||
if (cachedIds.has(id)) {
|
||
// Already saved → remove from cache. Guarded so a rapid double-click
|
||
// can't fire two deletes; the button shows a busy state meanwhile.
|
||
await runExclusive(removing, id, async () => {
|
||
updateNowPlayingActions();
|
||
try {
|
||
const res = await API.cacheDelete(id);
|
||
if (res && res.ok === false) throw new Error(res.error || 'delete failed');
|
||
cachedIds.delete(id);
|
||
markCardCacheState(id, 'none');
|
||
toast('Removed from offline cache');
|
||
} catch {
|
||
// Non-blocking error; leave it marked cached and re-enable the button.
|
||
toast('⚠ Could not remove from cache');
|
||
}
|
||
});
|
||
updateNowPlayingActions();
|
||
} else {
|
||
await preload(current.meta);
|
||
}
|
||
});
|
||
if ($('moreBtn')) $('moreBtn').addEventListener('click', openMoreActions);
|
||
if ($('addBtn')) $('addBtn').addEventListener('click', openAddChoice);
|
||
if ($('serviceBtn')) $('serviceBtn').addEventListener('click', () => enterServiceMode());
|
||
if ($('ctlMoreBtn')) $('ctlMoreBtn').addEventListener('click', openMoreControls);
|
||
if ($('exportBtn')) $('exportBtn').addEventListener('click', () => { if (current && current.meta) exportToDevice(current.meta); });
|
||
els.addPlaylistBtn.addEventListener('click', () => {
|
||
if (current && current.meta) openCardMenu(current.meta);
|
||
});
|
||
if (els.editBtn) els.editBtn.addEventListener('click', () => {
|
||
if (current && current.meta) openVideoEditor(current.meta);
|
||
});
|
||
if (els.brokenBtn) els.brokenBtn.addEventListener('click', () => {
|
||
if (!current || !current.meta || current.meta.custom) return;
|
||
const video = { ...current.meta };
|
||
const body = document.createElement('p');
|
||
body.textContent = `Re-download “${video.title || video.id}” on the server? Its saved copy is deleted `
|
||
+ 'and fetched again from YouTube; playback continues from YouTube meanwhile'
|
||
+ (cachedIds.has(video.id) ? ', and this device’s offline copy is replaced with the fresh one.' : '.');
|
||
showModal('Video broken?', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Re-download', primary: true, onClick: () => { closeModal(); reportBroken(video); } },
|
||
]);
|
||
});
|
||
els.queueBtn.addEventListener('click', () => {
|
||
if (current && current.meta) addToQueue(current.meta);
|
||
});
|
||
// Now-playing channel name → channel view
|
||
els.npChannel.addEventListener('click', () => {
|
||
if (current && current.meta && channelKeyOf(current.meta)) {
|
||
openChannel(channelKeyOf(current.meta), current.meta.channel);
|
||
}
|
||
});
|
||
$('miniChannel').addEventListener('click', () => {
|
||
if (current && current.meta && channelKeyOf(current.meta)) {
|
||
openChannel(channelKeyOf(current.meta), current.meta.channel);
|
||
}
|
||
});
|
||
|
||
// Controls
|
||
els.playBtn.addEventListener('click', () => Player.toggle());
|
||
els.nextBtn.addEventListener('click', playNext);
|
||
els.prevBtn.addEventListener('click', playPrev);
|
||
els.shuffleBtn.addEventListener('click', () => toggleShuffle());
|
||
if ($('modeBtn')) $('modeBtn').addEventListener('click', cyclePlayMode);
|
||
els.loopBtn.addEventListener('click', toggleLoopOne);
|
||
els.repeatBtn.addEventListener('click', toggleRepeat);
|
||
if ($('fsMini')) $('fsMini').addEventListener('click', () => els.fsBtn.click());
|
||
els.fsBtn.addEventListener('click', () => {
|
||
const stage = els.video.parentElement;
|
||
const video = els.video;
|
||
// iOS Safari — including an installed PWA running in standalone mode —
|
||
// does not implement the standard Fullscreen API for arbitrary elements.
|
||
// stage.requestFullscreen is simply undefined there, so the button did
|
||
// nothing (this is the iPhone-in-portrait-PWA bug report). WebKit instead
|
||
// exposes a video-only, non-standard fullscreen API that *does* work in
|
||
// standalone mode: HTMLVideoElement.webkitEnterFullscreen/ExitFullscreen.
|
||
// Try the standard API first everywhere else, then fall back to the
|
||
// WebKit video API before giving up.
|
||
if (document.fullscreenElement || video.webkitDisplayingFullscreen) {
|
||
try {
|
||
if (document.exitFullscreen) document.exitFullscreen()?.catch(() => {});
|
||
else if (video.webkitExitFullscreen) video.webkitExitFullscreen();
|
||
} catch { /* e.g. InvalidStateError — nothing more we can do */ }
|
||
return;
|
||
}
|
||
if (stage.requestFullscreen) {
|
||
// requestFullscreen() can both throw synchronously (e.g.
|
||
// InvalidStateError when preconditions like active user-gesture
|
||
// transient activation aren't met) and return a promise that rejects
|
||
// asynchronously. Guard against both instead of letting either surface
|
||
// as an uncaught error.
|
||
try {
|
||
stage.requestFullscreen()?.catch(() => {});
|
||
} catch { /* no-op — fullscreen simply won't engage this time */ }
|
||
} else if (video.webkitEnterFullscreen) {
|
||
if (Player.mode === 'audio') {
|
||
toast('Fullscreen isn’t available in audio-only mode');
|
||
return;
|
||
}
|
||
try {
|
||
// Throws InvalidStateError if the element has no loaded media (e.g.
|
||
// no video source yet) — nothing to show fullscreen in that case.
|
||
video.webkitEnterFullscreen();
|
||
} catch { /* no-op — no media loaded to go fullscreen with */ }
|
||
} else {
|
||
toast('Fullscreen isn’t supported on this device');
|
||
}
|
||
});
|
||
|
||
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();
|
||
syncMuteIcon();
|
||
persist();
|
||
});
|
||
els.muteBtn.addEventListener('click', () => {
|
||
if (data.settings.volume > 0) { lastVolume = data.settings.volume; data.settings.volume = 0; }
|
||
else { data.settings.volume = lastVolume > 0 ? lastVolume : 1; }
|
||
els.volume.value = String(data.settings.volume);
|
||
Player.applyVolume();
|
||
syncMuteIcon();
|
||
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();
|
||
Wave.refresh();
|
||
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' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
||
// Tap-sync owns Enter/Backspace while it runs (see lyrics tap-sync).
|
||
if (Notes.handleSyncKey(e)) 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();
|
||
else if (e.key === 'l') toggleLoopOne();
|
||
else if (e.key === 'r') toggleRepeat();
|
||
else if (e.key === 's') toggleShuffle();
|
||
else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); }
|
||
else if (e.key === 'a') { if (current) setAbA(); }
|
||
else if (e.key === 'b') { if (current) setAbB(); }
|
||
else if (e.key === 'p') { if (current) Notes.addBookmark(); }
|
||
else if (e.key === 'n') { if (current) Notes.addNote(); }
|
||
else if (e.key === 'i') PiP.toggle();
|
||
else if (e.key === 'e') EQ.open();
|
||
else if (e.key === '?') toggleShortcutHelp();
|
||
else if (e.key === 'Escape') {
|
||
if (isServiceModeOpen()) exitServiceMode();
|
||
else {
|
||
const app = document.querySelector('.app');
|
||
if (app && app.classList.contains('sidebar-open')) closeSidebar();
|
||
else if (!$('shortcutHelp').classList.contains('hidden')) $('shortcutHelp').classList.add('hidden');
|
||
else if (selectMode) { exitSelectMode(); renderList(); }
|
||
}
|
||
}
|
||
});
|
||
|
||
$('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
|
||
|
||
// Service mode controls
|
||
$('servicePlayBtn').addEventListener('click', () => Player.toggle());
|
||
$('serviceNextBtn').addEventListener('click', playNext);
|
||
$('servicePrevBtn').addEventListener('click', playPrev);
|
||
$('serviceExitBtn').addEventListener('click', exitServiceMode);
|
||
$('serviceVideoBtn').addEventListener('click', toggleServiceVideo);
|
||
|
||
// Mini now-playing bar
|
||
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
|
||
$('miniBar').addEventListener('click', () => {
|
||
hideMiniBar();
|
||
// Scroll the player into view if needed. In portrait PWA, scroll only the
|
||
// .body pane (the designated scroll container) — scrollIntoView() also
|
||
// scrolls overflow:hidden ancestors up to <html> on iOS, leaving the
|
||
// document offset and fixed-element hit testing broken.
|
||
if (isPortraitPWA()) scrollPlayerIntoViewPortrait();
|
||
else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
});
|
||
// Opens the set-list view for whatever is already playing. No playlist arg,
|
||
// so enterServiceMode leaves the live queue and position untouched.
|
||
$('miniServiceBtn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
enterServiceMode();
|
||
});
|
||
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); });
|
||
|
||
// Drag-to-reorder: prevent default on cards container
|
||
els.cards.addEventListener('dragover', (e) => {
|
||
if (view.type === 'playlist' || view.type === 'queue') e.preventDefault();
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Sleep timer
|
||
// ============================================================================
|
||
// Options live in data.settings: sleepFade (seconds, 0 = none) and
|
||
// sleepAction ('pause' | 'close' | 'screen'). "End of this song" pauses at the
|
||
// track's end instead of advancing (Transition and onTrackEnded honour it).
|
||
let sleepAtTrackEnd = false;
|
||
let sleepFading = false;
|
||
|
||
function sleepFadeSec() {
|
||
const f = Number(data.settings.sleepFade);
|
||
return Number.isFinite(f) ? Math.max(0, Math.min(120, f)) : 30;
|
||
}
|
||
|
||
// Fade the sound down over the last `sleepFadeSec()` seconds.
|
||
function applySleepFade(remaining) {
|
||
const fade = sleepFadeSec();
|
||
if (!fade || remaining > fade) {
|
||
if (sleepFading) { sleepFading = false; Player.applyVolume(); }
|
||
return;
|
||
}
|
||
sleepFading = true;
|
||
try { Player.soundEl.volume = (data.settings.volume ?? 1) * Math.max(0, remaining / fade); } catch { /* iOS: volume is read-only */ }
|
||
}
|
||
|
||
function startSleepTimer(minutes, { endOfSong = false } = {}) {
|
||
cancelSleepTimer();
|
||
if (endOfSong) {
|
||
sleepAtTrackEnd = true;
|
||
updateSleepTimerUI();
|
||
return;
|
||
}
|
||
if (!minutes) return;
|
||
sleepTimerRemaining = minutes * 60;
|
||
updateSleepTimerUI();
|
||
sleepTimerTick = setInterval(() => {
|
||
sleepTimerRemaining--;
|
||
if (sleepTimerRemaining <= 0) {
|
||
finishSleep();
|
||
} else {
|
||
applySleepFade(sleepTimerRemaining);
|
||
updateSleepTimerUI();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function cancelSleepTimer() {
|
||
if (sleepTimerTick) clearInterval(sleepTimerTick);
|
||
sleepTimerTick = null;
|
||
sleepTimerRemaining = 0;
|
||
sleepAtTrackEnd = false;
|
||
if (sleepFading) { sleepFading = false; Player.applyVolume(); }
|
||
updateSleepTimerUI();
|
||
}
|
||
|
||
// Time's up: pause (volume restored for next time), then the chosen action.
|
||
function finishSleep() {
|
||
cancelSleepTimer();
|
||
Player.pause();
|
||
Player.applyVolume();
|
||
const action = data.settings.sleepAction || 'pause';
|
||
if (action === 'close') {
|
||
toast('Sleep timer — paused, closing…');
|
||
try { window.close(); } catch { /* not allowed */ }
|
||
// Browsers only let scripts close windows they opened; if we're still
|
||
// here, fall back to the black screen.
|
||
setTimeout(() => showSleepCurtain('Sleep timer ended — this browser won’t let the app close itself · tap to wake'), 500);
|
||
} else if (action === 'screen') {
|
||
showSleepCurtain('Sleep timer ended · tap to wake');
|
||
} else {
|
||
toast('Sleep timer — paused');
|
||
}
|
||
}
|
||
|
||
// A black curtain; releasing the wake lock and leaving fullscreen lets the
|
||
// device's own screen timeout switch the display off.
|
||
function showSleepCurtain(msg) {
|
||
if (isServiceModeOpen()) exitServiceMode();
|
||
try { releaseWakeLock(); } catch { /* none held */ }
|
||
try { if (document.fullscreenElement) document.exitFullscreen(); } catch { /* fine */ }
|
||
const el = $('sleepCurtain');
|
||
$('sleepCurtainMsg').textContent = msg;
|
||
el.classList.remove('hidden', 'dim');
|
||
setTimeout(() => el.classList.add('dim'), 5000);
|
||
}
|
||
|
||
function updateSleepTimerUI() {
|
||
const active = sleepTimerRemaining > 0 || sleepAtTrackEnd;
|
||
$('sleepStatus').classList.toggle('hidden', !active);
|
||
if (sleepAtTrackEnd) {
|
||
$('sleepCountdown').textContent = '⏱ Pausing after this song';
|
||
} else if (active) {
|
||
const m = Math.floor(sleepTimerRemaining / 60);
|
||
const s = sleepTimerRemaining % 60;
|
||
$('sleepCountdown').textContent = `⏱ Pausing in ${m}:${String(s).padStart(2, '0')}${sleepFading ? ' · fading' : ''}`;
|
||
}
|
||
const btn = $('sleepTimerBtn');
|
||
if (btn) btn.classList.toggle('sleep-active', active);
|
||
}
|
||
|
||
function openSleepTimer() {
|
||
const body = document.createElement('div');
|
||
body.className = 'sleep-panel';
|
||
const fade = sleepFadeSec();
|
||
const action = data.settings.sleepAction || 'pause';
|
||
body.innerHTML = `
|
||
<div class="sp-label">Pause after</div>
|
||
<div class="sp-chips">
|
||
${[5, 15, 30, 45, 60, 90].map((m) => `<button type="button" class="sp-chip" data-min="${m}">${m} min</button>`).join('')}
|
||
<button type="button" class="sp-chip" data-end="1">End of this song</button>
|
||
</div>
|
||
<div class="sp-label">Fade out</div>
|
||
<div class="sp-chips sp-fade">
|
||
${[0, 10, 30, 60].map((f) => `<button type="button" class="sp-chip${f === fade ? ' on' : ''}" data-fade="${f}">${f ? f + ' s' : 'No fade'}</button>`).join('')}
|
||
</div>
|
||
<div class="sp-label">Then</div>
|
||
<div class="sp-chips sp-action">
|
||
<button type="button" class="sp-chip${action === 'pause' ? ' on' : ''}" data-action="pause">Just pause</button>
|
||
<button type="button" class="sp-chip${action === 'close' ? ' on' : ''}" data-action="close">Close the app</button>
|
||
<button type="button" class="sp-chip${action === 'screen' ? ' on' : ''}" data-action="screen">Turn the screen off</button>
|
||
</div>
|
||
<p class="sp-note">“Turn the screen off” blacks out the app and lets your device's screen timeout take over. Browsers only allow “Close the app” for windows they opened, so it falls back to the black screen. iPhones can't fade — they just pause.</p>`;
|
||
body.addEventListener('click', (e) => {
|
||
const b = e.target.closest('.sp-chip');
|
||
if (!b) return;
|
||
if (b.dataset.fade !== undefined) {
|
||
data.settings.sleepFade = Number(b.dataset.fade); persist();
|
||
body.querySelectorAll('.sp-fade .sp-chip').forEach((x) => x.classList.toggle('on', x === b));
|
||
} else if (b.dataset.action) {
|
||
data.settings.sleepAction = b.dataset.action; persist();
|
||
body.querySelectorAll('.sp-action .sp-chip').forEach((x) => x.classList.toggle('on', x === b));
|
||
} else if (b.dataset.end) {
|
||
startSleepTimer(0, { endOfSong: true });
|
||
closeModal();
|
||
toast('Pausing after this song');
|
||
} else if (b.dataset.min) {
|
||
startSleepTimer(Number(b.dataset.min));
|
||
closeModal();
|
||
toast(`Pausing in ${b.dataset.min} min${sleepFadeSec() ? ` (fading over the last ${sleepFadeSec()} s)` : ''}`);
|
||
}
|
||
});
|
||
const running = sleepTimerRemaining > 0 || sleepAtTrackEnd;
|
||
showModal('⏱ Sleep timer', body, [
|
||
...(running ? [{ label: 'Turn off', onClick: () => { cancelSleepTimer(); closeModal(); toast('Sleep timer off'); } }] : []),
|
||
{ label: 'Close', primary: true, onClick: closeModal },
|
||
]);
|
||
}
|
||
|
||
// ============================================================================
|
||
// A-B loop markers
|
||
// ============================================================================
|
||
function setAbA() {
|
||
if (!current) return;
|
||
abA = Player.master.currentTime;
|
||
saveAbMarkers();
|
||
updateAbUI();
|
||
toast(`A set at ${fmtTime(abA)}`);
|
||
}
|
||
function setAbB() {
|
||
if (!current) return;
|
||
abB = Player.master.currentTime;
|
||
saveAbMarkers();
|
||
updateAbUI();
|
||
toast(`B set at ${fmtTime(abB)}`);
|
||
}
|
||
function clearAb() {
|
||
abA = null; abB = null;
|
||
if (current && current.meta) {
|
||
delete data.abMarkers[current.meta.id];
|
||
const entry = currentPlaylistEntry();
|
||
if (entry) delete entry.ab;
|
||
persist();
|
||
}
|
||
updateAbUI();
|
||
toast('A-B loop cleared');
|
||
}
|
||
function saveAbMarkers() {
|
||
if (!current || !current.meta) return;
|
||
if (abA !== null || abB !== null) {
|
||
const marker = { a: abA, b: abB };
|
||
// When playing from a playlist, store the marker on that playlist's own
|
||
// copy of the video so each playlist keeps its own A-B loop and it syncs
|
||
// to the database alongside the playlist. Otherwise fall back to the
|
||
// global per-video map.
|
||
const entry = currentPlaylistEntry();
|
||
if (entry) entry.ab = marker;
|
||
else data.abMarkers[current.meta.id] = marker;
|
||
persist();
|
||
}
|
||
}
|
||
function restoreAbMarkers() {
|
||
if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; }
|
||
// Prefer a marker stored on the current playlist's copy of the video; fall
|
||
// back to the global per-video marker.
|
||
const entry = currentPlaylistEntry();
|
||
const saved = (entry && entry.ab) || data.abMarkers[current.meta.id];
|
||
abA = saved ? saved.a : null;
|
||
abB = saved ? saved.b : null;
|
||
updateAbUI();
|
||
}
|
||
// Returns the video entry inside the playlist currently being played, if the
|
||
// active playback source is a playlist and the playing video belongs to it.
|
||
function currentPlaylistEntry() {
|
||
if (!current || !current.meta) return null;
|
||
if (!queueSource || !queueSource.startsWith('playlist:')) return null;
|
||
const plId = queueSource.slice('playlist:'.length);
|
||
const pl = data.playlists.find((p) => p.id === plId);
|
||
if (!pl || !Array.isArray(pl.videos)) return null;
|
||
return pl.videos.find((v) => v.id === current.meta.id) || null;
|
||
}
|
||
// ---------- Remember-position flag ----------
|
||
// Like A-B markers, the flag is per-song-per-playlist: stored on the playlist's
|
||
// own copy of the video (`entry.rememberPos`) when playing from that playlist,
|
||
// with `data.rememberPos[videoId]` as the fallback for non-playlist playback.
|
||
// Resume positions are only saved and restored for songs with the flag on.
|
||
function rememberPosEnabled() {
|
||
if (!current || !current.meta) return false;
|
||
const entry = currentPlaylistEntry();
|
||
return entry ? !!entry.rememberPos : !!data.rememberPos[current.meta.id];
|
||
}
|
||
function toggleRememberPos() {
|
||
if (!current || !current.meta) return;
|
||
const id = current.meta.id;
|
||
const on = !rememberPosEnabled();
|
||
const entry = currentPlaylistEntry();
|
||
if (entry) {
|
||
if (on) entry.rememberPos = true; else delete entry.rememberPos;
|
||
} else {
|
||
if (on) data.rememberPos[id] = true; else delete data.rememberPos[id];
|
||
}
|
||
persist();
|
||
updateRememberPosUI();
|
||
toast(on ? 'Remembering playback position' : 'Not remembering position');
|
||
}
|
||
function updateRememberPosUI() {
|
||
const btn = $('rememberPosBtn');
|
||
if (btn) btn.classList.toggle('active', rememberPosEnabled());
|
||
}
|
||
|
||
function updateAbUI() {
|
||
const aSet = abA !== null, bSet = abB !== null;
|
||
const aBtn = $('abABtn'), bBtn = $('abBBtn'), clrBtn = $('abClearBtn');
|
||
const ind = $('abIndicator');
|
||
if (aBtn) aBtn.classList.toggle('active', aSet);
|
||
if (bBtn) bBtn.classList.toggle('active', bSet);
|
||
if (clrBtn) clrBtn.classList.toggle('hidden', !(aSet && bSet));
|
||
if (ind) ind.classList.toggle('hidden', !aSet && !bSet);
|
||
const q = document.querySelector('.dk-quick');
|
||
if (q) q.classList.toggle('ab-on', aSet || bSet);
|
||
const aLbl = $('abALabel'), bLbl = $('abBLabel');
|
||
if (aLbl) { aLbl.textContent = 'A: ' + (aSet ? fmtTime(abA) : '--'); aLbl.classList.toggle('active', aSet); }
|
||
if (bLbl) { bLbl.textContent = 'B: ' + (bSet ? fmtTime(abB) : '--'); bLbl.classList.toggle('active', bSet); }
|
||
}
|
||
|
||
// ============================================================================
|
||
// Related videos
|
||
// ============================================================================
|
||
async function loadRelated() {
|
||
if (!current || !current.meta) return;
|
||
$('relatedPanel').classList.add('hidden');
|
||
relatedVideos = [];
|
||
try {
|
||
const res = await API.search(current.meta.title);
|
||
if (!res || !res.ok || !current) return;
|
||
relatedVideos = (res.results || []).filter((v) => v.id !== current.meta.id).slice(0, 8);
|
||
renderRelated();
|
||
} catch { /* ignore */ }
|
||
}
|
||
function renderRelated() {
|
||
const panel = $('relatedPanel'), list = $('relatedList');
|
||
if (!panel || !list || !relatedVideos.length) { if (panel) panel.classList.add('hidden'); return; }
|
||
panel.classList.remove('hidden');
|
||
list.classList.toggle('hidden', relatedCollapsed);
|
||
list.innerHTML = '';
|
||
relatedVideos.forEach((v) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'related-item';
|
||
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
|
||
item.querySelector('.ri-title').textContent = v.title;
|
||
const riChEl = item.querySelector('.ri-channel');
|
||
riChEl.textContent = v.channel || '';
|
||
if (channelKeyOf(v)) {
|
||
riChEl.classList.add('link');
|
||
riChEl.title = 'View channel';
|
||
riChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
|
||
}
|
||
item.addEventListener('click', (e) => {
|
||
if (e.target.closest('.ri-channel.link')) return;
|
||
queue = [v]; queueIndex = 0; playFullMode = false; Player.loadVideo(v); renderUpNext();
|
||
});
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Smart / auto playlists
|
||
// ============================================================================
|
||
const SMART_PLAYLISTS = [
|
||
{ id: 'mostPlayed', label: 'Most Played' },
|
||
{ id: 'recentlyWatched', label: 'Recently Watched' },
|
||
{ id: 'unwatched', label: 'Unwatched' },
|
||
{ id: 'autoMix', label: 'Auto Mix' },
|
||
];
|
||
function renderSmartSidebar() {
|
||
const list = $('smartPlaylistList');
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
SMART_PLAYLISTS.forEach((sp) => {
|
||
const item = document.createElement('div');
|
||
const isActive = view.type === 'smart' && view.smartType === sp.id;
|
||
item.className = 'playlist-item smart-item' + (isActive ? ' active' : '');
|
||
item.innerHTML = `<span class="pl-name">${sp.label}</span>`;
|
||
item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); });
|
||
list.appendChild(item);
|
||
});
|
||
if (WEB && data.settings.p2pReceive !== false) {
|
||
const item = document.createElement('div');
|
||
item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : '');
|
||
item.innerHTML = '<span class="pl-name">📡 On other devices</span>';
|
||
item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
|
||
list.appendChild(item);
|
||
}
|
||
}
|
||
function getSmartList(smartType) {
|
||
switch (smartType) {
|
||
case 'mostPlayed': {
|
||
const counts = data.playCount || {};
|
||
return data.history.slice().sort((a, b) => (counts[b.id] || 0) - (counts[a.id] || 0)).slice(0, 50);
|
||
}
|
||
case 'recentlyWatched':
|
||
return data.history.slice(0, 50);
|
||
case 'unwatched': {
|
||
const watched = new Set(data.history.map((v) => v.id));
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const pl of data.playlists)
|
||
for (const v of pl.videos)
|
||
if (!watched.has(v.id) && !seen.has(v.id)) { seen.add(v.id); out.push(v); }
|
||
return out;
|
||
}
|
||
case 'autoMix': {
|
||
const pool = data.history.slice(0, 100);
|
||
for (let i = pool.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||
}
|
||
return pool.slice(0, 30);
|
||
}
|
||
default: return [];
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Batch operations
|
||
// ============================================================================
|
||
function exitSelectMode() {
|
||
selectMode = false;
|
||
selectedIds.clear();
|
||
const c = $('cards');
|
||
if (c) c.classList.remove('select-mode');
|
||
$('batchBar').classList.add('hidden');
|
||
}
|
||
function toggleSelectMode() {
|
||
selectMode = !selectMode;
|
||
selectedIds.clear();
|
||
const c = $('cards');
|
||
if (c) c.classList.toggle('select-mode', selectMode);
|
||
$('batchBar').classList.toggle('hidden', !selectMode);
|
||
$('batchCount').textContent = '0 selected';
|
||
renderList();
|
||
}
|
||
function toggleSelectCard(id) {
|
||
if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
|
||
$('batchCount').textContent = `${selectedIds.size} selected`;
|
||
document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => c.classList.toggle('selected', selectedIds.has(id)));
|
||
}
|
||
function newPlaylistWithVideos(videos) {
|
||
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: videos.map(slim) };
|
||
data.playlists.push(pl);
|
||
videos.forEach((v) => preload(v, { quiet: true }));
|
||
persist();
|
||
closeModal();
|
||
view = { type: 'playlist', id: pl.id };
|
||
render();
|
||
toast(`Created "${name}" with ${videos.length} videos`);
|
||
}},
|
||
]);
|
||
setTimeout(() => input.focus(), 50);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Auto-backup
|
||
// ============================================================================
|
||
function doAutoBackup(silent = false) {
|
||
const json = JSON.stringify({ ...data, _version: 1 }, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `ytplayer-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
data.lastAutoBackup = Date.now();
|
||
persist();
|
||
if (!silent) toast('Backup exported');
|
||
}
|
||
function checkAutoBackup() {
|
||
if (!data.settings.autoBackupEnabled) return;
|
||
const interval = (data.settings.autoBackupIntervalDays || 7) * 86400000;
|
||
if (Date.now() - (data.lastAutoBackup || 0) >= interval) doAutoBackup(true);
|
||
}
|
||
function importBackup(e) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
try {
|
||
const imp = JSON.parse(ev.target.result);
|
||
if (!imp.playlists || !Array.isArray(imp.playlists)) throw new Error('Invalid backup format');
|
||
showModal('Import backup?', document.createTextNode('Merges playlists, history, and resume positions. Existing data is preserved.'), [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
{ label: 'Import', primary: true, onClick: () => {
|
||
const existingPl = new Set(data.playlists.map((p) => p.id));
|
||
for (const pl of (imp.playlists || [])) if (pl.id && !existingPl.has(pl.id)) { data.playlists.push(pl); existingPl.add(pl.id); }
|
||
const histIds = new Set(data.history.map((v) => v.id));
|
||
for (const v of (imp.history || [])) if (!histIds.has(v.id)) { data.history.push(v); histIds.add(v.id); }
|
||
Object.assign(data.resumePositions, imp.resumePositions || {});
|
||
Object.assign(data.rememberPos, imp.rememberPos || {});
|
||
for (const [id, cnt] of Object.entries(imp.playCount || {})) data.playCount[id] = Math.max(data.playCount[id] || 0, cnt);
|
||
Object.assign(data.abMarkers, imp.abMarkers || {});
|
||
Object.assign(data.bookmarks, imp.bookmarks || {});
|
||
Object.assign(data.lyricOffsets, imp.lyricOffsets || {});
|
||
if (!data.stats && imp.stats) data.stats = imp.stats;
|
||
persist();
|
||
closeModal();
|
||
render();
|
||
toast('Backup imported');
|
||
}},
|
||
]);
|
||
} catch (err) { toast('⚠ Invalid backup: ' + err.message); }
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = '';
|
||
}
|
||
|
||
// ============================================================================
|
||
// Build-tag polling — detect server restarts / new deploys (WEB mode only)
|
||
//
|
||
// The server stamps every response from GET /api/version with a `buildTag`
|
||
// that is stable for the lifetime of the process (changes on restart/deploy).
|
||
// We baseline the tag on first fetch; any subsequent change means new code is
|
||
// live and we surface the existing update banner so the user can reload.
|
||
// ============================================================================
|
||
|
||
// The build this page is actually running: the server stamps it into
|
||
// index.html (<meta name="ytp-build">), and the service worker caches that
|
||
// index.html together with the rest of the shell, so it always describes the
|
||
// code in this tab. Null when served without the server (static test serve).
|
||
const RUNNING_BUILD = (() => {
|
||
const v = document.querySelector('meta[name="ytp-build"]')?.content || '';
|
||
return v && !v.startsWith('__') ? v : null;
|
||
})();
|
||
|
||
// Fallback baseline for pages without the meta tag: the first tag seen.
|
||
let _knownBuildTag = null;
|
||
|
||
async function fetchServerBuild() {
|
||
const res = await fetch('/api/version', { cache: 'no-store' });
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
return data.buildTag || null;
|
||
}
|
||
|
||
// The ONLY way the update banner opens: the server runs a different build
|
||
// than this page. Every trigger (the poll, a waiting worker, the activate
|
||
// broadcast) funnels through here, so none of them can re-open the banner
|
||
// once the page is current — which is what kept happening before.
|
||
async function maybeShowUpdateBanner() {
|
||
if (_updateBannerShown || _updating) return;
|
||
let server = null;
|
||
try { server = await fetchServerBuild(); } catch { return; /* offline — next poll */ }
|
||
if (!server) return;
|
||
const running = RUNNING_BUILD || _knownBuildTag;
|
||
if (!running) { _knownBuildTag = server; return; }
|
||
if (server !== running) {
|
||
// Start fetching the new build NOW, in the background: registration.update()
|
||
// installs the new worker, whose install precaches the whole shell. By the
|
||
// time the user presses "Refresh UI" it is usually already on disk, and the
|
||
// swap is instant. Purely an optimisation — if it never finishes, applyUpdate
|
||
// falls back to downloading.
|
||
prefetchUpdate();
|
||
showUpdateBanner(server);
|
||
}
|
||
}
|
||
|
||
// Kicks the service worker into installing the new build in the background.
|
||
// Safe to call repeatedly: update() is a no-op when a worker is already
|
||
// installing or waiting.
|
||
let _prefetchedFor = null;
|
||
function prefetchUpdate() {
|
||
if (!('serviceWorker' in navigator)) return;
|
||
const tag = _knownBuildTag || 'new';
|
||
if (_prefetchedFor === tag) return;
|
||
_prefetchedFor = tag;
|
||
(async () => {
|
||
try {
|
||
const reg = _swReg || await navigator.serviceWorker.getRegistration();
|
||
if (reg) await reg.update();
|
||
} catch { /* offline or blocked — "Refresh UI" still downloads */ }
|
||
})();
|
||
}
|
||
|
||
function checkBuildTag() {
|
||
return maybeShowUpdateBanner();
|
||
}
|
||
|
||
function pollBuildTag() {
|
||
checkBuildTag();
|
||
setInterval(checkBuildTag, 5 * 60 * 1000); // recheck every 5 minutes
|
||
}
|
||
|
||
// ============================================================================
|
||
// PWA — service worker registration and update banner (WEB mode only)
|
||
// ============================================================================
|
||
|
||
// Hard-reload helper: wipes every SW cache and unregisters the SW so the
|
||
// next page load fetches fresh assets from the network, not stale cache.
|
||
// Called from both the update dialog and the Settings → Force refresh UI button.
|
||
async function hardReloadUI() {
|
||
try {
|
||
// 1. Delete every named cache (app shell, thumbs, fonts, etc.)
|
||
if ('caches' in window) {
|
||
const keys = await caches.keys();
|
||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||
}
|
||
// 2. Unregister every service worker registration so the browser
|
||
// fetches sw.js fresh on the next load rather than serving a
|
||
// cached copy that could re-populate the caches.
|
||
if ('serviceWorker' in navigator) {
|
||
const regs = await navigator.serviceWorker.getRegistrations();
|
||
await Promise.all(regs.map((r) => r.unregister()));
|
||
}
|
||
} catch { /* best effort — reload regardless */ }
|
||
// Hard navigation to the root forces the browser to fetch index.html from
|
||
// the network (no SW is registered any more to intercept it).
|
||
window.location.href = window.location.origin + '/';
|
||
}
|
||
|
||
let _updateBannerShown = false;
|
||
let _updating = false;
|
||
let _updateToast = null;
|
||
function showUpdateBanner(target) {
|
||
// Only show the dialog once per page load
|
||
if (_updateBannerShown) return;
|
||
_updateBannerShown = true;
|
||
|
||
// Show a modal dialog instead of a fleeting toast so the user can't miss it.
|
||
const body = document.createElement('p');
|
||
body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.';
|
||
showModal('⬆ Update available', body, [
|
||
{ label: 'Later', onClick: closeModal },
|
||
{ label: 'Refresh UI', primary: true, onClick: async () => {
|
||
closeModal();
|
||
await applyUpdate(target);
|
||
}},
|
||
]);
|
||
}
|
||
|
||
// Tracks the current SW registration so applyUpdate() can reach the
|
||
// waiting worker without re-querying getRegistration().
|
||
let _swReg = null;
|
||
|
||
// "Refresh UI": download the new shell into the SW caches (all-or-nothing),
|
||
// activate a waiting worker if any, reload once — see sw-update.js. The
|
||
// attempt is recorded in sessionStorage so the reloaded page can confirm it
|
||
// is really running `target` (checkUpdateOutcome) instead of looping.
|
||
const UPDATE_STATE_KEY = 'ytpUpdateAttempt';
|
||
const UPDATE_MAX_ATTEMPTS = 3;
|
||
const SHELL_CACHE_RE = /^ytplayer-(?!thumbs$|fonts$)/;
|
||
|
||
function readUpdateState() {
|
||
try { return JSON.parse(sessionStorage.getItem(UPDATE_STATE_KEY) || 'null'); } catch { return null; }
|
||
}
|
||
function writeUpdateState(st) {
|
||
try {
|
||
if (st) sessionStorage.setItem(UPDATE_STATE_KEY, JSON.stringify(st));
|
||
else sessionStorage.removeItem(UPDATE_STATE_KEY);
|
||
} catch { /* storage blocked — the update still applies, just unverified */ }
|
||
}
|
||
|
||
async function applyUpdate(target, { quiet = false } = {}) {
|
||
if (_updating) return;
|
||
_updating = true;
|
||
const prev = readUpdateState();
|
||
writeUpdateState({ target, attempt: (prev ? prev.attempt : 0) + 1 });
|
||
// The toast is raised by onPhase below, once we know whether this is an
|
||
// instant swap or a download.
|
||
try {
|
||
const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null);
|
||
const SU = window.SwUpdate;
|
||
const reload = () => window.location.reload();
|
||
if (!SU) { reload(); return; } // helper failed to load (flaky link): a reload is the best we can do
|
||
await SU.applyUpdate({
|
||
reg,
|
||
container: navigator.serviceWorker,
|
||
reload,
|
||
// When the waiting worker already downloaded this build in the
|
||
// background, the toast says so and nothing is re-downloaded.
|
||
askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }),
|
||
onPhase: (phase) => {
|
||
if (quiet) return;
|
||
if (_updateToast) _updateToast.remove();
|
||
_updateToast = phase === 'ready'
|
||
? toast('Applying the update…', { duration: 20000 })
|
||
: toast('Downloading the update…', { duration: 125000 });
|
||
},
|
||
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
|
||
cachesApi: caches,
|
||
fetchFn: (url, init) => fetch(url, init),
|
||
isShellCache: (name) => SHELL_CACHE_RE.test(name),
|
||
timeoutMs: 120000,
|
||
attempts: 3,
|
||
attemptTimeoutMs: 25000,
|
||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||
}) : null,
|
||
});
|
||
} catch (err) {
|
||
_updating = false;
|
||
writeUpdateState(null);
|
||
if (_updateToast) { _updateToast.remove(); _updateToast = null; }
|
||
toast('⚠ Update didn’t download (' + (err && err.message ? err.message : 'connection problem') + '). It will be offered again.', { duration: 6000 });
|
||
// Let the next poll re-offer it.
|
||
_updateBannerShown = false;
|
||
}
|
||
}
|
||
|
||
// After an update reload: confirm the page now runs the build it asked for.
|
||
// If a stubborn cache still served the old one, retry (bounded) instead of
|
||
// re-opening the banner in a loop.
|
||
async function checkUpdateOutcome() {
|
||
const st = readUpdateState();
|
||
if (!st) return;
|
||
// Suppress the banner while this is being decided.
|
||
_updateBannerShown = true;
|
||
let server = null;
|
||
try { server = await fetchServerBuild(); } catch { /* offline */ }
|
||
// Current if it runs what was asked for — or whatever is deployed now (a
|
||
// newer deploy may have landed in between).
|
||
if (!RUNNING_BUILD || RUNNING_BUILD === st.target || (server && RUNNING_BUILD === server)) {
|
||
writeUpdateState(null);
|
||
_updateBannerShown = false;
|
||
if (RUNNING_BUILD) toast('Updated ✓');
|
||
return;
|
||
}
|
||
if (st.attempt < UPDATE_MAX_ATTEMPTS) {
|
||
_updateToast = toast('Finishing the update…', { duration: 125000 });
|
||
setTimeout(() => applyUpdate(server || st.target, { quiet: true }), 1500);
|
||
return;
|
||
}
|
||
_updateBannerShown = false;
|
||
writeUpdateState(null);
|
||
toast('⚠ Couldn’t finish updating — the connection looks unstable. It will be offered again later.', { duration: 6000 });
|
||
}
|
||
|
||
async function registerServiceWorker() {
|
||
if (!WEB || !('serviceWorker' in navigator)) return;
|
||
try {
|
||
// updateViaCache:'none' prevents the browser from serving a stale
|
||
// cached copy of sw.js — the server always returns it fresh (no-store).
|
||
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
|
||
_swReg = reg;
|
||
|
||
// A waiting worker only means "update pending" when this page is already
|
||
// controlled by a previous SW. On a FIRST install (fresh visit, or right
|
||
// after Settings → Force refresh unregisters everything) the brand-new
|
||
// worker passes through the `installed`/waiting state for a moment before
|
||
// activating — with no controller that is not an update, and showing the
|
||
// banner for it is exactly the "Update available keeps coming back after
|
||
// Refresh UI" loop.
|
||
const hasController = () => !!navigator.serviceWorker.controller;
|
||
|
||
// If a new SW is already waiting (e.g. user refreshed after an update),
|
||
// show the dialog right away.
|
||
if (reg.waiting && hasController()) maybeShowUpdateBanner();
|
||
|
||
// Listen for a new SW installing after the page is open.
|
||
reg.addEventListener('updatefound', () => {
|
||
const sw = reg.installing;
|
||
if (!sw) return;
|
||
sw.addEventListener('statechange', () => {
|
||
if (sw.state === 'installed' && reg.waiting && hasController()) maybeShowUpdateBanner();
|
||
});
|
||
});
|
||
|
||
// The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate.
|
||
navigator.serviceWorker.addEventListener('message', (e) => {
|
||
if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') maybeShowUpdateBanner();
|
||
});
|
||
|
||
// Check for updates in the background (useful for long-lived sessions)
|
||
reg.update().catch(() => {});
|
||
} catch (err) {
|
||
console.warn('[sw] registration failed:', err.message);
|
||
}
|
||
}
|
||
|
||
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
|
||
// Runs after wireUI() so the search form's submit handler exists; the param
|
||
// is stripped immediately so a reload doesn't re-run the search.
|
||
function bootSearchFromUrl() {
|
||
let query = '';
|
||
try {
|
||
query = (new URLSearchParams(location.search).get('q') || '').trim();
|
||
} catch { return; }
|
||
if (!query) return;
|
||
try {
|
||
const u = new URL(location.href);
|
||
u.searchParams.delete('q');
|
||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||
} catch { /* keep */ }
|
||
els.searchInput.value = query;
|
||
els.searchForm.requestSubmit();
|
||
}
|
||
|
||
// ============================================================================
|
||
// Boot
|
||
// ============================================================================
|
||
async function boot() {
|
||
perfMark('ytp:boot-start');
|
||
wirePlayerEvents();
|
||
wireUI();
|
||
wireShortcutHelp();
|
||
setupPortraitPwaWatcher();
|
||
setupViewportAnchorGuard();
|
||
try {
|
||
const loaded = await API.loadData();
|
||
if (loaded && typeof loaded === 'object') {
|
||
data = {
|
||
playlists: loaded.playlists || [],
|
||
history: loaded.history || [],
|
||
queue: loaded.queue || [],
|
||
customVideos: loaded.customVideos || [],
|
||
pinnedPlaylists: Array.isArray(loaded.pinnedPlaylists) ? loaded.pinnedPlaylists : [],
|
||
resumePositions: loaded.resumePositions || {},
|
||
rememberPos: loaded.rememberPos || {},
|
||
playCount: loaded.playCount || {},
|
||
abMarkers: loaded.abMarkers || {},
|
||
bookmarks: loaded.bookmarks || {},
|
||
lyricOffsets: loaded.lyricOffsets || {},
|
||
stats: loaded.stats || null,
|
||
lastAutoBackup: loaded.lastAutoBackup || 0,
|
||
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
|
||
profile: loaded.profile || null,
|
||
};
|
||
}
|
||
} catch {
|
||
// first run / bridge not ready — start with defaults
|
||
}
|
||
// A ?list=… or ?profile=… share link REPLACES the synced slice, so those two
|
||
// still run before the first paint — otherwise the old playlists would flash
|
||
// and be swapped out. They are no-ops without the URL parameter.
|
||
const hasShareParam = (() => {
|
||
try {
|
||
const q = new URLSearchParams(location.search);
|
||
return q.has('list') || q.has('profile');
|
||
} catch { return false; }
|
||
})();
|
||
if (hasShareParam) {
|
||
await adoptSharedPlaylistFromUrl();
|
||
await adoptProfileFromUrl();
|
||
}
|
||
|
||
// Everything this device already knows is in localStorage, so paint it NOW.
|
||
// On a slow link the profile pull and the playlist inbox used to be awaited
|
||
// first, which left the sidebar empty for as long as the network took.
|
||
renderSmartSidebar();
|
||
render();
|
||
perfMeasure('ytp:boot', 'ytp:boot-start');
|
||
|
||
// …then reconcile with the server in the background, with the spinner up,
|
||
// and re-render only if something actually changed.
|
||
syncOnLaunch();
|
||
startPlaylistInboxWatch();
|
||
Presenter.boot();
|
||
SectionRail.boot();
|
||
Share.bootFromUrl();
|
||
bootSearchFromUrl();
|
||
Party.boot();
|
||
Remote.boot();
|
||
Transition.renderButton();
|
||
PiP.render();
|
||
EQ.renderButton();
|
||
EQ.apply();
|
||
applyAppearance();
|
||
updateProfileStatus();
|
||
updateLoopRepeatButtons();
|
||
syncMuteIcon();
|
||
updateQueueBadge();
|
||
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();
|
||
startCacheResyncWatch();
|
||
// Peer-to-peer: report what this device holds (on by default; settings.p2pShare).
|
||
if (WEB && window.P2PClient) {
|
||
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '' });
|
||
if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => data.settings.p2pShare !== false });
|
||
}
|
||
data.playlists.forEach(preloadPlaylist);
|
||
preloadPinnedPlaylists();
|
||
// Not awaited — artwork backfill must never delay first paint.
|
||
warmOfflineThumbs();
|
||
|
||
checkAutoBackup();
|
||
render();
|
||
els.searchInput.focus();
|
||
|
||
// Register service worker + ping server with fingerprint (WEB mode only)
|
||
if (WEB) checkUpdateOutcome();
|
||
registerServiceWorker();
|
||
if (WEB) pollBuildTag();
|
||
if (WEB) {
|
||
fetch('/api/user/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
|
||
appVersion: APP_VERSION,
|
||
}),
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', boot);
|