add per-song remember-position flag and playlist play-in-full mode

This commit is contained in:
Jonathan Sykes
2026-07-14 15:15:11 +08:00
parent 3afb22b305
commit 6fff68884b
2 changed files with 73 additions and 14 deletions

View File

@@ -206,13 +206,16 @@ const DEFAULT_SETTINGS = {
autoBackupEnabled: false, autoBackupEnabled: false,
autoBackupIntervalDays: 7, 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 view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = []; let searchResults = [];
let channelData = { name: '', url: '', key: '', results: [], loading: false }; let channelData = { name: '', url: '', key: '', results: [], loading: false };
let queue = []; // list of video objects for autoplay let queue = []; // list of video objects for autoplay
let queueIndex = -1; let queueIndex = -1;
let queueSource = ''; // label of what's playing ('queue','playlist:<id>',…) let queueSource = ''; // label of what's playing ('queue','playlist:<id>',…)
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 current = null; // { meta, qualities, audioUrl, localUrl? }
let dragSource = -1; // index of card being dragged let dragSource = -1; // index of card being dragged
let listFilter = ''; let listFilter = '';
@@ -301,6 +304,7 @@ function profilePayload() {
history: data.history, history: data.history,
settings: data.settings, settings: data.settings,
resumePositions: data.resumePositions, resumePositions: data.resumePositions,
rememberPos: data.rememberPos,
playCount: data.playCount, playCount: data.playCount,
abMarkers: data.abMarkers, 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.playlists)) data.playlists = payload.playlists;
if (Array.isArray(payload.history)) data.history = payload.history; if (Array.isArray(payload.history)) data.history = payload.history;
if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions; 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.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers; if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers;
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings }; if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
@@ -741,14 +746,19 @@ const Player = {
if (this._revealOnLoad) scrollPlayerIntoViewPortrait(); if (this._revealOnLoad) scrollPlayerIntoViewPortrait();
// Restore A-B markers and load related (non-blocking) // Restore A-B markers and load related (non-blocking)
restoreAbMarkers(); restoreAbMarkers();
updateRememberPosUI();
loadRelated(); loadRelated();
exitSelectMode(); 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 — // 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 // a freshly selected track should start at the beginning, or at the A point
// when an A-B loop is set for it. // when an A-B loop is set for it.
const id = current.meta.id; 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 saved = data.resumePositions[id];
const restore = () => { const restore = () => {
Player.seek(saved); Player.seek(saved);
@@ -1139,14 +1149,16 @@ function wirePlayerEvents() {
const bucket = Math.floor(t / 10); const bucket = Math.floor(t / 10);
if (t > 5 && bucket !== Player._lastPersistBucket) { if (t > 5 && bucket !== Player._lastPersistBucket) {
Player._lastPersistBucket = bucket; Player._lastPersistBucket = bucket;
data.resumePositions[current.meta.id] = t; if (!playFullMode && rememberPosEnabled()) {
persist(); data.resumePositions[current.meta.id] = t;
persist();
}
} }
} }
} }
}); });
el.addEventListener('pause', () => { el.addEventListener('pause', () => {
if (masterIs(el) && current && current.meta) { if (masterIs(el) && current && current.meta && !playFullMode && rememberPosEnabled()) {
const t = Player.master.currentTime; const t = Player.master.currentTime;
if (t > 1) { if (t > 1) {
data.resumePositions[current.meta.id] = t; 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 // 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 // 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"). // 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) { if (data.settings.loopOne) {
Player.seek(abA); Player.seek(abA);
} else if (!advanceQueue()) { } else if (!advanceQueue()) {
@@ -1264,12 +1276,14 @@ function addToHistory(meta) {
// ============================================================================ // ============================================================================
// Queue / navigation // Queue / navigation
// ============================================================================ // ============================================================================
function playFromList(list, index, source = '') { function playFromList(list, index, source = '', { playFull = false } = {}) {
queue = list; queue = list;
queueIndex = index; queueIndex = index;
queueSource = source; queueSource = source;
playFullMode = playFull;
unshuffledQueue = null; 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]); Player.loadVideo(queue[queueIndex]);
renderUpNext(); renderUpNext();
} }
@@ -1386,7 +1400,7 @@ function renderUpNext() {
function advanceQueue() { function advanceQueue() {
if (queueIndex >= 0 && queueIndex < queue.length - 1) { if (queueIndex >= 0 && queueIndex < queue.length - 1) {
queueIndex++; queueIndex++;
} else if (data.settings.repeatMode === 'all' && queue.length) { } else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) {
queueIndex = 0; queueIndex = 0;
} else { } else {
return false; return false;
@@ -1403,8 +1417,12 @@ function playNext() {
} }
// Fired when a track finishes on its own — honors single-video loop first. // Fired when a track finishes on its own — honors single-video loop first.
function onTrackEnded() { function onTrackEnded() {
if (data.settings.loopOne) { Player.seek(0); Player.play(); return; } if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; }
if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); } if (!advanceQueue()) {
if (playFullMode) { playFullMode = false; toast('Finished playing in full'); }
updatePlayBtn();
$('upnext').classList.add('hidden');
}
} }
function toggleLoopOne() { function toggleLoopOne() {
data.settings.loopOne = !data.settings.loopOne; data.settings.loopOne = !data.settings.loopOne;
@@ -1592,6 +1610,14 @@ function renderList() {
const playAll = document.createElement('button'); const playAll = document.createElement('button');
playAll.textContent = '▶ Play all'; playAll.textContent = '▶ Play all';
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); }; 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'); const queueAll = document.createElement('button');
queueAll.textContent = ' Queue'; queueAll.textContent = ' Queue';
queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to 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'); const del = document.createElement('button');
del.textContent = 'Delete'; del.textContent = 'Delete';
del.onclick = () => deletePlaylist(pl); 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); $('abABtn').addEventListener('click', setAbA);
$('abBBtn').addEventListener('click', setAbB); $('abBBtn').addEventListener('click', setAbB);
$('abClearBtn').addEventListener('click', clearAb); $('abClearBtn').addEventListener('click', clearAb);
$('rememberPosBtn').addEventListener('click', toggleRememberPos);
// Related panel collapse toggle // Related panel collapse toggle
$('relatedToggleBtn').addEventListener('click', () => { $('relatedToggleBtn').addEventListener('click', () => {
@@ -3172,6 +3199,35 @@ function currentPlaylistEntry() {
if (!pl || !Array.isArray(pl.videos)) return null; if (!pl || !Array.isArray(pl.videos)) return null;
return pl.videos.find((v) => v.id === current.meta.id) || 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() { function updateAbUI() {
const aSet = abA !== null, bSet = abB !== null; const aSet = abA !== null, bSet = abB !== null;
const aBtn = $('abABtn'), bBtn = $('abBBtn'), clrBtn = $('abClearBtn'); const aBtn = $('abABtn'), bBtn = $('abBBtn'), clrBtn = $('abClearBtn');
@@ -3219,7 +3275,7 @@ function renderRelated() {
} }
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
if (e.target.closest('.ri-channel.link')) return; 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); list.appendChild(item);
}); });
@@ -3358,6 +3414,7 @@ function importBackup(e) {
const histIds = new Set(data.history.map((v) => v.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); } 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.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); 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 || {}); Object.assign(data.abMarkers, imp.abMarkers || {});
persist(); persist();
@@ -3526,6 +3583,7 @@ async function boot() {
history: loaded.history || [], history: loaded.history || [],
queue: loaded.queue || [], queue: loaded.queue || [],
resumePositions: loaded.resumePositions || {}, resumePositions: loaded.resumePositions || {},
rememberPos: loaded.rememberPos || {},
playCount: loaded.playCount || {}, playCount: loaded.playCount || {},
abMarkers: loaded.abMarkers || {}, abMarkers: loaded.abMarkers || {},
lastAutoBackup: loaded.lastAutoBackup || 0, lastAutoBackup: loaded.lastAutoBackup || 0,

View File

@@ -135,6 +135,7 @@
<button id="abABtn" class="ctrl ab-ctrl" title="Set A-B loop start [A]">A</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="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> <button id="abClearBtn" class="ctrl ab-ctrl hidden" title="Clear A-B loop"></button>
<button id="rememberPosBtn" class="ctrl" title="Remember playback position for this song (per playlist)">📍</button>
<div class="vol"> <div class="vol">
<button id="muteBtn" class="ctrl" title="Mute">🔊</button> <button id="muteBtn" class="ctrl" title="Mute">🔊</button>
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" /> <input id="volume" type="range" min="0" max="1" step="0.01" value="1" />