From 20cd2f29ba6e7bbc77b814a0b3f93788e0806934 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 21 Jun 2026 18:43:26 +0800 Subject: [PATCH] feat: sleep timer, A-B loop, related panel, smart playlists, history delete/search, batch ops, auto-backup --- frontend/app.js | 480 +++++++++++++++++++++++++++++++++++++++++++- frontend/index.html | 46 ++++- frontend/styles.css | 145 +++++++++++++ 3 files changed, 662 insertions(+), 9 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index aac684c..cd23546 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -60,8 +60,10 @@ const DEFAULT_SETTINGS = { density: 'comfortable', // 'comfortable' | 'compact' perfMode: false, // disable heavy visual effects for speed reduceMotion: false, // disable animations + autoBackupEnabled: false, + autoBackupIntervalDays: 7, }; -let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {} }; +let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0 }; let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let searchResults = []; let channelData = { name: '', url: '', key: '', results: [], loading: false }; @@ -70,6 +72,15 @@ let queueIndex = -1; let queueSource = ''; // label of what's playing ('queue','playlist:',…) 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 @@ -338,6 +349,24 @@ const Player = { updateNowPlayingActions(); markPlayingCard(); showMiniBar(); + // 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))); + } + // Restore A-B markers and load related (non-blocking) + restoreAbMarkers(); + loadRelated(); + exitSelectMode(); // Restore saved playback position const id = current.meta.id; if (data.resumePositions[id] && data.resumePositions[id] > 1) { @@ -636,6 +665,8 @@ function updateProgress() { els.durTime.textContent = fmtTime(dur); if (dur) els.seek.value = String((cur / dur) * 1000); updateMiniBar(); + // A-B loop: if both points are set and we pass B, jump to A + if (abA !== null && abB !== null && abB > abA && cur >= abB) Player.seek(abA); } // ============================================================================ @@ -665,6 +696,8 @@ function hideMiniBar() { // 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, @@ -854,6 +887,7 @@ function currentList() { 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 : []; @@ -883,8 +917,22 @@ function renderList() { if (view.type === 'saved') { renderSaved(); return; } if (view.type === 'downloads') { renderDownloads(); return; } - const list = currentList(); + // 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') { @@ -908,6 +956,18 @@ function renderList() { 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) { @@ -939,6 +999,16 @@ function renderList() { } } + // 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(); @@ -1286,6 +1356,26 @@ async function renderSettings() { + + +
+
Backup & restore
+ + +
+ + + +
`; c.appendChild(wrap); @@ -1384,15 +1474,23 @@ async function renderSettings() { $('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); } 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' : ''); + card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : ''); card.dataset.id = v.id; card.innerHTML = ` +
${v.duration ? `${fmtTime(v.duration)}` : ''} @@ -1417,6 +1515,7 @@ function renderCard(v, index, list) { } card.addEventListener('click', (e) => { if (e.target.closest('.card-menu') || e.target.closest('.card-del') || 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) => { @@ -1424,15 +1523,23 @@ function renderCard(v, index, list) { openCardMenu(v); }); - // Per-item delete in playlist and queue views. - if (view.type === 'playlist' || view.type === 'queue') { + // 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' : 'Remove from playlist'; + 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'); } }); @@ -1489,7 +1596,12 @@ function markPlayingCard() { } function render() { + listFilter = ''; + const fi = $('listFilterInput'); + if (fi) fi.value = ''; + exitSelectMode(); renderSidebar(); + renderSmartSidebar(); renderList(); } @@ -1778,6 +1890,85 @@ document.querySelectorAll('.chip').forEach((c) => { }); }); + // Sleep timer — click cycles through off/15/30/45/60/90 min + $('sleepTimerBtn').addEventListener('click', () => { + const options = [0, 15, 30, 45, 60, 90]; + const curMin = sleepTimerRemaining > 0 ? Math.ceil(sleepTimerRemaining / 60) : 0; + const next = options.find((o) => o > curMin) ?? 0; + if (next === 0) { cancelSleepTimer(); toast('Sleep timer off'); } + else { startSleepTimer(next); toast(`Pausing in ${next} min`); } + }); + $('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); }); + + // A-B markers + $('abABtn').addEventListener('click', setAbA); + $('abBBtn').addEventListener('click', setAbB); + $('abClearBtn').addEventListener('click', clearAb); + + // Related panel collapse toggle + $('relatedToggleBtn').addEventListener('click', () => { + relatedCollapsed = !relatedCollapsed; + $('relatedList').classList.toggle('hidden', relatedCollapsed); + $('relatedToggleBtn').textContent = 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; @@ -1891,8 +2082,13 @@ document.querySelectorAll('.chip').forEach((c) => { else if (e.key === 'l') toggleLoopOne(); else if (e.key === 'r') toggleRepeat(); 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 === '?') toggleShortcutHelp(); - else if (e.key === 'Escape' && !$('shortcutHelp').classList.contains('hidden')) { $('shortcutHelp').classList.add('hidden'); } + else if (e.key === 'Escape') { + if (!$('shortcutHelp').classList.contains('hidden')) $('shortcutHelp').classList.add('hidden'); + else if (selectMode) { exitSelectMode(); renderList(); } + } }); $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); }); @@ -1912,6 +2108,271 @@ document.querySelectorAll('.chip').forEach((c) => { }); } +// ============================================================================ +// Sleep timer +// ============================================================================ +function startSleepTimer(minutes) { + cancelSleepTimer(); + if (!minutes) return; + sleepTimerRemaining = minutes * 60; + updateSleepTimerUI(); + sleepTimerTick = setInterval(() => { + sleepTimerRemaining--; + if (sleepTimerRemaining <= 0) { + cancelSleepTimer(); + Player.pause(); + toast('Sleep timer — paused'); + } else { + updateSleepTimerUI(); + } + }, 1000); +} +function cancelSleepTimer() { + if (sleepTimerTick) clearInterval(sleepTimerTick); + sleepTimerTick = null; + sleepTimerRemaining = 0; + updateSleepTimerUI(); +} +function updateSleepTimerUI() { + const active = sleepTimerRemaining > 0; + $('sleepStatus').classList.toggle('hidden', !active); + if (active) { + const m = Math.floor(sleepTimerRemaining / 60); + const s = sleepTimerRemaining % 60; + $('sleepCountdown').textContent = `⏱ Pausing in ${m}:${String(s).padStart(2, '0')}`; + } + const btn = $('sleepTimerBtn'); + if (btn) btn.classList.toggle('sleep-active', active); +} + +// ============================================================================ +// 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]; persist(); } + updateAbUI(); + toast('A-B loop cleared'); +} +function saveAbMarkers() { + if (!current || !current.meta) return; + if (abA !== null || abB !== null) { + data.abMarkers[current.meta.id] = { a: abA, b: abB }; + persist(); + } +} +function restoreAbMarkers() { + if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; } + const saved = data.abMarkers[current.meta.id]; + abA = saved ? saved.a : null; + abB = saved ? saved.b : null; + updateAbUI(); +} +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 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 = `
`; + item.querySelector('.ri-title').textContent = v.title; + item.querySelector('.ri-channel').textContent = v.channel || ''; + item.addEventListener('click', () => { queue = [v]; queueIndex = 0; 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 = `${sp.label}`; + item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; 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 || {}); + 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 || {}); + persist(); + closeModal(); + render(); + toast('Backup imported'); + }}, + ]); + } catch (err) { toast('⚠ Invalid backup: ' + err.message); } + }; + reader.readAsText(file); + e.target.value = ''; +} + // ============================================================================ // Boot // ============================================================================ @@ -1927,6 +2388,9 @@ async function boot() { history: loaded.history || [], queue: loaded.queue || [], resumePositions: loaded.resumePositions || {}, + playCount: loaded.playCount || {}, + abMarkers: loaded.abMarkers || {}, + lastAutoBackup: loaded.lastAutoBackup || 0, settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }, }; } @@ -1944,6 +2408,8 @@ async function boot() { await refreshCachedIds(); data.playlists.forEach(preloadPlaylist); + renderSmartSidebar(); + checkAutoBackup(); render(); els.searchInput.focus(); } diff --git a/frontend/index.html b/frontend/index.html index 433e059..43e6f2f 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -39,6 +39,11 @@
+
+ Auto Playlists +
+
+ diff --git a/frontend/styles.css b/frontend/styles.css index bb49762..eddcc49 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1353,3 +1353,148 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } transition-duration: 0.001ms !important; } } + +/* ============================================================================ + * Seek wrap + A-B markers + * ========================================================================== */ +.seek-wrap { position: relative; flex: 1; display: flex; flex-direction: column; gap: 4px; } +.seek-wrap .seek { width: 100%; flex: none; } +.ab-indicator { + display: flex; align-items: center; padding: 0 2px; + font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.05em; +} +.ab-label { color: var(--text-dim); transition: color 0.16s; } +.ab-label.active { color: var(--accent); } +.ab-ctrl { font-family: var(--mono); font-size: 12px; font-weight: 800; min-width: 34px; } +.ab-ctrl.active { + color: #fff; + background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep)); + border-color: transparent; + box-shadow: 0 6px 16px -8px var(--accent-glow); +} + +/* ============================================================================ + * Sleep timer status bar + * ========================================================================== */ +.sleep-status { + display: flex; align-items: center; gap: 12px; + padding: 8px 16px; margin-top: 10px; + background: rgba(255,75,50,0.07); + border: 1px solid rgba(255,75,50,0.22); + border-radius: var(--radius-sm); +} +.sleep-countdown { + font-family: var(--mono); font-size: 12px; font-weight: 600; + color: var(--accent); flex: 1; +} +.sleep-cancel { + background: transparent; border: 1px solid rgba(255,75,50,0.35); + color: var(--accent); border-radius: 7px; + padding: 5px 10px; cursor: pointer; font-size: 12px; font-weight: 600; + transition: all 0.16s; +} +.sleep-cancel:hover { background: rgba(255,75,50,0.14); } +.ctrl.sleep-active { + color: #fff; + background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep)); + border-color: transparent; +} + +/* ============================================================================ + * Related videos panel + * ========================================================================== */ +.related-panel { + margin-top: 16px; + background: linear-gradient(180deg, var(--bg-2), var(--bg-1)); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; +} +.related-header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid var(--line-soft); +} +.related-label { + font-family: var(--mono); font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.16em; color: var(--text-dim); +} +.related-toggle-btn { + background: transparent; border: none; color: var(--text-dim); + font-size: 16px; cursor: pointer; padding: 0 4px; line-height: 1; + transition: color 0.16s; +} +.related-toggle-btn:hover { color: var(--text); } +.related-list { + display: flex; flex-direction: column; gap: 4px; + padding: 8px 10px; max-height: 280px; overflow-y: auto; +} +.related-item { + display: flex; gap: 10px; padding: 6px 8px; + border-radius: 9px; cursor: pointer; + border: 1px solid transparent; + transition: background 0.16s, border-color 0.16s; +} +.related-item:hover { background: var(--bg-3); border-color: var(--line); } +.related-item img { width: 80px; aspect-ratio: 16/9; border-radius: 6px; object-fit: cover; background: #000; flex-shrink: 0; } +.related-item .ri-info { min-width: 0; display: flex; flex-direction: column; justify-content: center; } +.related-item .ri-title { + font-size: 12.5px; font-weight: 600; line-height: 1.3; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.related-item .ri-channel { font-size: 11px; color: var(--text-dim); margin-top: 3px; } + +/* ============================================================================ + * Smart / auto playlists in sidebar + * ========================================================================== */ +.smart-pl-header { opacity: 0.75; margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--line-soft); } +.smart-pl-list { flex: 0 0 auto !important; margin-bottom: 6px; } +.smart-item .pl-name::before { content: "◈ "; color: var(--text-dim); font-size: 9px; } +.smart-item.active .pl-name::before { color: var(--accent); } + +/* ============================================================================ + * List filter bar + * ========================================================================== */ +.list-filter-bar { padding: 0 14px 8px; } +.list-filter-bar input { + width: 100%; background: var(--bg-2); border: 1px solid var(--line); + border-radius: 9px; padding: 8px 12px; + color: var(--text); font-family: var(--ui); font-size: 13px; + outline: none; transition: border-color 0.16s, box-shadow 0.16s; +} +.list-filter-bar input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(255,75,50,0.1); } +.list-filter-bar input::placeholder { color: var(--text-dim); } + +/* ============================================================================ + * Batch selection bar + * ========================================================================== */ +.batch-bar { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 8px 14px; + background: rgba(255,75,50,0.07); + border-bottom: 1px solid rgba(255,75,50,0.18); +} +.batch-count { font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--accent); flex: 1; } +.batch-btn { + background: var(--bg-3); border: 1px solid var(--line); color: var(--text-2); + border-radius: 7px; padding: 6px 11px; cursor: pointer; + font-family: var(--ui); font-size: 12px; font-weight: 600; transition: all 0.16s; +} +.batch-btn:hover { color: var(--text); border-color: var(--accent); } +.batch-danger { color: #ff8a7a !important; border-color: rgba(255,75,50,0.28) !important; } +.batch-danger:hover { background: rgba(255,75,50,0.12) !important; } + +/* Batch checkboxes on cards */ +.card .batch-check { + display: none; position: absolute; left: 8px; top: 8px; z-index: 3; + width: 20px; height: 20px; border-radius: 6px; + border: 2px solid var(--line); background: var(--bg-1); + align-items: center; justify-content: center; + font-size: 12px; font-weight: 800; transition: all 0.14s; + pointer-events: none; +} +.cards.select-mode .card .batch-check { display: flex; } +.cards.select-mode .card { cursor: pointer; } +.card.selected .batch-check { background: var(--accent); border-color: var(--accent); color: #fff; } +.card.selected .batch-check::after { content: "✓"; } +.card.selected { border-color: var(--accent) !important; background: var(--bg-2); }