feat: sleep timer, A-B loop, related panel, smart playlists, history delete/search, batch ops, auto-backup

This commit is contained in:
Jonathan Sykes
2026-06-21 18:43:26 +08:00
parent 3ad2e40606
commit 20cd2f29ba
3 changed files with 662 additions and 9 deletions

View File

@@ -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:<id>',…)
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() {
<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 &amp; 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>`;
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 = `
<div class="batch-check"></div>
<div class="thumb">
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
@@ -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 = `<img src="${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;
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 = `<span class="pl-name">${sp.label}</span>`;
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();
}

View File

@@ -39,6 +39,11 @@
</div>
<div id="playlistList" class="playlist-list"></div>
<div class="pl-header smart-pl-header">
<span>Auto Playlists</span>
</div>
<div id="smartPlaylistList" class="playlist-list smart-pl-list"></div>
<div class="sidebar-footer">
<label class="switch">
<input type="checkbox" id="audioOnlyToggle" />
@@ -95,7 +100,14 @@
<div id="controls" class="controls hidden">
<div class="seek-row">
<span id="curTime" class="time">0:00</span>
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
<div class="seek-wrap">
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
<div id="abIndicator" class="ab-indicator hidden">
<span id="abALabel" class="ab-label">A: --</span>
<span style="flex:1"></span>
<span id="abBLabel" class="ab-label">B: --</span>
</div>
</div>
<span id="durTime" class="time">0:00</span>
</div>
<div class="btn-row">
@@ -103,6 +115,9 @@
<button id="playBtn" class="ctrl play" title="Play/Pause"></button>
<button id="nextBtn" class="ctrl" title="Next"></button>
<button id="abABtn" class="ctrl ab-ctrl" title="Set A-B loop start [A]">A</button>
<button id="abBBtn" class="ctrl ab-ctrl" title="Set A-B loop end [B]">B</button>
<button id="abClearBtn" class="ctrl ab-ctrl hidden" title="Clear A-B loop"></button>
<div class="vol">
<button id="muteBtn" class="ctrl" title="Mute">🔊</button>
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" />
@@ -127,12 +142,18 @@
<select id="qualitySelect"></select>
</label>
<button id="sleepTimerBtn" class="ctrl" title="Sleep timer"></button>
<button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button>
<button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button>
<button id="fsBtn" class="ctrl" title="Fullscreen"></button>
</div>
</div>
<div id="sleepStatus" class="sleep-status hidden">
<span id="sleepCountdown" class="sleep-countdown"></span>
<button id="sleepCancelBtn" class="sleep-cancel">✕ Cancel</button>
</div>
<div id="nowPlayingMeta" class="now-meta hidden">
<div class="np-text">
<div class="np-title" id="npTitle"></div>
@@ -153,6 +174,15 @@
</div>
<div id="upnextList" class="upnext-list"></div>
</div>
<!-- Related videos -->
<div id="relatedPanel" class="related-panel hidden">
<div class="related-header">
<span class="related-label">Related</span>
<button id="relatedToggleBtn" class="related-toggle-btn"></button>
</div>
<div id="relatedList" class="related-list"></div>
</div>
</section>
<!-- List pane -->
@@ -161,6 +191,16 @@
<h2 id="listTitle">Search</h2>
<div id="listActions" class="list-actions"></div>
</div>
<div id="listFilterBar" class="list-filter-bar hidden">
<input id="listFilterInput" type="text" placeholder="Filter…" autocomplete="off" />
</div>
<div id="batchBar" class="batch-bar hidden">
<span id="batchCount" class="batch-count">0 selected</span>
<button id="batchQueueBtn" class="batch-btn"> Queue</button>
<button id="batchAddPlaylistBtn" class="batch-btn"> Playlist</button>
<button id="batchDeleteBtn" class="batch-btn batch-danger">Delete</button>
<button id="batchCancelBtn" class="batch-btn">Cancel</button>
</div>
<div id="status" class="status hidden"></div>
<div id="cards" class="cards"></div>
</section>
@@ -209,8 +249,10 @@
<div class="shortcut-row"><kbd>L</kbd><span>Loop current video</span></div>
<div class="shortcut-row"><kbd>R</kbd><span>Repeat list when finished</span></div>
<div class="shortcut-row"><kbd>Q</kbd><span>Add current video to queue</span></div>
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
<div class="shortcut-row"><kbd>Esc</kbd><span>Close help</span></div>
<div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div>
</div>
</div>
</div>

View File

@@ -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); }