diff --git a/src/app.rs b/src/app.rs index ebf8f1e..b86dd19 100644 --- a/src/app.rs +++ b/src/app.rs @@ -163,6 +163,11 @@ pub struct App { search_results: Vec, show_search: bool, search_focus: bool, + // Transcript viewer (floating window) + show_transcript: bool, + transcript_video: Option, + transcript_cues: Vec, + transcript_query: String, // Scheduler last_scheduled_check: Option, // Cards cache β€” recomputed only when inputs change @@ -487,6 +492,10 @@ impl App { search_results: Vec::new(), show_search: false, search_focus: false, + show_transcript: false, + transcript_video: None, + transcript_cues: Vec::new(), + transcript_query: String::new(), last_scheduled_check: None, cards_cache: Vec::new(), cards_cache_key: None, @@ -934,6 +943,98 @@ impl App { 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 = 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) { let cmd = self.config.player.command.clone(); // Only enable IPC for genuine mpv invocations β€” substring matching @@ -3232,6 +3333,15 @@ impl App { 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 { ui.label(egui::RichText::new("πŸ’¬ live chat").small().weak()); } @@ -3941,6 +4051,7 @@ impl eframe::App for App { self.folder_manager_window(ctx); self.move_to_folder_window(ctx); self.search_window(ctx); + self.transcript_window(ctx); match self.current_screen { Screen::Library => { diff --git a/src/main.rs b/src/main.rs index d9887f8..6cf88bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod stats; mod theme; mod tray; mod util; +mod vtt; mod web; mod ytdlp_bin; diff --git a/src/vtt.rs b/src/vtt.rs new file mode 100644 index 0000000..335d2ee --- /dev/null +++ b/src/vtt.rs @@ -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 `` 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 { + let lines: Vec<&str> = text.lines().collect(); + let mut cues: Vec = Vec::new(); + let mut i = 0; + while i < lines.len() { + // A cue timing line is " --> [settings]". + if let Some(arrow) = lines[i].find("-->") { + if let Some(start) = parse_timestamp(&lines[i][..arrow]) { + i += 1; + let mut buf: Vec = 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::>().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. ``, `<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 { + 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 world\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()); + } +} diff --git a/src/web_ui/index.html b/src/web_ui/index.html index e55b764..48628e1 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -87,6 +87,14 @@ .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-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 label{flex:1;font-size:13px} .settings-hint{font-size:11px;color:var(--muted)} @@ -161,6 +169,7 @@ .modal video{max-height:55vh} #details{max-height:160px;padding:8px 10px} .chapters-pane{display:none} + .transcript-pane{display:none !important} section#content{padding:8px} /* Modal "new download" form stacks vertically on small screens so the URL + button + quality picker don't squeeze each other. */ @@ -1248,10 +1257,17 @@ function shufflePlay(){ function playVideo(id){ const v=findVideo(id);if(!v||!v.video_url)return; currentPlayingId=id; + transcript={cues:[],loaded:false}; // reset for the new video const bg=document.createElement('div');bg.className='modal-bg'; bg.onclick=e=>{if(e.target===bg)closeModal(bg)}; const tracks=(v.subtitles||[]).map((s,i)=>``).join(''); const chapPane=v.has_chapters?`

Chapters

Loading…
`:''; + const hasSubs=v.subtitles&&v.subtitles.length; + const transcriptPane=hasSubs?``:''; // Show upload date + duration in the modal header so the viewer has // basic context without opening the metadata pane. const headerMeta=[ @@ -1262,10 +1278,11 @@ function playVideo(id){ bg.innerHTML=``; document.body.appendChild(bg); 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()}} +/* ── 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='No subtitle track.'} + } +} +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=`
Couldn't load transcript: ${esc(e.message)}
`} +} +// 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')[0].trim()); + if(isNaN(start)){continue} + const buf=[];i++; + while(i]+>/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=''+(q?'No lines match.':'No transcript text.')+'';return} + list.innerHTML=items.map(({c,i})=>`
${fmtDur(c.start)}${tHl(c.text,q)}
`).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[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'),'$1')}catch{return e}} + /* ── Comments viewer ───────────────────────────────────────────── */ let cmt={vid:null,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0}; async function openComments(videoId){