Show per-playlist offline status and add a keep-offline pin
This commit is contained in:
158
frontend/app.js
158
frontend/app.js
@@ -251,7 +251,7 @@ const DEFAULT_SETTINGS = {
|
||||
autoBackupEnabled: false,
|
||||
autoBackupIntervalDays: 7,
|
||||
};
|
||||
let data = { playlists: [], history: [], queue: [], customVideos: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
|
||||
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
|
||||
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
|
||||
let searchResults = [];
|
||||
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
||||
@@ -352,6 +352,7 @@ function profilePayload() {
|
||||
playlists: data.playlists,
|
||||
history: data.history,
|
||||
customVideos: data.customVideos,
|
||||
pinnedPlaylists: data.pinnedPlaylists || [],
|
||||
settings: data.settings,
|
||||
resumePositions: data.resumePositions,
|
||||
rememberPos: data.rememberPos,
|
||||
@@ -391,6 +392,7 @@ function applyProfileData(name, payload, updatedAt) {
|
||||
if (Array.isArray(payload.playlists)) data.playlists = payload.playlists;
|
||||
if (Array.isArray(payload.history)) data.history = payload.history;
|
||||
if (Array.isArray(payload.customVideos)) data.customVideos = payload.customVideos;
|
||||
if (Array.isArray(payload.pinnedPlaylists)) data.pinnedPlaylists = payload.pinnedPlaylists;
|
||||
if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions;
|
||||
if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos;
|
||||
if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
|
||||
@@ -766,6 +768,7 @@ function loadProfileFlow() {
|
||||
render();
|
||||
updateProfileStatus();
|
||||
data.playlists.forEach(preloadPlaylist);
|
||||
preloadPinnedPlaylists();
|
||||
toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 });
|
||||
} catch {
|
||||
toast('⚠ Network error — try again');
|
||||
@@ -923,6 +926,12 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
||||
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
|
||||
updateDownloadBadge();
|
||||
if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
|
||||
if (view.type === 'playlist') {
|
||||
const pl = data.playlists.find((p) => p.id === view.id);
|
||||
const offlineBtn = $('plOfflineBtn');
|
||||
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
|
||||
}
|
||||
renderSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,12 +945,34 @@ function updateDownloadBadge() {
|
||||
if (bb) { bb.textContent = String(n); bb.classList.toggle('hidden', n === 0); }
|
||||
}
|
||||
|
||||
// Helper: count how many videos in a playlist are cached offline.
|
||||
// Custom (edited) videos count as cached (produced by the editor, not downloaded).
|
||||
function playlistOfflineStats(pl) {
|
||||
if (!pl || !Array.isArray(pl.videos)) return { cached: 0, total: 0 };
|
||||
let cached = 0;
|
||||
for (const v of pl.videos) {
|
||||
if (v && (v.custom || cachedIds.has(v.id))) cached++;
|
||||
}
|
||||
return { cached, total: pl.videos.length };
|
||||
}
|
||||
|
||||
// Auto-preload every video in a playlist (respecting the setting).
|
||||
function preloadPlaylist(pl) {
|
||||
if (!data.settings.autoPreload || !pl) return;
|
||||
pl.videos.forEach((v) => preload(v, { quiet: true }));
|
||||
}
|
||||
|
||||
// Preload every pinned playlist's videos even when autoPreload is off.
|
||||
function preloadPinnedPlaylists() {
|
||||
const pinnedSet = new Set(data.pinnedPlaylists || []);
|
||||
if (!pinnedSet.size) return;
|
||||
for (const pl of data.playlists || []) {
|
||||
if (pinnedSet.has(pl.id) && Array.isArray(pl.videos)) {
|
||||
pl.videos.forEach((v) => preload(v, { quiet: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update a single card's saved badge without a full re-render.
|
||||
function markCardCacheState(id, state) {
|
||||
document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => {
|
||||
@@ -1016,6 +1047,12 @@ async function downloadEdited(customVideo) {
|
||||
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
|
||||
updateDownloadBadge();
|
||||
if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
|
||||
if (view.type === 'playlist') {
|
||||
const pl = data.playlists.find((p) => p.id === view.id);
|
||||
const offlineBtn = $('plOfflineBtn');
|
||||
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
|
||||
}
|
||||
renderSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2114,9 +2151,18 @@ function channelKeyOf(v) {
|
||||
function renderSidebar() {
|
||||
els.playlistList.innerHTML = '';
|
||||
for (const pl of data.playlists) {
|
||||
const stats = playlistOfflineStats(pl);
|
||||
let offlineBadge = '';
|
||||
if (stats.total > 0) {
|
||||
if (stats.cached === stats.total) {
|
||||
offlineBadge = `<span class="pl-offline-badge all" title="Available offline">⬇</span>`;
|
||||
} else if (stats.cached > 0) {
|
||||
offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||||
}
|
||||
}
|
||||
const item = document.createElement('div');
|
||||
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
|
||||
item.innerHTML = `<span class="pl-name"></span><span class="pl-count">${pl.videos.length}</span>`;
|
||||
item.innerHTML = `<span class="pl-name"></span>${offlineBadge}<span class="pl-count">${pl.videos.length}</span>`;
|
||||
item.querySelector('.pl-name').textContent = pl.name;
|
||||
item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||
els.playlistList.appendChild(item);
|
||||
@@ -2157,6 +2203,15 @@ function renderHomePlaylists() {
|
||||
for (const pl of lists) {
|
||||
const count = pl.videos.length;
|
||||
const thumb = (pl.videos.find((v) => v && v.thumbnail) || {}).thumbnail || '';
|
||||
const stats = playlistOfflineStats(pl);
|
||||
let offlineHtml = '';
|
||||
if (stats.total > 0) {
|
||||
if (stats.cached === stats.total) {
|
||||
offlineHtml = `<span class="home-pl-offline all" title="Available offline">⬇</span>`;
|
||||
} else if (stats.cached > 0) {
|
||||
offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||||
}
|
||||
}
|
||||
const card = document.createElement('button');
|
||||
card.className = 'home-pl-card';
|
||||
card.type = 'button';
|
||||
@@ -2164,6 +2219,7 @@ function renderHomePlaylists() {
|
||||
<span class="home-pl-thumb">
|
||||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" />`
|
||||
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
||||
${offlineHtml}
|
||||
<span class="home-pl-play" title="Play all">▶</span>
|
||||
</span>
|
||||
<span class="home-pl-name"></span>
|
||||
@@ -2329,7 +2385,20 @@ function renderList() {
|
||||
del.textContent = 'Delete';
|
||||
del.onclick = () => deletePlaylist(pl);
|
||||
|
||||
const buttons = [playAll, playFull, queueAll];
|
||||
const offlineBtn = document.createElement('button');
|
||||
offlineBtn.id = 'plOfflineBtn';
|
||||
updatePlaylistOfflineButton(offlineBtn, pl);
|
||||
offlineBtn.onclick = () => makePlaylistOffline(pl);
|
||||
|
||||
const pinBtn = document.createElement('button');
|
||||
pinBtn.id = 'plPinBtn';
|
||||
const isPinned = (data.pinnedPlaylists || []).includes(pl.id);
|
||||
pinBtn.textContent = isPinned ? '📌 Pinned' : '📌 Keep offline';
|
||||
pinBtn.classList.toggle('pinned', isPinned);
|
||||
pinBtn.title = isPinned ? 'Pinned for offline keep — click to unpin' : 'Pin to keep all videos in this playlist available offline';
|
||||
pinBtn.onclick = () => togglePinPlaylist(pl);
|
||||
|
||||
const buttons = [playAll, playFull, queueAll, offlineBtn, pinBtn];
|
||||
if (pl.videos && pl.videos.length >= 1) {
|
||||
const share = document.createElement('button');
|
||||
share.textContent = '🔗 Share';
|
||||
@@ -3465,12 +3534,93 @@ function renamePlaylist(pl) {
|
||||
setTimeout(() => { input.focus(); input.select(); }, 50);
|
||||
}
|
||||
|
||||
function updatePlaylistOfflineButton(btn, pl) {
|
||||
if (!btn || !pl) return;
|
||||
const stats = playlistOfflineStats(pl);
|
||||
const isDownloading = (pl.videos || []).some((v) => v && downloading.has(v.id));
|
||||
if (stats.total > 0 && stats.cached === stats.total) {
|
||||
btn.textContent = '✓ Available offline';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('disabled');
|
||||
btn.title = 'All videos in this playlist are available offline';
|
||||
} else if (isDownloading) {
|
||||
btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
|
||||
btn.disabled = true;
|
||||
btn.classList.add('disabled');
|
||||
btn.title = `${stats.cached} of ${stats.total} videos saved offline`;
|
||||
} else {
|
||||
btn.textContent = '⬇ Make available offline';
|
||||
btn.disabled = stats.total === 0;
|
||||
btn.classList.toggle('disabled', stats.total === 0);
|
||||
btn.title = stats.total === 0 ? 'Playlist is empty' : 'Download all videos for offline playback';
|
||||
}
|
||||
}
|
||||
|
||||
async function makePlaylistOffline(pl) {
|
||||
if (!pl || !Array.isArray(pl.videos) || !pl.videos.length) return;
|
||||
const uncollected = pl.videos.filter((v) => v && !v.custom && !cachedIds.has(v.id));
|
||||
if (!uncollected.length) return;
|
||||
|
||||
const btn = $('plOfflineBtn');
|
||||
if (btn) {
|
||||
const stats = playlistOfflineStats(pl);
|
||||
btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
|
||||
btn.disabled = true;
|
||||
btn.classList.add('disabled');
|
||||
}
|
||||
|
||||
toast(`Saving ${uncollected.length} video${uncollected.length === 1 ? '' : 's'} for offline…`);
|
||||
|
||||
const promises = uncollected.map(async (v) => {
|
||||
await preload(v, { quiet: true });
|
||||
const b = $('plOfflineBtn');
|
||||
if (b && view.type === 'playlist' && view.id === pl.id) {
|
||||
updatePlaylistOfflineButton(b, pl);
|
||||
}
|
||||
renderSidebar();
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
const finalBtn = $('plOfflineBtn');
|
||||
if (finalBtn && view.type === 'playlist' && view.id === pl.id) {
|
||||
updatePlaylistOfflineButton(finalBtn, pl);
|
||||
}
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
function togglePinPlaylist(pl) {
|
||||
if (!pl) return;
|
||||
data.pinnedPlaylists = data.pinnedPlaylists || [];
|
||||
const isPinned = data.pinnedPlaylists.includes(pl.id);
|
||||
if (isPinned) {
|
||||
data.pinnedPlaylists = data.pinnedPlaylists.filter((id) => id !== pl.id);
|
||||
persist();
|
||||
toast(`Unpinned “${pl.name}”`);
|
||||
} else {
|
||||
data.pinnedPlaylists.push(pl.id);
|
||||
persist();
|
||||
(pl.videos || []).forEach((v) => preload(v, { quiet: true }));
|
||||
toast(`Pinned “${pl.name}” — keeping offline`);
|
||||
}
|
||||
const pinBtn = $('plPinBtn');
|
||||
if (pinBtn) {
|
||||
const nowPinned = data.pinnedPlaylists.includes(pl.id);
|
||||
pinBtn.textContent = nowPinned ? '📌 Pinned' : '📌 Keep offline';
|
||||
pinBtn.classList.toggle('pinned', nowPinned);
|
||||
pinBtn.title = nowPinned ? 'Pinned for offline keep — click to unpin' : 'Pin to keep all videos in this playlist available offline';
|
||||
}
|
||||
const offlineBtn = $('plOfflineBtn');
|
||||
if (offlineBtn) updatePlaylistOfflineButton(offlineBtn, pl);
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
function deletePlaylist(pl) {
|
||||
showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [
|
||||
{ label: 'Cancel', onClick: closeModal },
|
||||
{
|
||||
label: 'Delete', danger: true, onClick: () => {
|
||||
data.playlists = data.playlists.filter((p) => p.id !== pl.id);
|
||||
data.pinnedPlaylists = (data.pinnedPlaylists || []).filter((id) => id !== pl.id);
|
||||
persist();
|
||||
closeModal();
|
||||
view = { type: 'search' };
|
||||
@@ -4497,6 +4647,7 @@ async function boot() {
|
||||
history: loaded.history || [],
|
||||
queue: loaded.queue || [],
|
||||
customVideos: loaded.customVideos || [],
|
||||
pinnedPlaylists: Array.isArray(loaded.pinnedPlaylists) ? loaded.pinnedPlaylists : [],
|
||||
resumePositions: loaded.resumePositions || {},
|
||||
rememberPos: loaded.rememberPos || {},
|
||||
playCount: loaded.playCount || {},
|
||||
@@ -4526,6 +4677,7 @@ async function boot() {
|
||||
// Learn what's already cached, then top up any playlist videos that aren't.
|
||||
await refreshCachedIds();
|
||||
data.playlists.forEach(preloadPlaylist);
|
||||
preloadPinnedPlaylists();
|
||||
// Not awaited — artwork backfill must never delay first paint.
|
||||
warmOfflineThumbs();
|
||||
|
||||
|
||||
@@ -204,6 +204,30 @@ body::after {
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.playlist-item .pl-offline-badge {
|
||||
font-family: var(--mono);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.playlist-item .pl-offline-badge.all {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 1px 4px var(--accent-glow);
|
||||
}
|
||||
.playlist-item .pl-offline-badge.partial {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.sidebar-footer { border-top: 1px solid var(--line-soft); padding-top: 12px; margin-top: auto; flex-shrink: 0; }
|
||||
|
||||
@@ -422,6 +446,32 @@ body::after {
|
||||
background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(4px);
|
||||
opacity: 0; transition: opacity 0.16s, background 0.16s;
|
||||
}
|
||||
.home-pl-offline {
|
||||
position: absolute; top: 6px; left: 6px;
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--mono);
|
||||
line-height: 1;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-pl-offline.all {
|
||||
width: 22px; height: 22px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.home-pl-offline.partial {
|
||||
padding: 3px 6px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: var(--text-2);
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.home-pl-card:hover .home-pl-play, .home-pl-play:focus-visible { opacity: 1; }
|
||||
.home-pl-play:hover { background: var(--accent); }
|
||||
.home-pl-name {
|
||||
@@ -740,6 +790,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
transition: all 0.16s;
|
||||
}
|
||||
.list-actions button:hover { color: var(--text); border-color: var(--accent); background: var(--bg-3); }
|
||||
.list-actions button:disabled,
|
||||
.list-actions button.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
border-color: var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.list-actions button.pinned {
|
||||
background: var(--bg-3);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status { padding: 16px 20px; color: var(--text-dim); font-size: 13px; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user