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:
@@ -575,6 +575,22 @@ class Store:
|
||||
cur.execute("SELECT * FROM scrape_jobs ORDER BY started_at DESC LIMIT ?", (limit,))
|
||||
return [_row_to_jobrow(r) for r in cur.fetchall()]
|
||||
|
||||
TERMINAL_STATUSES = ("done", "error", "cancelled")
|
||||
|
||||
def delete_job(self, job_id: str) -> bool:
|
||||
with self._cursor() as cur:
|
||||
cur.execute("DELETE FROM scrape_jobs WHERE id = ?", (job_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
def delete_terminal_jobs(self) -> int:
|
||||
placeholders = ",".join("?" for _ in self.TERMINAL_STATUSES)
|
||||
with self._cursor() as cur:
|
||||
cur.execute(
|
||||
f"DELETE FROM scrape_jobs WHERE status IN ({placeholders})",
|
||||
list(self.TERMINAL_STATUSES),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
# ------------------------------------------------------------------ aggregates
|
||||
|
||||
def stats(self, channel_id: str | None = None) -> dict[str, int]:
|
||||
|
||||
@@ -134,8 +134,19 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
|
||||
await asyncio.sleep(0.25)
|
||||
return EventSourceResponse(gen())
|
||||
|
||||
@r.delete("/scrape/history")
|
||||
def clear_job_history():
|
||||
n = store.delete_terminal_jobs()
|
||||
return {"deleted": n}
|
||||
|
||||
@r.delete("/scrape/{job_id}")
|
||||
def cancel_job(job_id: str):
|
||||
def delete_or_cancel_job(job_id: str):
|
||||
job = store.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
if job.status in store.TERMINAL_STATUSES:
|
||||
store.delete_job(job_id)
|
||||
return {"deleted": job_id}
|
||||
ok = jobs.cancel(job_id)
|
||||
return {"cancelled": ok}
|
||||
|
||||
@@ -329,6 +340,14 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
|
||||
raise HTTPException(404, "markdown file missing on disk")
|
||||
return FileResponse(str(p), filename=p.name, media_type="text/markdown")
|
||||
|
||||
@r.api_route("/videos/{video_id}/audio", methods=["GET", "HEAD"])
|
||||
def video_audio(video_id: str, request: Request):
|
||||
# audio is stored as data/audio/<video_id>.mp3 (see jobs._run_audio outtmpl)
|
||||
p = Path(cfg.output_dir_resolved).parent / "audio" / f"{video_id}.mp3"
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "audio not downloaded yet")
|
||||
return FileResponse(str(p), filename=f"{video_id}.mp3", media_type="audio/mpeg")
|
||||
|
||||
# -------------------------------------------------- thumbnails (local cache)
|
||||
@r.post("/tools/thumbnails")
|
||||
async def download_thumbnails(payload: dict):
|
||||
|
||||
@@ -167,7 +167,7 @@ class JobManager:
|
||||
self._emit(job_id, "log", {"msg": f"downloading {total} audio tracks -> {out_dir}"})
|
||||
ydl_opts = {
|
||||
"format": "bestaudio/best",
|
||||
"outtmpl": str(out_dir / "%(title)s.%(ext)s"),
|
||||
"outtmpl": str(out_dir / "%(id)s.%(ext)s"),
|
||||
"postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "128"}],
|
||||
"quiet": True, "no_warnings": True, "noprogress": True,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
dash: { channels: [], status_breakdown: {}, uploads_over_time: [], duration_histogram: [], top_tags: [] },
|
||||
channels: { items: [], pending: {} },
|
||||
videos: { items: [], total: 0, page: 1, size: 25, selected: [] },
|
||||
detail: { video: null, transcript: [] },
|
||||
detail: { video: null, transcript: [], hasAudio: false, audioPlaying: false, activeSeg: -1 },
|
||||
search: { q: "", channel: "", items: [], ran: false },
|
||||
analysis: { tab: "wordcloud", channel: "", term: "", words: [], timeline: [] },
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
stats: { open: false, data: null, loading: false, channel: "" },
|
||||
|
||||
// ----- floating job widget -----
|
||||
jobWidget: { collapsed: false },
|
||||
jobWidget: { collapsed: false, dismissed: false, _hideTimer: null },
|
||||
|
||||
// ----- tools / folders -----
|
||||
folders: { markdown: { path: "", exists: false }, exports: { path: "", exists: false }, audio: { path: "", exists: false }, analysis: { path: "", exists: false }, database: { path: "", exists: false } },
|
||||
@@ -228,7 +228,7 @@
|
||||
this.view = "detail";
|
||||
this.loading.detail = true;
|
||||
this.loading.transcript = true;
|
||||
this.detail = { video: null, transcript: [] };
|
||||
this.detail = { video: null, transcript: [], hasAudio: false, audioPlaying: false, activeSeg: -1 };
|
||||
try {
|
||||
const v = await this.api("/api/videos/" + encodeURIComponent(id));
|
||||
// chapters may come embedded or be absent
|
||||
@@ -241,6 +241,73 @@
|
||||
this.detail.transcript = (t && t.items) || [];
|
||||
} catch (_) { this.detail.transcript = []; }
|
||||
finally { this.loading.transcript = false; }
|
||||
// probe audio availability (HEAD) for the integrated player
|
||||
this.checkAudio(id);
|
||||
},
|
||||
|
||||
async checkAudio(id) {
|
||||
try {
|
||||
const r = await fetch("/api/videos/" + encodeURIComponent(id) + "/audio", { method: "HEAD" });
|
||||
const ok = !!(r && r.ok);
|
||||
if (this.detail.video && this.detail.video.video_id === id) this.detail.hasAudio = ok;
|
||||
return ok;
|
||||
} catch (_) { return false; }
|
||||
},
|
||||
|
||||
async downloadAudioOne(id) {
|
||||
try {
|
||||
const d = await this.api("/api/tools/audio", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: [id] }) });
|
||||
this.toast("Audio download started — player will activate when ready");
|
||||
this.subscribeJob(d.job_id, "audio");
|
||||
this._pollAudio(id);
|
||||
} catch (e) { this.toast("Audio failed: " + e.message, "error"); }
|
||||
},
|
||||
|
||||
_pollAudio(id) {
|
||||
if (this._audioPoll) clearInterval(this._audioPoll);
|
||||
let attempts = 0;
|
||||
this._audioPoll = setInterval(async () => {
|
||||
attempts++;
|
||||
const ready = await this.checkAudio(id);
|
||||
if ((ready && this.detail.video && this.detail.video.video_id === id) || attempts > 90) {
|
||||
clearInterval(this._audioPoll); this._audioPoll = null;
|
||||
if (ready && this.detail.video && this.detail.video.video_id === id) this.toast("Audio ready — press play ▶");
|
||||
}
|
||||
}, 4000);
|
||||
},
|
||||
|
||||
// audio playback -> transcript follower
|
||||
onAudioTime(e) {
|
||||
const t = (e && e.target && typeof e.target.currentTime === "number") ? e.target.currentTime : 0;
|
||||
const segs = this.detail.transcript || [];
|
||||
if (!segs.length) return;
|
||||
let active = -1;
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
if ((segs[i].start_sec || 0) <= t + 0.05) active = segs[i].idx;
|
||||
else break;
|
||||
}
|
||||
if (active !== this.detail.activeSeg) {
|
||||
this.detail.activeSeg = active;
|
||||
this.$nextTick(() => {
|
||||
const el = document.getElementById("seg-" + active);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
seekAudio(sec) {
|
||||
const a = this.$refs.audioPlayer;
|
||||
if (!a) return false;
|
||||
try { a.currentTime = sec; a.play().catch(() => {}); } catch (_) {}
|
||||
return true;
|
||||
},
|
||||
|
||||
seekTo(sec) {
|
||||
// seek the local audio player if available; otherwise just scroll the transcript
|
||||
if (this.detail.hasAudio && this.$refs.audioPlayer) {
|
||||
if (this.seekAudio(sec)) return;
|
||||
}
|
||||
this.seekTranscript(sec);
|
||||
},
|
||||
|
||||
seekTranscript(sec) {
|
||||
@@ -563,6 +630,8 @@
|
||||
this.scrape.log = ["[stream] connecting…"];
|
||||
this.scrape.progress = { completed: 0, total: 0 };
|
||||
this.jobWidget.collapsed = false;
|
||||
this.jobWidget.dismissed = false;
|
||||
this._disarmAutoHide();
|
||||
const es = new EventSource("/api/scrape/" + encodeURIComponent(jobId) + "/stream");
|
||||
this.scrape.es = es;
|
||||
es.addEventListener("log", e => { try { const d = JSON.parse(e.data); this.scrape.log.push(d.msg || ""); } catch (_) {} });
|
||||
@@ -577,13 +646,15 @@
|
||||
if (this.view === "videos") this.loadVideos();
|
||||
if (this.view === "channels") this.loadChannels();
|
||||
if (this.view === "dashboard") this.loadDashboard();
|
||||
this._armAutoHide();
|
||||
});
|
||||
es.addEventListener("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); });
|
||||
es.addEventListener("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); this._armAutoHide(); });
|
||||
es.addEventListener("error", e => {
|
||||
if (this.scrape.es === null) return; // already closed by done/cancelled
|
||||
let msg = "connection error";
|
||||
try { if (e.data) { const d = JSON.parse(e.data); msg = d.msg || msg; this.scrape.error = msg; this.scrape.log.push("[error] " + msg); } } catch (_) { this.scrape.error = msg; }
|
||||
this.closeStream(); this.loadJobs();
|
||||
this._armAutoHide();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -599,6 +670,32 @@
|
||||
catch (e) { this.toast("Delete failed: " + e.message, "error"); }
|
||||
},
|
||||
|
||||
closeJobWidget() {
|
||||
if (this.scrape.jobId && !this.scrape.done && !this.scrape.error) {
|
||||
this.jobWidget.collapsed = true;
|
||||
return;
|
||||
}
|
||||
this._disarmAutoHide();
|
||||
this.jobWidget.dismissed = true;
|
||||
},
|
||||
_armAutoHide() {
|
||||
this._disarmAutoHide();
|
||||
this.jobWidget._hideTimer = setTimeout(() => { this.jobWidget.dismissed = true; }, 6000);
|
||||
},
|
||||
_disarmAutoHide() {
|
||||
if (this.jobWidget._hideTimer) { clearTimeout(this.jobWidget._hideTimer); this.jobWidget._hideTimer = null; }
|
||||
},
|
||||
async clearJobHistory() {
|
||||
if (!confirm("Clear all finished jobs from history?\nDownloaded .md files and video data are NOT affected.")) return;
|
||||
try {
|
||||
const d = await this.api("/api/scrape/history", { method: "DELETE" });
|
||||
this.toast("Cleared " + ((d && d.deleted) || 0) + " finished job(s)", "success");
|
||||
this.loadJobs();
|
||||
} catch (e) { this.toast("Clear failed: " + e.message, "error"); }
|
||||
},
|
||||
jobIsTerminal(j) { return j && ["done", "error", "cancelled"].includes(j.status); },
|
||||
jobActionLabel(j) { return this.jobIsTerminal(j) ? "Clear" : "Cancel"; },
|
||||
|
||||
scrapePct() {
|
||||
const p = this.scrape.progress || {};
|
||||
if (!p.total) return this.scrape.done ? 100 : 0;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -235,3 +235,21 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp
|
||||
}
|
||||
.stat-lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; color: #71717a; font-weight: 600; }
|
||||
.stat-val { font-size: 1.25rem; font-weight: 700; color: #fff; font-family: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace; margin-top: 0.15rem; }
|
||||
|
||||
/* ===== audio player + synced transcript follower ===== */
|
||||
.audio-bar { filter: invert(0.92) hue-rotate(170deg) sepia(0.15); height: 36px; }
|
||||
.audio-bar::-webkit-media-controls-panel { background: rgba(24,24,27,0.85); }
|
||||
|
||||
/* the transcript segment currently matching audio playback */
|
||||
.seg-row { border-left: 2px solid transparent; }
|
||||
.seg-active {
|
||||
background: linear-gradient(90deg, rgba(244,63,94,0.18) 0%, rgba(244,63,94,0.04) 100%);
|
||||
border-left-color: #f43f5e;
|
||||
box-shadow: 0 0 0 1px rgba(244,63,94,0.15);
|
||||
}
|
||||
.seg-active .accent-text { color: #fb7185; }
|
||||
|
||||
/* smooth scrolling within the transcript pane */
|
||||
.transcript-scroll { scroll-behavior: smooth; }
|
||||
.transcript-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.transcript-scroll::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 4px; }
|
||||
|
||||
Reference in New Issue
Block a user