From 6fff68884b02399af43b3e8fa005e345e470e8f1 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Tue, 14 Jul 2026 15:15:11 +0800 Subject: [PATCH] add per-song remember-position flag and playlist play-in-full mode --- frontend/app.js | 86 +++++++++++++++++++++++++++++++++++++-------- frontend/index.html | 1 + 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index a1360cc..910f2fb 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -206,13 +206,16 @@ const DEFAULT_SETTINGS = { autoBackupEnabled: false, autoBackupIntervalDays: 7, }; -let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null }; +let data = { playlists: [], history: [], queue: [], 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 = []; let channelData = { name: '', url: '', key: '', results: [], loading: false }; let queue = []; // list of video objects for autoplay let queueIndex = -1; let queueSource = ''; // label of what's playing ('queue','playlist:',…) +let playFullMode = false; // "play in full" session: ignore resume positions, + // A-B markers, shuffle, loop and repeat — play the + // list start-to-finish, then stop. let current = null; // { meta, qualities, audioUrl, localUrl? } let dragSource = -1; // index of card being dragged let listFilter = ''; @@ -301,6 +304,7 @@ function profilePayload() { history: data.history, settings: data.settings, resumePositions: data.resumePositions, + rememberPos: data.rememberPos, playCount: data.playCount, abMarkers: data.abMarkers, }; @@ -337,6 +341,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 (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; if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers; if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings }; @@ -741,14 +746,19 @@ const Player = { if (this._revealOnLoad) scrollPlayerIntoViewPortrait(); // Restore A-B markers and load related (non-blocking) restoreAbMarkers(); + updateRememberPosUI(); loadRelated(); exitSelectMode(); - // Restore saved playback position. + // Restore saved playback position — only when this song's "remember + // position" flag is on (per-playlist copy first, global fallback), and + // never during a "play in full" session. // On auto-advance (or prev/next) we do NOT resume the previous timestamp — // a freshly selected track should start at the beginning, or at the A point // when an A-B loop is set for it. const id = current.meta.id; - if (this._resumeOnLoad && data.resumePositions[id] && data.resumePositions[id] > 1) { + if (playFullMode) { + // Play-in-full ignores resume positions and A markers alike. + } else if (this._resumeOnLoad && rememberPosEnabled() && data.resumePositions[id] && data.resumePositions[id] > 1) { const saved = data.resumePositions[id]; const restore = () => { Player.seek(saved); @@ -1139,14 +1149,16 @@ function wirePlayerEvents() { const bucket = Math.floor(t / 10); if (t > 5 && bucket !== Player._lastPersistBucket) { Player._lastPersistBucket = bucket; - data.resumePositions[current.meta.id] = t; - persist(); + if (!playFullMode && rememberPosEnabled()) { + data.resumePositions[current.meta.id] = t; + persist(); + } } } } }); el.addEventListener('pause', () => { - if (masterIs(el) && current && current.meta) { + if (masterIs(el) && current && current.meta && !playFullMode && rememberPosEnabled()) { const t = Player.master.currentTime; if (t > 1) { data.resumePositions[current.meta.id] = t; @@ -1206,7 +1218,7 @@ function updateProgress() { // A-B loop: passing B behaves like the track ending. Loop the A-B segment only // when "loop current" is on; otherwise B is the effective end of the track and // we advance per the repeat settings (next track, or wrap when "repeat list"). - if (abA !== null && abB !== null && abB > abA && cur >= abB) { + if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB) { if (data.settings.loopOne) { Player.seek(abA); } else if (!advanceQueue()) { @@ -1264,12 +1276,14 @@ function addToHistory(meta) { // ============================================================================ // Queue / navigation // ============================================================================ -function playFromList(list, index, source = '') { +function playFromList(list, index, source = '', { playFull = false } = {}) { queue = list; queueIndex = index; queueSource = source; + playFullMode = playFull; unshuffledQueue = null; - if (data.settings.shuffle) applyShuffle(); + // Play-in-full keeps the list order — a service set plays as arranged. + if (data.settings.shuffle && !playFull) applyShuffle(); Player.loadVideo(queue[queueIndex]); renderUpNext(); } @@ -1386,7 +1400,7 @@ function renderUpNext() { function advanceQueue() { if (queueIndex >= 0 && queueIndex < queue.length - 1) { queueIndex++; - } else if (data.settings.repeatMode === 'all' && queue.length) { + } else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) { queueIndex = 0; } else { return false; @@ -1403,8 +1417,12 @@ function playNext() { } // Fired when a track finishes on its own — honors single-video loop first. function onTrackEnded() { - if (data.settings.loopOne) { Player.seek(0); Player.play(); return; } - if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); } + if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; } + if (!advanceQueue()) { + if (playFullMode) { playFullMode = false; toast('Finished playing in full'); } + updatePlayBtn(); + $('upnext').classList.add('hidden'); + } } function toggleLoopOne() { data.settings.loopOne = !data.settings.loopOne; @@ -1592,6 +1610,14 @@ function renderList() { const playAll = document.createElement('button'); playAll.textContent = '▶ Play all'; playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); }; + const playFull = document.createElement('button'); + playFull.textContent = '▶ Play in full'; + playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one'; + playFull.onclick = () => { + if (!pl.videos.length) return; + playFromList(pl.videos, 0, 'playlist:' + pl.id, { playFull: true }); + toast('Playing in full — stops after the last video'); + }; const queueAll = document.createElement('button'); queueAll.textContent = '+ Queue'; queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); }; @@ -1601,7 +1627,7 @@ function renderList() { const del = document.createElement('button'); del.textContent = 'Delete'; del.onclick = () => deletePlaylist(pl); - els.listActions.append(playAll, queueAll, rename, del); + els.listActions.append(playAll, playFull, queueAll, rename, del); } } @@ -2820,6 +2846,7 @@ document.querySelectorAll('.chip').forEach((c) => { $('abABtn').addEventListener('click', setAbA); $('abBBtn').addEventListener('click', setAbB); $('abClearBtn').addEventListener('click', clearAb); + $('rememberPosBtn').addEventListener('click', toggleRememberPos); // Related panel collapse toggle $('relatedToggleBtn').addEventListener('click', () => { @@ -3172,6 +3199,35 @@ function currentPlaylistEntry() { if (!pl || !Array.isArray(pl.videos)) return null; return pl.videos.find((v) => v.id === current.meta.id) || null; } +// ---------- Remember-position flag ---------- +// Like A-B markers, the flag is per-song-per-playlist: stored on the playlist's +// own copy of the video (`entry.rememberPos`) when playing from that playlist, +// with `data.rememberPos[videoId]` as the fallback for non-playlist playback. +// Resume positions are only saved and restored for songs with the flag on. +function rememberPosEnabled() { + if (!current || !current.meta) return false; + const entry = currentPlaylistEntry(); + return entry ? !!entry.rememberPos : !!data.rememberPos[current.meta.id]; +} +function toggleRememberPos() { + if (!current || !current.meta) return; + const id = current.meta.id; + const on = !rememberPosEnabled(); + const entry = currentPlaylistEntry(); + if (entry) { + if (on) entry.rememberPos = true; else delete entry.rememberPos; + } else { + if (on) data.rememberPos[id] = true; else delete data.rememberPos[id]; + } + persist(); + updateRememberPosUI(); + toast(on ? 'Remembering playback position' : 'Not remembering position'); +} +function updateRememberPosUI() { + const btn = $('rememberPosBtn'); + if (btn) btn.classList.toggle('active', rememberPosEnabled()); +} + function updateAbUI() { const aSet = abA !== null, bSet = abB !== null; const aBtn = $('abABtn'), bBtn = $('abBBtn'), clrBtn = $('abClearBtn'); @@ -3219,7 +3275,7 @@ function renderRelated() { } item.addEventListener('click', (e) => { if (e.target.closest('.ri-channel.link')) return; - queue = [v]; queueIndex = 0; Player.loadVideo(v); renderUpNext(); + queue = [v]; queueIndex = 0; playFullMode = false; Player.loadVideo(v); renderUpNext(); }); list.appendChild(item); }); @@ -3358,6 +3414,7 @@ function importBackup(e) { 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 || {}); + Object.assign(data.rememberPos, imp.rememberPos || {}); 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(); @@ -3526,6 +3583,7 @@ async function boot() { history: loaded.history || [], queue: loaded.queue || [], resumePositions: loaded.resumePositions || {}, + rememberPos: loaded.rememberPos || {}, playCount: loaded.playCount || {}, abMarkers: loaded.abMarkers || {}, lastAutoBackup: loaded.lastAutoBackup || 0, diff --git a/frontend/index.html b/frontend/index.html index 642c397..023b209 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -135,6 +135,7 @@ +