feat: integrated audio player + animated transcript follower

- Video detail (done videos): added 'Download audio' button; removed the
  manual 'Thumbnail' button (thumbnails auto-download now, so it was redundant).
- New 'GET /api/videos/{id}/audio' endpoint (also serves HEAD for probing),
  serving data/audio/<video_id>.mp3. Audio job outtmpl switched to %(id)s so
  files are addressable per video.
- Integrated <audio> player appears in the detail view once the MP3 is present
  (probed via HEAD on openVideo; polled after a download job until ready).
- Synced transcript follower: as audio plays, the matching segment is
  highlighted (seg-active) and auto-scrolled into view like a karaoke/lyrics
  tracker. Clicking any segment timestamp or chapter seeks the audio to that
  point (falls back to scroll when no audio). Playback indicator pulses while
  playing.
This commit is contained in:
urieljareth
2026-07-27 00:00:28 -06:00
parent 0866c92650
commit 06497299ab
51 changed files with 16013 additions and 17 deletions
+48 -11
View File
@@ -292,7 +292,9 @@
</div>
<div class="flex flex-wrap gap-2 mt-4">
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="detail.video.status==='done' ? downloadMd(detail.video.video_id) : processOne(detail.video.video_id)" x-text="detail.video.status==='done' ? 'Download .md' : 'Process to .md'"></button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadThumbnails([detail.video.video_id])">Thumbnail</button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudioOne(detail.video.video_id)" x-show="detail.video.status==='done'" x-text="detail.hasAudio ? 'Re-download audio' : 'Download audio'">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="openClip(detail.video.video_id)">Clip</button>
<template x-if="detail.video.url"><a class="btn btn-ghost !py-1.5 !px-3 text-xs" :href="detail.video.url" target="_blank" rel="noopener">Open on YouTube ↗</a></template>
</div>
@@ -302,13 +304,38 @@
</div>
</div>
<!-- audio player + synced transcript follower -->
<div class="glass p-4" x-show="detail.video && detail.video.status==='done'">
<template x-if="detail.hasAudio">
<div class="space-y-2">
<div class="flex items-center gap-2">
<audio x-ref="audioPlayer" :src="'/api/videos/'+detail.video.video_id+'/audio'" controls preload="metadata" class="w-full audio-bar"
@timeupdate="onAudioTime($event)" @pause="detail.audioPlaying=false" @play="detail.audioPlaying=true" @ended="detail.activeSeg=-1; detail.audioPlaying=false"></audio>
</div>
<div class="text-xs text-zinc-500 flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full" :class="detail.audioPlaying ? 'bg-rose-500 animate-pulse' : 'bg-zinc-600'"></span>
<span x-text="detail.audioPlaying ? 'transcript following playback' : 'press play — transcript follows the audio'"></span>
</div>
</div>
</template>
<template x-if="!detail.hasAudio">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="text-sm text-zinc-400">No audio downloaded — download the MP3 to unlock the synced transcript player.</div>
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadAudioOne(detail.video.video_id)">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Download audio
</button>
</div>
</template>
</div>
<div class="grid lg:grid-cols-3 gap-5">
<!-- chapters -->
<div class="glass p-5 lg:col-span-1" x-show="detail.video.has_chapters && (detail.video.chapters||[]).length">
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Chapters</h3>
<div class="space-y-1 max-h-[28rem] overflow-y-auto">
<template x-for="(ch, i) in (detail.video.chapters||[])" :key="i">
<button class="w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded hover:bg-zinc-800/60" @click="seekTranscript(ch.start_sec)">
<button class="w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded hover:bg-zinc-800/60" @click="seekTo(ch.start_sec)">
<span class="font-mono accent-text shrink-0" x-text="ts(ch.start_sec)"></span>
<span class="text-zinc-300 truncate" x-text="ch.title"></span>
</button>
@@ -321,11 +348,15 @@
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Transcript</h3>
<template x-if="loading.transcript"><div class="text-zinc-500 text-sm">loading…</div></template>
<template x-if="!loading.transcript && detail.transcript.length===0"><div class="text-zinc-500 text-sm">No transcript available.</div></template>
<div class="space-y-2 max-h-[34rem] overflow-y-auto pr-1">
<div class="space-y-1 max-h-[34rem] overflow-y-auto pr-1 transcript-scroll">
<template x-for="seg in detail.transcript" :key="seg.idx">
<div class="flex gap-3 text-sm leading-relaxed hover:bg-zinc-800/40 rounded px-2 py-1" :id="'seg-'+seg.idx">
<a class="font-mono accent-text shrink-0 mt-0.5 cursor-pointer" :href="detail.video.url ? detail.video.url+'&t='+Math.floor(seg.start_sec)+'s' : null" target="_blank" rel="noopener" x-text="ts(seg.start_sec)"></a>
<span class="text-zinc-300" x-text="seg.text"></span>
<div class="seg-row flex gap-3 text-sm leading-relaxed rounded-lg px-3 py-1.5 transition-all duration-200"
:class="detail.activeSeg===seg.idx ? 'seg-active' : 'hover:bg-zinc-800/40'"
:id="'seg-'+seg.idx">
<button class="font-mono shrink-0 mt-0.5 cursor-pointer hover:text-rose-300 transition-colors"
:class="detail.activeSeg===seg.idx ? 'accent-text font-bold' : 'text-zinc-500'"
@click="seekTo(seg.start_sec)" x-text="ts(seg.start_sec)"></button>
<span :class="detail.activeSeg===seg.idx ? 'text-white' : 'text-zinc-300'" x-text="seg.text"></span>
</div>
</template>
</div>
@@ -467,8 +498,14 @@
<!-- recent jobs -->
<div class="glass overflow-hidden">
<div class="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between">
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="loadJobs()">Refresh</button>
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
<span class="text-[0.65rem] text-zinc-600 mt-0.5">Clearing history removes job records only — downloaded .md files stay.</span>
</div>
<div class="flex gap-1.5">
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="loadJobs()">Refresh</button>
<button class="btn btn-danger !py-1 !px-2 text-xs" @click="clearJobHistory()" :disabled="scrape.jobs.length === 0">Clear history</button>
</div>
</div>
<table class="tbl">
<thead><tr><th>Channel</th><th>Status</th><th>Progress</th><th>Started</th><th>Finished</th><th></th></tr></thead>
@@ -482,7 +519,7 @@
<td class="font-mono text-xs text-zinc-400" x-text="(j.completed||0)+' / '+(j.total||0)"></td>
<td class="font-mono text-xs text-zinc-500" x-text="j.started_at ? fmtDateTime(j.started_at) : '—'"></td>
<td class="font-mono text-xs text-zinc-500" x-text="j.finished_at ? fmtDateTime(j.finished_at) : '—'"></td>
<td class="text-right"><button class="btn btn-danger !py-1 !px-2 text-xs" @click="deleteJob(j.id)">Delete</button></td>
<td class="text-right"><button class="btn !py-1 !px-2 text-xs" :class="jobIsTerminal(j) ? 'btn-ghost' : 'btn-danger'" @click="deleteJob(j.id)" x-text="jobActionLabel(j)" :title="jobIsTerminal(j) ? 'Remove this job from history (downloads stay)' : 'Cancel this running job'"></button></td>
</tr>
</template>
</tbody>
@@ -749,7 +786,7 @@
</main>
<!-- ============ FLOATING JOB WIDGET ============ -->
<template x-if="scrape.jobId && view !== 'scrape'">
<template x-if="scrape.jobId && view !== 'scrape' && !jobWidget.dismissed">
<div class="job-widget" x-transition.opacity>
<template x-if="!jobWidget.collapsed">
<div class="space-y-2.5">
@@ -759,7 +796,7 @@
<span class="text-xs font-semibold text-white uppercase tracking-wider" x-text="scrape.kind ? scrape.kind + ' job' : 'job'"></span>
<span class="pill !text-[0.6rem] !py-0" :class="scrape.error ? 'st-error' : (scrape.done ? 'st-done' : 'st-running')" x-text="scrape.error ? 'error' : (scrape.done ? 'done' : 'running')"></span>
</div>
<button class="jw-x" @click="jobWidget.collapsed = true" title="Collapse">—</button>
<button class="jw-x" @click="closeJobWidget()" :title="(!scrape.done && !scrape.error) ? 'Minimize' : 'Dismiss'">✕</button>
</div>
<div class="flex justify-between text-[0.7rem] text-zinc-500">
<span>Progress</span>