add per-song remember-position flag and playlist play-in-full mode
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user