diff --git a/frontend/app.js b/frontend/app.js index 050948d..f00585f 100755 --- a/frontend/app.js +++ b/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 = `⬇`; + } else if (stats.cached > 0) { + offlineBadge = `${stats.cached}/${stats.total}`; + } + } const item = document.createElement('div'); item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : ''); - item.innerHTML = `${pl.videos.length}`; + item.innerHTML = `${offlineBadge}${pl.videos.length}`; 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 = `⬇`; + } else if (stats.cached > 0) { + offlineHtml = `${stats.cached}/${stats.total}`; + } + } const card = document.createElement('button'); card.className = 'home-pl-card'; card.type = 'button'; @@ -2164,6 +2219,7 @@ function renderHomePlaylists() { ${thumb ? `` : '🎵'} + ${offlineHtml} ▶ @@ -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(); diff --git a/frontend/styles.css b/frontend/styles.css index 89cea7d..88a23d5 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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; }