YouTube chapters: extracted automatically, ticks on the seek bar and Glass scrubber, [ / ] to jump

- Server: chapters come from yt-dlp's `chapters` (creator chapters and
  description timestamps) whenever a video is resolved, kept in a yt_chapters
  table; GET /api/chapters?v= serves them (resolving once if needed, 30-day
  cache) so server-cached copies get them too. Never fails the player.
- Player: a video's shared chapters win; with none, YouTube's stand in —
  listed as "Chapters from YouTube", read-only (no edit/delete; + Chapter
  copies them into an editable list). They drive the existing seek-bar ticks
  (classic and Glass Stage), the chapter badge and the chapter list, and are
  cached offline with the video's notes.
- Keys: [ previous chapter (restarts the current one when >3 s in), ] next.
This commit is contained in:
Jonathan Sykes
2026-10-03 03:52:50 +08:00
parent 8084411f6a
commit 54fcd12119
4 changed files with 107 additions and 7 deletions

View File

@@ -3268,7 +3268,7 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
const Notes = (() => {
const LC = window.LyricsCore;
const panel = $('notesPanel');
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {}, chapterAt() { return null; } };
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {}, chapterAt() { return null; }, jumpChapter() {} };
if (!panel || !LC) return noop;
const CACHE_KEY = 'ytpNotesCache';
@@ -3283,7 +3283,11 @@ const Notes = (() => {
const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || '');
const profileName = () => (data.profile && data.profile.name) || '';
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []);
const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
// Shared chapters win; with none, YouTube's own chapters (description
// timestamps) stand in, read-only (auto: true).
const ownChapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
const chapters = () => { const own = ownChapters(); return own.length ? own : (st.ytChapters || []); };
const autoChapters = () => !ownChapters().length && (st.ytChapters || []).length > 0;
const bookmarks = () => {
if (!st.videoId || !data.bookmarks) return [];
return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : [];
@@ -3347,6 +3351,8 @@ const Notes = (() => {
const cached = readCache()[id];
st.lyrics = cached ? cached.lyrics || null : null;
st.chapters = cached ? cached.chapters || null : null;
st.ytChapters = cached && Array.isArray(cached.ytChapters) ? cached.ytChapters : [];
loadYtChapters(id, seq);
panel.classList.remove('hidden');
setStatus(cached ? '' : 'Loading…');
renderAll();
@@ -3441,6 +3447,19 @@ const Notes = (() => {
return out.sort((a, b) => a.t - b.t);
}
// YouTube's chapters arrive on their own (never blocks the notes load).
async function loadYtChapters(id, seq) {
if (isUploadId(id) || String(id).startsWith('edit_')) return;
try {
const j = await (await fetch(`/api/chapters?v=${encodeURIComponent(id)}`)).json();
if (seq !== st.loadSeq || st.videoId !== id || !j || !j.ok) return;
st.ytChapters = (j.chapters || []).map((c) => ({ t: c.t, title: c.title, auto: true }));
const entry = readCache()[id] || {};
writeCache(id, { lyrics: entry.lyrics || st.lyrics, chapters: entry.chapters || st.chapters, ytChapters: st.ytChapters });
if (!st.sync) renderAll();
} catch { /* offline: keep the cached copy */ }
}
function renderMarkers() {
const chs = chapters();
const bms = bookmarks();
@@ -3463,11 +3482,12 @@ const Notes = (() => {
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
</div>
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>
${m.auto ? '' : `<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>`}
</div>`;
$('chapterList').innerHTML = chs.length
? chs.map((c, i) => row(c, 'chapter', i)).join('')
? (autoChapters() ? '<div class="notes-empty small">Chapters from YouTube · + Chapter starts your own list from these</div>' : '') +
chs.map((c, i) => row(c, 'chapter', i)).join('')
: '<div class="notes-empty small">No chapters yet — tap + Chapter while the song plays.</div>';
$('bookmarkList').innerHTML = bms.length
? bms.map((b, i) => row(b, 'bookmark', i)).join('')
@@ -3863,10 +3883,30 @@ const Notes = (() => {
const baseRev = st.chapters ? st.chapters.rev : 0;
const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' });
if (!v) return;
await saveShared('chapters', { items: [...chapters(), v] }, baseRev);
// Starting from YouTube's chapters copies them in as ordinary ones.
await saveShared('chapters', { items: [...chapters().map((c) => ({ t: c.t, title: c.title })), v] }, baseRev);
}
// [ / ] — previous / next chapter. "Previous" restarts the current chapter
// first when more than 3 s into it, like a CD player.
function jumpChapter(dir) {
const cs = chapters().slice().sort((a, b) => a.t - b.t);
if (!cs.length || !Player.master) { toast('No chapters for this video'); return; }
const cur = Player.master.currentTime || 0;
let target = null;
if (dir > 0) target = cs.find((c) => c.t > cur + 0.5);
else {
const before = cs.filter((c) => c.t <= cur + 0.5);
const here = before[before.length - 1];
target = here && cur - here.t > 3 ? here : before[before.length - 2] || cs[0];
}
if (!target) { toast('Last chapter'); return; }
Player.seek(target.t);
toast(`${cs.indexOf(target) + 1}/${cs.length} · ${target.title}`, { duration: 1500 });
}
async function editMarker(kind, i) {
if (kind === 'chapter' && autoChapters()) { toast('YouTube’s chapters can’t be edited — + Chapter starts your own list'); return; }
const list = kind === 'chapter' ? chapters() : bookmarks();
const item = list[i];
if (!item) return;
@@ -3882,6 +3922,7 @@ const Notes = (() => {
}
async function deleteMarker(kind, i) {
if (kind === 'chapter' && autoChapters()) { toast('YouTube’s chapters can’t be deleted — + Chapter starts your own list'); return; }
if (kind === 'chapter') {
if (!requireProfile()) return;
const next = chapters().filter((_, j) => j !== i);
@@ -4396,6 +4437,7 @@ const Notes = (() => {
return {
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
jumpChapter,
chapterAt: (t) => { const c = chapters().slice().sort((a, b) => a.t - b.t); let k = -1; c.forEach((x, i) => { if (x.t <= t) k = i; }); return k < 0 ? null : { n: k + 1, of: c.length, title: c[k].title || '' }; },
};
})();
@@ -10777,6 +10819,8 @@ document.querySelectorAll('.chip').forEach((c) => {
else if (e.key === 'p') { if (current) Notes.addBookmark(); }
else if (e.key === 'n') { if (current) Notes.addNote(); }
else if (e.key === 'i') PiP.toggle();
else if (e.key === '[') { if (current) Notes.jumpChapter(-1); }
else if (e.key === ']') { if (current) Notes.jumpChapter(1); }
else if (e.key === 'e') EQ.open();
else if (e.key === '?') toggleShortcutHelp();
else if (e.key === 'Escape') {

View File

@@ -621,6 +621,7 @@
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
<div class="shortcut-row"><kbd>[</kbd><kbd>]</kbd><span>Previous / next chapter</span></div>
<div class="shortcut-row"><kbd>N</kbd><span>Write a note at this moment</span></div>
<div class="shortcut-row"><kbd>I</kbd><span>Picture-in-picture</span></div>
<div class="shortcut-row"><kbd>E</kbd><span>Equalizer</span></div>