|
|
|
|
@@ -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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -1550,6 +1568,19 @@ async function renderSettings() {
|
|
|
|
|
<button id="resetAppBtn" class="btn danger">Reset app</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="set-group">
|
|
|
|
|
<div class="set-group-title">About</div>
|
|
|
|
|
<div class="set-row">
|
|
|
|
|
<span>
|
|
|
|
|
Version
|
|
|
|
|
<small>App version and current build.</small>
|
|
|
|
|
</span>
|
|
|
|
|
<div class="set-actions" style="margin:0">
|
|
|
|
|
<span id="aboutVersion" class="about-version">v${APP_VERSION}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
c.appendChild(wrap);
|
|
|
|
|
@@ -1656,6 +1687,18 @@ async function renderSettings() {
|
|
|
|
|
$('importBackupBtn').addEventListener('click', () => $('backupFileInput').click());
|
|
|
|
|
$('backupFileInput').addEventListener('change', importBackup);
|
|
|
|
|
|
|
|
|
|
// ---- About: append server build tag (WEB mode only) ----
|
|
|
|
|
if (WEB) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/version', { cache: 'no-store' });
|
|
|
|
|
if (res.ok && view.type === 'settings') {
|
|
|
|
|
const v = await res.json();
|
|
|
|
|
const el = $('aboutVersion');
|
|
|
|
|
if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`;
|
|
|
|
|
}
|
|
|
|
|
} catch { /* offline — leave the static version in place */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Reset ----
|
|
|
|
|
$('refreshUiBtn').addEventListener('click', async () => {
|
|
|
|
|
const btn = $('refreshUiBtn');
|
|
|
|
|
@@ -2472,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');
|
|
|
|
|
|