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,
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:<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 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,

View File

@@ -135,6 +135,7 @@
<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="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">
<button id="muteBtn" class="ctrl" title="Mute">🔊</button>
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" />