Remember and restore last playback position per video

Saves playback position to store on pause and periodically every
10s during playback. Restores position (>1s) when the same video
is loaded again, showing a 'Resumed from 0:42' toast.

Files:
- frontend/app.js: resumePositions in data, save in pause/timeupdate,
  restore in Player.afterLoad, boot merge includes resumePositions
This commit is contained in:
Jonathan Sykes
2026-06-14 14:31:19 +08:00
parent 81aced9bc5
commit ddfa1494c6
5 changed files with 38 additions and 3 deletions

View File

@@ -19,7 +19,7 @@ bottom as they come up.
- [x] Loading skeletons for search results instead of a bare "Searching…". - [x] Loading skeletons for search results instead of a bare "Searching…".
- [x] Keyboard-shortcut help overlay (press `?`), listing space / arrows / f / m. - [x] Keyboard-shortcut help overlay (press `?`), listing space / arrows / f / m.
- [x] Download/preload progress indicator on cards (percent or a bar, not just a pulse). - [x] Download/preload progress indicator on cards (percent or a bar, not just a pulse).
- [ ] Remember and restore the last playback position per video. - [x] Remember and restore the last playback position per video.
- [ ] Toast queue (stack multiple toasts instead of replacing). - [ ] Toast queue (stack multiple toasts instead of replacing).
## Features ## Features

View File

@@ -45,7 +45,7 @@ function toAssetUrl(path) {
} }
// ---------- State ---------- // ---------- State ----------
let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true } }; let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true }, resumePositions: {} };
let view = { type: 'search' }; // 'search' | 'history' | 'playlist' | 'settings' let view = { type: 'search' }; // 'search' | 'history' | 'playlist' | 'settings'
let searchResults = []; let searchResults = [];
let queue = []; // list of video objects for autoplay let queue = []; // list of video objects for autoplay
@@ -245,6 +245,19 @@ const Player = {
els.npChannel.textContent = current.meta.channel || ''; els.npChannel.textContent = current.meta.channel || '';
updateNowPlayingActions(); updateNowPlayingActions();
markPlayingCard(); markPlayingCard();
// Restore saved playback position
const id = current.meta.id;
if (data.resumePositions[id] && data.resumePositions[id] > 1) {
const saved = data.resumePositions[id];
const restore = () => {
Player.seek(saved);
toast('Resumed from ' + fmtTime(saved));
Player.master.removeEventListener('canplay', restore);
Player.master.removeEventListener('loadedmetadata', restore);
};
Player.master.addEventListener('canplay', restore, { once: true });
Player.master.addEventListener('loadedmetadata', restore, { once: true });
}
}, },
// Ordered list of qualities to try if playback errors out. Progressive // Ordered list of qualities to try if playback errors out. Progressive
@@ -459,7 +472,28 @@ function wirePlayerEvents() {
el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } }); el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } });
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } }); el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); }); el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
el.addEventListener('timeupdate', () => { if (masterIs(el)) updateProgress(); }); el.addEventListener('timeupdate', () => {
if (masterIs(el)) {
updateProgress();
// Persist playback position every 10s
if (current && current.meta) {
const t = Player.master.currentTime;
if (t > 5 && Math.floor(t) % 10 === 0) {
data.resumePositions[current.meta.id] = t;
persist();
}
}
}
});
el.addEventListener('pause', () => {
if (masterIs(el) && current && current.meta) {
const t = Player.master.currentTime;
if (t > 1) {
data.resumePositions[current.meta.id] = t;
persist();
}
}
});
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); }); el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
el.addEventListener('ended', () => { if (masterIs(el)) playNext(); }); el.addEventListener('ended', () => { if (masterIs(el)) playNext(); });
el.addEventListener('error', () => { el.addEventListener('error', () => {
@@ -1137,6 +1171,7 @@ async function boot() {
data = { data = {
playlists: loaded.playlists || [], playlists: loaded.playlists || [],
history: loaded.history || [], history: loaded.history || [],
resumePositions: loaded.resumePositions || {},
settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, ...(loaded.settings || {}) }, settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, ...(loaded.settings || {}) },
}; };
} }

Binary file not shown.