Transcript viewer + search, in both UIs (3.x)
Surface the downloaded .vtt subtitles as a searchable, click-to-seek transcript — a thing Tartube doesn't do at all. Web (pure frontend): a 📄 button in the player opens a transcript pane beside the video. It fetches the already-served .vtt, parses cues client-side, and offers full-text search (highlighted), click-to-seek, and a live highlight + auto-scroll of the current line as the video plays. Reuses the chapters-pane layout; hidden on narrow screens. Desktop (egui): a 📄 Transcript button on the detail panel opens a floating window that reads the .vtt off disk (new `vtt` parser module), shows the searchable cue list, and seeks the running mpv via its JSON-IPC socket when you click a line. New `src/vtt.rs`: a small, tolerant WebVTT/SRT cue parser (start time + tag-stripped text, consecutive-duplicate collapse) with unit tests. Closes the desktop/web parity gap for transcripts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a47c7991b4
commit
fcd202fde0
4 changed files with 314 additions and 1 deletions
111
src/app.rs
111
src/app.rs
|
|
@ -163,6 +163,11 @@ pub struct App {
|
||||||
search_results: Vec<crate::database::SearchHit>,
|
search_results: Vec<crate::database::SearchHit>,
|
||||||
show_search: bool,
|
show_search: bool,
|
||||||
search_focus: bool,
|
search_focus: bool,
|
||||||
|
// Transcript viewer (floating window)
|
||||||
|
show_transcript: bool,
|
||||||
|
transcript_video: Option<String>,
|
||||||
|
transcript_cues: Vec<crate::vtt::Cue>,
|
||||||
|
transcript_query: String,
|
||||||
// Scheduler
|
// Scheduler
|
||||||
last_scheduled_check: Option<Instant>,
|
last_scheduled_check: Option<Instant>,
|
||||||
// Cards cache — recomputed only when inputs change
|
// Cards cache — recomputed only when inputs change
|
||||||
|
|
@ -487,6 +492,10 @@ impl App {
|
||||||
search_results: Vec::new(),
|
search_results: Vec::new(),
|
||||||
show_search: false,
|
show_search: false,
|
||||||
search_focus: false,
|
search_focus: false,
|
||||||
|
show_transcript: false,
|
||||||
|
transcript_video: None,
|
||||||
|
transcript_cues: Vec::new(),
|
||||||
|
transcript_query: String::new(),
|
||||||
last_scheduled_check: None,
|
last_scheduled_check: None,
|
||||||
cards_cache: Vec::new(),
|
cards_cache: Vec::new(),
|
||||||
cards_cache_key: None,
|
cards_cache_key: None,
|
||||||
|
|
@ -934,6 +943,98 @@ impl App {
|
||||||
if let Some(id) = to_play { self.play_by_id(&id); }
|
if let Some(id) = to_play { self.play_by_id(&id); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load a video's first subtitle file into the transcript viewer.
|
||||||
|
fn open_transcript(&mut self, id: &str, path: PathBuf) {
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(text) => {
|
||||||
|
self.transcript_cues = crate::vtt::parse(&text);
|
||||||
|
self.transcript_video = Some(id.to_string());
|
||||||
|
self.transcript_query.clear();
|
||||||
|
self.show_transcript = true;
|
||||||
|
if self.transcript_cues.is_empty() {
|
||||||
|
self.status = "That subtitle file had no readable cues".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.status = format!("Couldn't read transcript: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Floating transcript window: searchable cue list; clicking a line seeks
|
||||||
|
/// the running mpv (via its JSON-IPC socket) when this video is playing.
|
||||||
|
/// The web UI's 📄 transcript pane is the browser-side equivalent.
|
||||||
|
fn transcript_window(&mut self, ctx: &egui::Context) {
|
||||||
|
if !self.show_transcript { return; }
|
||||||
|
let mut open = true;
|
||||||
|
let mut seek_to: Option<f64> = None;
|
||||||
|
let playing = self.currently_playing.is_some()
|
||||||
|
&& self.currently_playing.as_deref() == self.transcript_video.as_deref();
|
||||||
|
egui::Window::new("📄 Transcript")
|
||||||
|
.open(&mut open)
|
||||||
|
.default_width(440.0)
|
||||||
|
.default_height(520.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.transcript_query)
|
||||||
|
.hint_text("search the transcript…")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(if playing {
|
||||||
|
"Click a line to jump there in mpv."
|
||||||
|
} else {
|
||||||
|
"Play this video to seek by clicking a line."
|
||||||
|
})
|
||||||
|
.small().weak(),
|
||||||
|
);
|
||||||
|
ui.separator();
|
||||||
|
let q = self.transcript_query.trim().to_lowercase();
|
||||||
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||||
|
for cue in &self.transcript_cues {
|
||||||
|
if !q.is_empty() && !cue.text.to_lowercase().contains(&q) { continue; }
|
||||||
|
let line = format!("{} {}", format_duration(cue.start), cue.text);
|
||||||
|
let resp = ui.add(
|
||||||
|
egui::Label::new(line).wrap().sense(egui::Sense::click()),
|
||||||
|
);
|
||||||
|
if resp.clicked() { seek_to = Some(cue.start); }
|
||||||
|
if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if !open { self.show_transcript = false; }
|
||||||
|
if let Some(s) = seek_to { self.mpv_seek(s); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek the running mpv to `secs` by writing a `seek … absolute` command
|
||||||
|
/// to its JSON-IPC socket (set up in [`Self::play_with_tracking`]). No-op
|
||||||
|
/// with a status hint when this video isn't the one currently playing.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn mpv_seek(&mut self, secs: f64) {
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
let Some(id) = self.transcript_video.clone() else { return; };
|
||||||
|
if self.currently_playing.as_deref() != Some(id.as_str()) {
|
||||||
|
self.status = "Play this video first, then click a line to seek".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sock = format!("/tmp/yt-offline-{id}.sock");
|
||||||
|
match UnixStream::connect(&sock) {
|
||||||
|
Ok(mut s) => {
|
||||||
|
let cmd = format!("{{\"command\":[\"seek\",{secs},\"absolute\"]}}\n");
|
||||||
|
if s.write_all(cmd.as_bytes()).is_ok() {
|
||||||
|
self.status = format!("Seek to {}", format_duration(secs));
|
||||||
|
} else {
|
||||||
|
self.status = "Couldn't send seek to mpv".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => self.status = "Couldn't reach mpv (is it still playing with IPC?)".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn mpv_seek(&mut self, _secs: f64) {
|
||||||
|
self.status = "Seeking the player requires mpv IPC (Unix only)".into();
|
||||||
|
}
|
||||||
|
|
||||||
fn play_with_tracking(&mut self, path: &Path, video_id: String) {
|
fn play_with_tracking(&mut self, path: &Path, video_id: String) {
|
||||||
let cmd = self.config.player.command.clone();
|
let cmd = self.config.player.command.clone();
|
||||||
// Only enable IPC for genuine mpv invocations — substring matching
|
// Only enable IPC for genuine mpv invocations — substring matching
|
||||||
|
|
@ -3232,6 +3333,15 @@ impl App {
|
||||||
self.toggle_watched(&selected_id);
|
self.toggle_watched(&selected_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(sub) = video.subtitles.first() {
|
||||||
|
if ui.button("📄 Transcript")
|
||||||
|
.on_hover_text("Searchable transcript; click a line to seek the player")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.open_transcript(&selected_id, sub.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if video.has_live_chat {
|
if video.has_live_chat {
|
||||||
ui.label(egui::RichText::new("💬 live chat").small().weak());
|
ui.label(egui::RichText::new("💬 live chat").small().weak());
|
||||||
}
|
}
|
||||||
|
|
@ -3941,6 +4051,7 @@ impl eframe::App for App {
|
||||||
self.folder_manager_window(ctx);
|
self.folder_manager_window(ctx);
|
||||||
self.move_to_folder_window(ctx);
|
self.move_to_folder_window(ctx);
|
||||||
self.search_window(ctx);
|
self.search_window(ctx);
|
||||||
|
self.transcript_window(ctx);
|
||||||
|
|
||||||
match self.current_screen {
|
match self.current_screen {
|
||||||
Screen::Library => {
|
Screen::Library => {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ mod stats;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod tray;
|
mod tray;
|
||||||
mod util;
|
mod util;
|
||||||
|
mod vtt;
|
||||||
mod web;
|
mod web;
|
||||||
mod ytdlp_bin;
|
mod ytdlp_bin;
|
||||||
|
|
||||||
|
|
|
||||||
114
src/vtt.rs
Normal file
114
src/vtt.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
//! Minimal WebVTT / SRT cue parser for the transcript viewer.
|
||||||
|
//!
|
||||||
|
//! The transcript viewer only needs each cue's **start time** and its
|
||||||
|
//! **text** — not end times, styling, regions, or positioning — so this is
|
||||||
|
//! deliberately small and tolerant of both WebVTT (`.vtt`) and SRT (`.srt`)
|
||||||
|
//! timestamps. The web UI parses the served `.vtt` in JavaScript; the desktop
|
||||||
|
//! reads the file off disk and uses this.
|
||||||
|
|
||||||
|
/// One subtitle cue: when it starts (seconds into the video) and its
|
||||||
|
/// tag-stripped text.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Cue {
|
||||||
|
pub start: f64,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse VTT/SRT text into cues, in file order. Inline tags like `<c>` are
|
||||||
|
/// stripped and consecutive duplicate lines (common in auto-generated
|
||||||
|
/// captions, which re-emit the rolling line) are collapsed.
|
||||||
|
pub fn parse(text: &str) -> Vec<Cue> {
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
let mut cues: Vec<Cue> = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < lines.len() {
|
||||||
|
// A cue timing line is "<start> --> <end> [settings]".
|
||||||
|
if let Some(arrow) = lines[i].find("-->") {
|
||||||
|
if let Some(start) = parse_timestamp(&lines[i][..arrow]) {
|
||||||
|
i += 1;
|
||||||
|
let mut buf: Vec<String> = Vec::new();
|
||||||
|
while i < lines.len() && !lines[i].trim().is_empty() {
|
||||||
|
buf.push(strip_tags(lines[i]));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let text: String = buf.join(" ").split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if !text.is_empty() && cues.last().map(|c| c.text.as_str()) != Some(text.as_str()) {
|
||||||
|
cues.push(Cue { start, text });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
cues
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip simple `<...>` inline tags (e.g. `<c.colorE5E5E5>`, `<00:00:01.000>`)
|
||||||
|
/// from a caption line.
|
||||||
|
fn strip_tags(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' => in_tag = false,
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `HH:MM:SS.mmm` / `MM:SS.mmm` (WebVTT) or comma-decimal (SRT)
|
||||||
|
/// timestamp into seconds. Returns `None` for non-timestamp lines.
|
||||||
|
fn parse_timestamp(s: &str) -> Option<f64> {
|
||||||
|
let token = s.trim().replace(',', ".");
|
||||||
|
let token = token.split_whitespace().next()?; // drop trailing cue settings
|
||||||
|
let mut secs = 0f64;
|
||||||
|
let mut any = false;
|
||||||
|
for part in token.split(':') {
|
||||||
|
let v: f64 = part.parse().ok()?;
|
||||||
|
secs = secs * 60.0 + v;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if any { Some(secs) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_vtt_with_tags() {
|
||||||
|
let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello <c>world</c>\n\n\
|
||||||
|
00:01:05.500 --> 00:01:07.000 align:start\nSecond line\n";
|
||||||
|
let cues = parse(vtt);
|
||||||
|
assert_eq!(cues.len(), 2);
|
||||||
|
assert_eq!(cues[0], Cue { start: 1.0, text: "Hello world".into() });
|
||||||
|
assert_eq!(cues[1].start, 65.5);
|
||||||
|
assert_eq!(cues[1].text, "Second line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_srt_and_collapses_dupes() {
|
||||||
|
let srt = "1\n00:00:02,000 --> 00:00:04,000\nsame\n\n\
|
||||||
|
2\n00:00:04,000 --> 00:00:06,000\nsame\n";
|
||||||
|
let cues = parse(srt);
|
||||||
|
assert_eq!(cues.len(), 1, "consecutive duplicate lines collapse");
|
||||||
|
assert_eq!(cues[0].start, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_line_cue_joins() {
|
||||||
|
let vtt = "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nline one\nline two\n";
|
||||||
|
let cues = parse(vtt);
|
||||||
|
assert_eq!(cues.len(), 1);
|
||||||
|
assert_eq!(cues[0].text, "line one line two");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_only_is_empty() {
|
||||||
|
assert!(parse("WEBVTT\n\n").is_empty());
|
||||||
|
assert!(parse("").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,14 @@
|
||||||
.chapter.active{background:var(--card);border-left:3px solid var(--accent)}
|
.chapter.active{background:var(--card);border-left:3px solid var(--accent)}
|
||||||
.chapter .ch-time{color:var(--muted);font-size:11px;font-family:monospace}
|
.chapter .ch-time{color:var(--muted);font-size:11px;font-family:monospace}
|
||||||
.chapter .ch-title{font-weight:500;margin-top:1px}
|
.chapter .ch-title{font-weight:500;margin-top:1px}
|
||||||
|
.transcript-pane{width:300px;background:var(--bg);border:1px solid var(--border);border-radius:4px;display:flex;flex-direction:column;flex-shrink:0;min-height:0}
|
||||||
|
.transcript-pane h4{font-size:11px;padding:7px 10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}
|
||||||
|
.t-search{margin:6px;padding:4px 8px;background:var(--panel);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px}
|
||||||
|
.t-list{overflow-y:auto;flex:1;min-height:0}
|
||||||
|
.t-cue{padding:5px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border);line-height:1.35}
|
||||||
|
.t-cue:hover{background:var(--card)}
|
||||||
|
.t-cue.active{background:var(--card);border-left:3px solid var(--accent)}
|
||||||
|
.t-cue .t-time{color:var(--muted);font-size:10px;font-family:monospace;margin-right:6px}
|
||||||
.settings-row{display:flex;align-items:center;gap:8px;padding:6px 0}
|
.settings-row{display:flex;align-items:center;gap:8px;padding:6px 0}
|
||||||
.settings-row label{flex:1;font-size:13px}
|
.settings-row label{flex:1;font-size:13px}
|
||||||
.settings-hint{font-size:11px;color:var(--muted)}
|
.settings-hint{font-size:11px;color:var(--muted)}
|
||||||
|
|
@ -161,6 +169,7 @@
|
||||||
.modal video{max-height:55vh}
|
.modal video{max-height:55vh}
|
||||||
#details{max-height:160px;padding:8px 10px}
|
#details{max-height:160px;padding:8px 10px}
|
||||||
.chapters-pane{display:none}
|
.chapters-pane{display:none}
|
||||||
|
.transcript-pane{display:none !important}
|
||||||
section#content{padding:8px}
|
section#content{padding:8px}
|
||||||
/* Modal "new download" form stacks vertically on small screens so
|
/* Modal "new download" form stacks vertically on small screens so
|
||||||
the URL + button + quality picker don't squeeze each other. */
|
the URL + button + quality picker don't squeeze each other. */
|
||||||
|
|
@ -1248,10 +1257,17 @@ function shufflePlay(){
|
||||||
function playVideo(id){
|
function playVideo(id){
|
||||||
const v=findVideo(id);if(!v||!v.video_url)return;
|
const v=findVideo(id);if(!v||!v.video_url)return;
|
||||||
currentPlayingId=id;
|
currentPlayingId=id;
|
||||||
|
transcript={cues:[],loaded:false}; // reset for the new video
|
||||||
const bg=document.createElement('div');bg.className='modal-bg';
|
const bg=document.createElement('div');bg.className='modal-bg';
|
||||||
bg.onclick=e=>{if(e.target===bg)closeModal(bg)};
|
bg.onclick=e=>{if(e.target===bg)closeModal(bg)};
|
||||||
const tracks=(v.subtitles||[]).map((s,i)=>`<track kind="subtitles" src="${esc(safeUrl(s.url))}" srclang="${esc(s.lang)}" label="${esc(s.label)}"${i===0?' default':''}>`).join('');
|
const tracks=(v.subtitles||[]).map((s,i)=>`<track kind="subtitles" src="${esc(safeUrl(s.url))}" srclang="${esc(s.lang)}" label="${esc(s.label)}"${i===0?' default':''}>`).join('');
|
||||||
const chapPane=v.has_chapters?`<div class="chapters-pane" id="chapters-pane"><h4>Chapters</h4><div id="chapters-list"><em style="padding:10px;display:block;color:var(--muted)">Loading…</em></div></div>`:'';
|
const chapPane=v.has_chapters?`<div class="chapters-pane" id="chapters-pane"><h4>Chapters</h4><div id="chapters-list"><em style="padding:10px;display:block;color:var(--muted)">Loading…</em></div></div>`:'';
|
||||||
|
const hasSubs=v.subtitles&&v.subtitles.length;
|
||||||
|
const transcriptPane=hasSubs?`<div class="transcript-pane" id="transcript-pane" style="display:none">
|
||||||
|
<h4>Transcript</h4>
|
||||||
|
<input class="t-search" id="t-search" placeholder="Search transcript…" oninput="filterTranscript()">
|
||||||
|
<div class="t-list" id="t-list"><em style="padding:10px;display:block;color:var(--muted)">Loading…</em></div>
|
||||||
|
</div>`:'';
|
||||||
// Show upload date + duration in the modal header so the viewer has
|
// Show upload date + duration in the modal header so the viewer has
|
||||||
// basic context without opening the metadata pane.
|
// basic context without opening the metadata pane.
|
||||||
const headerMeta=[
|
const headerMeta=[
|
||||||
|
|
@ -1262,10 +1278,11 @@ function playVideo(id){
|
||||||
bg.innerHTML=`<div class="modal">
|
bg.innerHTML=`<div class="modal">
|
||||||
<div class="modal-hdr">
|
<div class="modal-hdr">
|
||||||
<h2>${esc(v.title)}${metaLine}</h2>
|
<h2>${esc(v.title)}${metaLine}</h2>
|
||||||
|
${hasSubs?`<button onclick="toggleTranscript('${esc(id)}')" title="Transcript (searchable, click to seek)">📄</button>`:''}
|
||||||
<button onclick="openComments('${esc(id)}')" title="Comments">💬</button>
|
<button onclick="openComments('${esc(id)}')" title="Comments">💬</button>
|
||||||
<button onclick="closeModal(this.closest('.modal-bg'))">✕ Close</button>
|
<button onclick="closeModal(this.closest('.modal-bg'))">✕ Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body"><video id="player-video" src="${esc(safeUrl(v.video_url))}" controls autoplay crossorigin="anonymous">${tracks}</video>${chapPane}</div>
|
<div class="modal-body"><video id="player-video" src="${esc(safeUrl(v.video_url))}" controls autoplay crossorigin="anonymous">${tracks}</video>${chapPane}${transcriptPane}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(bg);
|
document.body.appendChild(bg);
|
||||||
const vid=bg.querySelector('#player-video');
|
const vid=bg.querySelector('#player-video');
|
||||||
|
|
@ -1313,6 +1330,76 @@ async function loadChapters(id){
|
||||||
}
|
}
|
||||||
function jumpToChapter(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
|
function jumpToChapter(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
|
||||||
|
|
||||||
|
/* ── Transcript viewer (parses the served .vtt client-side) ──────── */
|
||||||
|
let transcript={cues:[],loaded:false};
|
||||||
|
let tSearching=false;
|
||||||
|
function toggleTranscript(id){
|
||||||
|
const pane=document.getElementById('transcript-pane');if(!pane)return;
|
||||||
|
const showing=pane.style.display!=='none';
|
||||||
|
pane.style.display=showing?'none':'flex';
|
||||||
|
if(!showing&&!transcript.loaded){
|
||||||
|
const v=findVideo(id);const sub=((v&&v.subtitles)||[])[0];
|
||||||
|
if(sub&&sub.url)loadTranscript(sub.url); else {const l=document.getElementById('t-list');if(l)l.innerHTML='<em style="padding:10px;display:block;color:var(--muted)">No subtitle track.</em>'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadTranscript(url){
|
||||||
|
const list=document.getElementById('t-list');
|
||||||
|
try{
|
||||||
|
const text=await(await fetch(safeUrl(url))).text();
|
||||||
|
transcript.cues=parseVtt(text);
|
||||||
|
transcript.loaded=true;
|
||||||
|
renderCues(transcript.cues.map((c,i)=>({c,i})),'');
|
||||||
|
const vid=document.getElementById('player-video');
|
||||||
|
if(vid)vid.addEventListener('timeupdate',syncTranscript);
|
||||||
|
}catch(e){if(list)list.innerHTML=`<div style="color:#f87171;padding:10px">Couldn't load transcript: ${esc(e.message)}</div>`}
|
||||||
|
}
|
||||||
|
// Minimal WebVTT/SRT cue parser: pull out each "start --> end" line and the
|
||||||
|
// text lines under it, stripping inline tags. Tolerant of SRT comma decimals.
|
||||||
|
function parseVtt(text){
|
||||||
|
const cues=[],lines=text.split(/\r?\n/);
|
||||||
|
const tc=/-->/;
|
||||||
|
for(let i=0;i<lines.length;i++){
|
||||||
|
if(tc.test(lines[i])){
|
||||||
|
const start=parseVttTime(lines[i].split('-->')[0].trim());
|
||||||
|
if(isNaN(start)){continue}
|
||||||
|
const buf=[];i++;
|
||||||
|
while(i<lines.length&&lines[i].trim()!==''){buf.push(lines[i]);i++}
|
||||||
|
const txt=buf.join(' ').replace(/<[^>]+>/g,'').replace(/\s+/g,' ').trim();
|
||||||
|
if(txt)cues.push({start,text:txt});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse consecutive duplicate lines (common in auto-captions).
|
||||||
|
return cues.filter((c,i)=>i===0||c.text!==cues[i-1].text);
|
||||||
|
}
|
||||||
|
function parseVttTime(s){
|
||||||
|
s=(s||'').replace(',','.').replace(/[^\d:.]/g,'');
|
||||||
|
const p=s.split(':').map(Number);
|
||||||
|
if(p.some(isNaN)||!p.length)return NaN;
|
||||||
|
return p.reduce((acc,x)=>acc*60+x,0);
|
||||||
|
}
|
||||||
|
function renderCues(items,q){
|
||||||
|
const list=document.getElementById('t-list');if(!list)return;
|
||||||
|
if(!items.length){list.innerHTML='<em style="padding:10px;display:block;color:var(--muted)">'+(q?'No lines match.':'No transcript text.')+'</em>';return}
|
||||||
|
list.innerHTML=items.map(({c,i})=>`<div class="t-cue" data-i="${i}" onclick="seekTo(${c.start})"><span class="t-time">${fmtDur(c.start)}</span>${tHl(c.text,q)}</div>`).join('');
|
||||||
|
}
|
||||||
|
function filterTranscript(){
|
||||||
|
const q=(document.getElementById('t-search')?.value||'').toLowerCase().trim();
|
||||||
|
tSearching=!!q;
|
||||||
|
const items=transcript.cues.map((c,i)=>({c,i}));
|
||||||
|
renderCues(q?items.filter(({c})=>c.text.toLowerCase().includes(q)):items,q);
|
||||||
|
}
|
||||||
|
function seekTo(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
|
||||||
|
function syncTranscript(){
|
||||||
|
if(tSearching)return; // don't fight the user's filtered view
|
||||||
|
const v=document.getElementById('player-video'),list=document.getElementById('t-list');
|
||||||
|
if(!v||!list)return;
|
||||||
|
const t=v.currentTime,cues=transcript.cues;let ai=-1;
|
||||||
|
for(let i=0;i<cues.length;i++){if(t>=cues[i].start)ai=i;else break}
|
||||||
|
list.querySelectorAll('.t-cue.active').forEach(el=>el.classList.remove('active'));
|
||||||
|
if(ai>=0){const el=list.querySelector('.t-cue[data-i="'+ai+'"]');if(el){el.classList.add('active');el.scrollIntoView({block:'nearest'})}}
|
||||||
|
}
|
||||||
|
function tHl(s,q){const e=esc(s);if(!q)return e;try{return e.replace(new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','ig'),'<mark>$1</mark>')}catch{return e}}
|
||||||
|
|
||||||
/* ── Comments viewer ───────────────────────────────────────────── */
|
/* ── Comments viewer ───────────────────────────────────────────── */
|
||||||
let cmt={vid:null,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0};
|
let cmt={vid:null,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0};
|
||||||
async function openComments(videoId){
|
async function openComments(videoId){
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue