diff options
author | Malte Voos <git@mal.tc> | 2025-10-01 00:20:10 +0200 |
---|---|---|
committer | Malte Voos <git@mal.tc> | 2025-10-01 00:20:10 +0200 |
commit | 338babaad2189f7ff1ee088994c8c20a0646ff4d (patch) | |
tree | 29fb2620f748d32a42c1d1eb3346771600a8d75b /src | |
download | lleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.tar.gz lleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.zip |
init
Diffstat (limited to 'src')
-rw-r--r-- | src/app.rs | 371 | ||||
-rw-r--r-- | src/cue_view.rs | 179 | ||||
-rw-r--r-- | src/main.rs | 45 | ||||
-rw-r--r-- | src/player.rs | 298 | ||||
-rw-r--r-- | src/preferences.rs | 71 | ||||
-rw-r--r-- | src/subtitle_extractor.rs | 209 | ||||
-rw-r--r-- | src/subtitle_view.rs | 94 | ||||
-rw-r--r-- | src/transcript.rs | 143 | ||||
-rw-r--r-- | src/util/mod.rs | 3 | ||||
-rw-r--r-- | src/util/option_tracker.rs | 43 |
10 files changed, 1456 insertions, 0 deletions
diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..10c20e6 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,371 @@ +use adw::prelude::*; +use gst::glib::clone; +use gtk::gio::{Menu, MenuItem, SimpleAction, SimpleActionGroup}; +use relm4::{WorkerController, prelude::*}; + +use crate::{ + player::{Player, PlayerMsg, PlayerOutput}, + preferences::{Preferences, PreferencesMsg}, + subtitle_extractor::{ + StreamIndex, SubtitleExtractor, SubtitleExtractorMsg, SubtitleExtractorOutput, TRACKS, + }, + subtitle_view::{SubtitleView, SubtitleViewMsg, SubtitleViewOutput}, + transcript::{Transcript, TranscriptMsg, TranscriptOutput}, + util::OptionTracker, +}; + +const TRACK_SELECTION_ACTION_GROUP_NAME: &str = "subtitle_track_selection"; + +pub struct App { + url: String, + transcript: Controller<Transcript>, + player: Controller<Player>, + subtitle_view: Controller<SubtitleView>, + extractor: WorkerController<SubtitleExtractor>, + preferences: Controller<Preferences>, + + subtitle_selection_menu: Menu, + subtitle_selection_action_group: SimpleActionGroup, + + primary_stream_ix: Option<StreamIndex>, + primary_last_cue_ix: OptionTracker<usize>, + secondary_stream_ix: Option<StreamIndex>, + secondary_last_cue_ix: OptionTracker<usize>, + + // for auto-pausing + autopaused: bool, + primary_cue_active: bool, + hovering_primary_cue: bool, +} + +#[derive(Debug)] +pub enum AppMsg { + NewOrUpdatedTrackMetadata(StreamIndex), + NewCue(StreamIndex, crate::subtitle_extractor::SubtitleCue), + ExtractionComplete, + TrackSelected(StreamIndex), + PositionUpdate(gst::ClockTime), + SetHoveringSubtitleCue(bool), + ShowPreferences, +} + +#[relm4::component(pub)] +impl SimpleComponent for App { + type Init = String; + type Input = AppMsg; + type Output = (); + + view! { + #[root] + adw::ApplicationWindow { + set_title: Some("lleap"), + set_default_width: 800, + set_default_height: 600, + + #[name(toolbar_view)] + adw::ToolbarView { + add_top_bar = &adw::HeaderBar { + pack_start = >k::MenuButton { + set_label: "Select Subtitle Track", + set_popover: Some(>k::PopoverMenu::from_model(Some(&model.subtitle_selection_menu))), + }, + pack_start = >k::Button { + set_label: "Preferences", + connect_clicked => AppMsg::ShowPreferences, + add_css_class: "flat", + } + }, + + #[wrap(Some)] + set_content = >k::Paned { + set_orientation: gtk::Orientation::Vertical, + #[wrap(Some)] + set_start_child = >k::Paned { + set_start_child: Some(model.player.widget()), + set_end_child: Some(model.transcript.widget()), + }, + set_end_child: Some(model.subtitle_view.widget()), + set_shrink_end_child: false, + } + } + } + } + + fn init( + url: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let subtitle_selection_menu = Menu::new(); + let subtitle_selection_action_group = SimpleActionGroup::new(); + root.insert_action_group( + TRACK_SELECTION_ACTION_GROUP_NAME, + Some(&subtitle_selection_action_group), + ); + Self::add_dummy_menu_item(&subtitle_selection_action_group, &subtitle_selection_menu); + + let subtitle_view = SubtitleView::builder().launch(()).forward( + sender.input_sender(), + |output| match output { + SubtitleViewOutput::SetHoveringCue(val) => AppMsg::SetHoveringSubtitleCue(val), + }, + ); + let player = Player::builder() + .launch(()) + .forward(sender.input_sender(), |output| match output { + PlayerOutput::PositionUpdate(pos) => AppMsg::PositionUpdate(pos), + }); + let transcript = + Transcript::builder() + .launch(()) + .forward(player.sender(), |msg| match msg { + TranscriptOutput::SeekTo(pos) => PlayerMsg::SeekTo(pos), + }); + + let extractor = SubtitleExtractor::builder().detach_worker(()).forward( + sender.input_sender(), + |output| match output { + SubtitleExtractorOutput::NewOrUpdatedTrackMetadata(stream_index) => { + AppMsg::NewOrUpdatedTrackMetadata(stream_index) + } + SubtitleExtractorOutput::NewCue(stream_index, cue) => { + AppMsg::NewCue(stream_index, cue) + } + SubtitleExtractorOutput::ExtractionComplete => AppMsg::ExtractionComplete, + }, + ); + + let preferences = Preferences::builder().launch(root.clone().into()).detach(); + + let model = Self { + url: url.clone(), // TODO remove clone + player, + transcript, + subtitle_view, + extractor, + preferences, + subtitle_selection_menu, + subtitle_selection_action_group, + + primary_stream_ix: None, + primary_last_cue_ix: OptionTracker::new(None), + secondary_stream_ix: None, + secondary_last_cue_ix: OptionTracker::new(None), + + autopaused: false, + primary_cue_active: false, + hovering_primary_cue: false, + }; + + let widgets = view_output!(); + + model + .player + .sender() + .send(PlayerMsg::SetUrl(url.clone())) + .unwrap(); + model + .extractor + .sender() + .send(SubtitleExtractorMsg::ExtractFromUrl(url)) + .unwrap(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) { + self.primary_last_cue_ix.reset(); + self.secondary_last_cue_ix.reset(); + + match msg { + AppMsg::NewOrUpdatedTrackMetadata(_stream_index) => { + self.update_subtitle_selection_menu(&sender); + } + AppMsg::NewCue(stream_index, cue) => { + self.transcript + .sender() + .send(TranscriptMsg::NewCue(stream_index, cue)) + .unwrap(); + } + AppMsg::ExtractionComplete => { + println!("Subtitle extraction complete"); + } + AppMsg::TrackSelected(stream_index) => { + self.primary_stream_ix = Some(stream_index); + + self.transcript + .sender() + .send(TranscriptMsg::SelectTrack(stream_index)) + .unwrap(); + } + AppMsg::PositionUpdate(pos) => { + if let Some(stream_ix) = self.primary_stream_ix { + let cue = + Self::get_cue_and_update_ix(stream_ix, pos, &mut self.primary_last_cue_ix); + let cue_is_some = cue.is_some(); + + // beginning of new subtitle + if self.primary_last_cue_ix.is_dirty() + || (!self.primary_cue_active && cue_is_some) + { + self.subtitle_view + .sender() + .send(SubtitleViewMsg::SetPrimaryCue(cue)) + .unwrap(); + self.primary_cue_active = cue_is_some; + + if let Some(ix) = self.primary_last_cue_ix.get() { + self.transcript + .sender() + .send(TranscriptMsg::ScrollToCue(*ix)) + .unwrap(); + } + + self.primary_last_cue_ix.reset(); + } + + // end of current subtitle + if self.primary_cue_active && !cue_is_some && !self.autopaused { + if self.hovering_primary_cue { + self.player.sender().send(PlayerMsg::Pause).unwrap(); + self.autopaused = true; + } else { + self.subtitle_view + .sender() + .send(SubtitleViewMsg::SetPrimaryCue(None)) + .unwrap(); + self.primary_cue_active = false; + } + } + } + if let Some(stream_ix) = self.secondary_stream_ix { + self.subtitle_view + .sender() + .send(SubtitleViewMsg::SetPrimaryCue(Self::get_cue_and_update_ix( + stream_ix, + pos, + &mut self.primary_last_cue_ix, + ))) + .unwrap(); + } + } + AppMsg::SetHoveringSubtitleCue(hovering) => { + self.hovering_primary_cue = hovering; + if !hovering && self.autopaused { + self.player.sender().send(PlayerMsg::Play).unwrap(); + self.autopaused = false; + } + } + AppMsg::ShowPreferences => { + self.preferences + .sender() + .send(PreferencesMsg::Show) + .unwrap(); + } + } + } +} + +impl App { + fn update_subtitle_selection_menu(&mut self, sender: &ComponentSender<Self>) { + self.subtitle_selection_menu.remove_all(); + + for action_name in self.subtitle_selection_action_group.list_actions() { + self.subtitle_selection_action_group + .remove_action(&action_name); + } + + let tracks = TRACKS.read(); + if tracks.is_empty() { + Self::add_dummy_menu_item( + &self.subtitle_selection_action_group, + &self.subtitle_selection_menu, + ); + } else { + for (stream_index, track) in tracks.iter() { + let unknown_string = "<unknown>".to_string(); + let language = track.language_code.as_ref().unwrap_or(&unknown_string); + let label = format!("{} (Stream {})", language, stream_index); + + let action_name = format!("select_{}", stream_index); + let action = SimpleAction::new(&action_name, None); + + action.connect_activate(clone!( + #[strong] + sender, + #[strong] + stream_index, + move |_, _| { + let _ = sender.input(AppMsg::TrackSelected(stream_index)); + } + )); + + self.subtitle_selection_action_group.add_action(&action); + + // Create menu item + let action_target = + format!("{}.{}", TRACK_SELECTION_ACTION_GROUP_NAME, action_name); + let item = MenuItem::new(Some(&label), Some(&action_target)); + self.subtitle_selection_menu.append_item(&item); + } + } + } + + // Add disabled "No tracks available" item + fn add_dummy_menu_item(action_group: &SimpleActionGroup, menu: &Menu) { + let disabled_action = SimpleAction::new("no_tracks", None); + disabled_action.set_enabled(false); + action_group.add_action(&disabled_action); + + let action_target = format!("{}.no_tracks", TRACK_SELECTION_ACTION_GROUP_NAME); + let item = MenuItem::new(Some("No tracks available"), Some(&action_target)); + menu.append_item(&item); + } + + fn get_cue_and_update_ix( + stream_ix: StreamIndex, + position: gst::ClockTime, + last_cue_ix: &mut OptionTracker<usize>, + ) -> Option<String> { + let lock = TRACKS.read(); + let track = lock.get(&stream_ix)?; + + // try to find current cue quickly (should usually succeed during playback) + if let Some(ix) = last_cue_ix.get() { + let last_cue = track.cues.get(*ix)?; + if last_cue.start <= position && position <= last_cue.end { + return Some(last_cue.text.clone()); + } + let next_cue = track.cues.get(ix + 1)?; + if last_cue.end < position && position < next_cue.start { + return None; + } + if next_cue.start <= position && position <= next_cue.end { + last_cue_ix.set(Some(ix + 1)); + return Some(next_cue.text.clone()); + } + } + + // if we are before the first subtitle, no need to look further + if position < track.cues.first()?.start { + last_cue_ix.set(None); + return None; + } + + // otherwise, search the whole track (e.g. after seeking) + let (ix, cue) = track + .cues + .iter() + .enumerate() + .rev() + .find(|(_ix, cue)| cue.start <= position)?; + + last_cue_ix.set(Some(ix)); + + if position <= cue.end { + Some(cue.text.clone()) + } else { + None + } + } +} diff --git a/src/cue_view.rs b/src/cue_view.rs new file mode 100644 index 0000000..c031720 --- /dev/null +++ b/src/cue_view.rs @@ -0,0 +1,179 @@ +use std::ops::Range; +use std::str::FromStr; + +use gtk::gdk; +use gtk::glib; +use gtk::{pango, prelude::*}; +use relm4::prelude::*; +use relm4::{ComponentParts, SimpleComponent}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::util::OptionTracker; + +pub struct CueView { + text: OptionTracker<String>, + // byte ranges for the words in `text` + word_ranges: Vec<Range<usize>>, +} + +#[derive(Debug)] +pub enum CueViewMsg { + // messages from the app + SetText(Option<String>), + // messages from UI + MouseMotion, +} + +#[derive(Debug)] +pub enum CueViewOutput { + MouseEnter, + MouseLeave, +} + +#[relm4::component(pub)] +impl SimpleComponent for CueView { + type Init = (); + type Input = CueViewMsg; + type Output = CueViewOutput; + + view! { + #[root] + #[name(label)] + gtk::Label { + add_controller: event_controller.clone(), + set_use_markup: true, + set_visible: false, + set_justify: gtk::Justification::Center, + add_css_class: "cue-view", + }, + + #[name(event_controller)] + gtk::EventControllerMotion { + connect_enter[sender] => move |_, _, _| { sender.output(CueViewOutput::MouseEnter).unwrap() }, + connect_motion[sender] => move |_, _, _| { sender.input(CueViewMsg::MouseMotion) }, + connect_leave[sender] => move |_| { sender.output(CueViewOutput::MouseLeave).unwrap() }, + }, + + #[name(popover)] + gtk::Popover { + set_parent: &root, + set_position: gtk::PositionType::Top, + set_autohide: false, + + #[name(popover_label)] + gtk::Label { } + } + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: relm4::ComponentSender<Self>, + ) -> relm4::ComponentParts<Self> { + let model = Self { + text: OptionTracker::new(None), + word_ranges: Vec::new(), + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender<Self>) { + match message { + CueViewMsg::SetText(text) => { + self.text.set(text); + + if let Some(text) = self.text.get() { + self.word_ranges = UnicodeSegmentation::unicode_word_indices(text.as_str()) + .map(|(offset, slice)| Range { + start: offset, + end: offset + slice.len(), + }) + .collect(); + } else { + self.word_ranges = Vec::new(); + } + } + CueViewMsg::MouseMotion => { + // only used to update popover in view + } + } + } + + fn post_view() { + if self.text.is_dirty() { + if let Some(text) = self.text.get() { + let mut markup = String::new(); + + let mut it = self.word_ranges.iter().enumerate().peekable(); + if let Some((_, first_word_range)) = it.peek() { + markup.push_str( + glib::markup_escape_text(&text[..first_word_range.start]).as_str(), + ); + } + while let Some((word_ix, word_range)) = it.next() { + markup.push_str(&format!( + "<a href=\"{}\">{}</a>", + word_ix, + glib::markup_escape_text(&text[word_range.clone()]) + )); + let next_gap_range = if let Some((_, next_word_range)) = it.peek() { + word_range.end..next_word_range.start + } else { + word_range.end..text.len() + }; + markup.push_str(glib::markup_escape_text(&text[next_gap_range]).as_str()); + } + + widgets.label.set_markup(markup.as_str()); + widgets.label.set_visible(true); + } else { + widgets.label.set_visible(false); + } + } + + if let Some(word_ix_str) = widgets.label.current_uri() { + let range = self + .word_ranges + .get(usize::from_str(word_ix_str.as_str()).unwrap()) + .unwrap(); + widgets + .popover_label + .set_text(&self.text.get().as_ref().unwrap()[range.clone()]); + widgets + .popover + .set_pointing_to(Some(&Self::get_rect_of_byte_range(&widgets.label, &range))); + widgets.popover.popup(); + } else { + widgets.popover.popdown(); + } + } + + fn shutdown(&mut self, widgets: &mut Self::Widgets, _output: relm4::Sender<Self::Output>) { + widgets.popover.unparent(); + } +} + +impl CueView { + fn get_rect_of_byte_range(label: >k::Label, range: &Range<usize>) -> gdk::Rectangle { + let layout = label.layout(); + let (offset_x, offset_y) = label.layout_offsets(); + + let start_pos = layout.index_to_pos(range.start as i32); + let end_pos = layout.index_to_pos(range.end as i32); + let (x, width) = if start_pos.x() <= end_pos.x() { + (start_pos.x(), end_pos.x() - start_pos.x()) + } else { + (end_pos.x(), start_pos.x() - end_pos.x()) + }; + + gdk::Rectangle::new( + x / pango::SCALE + offset_x, + start_pos.y() / pango::SCALE + offset_y, + width / pango::SCALE, + start_pos.height() / pango::SCALE, + ) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d902eaa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,45 @@ +mod app; +mod cue_view; +mod player; +mod preferences; +mod subtitle_extractor; +mod subtitle_view; +mod transcript; +mod util; + +use std::env; + +use gtk::{CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, gdk, glib}; +use relm4::RelmApp; + +use crate::app::App; + +fn main() { + env_logger::init(); + + let args: Vec<String> = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: {} <video_url>", args[0]); + std::process::exit(1); + } + let video_url = args[1].clone(); + + gtk::init().expect("Failed to initialize GTK"); + gst::init().expect("Failed to initialize GStreamer"); + ffmpeg::init().expect("Failed to initialize FFmpeg"); + + let css_provider = CssProvider::new(); + css_provider.load_from_bytes(&glib::Bytes::from_static(include_bytes!( + "../resources/style.css" + ))); + gtk::style_context_add_provider_for_display( + &gdk::Display::default().unwrap(), + &css_provider, + STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + relm4::RELM_THREADS.set(4).unwrap(); + + let relm = RelmApp::new("tc.mal.lleap").with_args(vec![]); + relm.run::<App>(video_url); +} diff --git a/src/player.rs b/src/player.rs new file mode 100644 index 0000000..c784a04 --- /dev/null +++ b/src/player.rs @@ -0,0 +1,298 @@ +use gst::bus::BusWatchGuard; +use gst::prelude::*; +use gst_play::{Play, PlayMessage, PlayVideoOverlayVideoRenderer}; +use gtk::gdk; +use gtk::glib::{self, clone}; +use gtk::prelude::*; +use relm4::{ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent}; + +#[allow(dead_code)] +pub struct Player { + // GStreamer-related + gtksink: gst::Element, + renderer: PlayVideoOverlayVideoRenderer, + player: Play, + bus_watch: BusWatchGuard, + // UI state + is_playing: bool, + duration: gst::ClockTime, + position: gst::ClockTime, + seeking: bool, +} + +#[derive(Debug)] +pub enum PlayerMsg { + SetUrl(String), + PlayPause, + Play, + Pause, + SeekTo(gst::ClockTime), + StartSeeking, + StopSeeking, + // messages from GStreamer + UpdatePosition(gst::ClockTime), + UpdateDuration(gst::ClockTime), +} + +#[derive(Debug)] +pub enum PlayerOutput { + PositionUpdate(gst::ClockTime), +} + +fn format_time(time: gst::ClockTime) -> String { + let seconds = time.seconds(); + let minutes = seconds / 60; + let hours = minutes / 60; + + if hours > 0 { + format!("{}:{:02}:{:02}", hours, minutes % 60, seconds % 60) + } else { + format!("{}:{:02}", minutes, seconds % 60) + } +} + +#[relm4::component(pub)] +impl SimpleComponent for Player { + type Input = PlayerMsg; + type Output = PlayerOutput; + type Init = (); + + view! { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + + // Video area + gtk::Picture { + set_paintable: Some(&paintable), + set_vexpand: true, + }, + + // Control bar + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 10, + set_margin_all: 10, + + // Play/Pause button + #[name = "play_pause_btn"] + gtk::Button { + #[watch] + set_icon_name: if model.is_playing { "media-playback-pause" } else { "media-playback-start" }, + connect_clicked => PlayerMsg::PlayPause, + add_css_class: "circular", + }, + + // Current time label + gtk::Label { + #[watch] + set_text: &format_time(model.position), + set_width_chars: 8, + }, + + // Seek slider + #[name = "seek_scale"] + gtk::Scale { + set_hexpand: true, + set_orientation: gtk::Orientation::Horizontal, + #[watch] + set_range: (0.0, model.duration.mseconds() as f64), + #[watch] + set_value?: if !model.seeking { + Some(model.position.mseconds() as f64) + } else { + None + }, + connect_change_value[sender] => move |_, _, value| { + let position = gst::ClockTime::from_mseconds(value as u64); + sender.input(PlayerMsg::SeekTo(position)); + glib::Propagation::Proceed + }, + }, + + // Duration label + #[name = "duration_label"] + gtk::Label { + #[watch] + set_text: &format_time(model.duration), + set_width_chars: 8, + }, + } + } + } + + fn init( + _init: Self::Init, + _window: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let gtksink = gst::ElementFactory::make("gtk4paintablesink") + .build() + .expect("Failed to create gtk4paintablesink"); + + // Need to set state to Ready to get a GL context + gtksink + .set_state(gst::State::Ready) + .expect("Failed to set GTK sink state to ready"); + + let paintable = gtksink.property::<gdk::Paintable>("paintable"); + + let sink = if paintable + .property::<Option<gdk::GLContext>>("gl-context") + .is_some() + { + gst::ElementFactory::make("glsinkbin") + .property("sink", >ksink) + .build() + .expect("Failed to build glsinkbin") + } else { + gtksink.clone() + }; + + let renderer = PlayVideoOverlayVideoRenderer::with_sink(&sink); + + let player = Play::new(Some( + renderer.clone().upcast::<gst_play::PlayVideoRenderer>(), + )); + + let mut config = player.config(); + config.set_position_update_interval(5); + player.set_config(config).unwrap(); + + // 100MiB ring buffer to improve seek performance + player + .pipeline() + .set_property("ring-buffer-max-size", 100 * 1024 * 1024 as u64); + + let bus_watch = player + .message_bus() + .add_watch_local(clone!( + #[strong] + sender, + move |_, message| { + let play_message = if let Ok(msg) = PlayMessage::parse(message) { + msg + } else { + return glib::ControlFlow::Continue; + }; + + match play_message { + PlayMessage::Error(error_msg) => { + eprintln!("Playback error: {:?}", error_msg.error()); + if let Some(details) = error_msg.details() { + eprintln!("Error details: {:?}", details); + } + } + PlayMessage::PositionUpdated(pos) => { + if let Some(position) = pos.position() { + sender.input(PlayerMsg::UpdatePosition(position)); + sender + .output(PlayerOutput::PositionUpdate(position)) + .unwrap(); + } + } + PlayMessage::DurationChanged(dur) => { + if let Some(duration) = dur.duration() { + sender.input(PlayerMsg::UpdateDuration(duration)); + } + } + PlayMessage::Buffering(_) => { + // TODO + } + _ => {} + } + + glib::ControlFlow::Continue + } + )) + .expect("Failed to add message bus watch"); + + let model = Player { + gtksink, + renderer, + player, + bus_watch, + is_playing: false, + duration: gst::ClockTime::ZERO, + position: gst::ClockTime::ZERO, + seeking: false, + }; + + let widgets = view_output!(); + + // find the existing GestureClick controller in the Scale widget + // instead of adding a new one to avoid this bug: + // https://gitlab.gnome.org/GNOME/gtk/-/issues/4939 + let gesture = widgets + .seek_scale + .observe_controllers() + .into_iter() + .find_map(|controller| controller.unwrap().downcast::<gtk::GestureClick>().ok()) + .unwrap(); + + gesture.connect_pressed(clone!( + #[strong] + sender, + move |_, _, _, _| { + sender.input(PlayerMsg::StartSeeking); + } + )); + gesture.connect_released(clone!( + #[strong] + sender, + move |_, _, _, _| { + sender.input(PlayerMsg::StopSeeking); + } + )); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + match msg { + PlayerMsg::SetUrl(url) => { + self.player.set_uri(Some(&url)); + self.play(); + } + PlayerMsg::PlayPause => { + if self.is_playing { + self.pause(); + } else { + self.play(); + } + } + PlayerMsg::Play => self.play(), + PlayerMsg::Pause => self.pause(), + PlayerMsg::SeekTo(position) => { + //self.seek_position = position_ms; + self.player.seek(position); + self.position = position; + } + PlayerMsg::StartSeeking => { + self.seeking = true; + } + PlayerMsg::StopSeeking => { + self.seeking = false; + } + PlayerMsg::UpdatePosition(position) => { + if !self.seeking { + self.position = position; + } + } + PlayerMsg::UpdateDuration(duration) => { + self.duration = duration; + } + } + } +} + +impl Player { + fn play(&mut self) { + self.player.play(); + self.is_playing = true; + } + + fn pause(&mut self) { + self.player.pause(); + self.is_playing = false; + } +} diff --git a/src/preferences.rs b/src/preferences.rs new file mode 100644 index 0000000..c5f9bb1 --- /dev/null +++ b/src/preferences.rs @@ -0,0 +1,71 @@ +use adw::prelude::*; +use gtk::gio; +use relm4::prelude::*; + +pub struct Preferences { + parent_window: adw::ApplicationWindow, + dialog: adw::PreferencesDialog, +} + +#[derive(Debug)] +pub enum PreferencesMsg { + Show, +} + +#[derive(Debug)] +pub enum PreferencesOutput {} + +#[relm4::component(pub)] +impl SimpleComponent for Preferences { + type Init = adw::ApplicationWindow; + type Input = PreferencesMsg; + type Output = PreferencesOutput; + + view! { + #[root] + adw::PreferencesDialog { + set_title: "Preferences", + add: &page, + }, + + #[name(page)] + adw::PreferencesPage { + adw::PreferencesGroup { + set_title: "Machine Translation", + + adw::EntryRow { + set_title: "DeepL API key", + set_text: settings.string("deepl-api-key").as_str(), + connect_changed[settings] => move |entry| { + settings.set_string("deepl-api-key", entry.text().as_str()).unwrap() + } + }, + } + } + } + + fn init( + parent_window: Self::Init, + root: Self::Root, + _sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let settings = gio::Settings::new("tc.mal.lleap"); + + let model = Self { + parent_window, + dialog: root.clone(), + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + match msg { + PreferencesMsg::Show => { + self.dialog.present(Some(&self.parent_window)); + } + } + } +} diff --git a/src/subtitle_extractor.rs b/src/subtitle_extractor.rs new file mode 100644 index 0000000..53655a0 --- /dev/null +++ b/src/subtitle_extractor.rs @@ -0,0 +1,209 @@ +use std::collections::BTreeMap; + +use anyhow::Result; + +use ffmpeg::Rational; +use log::{debug, error, info}; +use relm4::{ComponentSender, SharedState, Worker}; + +pub type StreamIndex = usize; + +#[derive(Debug, Clone)] +pub struct SubtitleCue { + pub start: gst::ClockTime, + pub end: gst::ClockTime, + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct SubtitleTrack { + pub language_code: Option<String>, + pub title: Option<String>, + pub cues: Vec<SubtitleCue>, +} + +pub static TRACKS: SharedState<BTreeMap<StreamIndex, SubtitleTrack>> = SharedState::new(); + +pub struct SubtitleExtractor {} + +#[derive(Debug)] +pub enum SubtitleExtractorMsg { + ExtractFromUrl(String), +} + +#[derive(Debug)] +pub enum SubtitleExtractorOutput { + NewOrUpdatedTrackMetadata(StreamIndex), + NewCue(StreamIndex, SubtitleCue), + ExtractionComplete, +} + +impl Worker for SubtitleExtractor { + type Init = (); + type Input = SubtitleExtractorMsg; + type Output = SubtitleExtractorOutput; + + fn init(_init: Self::Init, _sender: ComponentSender<Self>) -> Self { + Self {} + } + + fn update(&mut self, msg: SubtitleExtractorMsg, sender: ComponentSender<Self>) { + match msg { + SubtitleExtractorMsg::ExtractFromUrl(url) => { + self.handle_extract_from_url(url, sender); + } + } + } +} + +impl SubtitleExtractor { + fn handle_extract_from_url(&mut self, url: String, sender: ComponentSender<Self>) { + // Clear existing tracks + TRACKS.write().clear(); + + // Try to extract subtitles using ffmpeg + match self.extract_subtitles_ffmpeg(&url, &sender) { + Ok(_) => { + info!("Subtitle extraction completed successfully"); + sender + .output(SubtitleExtractorOutput::ExtractionComplete) + .unwrap(); + } + Err(e) => { + error!("FFmpeg extraction failed: {}", e); + } + } + } + + fn extract_subtitles_ffmpeg(&self, url: &str, sender: &ComponentSender<Self>) -> Result<()> { + let mut input = ffmpeg::format::input(&url)?; + + let mut subtitle_decoders = BTreeMap::new(); + + // create decoder for each subtitle stream + for (stream_index, stream) in input.streams().enumerate() { + if stream.parameters().medium() == ffmpeg::media::Type::Subtitle { + let language_code = stream.metadata().get("language").map(|s| s.to_string()); + let title = stream.metadata().get("title").map(|s| s.to_string()); + + let track = SubtitleTrack { + language_code, + title, + cues: Vec::new(), + }; + + TRACKS.write().insert(stream_index, track); + + sender + .output(SubtitleExtractorOutput::NewOrUpdatedTrackMetadata( + stream_index, + )) + .unwrap(); + + let context = + ffmpeg::codec::context::Context::from_parameters(stream.parameters())?; + if let Ok(decoder) = context.decoder().subtitle() { + subtitle_decoders.insert(stream_index, decoder); + debug!("Created decoder for subtitle stream {}", stream_index); + } else { + error!( + "Failed to create decoder for subtitle stream {}", + stream_index + ); + } + } + } + + // process packets + for (stream, packet) in input.packets() { + let stream_index = stream.index(); + + if let Some(decoder) = subtitle_decoders.get_mut(&stream_index) { + let mut subtitle = ffmpeg::Subtitle::new(); + if decoder.decode(&packet, &mut subtitle).is_ok() { + if let Some(cue) = Self::subtitle_to_cue(&subtitle, &packet, stream.time_base()) + { + if let Some(track) = TRACKS.write().get_mut(&stream_index) { + track.cues.push(cue.clone()); + } + + sender + .output(SubtitleExtractorOutput::NewCue(stream_index, cue)) + .unwrap(); + } + } + } + } + + Ok(()) + } + + fn subtitle_to_cue( + subtitle: &ffmpeg::Subtitle, + packet: &ffmpeg::Packet, + time_base: Rational, + ) -> Option<SubtitleCue> { + let time_to_clock_time = |time: i64| { + let nseconds: i64 = (time * time_base.numerator() as i64 * 1_000_000_000) + / time_base.denominator() as i64; + gst::ClockTime::from_nseconds(nseconds as u64) + }; + + let text = subtitle + .rects() + .into_iter() + .map(|rect| match rect { + ffmpeg::subtitle::Rect::Text(text) => text.get().to_string(), + ffmpeg::subtitle::Rect::Ass(ass) => { + Self::extract_dialogue_text(ass.get()).unwrap_or(String::new()) + } + _ => String::new(), + }) + .collect::<Vec<String>>() + .join("\n— "); + + let start = time_to_clock_time(packet.pts()?); + let end = time_to_clock_time(packet.pts()? + packet.duration()); + + Some(SubtitleCue { start, end, text }) + } + + fn extract_dialogue_text(dialogue_line: &str) -> Option<String> { + // ASS dialogue format: ReadOrder,Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text + // we need the 9th field (Text), so split on comma but only take first 9 splits + // see also https://github.com/FFmpeg/FFmpeg/blob/a700f0f72d1f073e5adcfbb16f4633850b0ef51c/libavcodec/ass_split.c#L433 + let text = dialogue_line.splitn(9, ',').last()?; + + // remove ASS override codes (formatting tags) like {\b1}, {\i1}, {\c&Hffffff&}, etc. + let mut result = String::new(); + let mut in_tag = false; + let mut char_iter = text.chars().peekable(); + + while let Some(c) = char_iter.next() { + if c == '{' && char_iter.peek() == Some(&'\\') { + in_tag = true; + } else if c == '}' { + in_tag = false; + } else if !in_tag { + // process line breaks and hard spaces + if c == '\\' { + match char_iter.peek() { + Some(&'N') => { + char_iter.next(); + result.push('\n'); + } + Some(&'n') | Some(&'h') => { + char_iter.next(); + result.push(' '); + } + _ => result.push(c), + } + } else { + result.push(c); + } + } + } + + Some(result) + } +} diff --git a/src/subtitle_view.rs b/src/subtitle_view.rs new file mode 100644 index 0000000..30c089c --- /dev/null +++ b/src/subtitle_view.rs @@ -0,0 +1,94 @@ +use crate::cue_view::{CueView, CueViewMsg, CueViewOutput}; +use crate::util::OptionTracker; +use gtk::prelude::*; +use relm4::prelude::*; + +pub struct SubtitleView { + primary_cue: Controller<CueView>, + secondary_cue: OptionTracker<String>, +} + +#[derive(Debug)] +pub enum SubtitleViewMsg { + SetPrimaryCue(Option<String>), + SetSecondaryCue(Option<String>), +} + +#[derive(Debug)] +pub enum SubtitleViewOutput { + SetHoveringCue(bool), +} + +#[relm4::component(pub)] +impl SimpleComponent for SubtitleView { + type Init = (); + type Input = SubtitleViewMsg; + type Output = SubtitleViewOutput; + + view! { + gtk::ScrolledWindow { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_vexpand: true, + set_halign: gtk::Align::Center, + + gtk::Box { + set_vexpand: true, + }, + + model.primary_cue.widget(), + + gtk::Box { + set_vexpand: true, + }, + + gtk::Label { + #[track = "model.secondary_cue.is_dirty()"] + set_text: model.secondary_cue.get().as_ref().map(|val| val.as_str()).unwrap_or("TODO placeholder"), + set_justify: gtk::Justification::Center, + }, + + gtk::Box { + set_vexpand: true, + }, + } + }, + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let model = Self { + primary_cue: CueView::builder() + .launch(()) + .forward(sender.output_sender(), |output| match output { + CueViewOutput::MouseEnter => SubtitleViewOutput::SetHoveringCue(true), + CueViewOutput::MouseLeave => SubtitleViewOutput::SetHoveringCue(false), + }), + secondary_cue: OptionTracker::default(), + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + // Reset trackers + self.secondary_cue.reset(); + + match msg { + SubtitleViewMsg::SetPrimaryCue(value) => { + self.primary_cue + .sender() + .send(CueViewMsg::SetText(value)) + .unwrap(); + } + SubtitleViewMsg::SetSecondaryCue(value) => { + self.secondary_cue.set(value); + } + } + } +} diff --git a/src/transcript.rs b/src/transcript.rs new file mode 100644 index 0000000..2bddb72 --- /dev/null +++ b/src/transcript.rs @@ -0,0 +1,143 @@ +use gtk::{ListBox, pango::WrapMode, prelude::*}; +use relm4::prelude::*; + +use crate::subtitle_extractor::{StreamIndex, SubtitleCue, TRACKS}; + +#[derive(Debug)] +pub enum SubtitleCueOutput { + SeekTo(gst::ClockTime), +} + +#[relm4::factory(pub)] +impl FactoryComponent for SubtitleCue { + type Init = Self; + type Input = (); + type Output = SubtitleCueOutput; + type CommandOutput = (); + type ParentWidget = gtk::ListBox; + + view! { + gtk::Button { + inline_css: "padding: 5px; border-radius: 0;", + connect_clicked: { + let start = self.start; + move |_| { + sender.output(SubtitleCueOutput::SeekTo(start)).unwrap() + } + }, + + gtk::Label { + set_label: &self.text, + set_wrap: true, + set_wrap_mode: WrapMode::Word, + set_xalign: 0.0, + add_css_class: "body", + } + } + } + + fn init_model(init: Self::Init, _index: &Self::Index, _sender: FactorySender<Self>) -> Self { + init + } +} + +pub struct Transcript { + active_stream_index: Option<StreamIndex>, + active_cues: FactoryVecDeque<SubtitleCue>, + pending_scroll: Option<usize>, +} + +#[derive(Debug)] +pub enum TranscriptMsg { + NewCue(StreamIndex, SubtitleCue), + SelectTrack(StreamIndex), + ScrollToCue(usize), +} + +#[derive(Debug)] +pub enum TranscriptOutput { + SeekTo(gst::ClockTime), +} + +pub struct TranscriptWidgets { + viewport: gtk::Viewport, +} + +impl SimpleComponent for Transcript { + type Init = (); + type Input = TranscriptMsg; + type Output = TranscriptOutput; + type Widgets = TranscriptWidgets; + type Root = gtk::ScrolledWindow; + + fn init( + _init: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let listbox = ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .build(); + + let active_cues = + FactoryVecDeque::builder() + .launch(listbox) + .forward(sender.output_sender(), |output| match output { + SubtitleCueOutput::SeekTo(pos) => TranscriptOutput::SeekTo(pos), + }); + + let model = Self { + active_stream_index: None, + active_cues, + pending_scroll: None, + }; + + let widgets = TranscriptWidgets { + viewport: gtk::Viewport::builder().build(), + }; + + widgets.viewport.set_child(Some(model.active_cues.widget())); + root.set_child(Some(&widgets.viewport)); + + ComponentParts { model, widgets } + } + + fn init_root() -> Self::Root { + gtk::ScrolledWindow::new() + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + self.pending_scroll = None; + + match msg { + TranscriptMsg::NewCue(stream_index, cue) => { + if self.active_stream_index == Some(stream_index) { + self.active_cues.guard().push_back(cue); + } + } + TranscriptMsg::SelectTrack(stream_index) => { + self.active_stream_index = Some(stream_index); + + // Clear current widgets and populate with selected track's cues + self.active_cues.guard().clear(); + let tracks = TRACKS.read(); + if let Some(track) = tracks.get(&stream_index) { + for cue in &track.cues { + self.active_cues.guard().push_back(cue.clone()); + } + } + } + TranscriptMsg::ScrollToCue(ix) => { + self.pending_scroll = Some(ix); + } + } + } + + fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender<Self>) { + if let Some(ix) = self.pending_scroll { + if let Some(row) = self.active_cues.widget().row_at_index(ix as i32) { + widgets.viewport.scroll_to(&row, None); + } + } + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..5b0c6ac --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,3 @@ +mod option_tracker; + +pub use option_tracker::OptionTracker; diff --git a/src/util/option_tracker.rs b/src/util/option_tracker.rs new file mode 100644 index 0000000..3c19ee5 --- /dev/null +++ b/src/util/option_tracker.rs @@ -0,0 +1,43 @@ +pub struct OptionTracker<T> { + inner: Option<T>, + dirty: bool, +} + +/// Tracks changes to an inner Option<T>. Any change using `set` will cause the +/// tracker to be marked as dirty, unless both the current and new value are +/// `None`. This should be used when changes from `Some(something)` to +/// `Some(something_different)` are rare, or when comparing inner values is more +/// expensive than performing an update which will mark the tracker as clean. +impl<T> OptionTracker<T> { + pub fn new(inner: Option<T>) -> Self { + Self { inner, dirty: true } + } + + pub fn get(&self) -> &Option<T> { + &self.inner + } + + pub fn set(&mut self, value: Option<T>) { + match (&self.inner, &value) { + (None, None) => {} + _ => self.dirty = true, + } + + self.inner = value; + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Marks the tracker as clean. + pub fn reset(&mut self) { + self.dirty = false; + } +} + +impl<T> Default for OptionTracker<T> { + fn default() -> Self { + Self::new(Option::default()) + } +} |