feat: sleep timer, A-B loop, related panel, smart playlists, history delete/search, batch ops, auto-backup
This commit is contained in:
480
frontend/app.js
480
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:<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 & 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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user