fix: start next track at beginning (or A point) instead of resuming, and store A-B per playlist

When auto-advancing through a queue or playlist — or stepping with
next/prev — the next video previously jumped to its saved resume
timestamp. A freshly selected track now starts at the beginning, or at
the A marker when an A-B loop is set for it. Resuming still applies when
you reopen a single video directly.

A-B markers set while playing from a playlist are now stored on that
playlist's own copy of the video (entry.ab), so each playlist keeps its
own loop and the markers sync to the database alongside the playlist.
Non-playlist playback keeps using the global per-video marker map.

Bumps service worker to v1.0.3 to bust the client cache.
This commit is contained in:
Jonathan Sykes
2026-07-01 04:29:00 +08:00
parent 79359dba44
commit ebe60a7be2
2 changed files with 54 additions and 11 deletions

View File

@@ -412,7 +412,10 @@ const Player = {
return this.mode === 'dual' ? els.audio : this.master;
},
async loadVideo(videoObj, { preferStream = false } = {}) {
async loadVideo(videoObj, { preferStream = false, resume = true } = {}) {
// When false (auto-advance / prev), skip resuming the saved timestamp and
// start from the beginning (or the A marker, if an A-B loop is set).
this._resumeOnLoad = resume;
showSpinner(true);
els.placeholder.classList.add('hidden');
// Revoke any previous OPFS blob URL to free memory
@@ -464,7 +467,7 @@ const Player = {
btn.textContent = '↻ Retry';
btn.addEventListener('click', () => {
btn.remove();
Player.loadVideo(videoObj, { preferStream });
Player.loadVideo(videoObj, { preferStream, resume });
});
els.playerPane.appendChild(btn);
}
@@ -503,9 +506,12 @@ const Player = {
restoreAbMarkers();
loadRelated();
exitSelectMode();
// Restore saved playback position
// Restore saved playback position.
// 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 (data.resumePositions[id] && data.resumePositions[id] > 1) {
if (this._resumeOnLoad && data.resumePositions[id] && data.resumePositions[id] > 1) {
const saved = data.resumePositions[id];
const restore = () => {
Player.seek(saved);
@@ -515,6 +521,15 @@ const Player = {
};
Player.master.addEventListener('canplay', restore, { once: true });
Player.master.addEventListener('loadedmetadata', restore, { once: true });
} else if (abA !== null && abA > 1) {
// Not resuming, but an A marker exists — start the loop from A.
const seekToA = () => {
Player.seek(abA);
Player.master.removeEventListener('canplay', seekToA);
Player.master.removeEventListener('loadedmetadata', seekToA);
};
Player.master.addEventListener('canplay', seekToA, { once: true });
Player.master.addEventListener('loadedmetadata', seekToA, { once: true });
}
},
@@ -540,7 +555,8 @@ const Player = {
const meta = current.meta;
current.localUrl = null;
toast('Cached copy unavailable — streaming instead…');
this.loadVideo(meta, { preferStream: true });
// Preserve the resume intent of the load that just failed.
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad });
return;
}
// Advance to the next candidate stream; give up with a clear message at the end.
@@ -941,7 +957,9 @@ function advanceQueue() {
} else {
return false;
}
Player.loadVideo(queue[queueIndex]);
// Advancing to a new track always starts from the beginning (or its A point),
// never the previous resume timestamp.
Player.loadVideo(queue[queueIndex], { resume: false });
renderUpNext();
return true;
}
@@ -973,7 +991,7 @@ function playPrev() {
if (Player.master.currentTime > 3) { Player.seek(0); return; }
if (queueIndex > 0) {
queueIndex--;
Player.loadVideo(queue[queueIndex]);
Player.loadVideo(queue[queueIndex], { resume: false });
renderUpNext();
}
}
@@ -2497,24 +2515,49 @@ function setAbB() {
}
function clearAb() {
abA = null; abB = null;
if (current && current.meta) { delete data.abMarkers[current.meta.id]; persist(); }
if (current && current.meta) {
delete data.abMarkers[current.meta.id];
const entry = currentPlaylistEntry();
if (entry) delete entry.ab;
persist();
}
updateAbUI();
toast('A-B loop cleared');
}
function saveAbMarkers() {
if (!current || !current.meta) return;
if (abA !== null || abB !== null) {
data.abMarkers[current.meta.id] = { a: abA, b: abB };
const marker = { a: abA, b: abB };
// When playing from a playlist, store the marker on that playlist's own
// copy of the video so each playlist keeps its own A-B loop and it syncs
// to the database alongside the playlist. Otherwise fall back to the
// global per-video map.
const entry = currentPlaylistEntry();
if (entry) entry.ab = marker;
else data.abMarkers[current.meta.id] = marker;
persist();
}
}
function restoreAbMarkers() {
if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; }
const saved = data.abMarkers[current.meta.id];
// Prefer a marker stored on the current playlist's copy of the video; fall
// back to the global per-video marker.
const entry = currentPlaylistEntry();
const saved = (entry && entry.ab) || data.abMarkers[current.meta.id];
abA = saved ? saved.a : null;
abB = saved ? saved.b : null;
updateAbUI();
}
// Returns the video entry inside the playlist currently being played, if the
// active playback source is a playlist and the playing video belongs to it.
function currentPlaylistEntry() {
if (!current || !current.meta) return null;
if (!queueSource || !queueSource.startsWith('playlist:')) return null;
const plId = queueSource.slice('playlist:'.length);
const pl = data.playlists.find((p) => p.id === plId);
if (!pl || !Array.isArray(pl.videos)) return null;
return pl.videos.find((v) => v.id === current.meta.id) || null;
}
function updateAbUI() {
const aSet = abA !== null, bSet = abB !== null;
const aBtn = $('abABtn'), bBtn = $('abBBtn'), clrBtn = $('abClearBtn');

View File

@@ -16,7 +16,7 @@
* 5. SW calls skipWaiting() → takes over → client reloads.
* ========================================================================== */
const VERSION = 'v1.0.2'; // ← bump this on every deploy to bust the cache
const VERSION = 'v1.0.3'; // ← bump this on every deploy to bust the cache
const CACHE = 'ytplayer-' + VERSION;
// Files that form the installable app shell.