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