summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
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())
+    }
+}