aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMalte Voos <git@mal.tc>2025-10-01 00:20:10 +0200
committerMalte Voos <git@mal.tc>2025-10-01 00:20:10 +0200
commit338babaad2189f7ff1ee088994c8c20a0646ff4d (patch)
tree29fb2620f748d32a42c1d1eb3346771600a8d75b /src
downloadlleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.tar.gz
lleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.zip
init
Diffstat (limited to 'src')
-rw-r--r--src/app.rs371
-rw-r--r--src/cue_view.rs179
-rw-r--r--src/main.rs45
-rw-r--r--src/player.rs298
-rw-r--r--src/preferences.rs71
-rw-r--r--src/subtitle_extractor.rs209
-rw-r--r--src/subtitle_view.rs94
-rw-r--r--src/transcript.rs143
-rw-r--r--src/util/mod.rs3
-rw-r--r--src/util/option_tracker.rs43
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 = &gtk::MenuButton {
+ set_label: "Select Subtitle Track",
+ set_popover: Some(&gtk::PopoverMenu::from_model(Some(&model.subtitle_selection_menu))),
+ },
+ pack_start = &gtk::Button {
+ set_label: "Preferences",
+ connect_clicked => AppMsg::ShowPreferences,
+ add_css_class: "flat",
+ }
+ },
+
+ #[wrap(Some)]
+ set_content = &gtk::Paned {
+ set_orientation: gtk::Orientation::Vertical,
+ #[wrap(Some)]
+ set_start_child = &gtk::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: &gtk::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", &gtksink)
+ .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())
+ }
+}