summary refs log tree commit diff
diff options
context:
space:
mode:
authorMalte Voos <git@mal.tc>2025-10-07 20:57:48 +0200
committerMalte Voos <git@mal.tc>2025-10-07 20:57:48 +0200
commitee29a3b1291e9cedd8b54c31fa9f273e39f51970 (patch)
treee41665482ef2668e0313adc9701f03384152b208
parent8aa48d67b0908b62d012b589df9b35f2f8551968 (diff)
downloadlleap-ee29a3b1291e9cedd8b54c31fa9f273e39f51970.tar.gz
lleap-ee29a3b1291e9cedd8b54c31fa9f273e39f51970.zip
revamp subtitle selection
-rw-r--r--Cargo.lock3
-rw-r--r--Cargo.toml2
-rw-r--r--build.rs5
-rw-r--r--flake.nix7
-rw-r--r--src/app.rs146
-rw-r--r--src/main.rs1
-rw-r--r--src/player.rs13
-rw-r--r--src/subtitle_extractor.rs4
-rw-r--r--src/subtitle_selection_dialog.rs257
-rw-r--r--src/subtitle_view.rs3
-rw-r--r--src/transcript.rs15
-rw-r--r--src/util/mod.rs2
-rw-r--r--src/util/tracker.rs41
13 files changed, 382 insertions, 117 deletions
diff --git a/Cargo.lock b/Cargo.lock
index f5608ef..7ca1eff 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -888,8 +888,7 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 [[package]]
 name = "isolang"
 version = "2.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe50d48c77760c55188549098b9a7f6e37ae980c586a24693d6b01c3b2010c3c"
+source = "git+https://github.com/humenda/isolang-rs#2e184a9a9d29d82561aedf2a3f5b91b9b78c7d1f"
 dependencies = [
  "phf",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 9d2e084..ca38001 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,7 +18,7 @@ env_logger = "0.11"
 log = "0.4"
 tracker = "0.2.2"
 unicode-segmentation = "1.12.0"
-isolang = "2.4.0"
+isolang = { git = "https://github.com/humenda/isolang-rs" }
 
 
 # TODO remove
diff --git a/build.rs b/build.rs
index 9e7d61e..875e8c6 100644
--- a/build.rs
+++ b/build.rs
@@ -3,29 +3,24 @@ use std::path::Path;
 use std::process::Command;
 
 fn main() {
-    // Tell cargo to rerun this build script if the schema file changes
     println!("cargo:rerun-if-changed=data/tc.mal.lleap.gschema.xml");
 
     let out_dir = env::var("OUT_DIR").unwrap();
     let schema_dir = Path::new(&out_dir).join("glib-2.0").join("schemas");
 
-    // Create the schema directory
     std::fs::create_dir_all(&schema_dir).unwrap();
 
-    // Copy the schema file to the output directory
     std::fs::copy(
         "data/tc.mal.lleap.gschema.xml",
         schema_dir.join("tc.mal.lleap.gschema.xml"),
     )
     .unwrap();
 
-    // Compile the schema using glib-compile-schemas
     Command::new("glib-compile-schemas")
         .arg(&schema_dir)
         .output()
         .unwrap();
 
-    // Set environment variable for the schema directory
     println!(
         "cargo:rustc-env=GSETTINGS_SCHEMA_DIR={}",
         schema_dir.display()
diff --git a/flake.nix b/flake.nix
index 4d73bfc..a5e7874 100644
--- a/flake.nix
+++ b/flake.nix
@@ -47,6 +47,8 @@
           nativeBuildInputs = with pkgs; [
             pkg-config
             rustPlatform.bindgenHook
+            wrapGAppsHook4
+            glib
           ];
 
           buildInputs = with pkgs; [
@@ -62,6 +64,11 @@
             gst_all_1.gst-vaapi
             ffmpeg_7-full.dev
           ];
+
+          postInstall = ''
+            install -D -m444 -t $out/share/glib-2.0/schemas data/*.gschema.xml
+            glib-compile-schemas $out/share/glib-2.0/schemas
+          '';
         };
 
         devShell = pkgs.mkShell {
diff --git a/src/app.rs b/src/app.rs
index 10c20e6..18f03e8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,6 +1,4 @@
 use adw::prelude::*;
-use gst::glib::clone;
-use gtk::gio::{Menu, MenuItem, SimpleAction, SimpleActionGroup};
 use relm4::{WorkerController, prelude::*};
 
 use crate::{
@@ -9,13 +7,14 @@ use crate::{
     subtitle_extractor::{
         StreamIndex, SubtitleExtractor, SubtitleExtractorMsg, SubtitleExtractorOutput, TRACKS,
     },
+    subtitle_selection_dialog::{
+        SubtitleSelectionDialog, SubtitleSelectionDialogMsg, SubtitleSelectionDialogOutput,
+    },
     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>,
@@ -23,9 +22,7 @@ pub struct App {
     subtitle_view: Controller<SubtitleView>,
     extractor: WorkerController<SubtitleExtractor>,
     preferences: Controller<Preferences>,
-
-    subtitle_selection_menu: Menu,
-    subtitle_selection_action_group: SimpleActionGroup,
+    subtitle_selection_dialog: Controller<SubtitleSelectionDialog>,
 
     primary_stream_ix: Option<StreamIndex>,
     primary_last_cue_ix: OptionTracker<usize>,
@@ -42,11 +39,13 @@ pub struct App {
 pub enum AppMsg {
     NewOrUpdatedTrackMetadata(StreamIndex),
     NewCue(StreamIndex, crate::subtitle_extractor::SubtitleCue),
-    ExtractionComplete,
-    TrackSelected(StreamIndex),
+    SubtitleExtractionComplete,
+    PrimarySubtitleTrackSelected(Option<StreamIndex>),
+    SecondarySubtitleTrackSelected(Option<StreamIndex>),
     PositionUpdate(gst::ClockTime),
     SetHoveringSubtitleCue(bool),
     ShowPreferences,
+    ShowSubtitleSelectionDialog,
 }
 
 #[relm4::component(pub)]
@@ -65,14 +64,9 @@ impl SimpleComponent for App {
             #[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",
+                        set_icon_name: "settings-symbolic",
                         connect_clicked => AppMsg::ShowPreferences,
-                        add_css_class: "flat",
                     }
                 },
 
@@ -96,14 +90,6 @@ impl SimpleComponent for App {
         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 {
@@ -114,6 +100,7 @@ impl SimpleComponent for App {
             .launch(())
             .forward(sender.input_sender(), |output| match output {
                 PlayerOutput::PositionUpdate(pos) => AppMsg::PositionUpdate(pos),
+                PlayerOutput::SubtitleSelectionButtonPressed => AppMsg::ShowSubtitleSelectionDialog,
             });
         let transcript =
             Transcript::builder()
@@ -131,11 +118,21 @@ impl SimpleComponent for App {
                 SubtitleExtractorOutput::NewCue(stream_index, cue) => {
                     AppMsg::NewCue(stream_index, cue)
                 }
-                SubtitleExtractorOutput::ExtractionComplete => AppMsg::ExtractionComplete,
+                SubtitleExtractorOutput::ExtractionComplete => AppMsg::SubtitleExtractionComplete,
             },
         );
 
         let preferences = Preferences::builder().launch(root.clone().into()).detach();
+        let subtitle_selection_dialog = SubtitleSelectionDialog::builder()
+            .launch(root.clone().into())
+            .forward(sender.input_sender(), |output| match output {
+                SubtitleSelectionDialogOutput::PrimaryTrackSelected(ix) => {
+                    AppMsg::PrimarySubtitleTrackSelected(ix)
+                }
+                SubtitleSelectionDialogOutput::SecondaryTrackSelected(ix) => {
+                    AppMsg::SecondarySubtitleTrackSelected(ix)
+                }
+            });
 
         let model = Self {
             url: url.clone(), // TODO remove clone
@@ -144,8 +141,7 @@ impl SimpleComponent for App {
             subtitle_view,
             extractor,
             preferences,
-            subtitle_selection_menu,
-            subtitle_selection_action_group,
+            subtitle_selection_dialog,
 
             primary_stream_ix: None,
             primary_last_cue_ix: OptionTracker::new(None),
@@ -173,31 +169,32 @@ impl SimpleComponent for App {
         ComponentParts { model, widgets }
     }
 
-    fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
+    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::NewOrUpdatedTrackMetadata(_stream_index) => {}
             AppMsg::NewCue(stream_index, cue) => {
                 self.transcript
                     .sender()
                     .send(TranscriptMsg::NewCue(stream_index, cue))
                     .unwrap();
             }
-            AppMsg::ExtractionComplete => {
-                println!("Subtitle extraction complete");
+            AppMsg::SubtitleExtractionComplete => {
+                log::info!("Subtitle extraction complete");
             }
-            AppMsg::TrackSelected(stream_index) => {
-                self.primary_stream_ix = Some(stream_index);
+            AppMsg::PrimarySubtitleTrackSelected(stream_index) => {
+                self.primary_stream_ix = stream_index;
 
                 self.transcript
                     .sender()
                     .send(TranscriptMsg::SelectTrack(stream_index))
                     .unwrap();
             }
+            AppMsg::SecondarySubtitleTrackSelected(stream_index) => {
+                self.secondary_stream_ix = stream_index;
+            }
             AppMsg::PositionUpdate(pos) => {
                 if let Some(stream_ix) = self.primary_stream_ix {
                     let cue =
@@ -239,14 +236,18 @@ impl SimpleComponent for App {
                     }
                 }
                 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();
+                    if !self.autopaused {
+                        self.subtitle_view
+                            .sender()
+                            .send(SubtitleViewMsg::SetSecondaryCue(
+                                Self::get_cue_and_update_ix(
+                                    stream_ix,
+                                    pos,
+                                    &mut self.secondary_last_cue_ix,
+                                ),
+                            ))
+                            .unwrap();
+                    }
                 }
             }
             AppMsg::SetHoveringSubtitleCue(hovering) => {
@@ -262,66 +263,17 @@ impl SimpleComponent for App {
                     .send(PreferencesMsg::Show)
                     .unwrap();
             }
+            AppMsg::ShowSubtitleSelectionDialog => {
+                self.subtitle_selection_dialog
+                    .sender()
+                    .send(SubtitleSelectionDialogMsg::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,
diff --git a/src/main.rs b/src/main.rs
index d902eaa..6ce1ca9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@ mod cue_view;
 mod player;
 mod preferences;
 mod subtitle_extractor;
+mod subtitle_selection_dialog;
 mod subtitle_view;
 mod transcript;
 mod util;
diff --git a/src/player.rs b/src/player.rs
index c784a04..2e234b6 100644
--- a/src/player.rs
+++ b/src/player.rs
@@ -37,6 +37,7 @@ pub enum PlayerMsg {
 #[derive(Debug)]
 pub enum PlayerOutput {
     PositionUpdate(gst::ClockTime),
+    SubtitleSelectionButtonPressed,
 }
 
 fn format_time(time: gst::ClockTime) -> String {
@@ -74,12 +75,10 @@ impl SimpleComponent for Player {
                 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" },
+                    set_icon_name: if model.is_playing { "media-playback-pause-symbolic" } else { "media-playback-start-symbolic" },
                     connect_clicked => PlayerMsg::PlayPause,
-                    add_css_class: "circular",
                 },
 
                 // Current time label
@@ -116,6 +115,14 @@ impl SimpleComponent for Player {
                     set_text: &format_time(model.duration),
                     set_width_chars: 8,
                 },
+
+                // Subtitle selection button
+                gtk::Button {
+                    set_icon_name: "media-view-subtitles-symbolic",
+                    connect_clicked[sender] => move |_| {
+                        sender.output(PlayerOutput::SubtitleSelectionButtonPressed).unwrap();
+                    },
+                },
             }
         }
     }
diff --git a/src/subtitle_extractor.rs b/src/subtitle_extractor.rs
index 53655a0..b628d73 100644
--- a/src/subtitle_extractor.rs
+++ b/src/subtitle_extractor.rs
@@ -17,7 +17,7 @@ pub struct SubtitleCue {
 
 #[derive(Debug, Clone)]
 pub struct SubtitleTrack {
-    pub language_code: Option<String>,
+    pub language: Option<isolang::Language>,
     pub title: Option<String>,
     pub cues: Vec<SubtitleCue>,
 }
@@ -87,7 +87,7 @@ impl SubtitleExtractor {
                 let title = stream.metadata().get("title").map(|s| s.to_string());
 
                 let track = SubtitleTrack {
-                    language_code,
+                    language: language_code.and_then(|code| isolang::Language::from_639_2b(&code)),
                     title,
                     cues: Vec::new(),
                 };
diff --git a/src/subtitle_selection_dialog.rs b/src/subtitle_selection_dialog.rs
new file mode 100644
index 0000000..0c7f1cd
--- /dev/null
+++ b/src/subtitle_selection_dialog.rs
@@ -0,0 +1,257 @@
+use adw::prelude::*;
+use gtk::{gio, glib};
+use relm4::prelude::*;
+
+use crate::subtitle_extractor::{StreamIndex, TRACKS};
+use crate::util::Tracker;
+
+// Custom GObject wrapper for subtitle track information
+glib::wrapper! {
+    pub struct SubtitleTrackInfo(ObjectSubclass<imp::SubtitleTrackInfo>);
+}
+
+impl SubtitleTrackInfo {
+    pub fn new(
+        stream_index: StreamIndex,
+        language: Option<&'static str>,
+        title: Option<String>,
+    ) -> Self {
+        glib::Object::builder()
+            .property("stream-index", stream_index as i64)
+            .property("language", language.unwrap_or_default())
+            .property("title", title.unwrap_or_default())
+            .build()
+    }
+
+    pub fn get_stream_index(&self) -> StreamIndex {
+        let index: i64 = self.property("stream-index");
+        index as usize
+    }
+}
+
+mod imp {
+    use gtk::{glib, prelude::*, subclass::prelude::*};
+    use std::cell::RefCell;
+
+    #[derive(Default, glib::Properties)]
+    #[properties(wrapper_type = super::SubtitleTrackInfo)]
+    pub struct SubtitleTrackInfo {
+        #[property(get, set)]
+        stream_index: RefCell<i64>,
+        #[property(get, set)]
+        language: RefCell<String>,
+        #[property(get, set)]
+        title: RefCell<String>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for SubtitleTrackInfo {
+        const NAME: &'static str = "SubtitleTrackInfo";
+        type Type = super::SubtitleTrackInfo;
+    }
+
+    #[glib::derived_properties]
+    impl ObjectImpl for SubtitleTrackInfo {}
+}
+
+pub struct SubtitleSelectionDialog {
+    parent_window: adw::ApplicationWindow,
+    dialog: adw::PreferencesDialog,
+    track_list_model: Tracker<gio::ListStore>,
+    primary_track_ix: Option<StreamIndex>,
+    secondary_track_ix: Option<StreamIndex>,
+}
+
+#[derive(Debug)]
+pub enum SubtitleSelectionDialogMsg {
+    Show,
+    PrimaryTrackChanged(Option<StreamIndex>),
+    SecondaryTrackChanged(Option<StreamIndex>),
+}
+
+#[derive(Debug)]
+pub enum SubtitleSelectionDialogOutput {
+    PrimaryTrackSelected(Option<StreamIndex>),
+    SecondaryTrackSelected(Option<StreamIndex>),
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for SubtitleSelectionDialog {
+    type Init = adw::ApplicationWindow;
+    type Input = SubtitleSelectionDialogMsg;
+    type Output = SubtitleSelectionDialogOutput;
+
+    view! {
+        #[root]
+        adw::PreferencesDialog {
+            set_title: "Subtitle Settings",
+            add: &page,
+        },
+
+        #[name(page)]
+        adw::PreferencesPage {
+            adw::PreferencesGroup {
+                #[name(primary_combo)]
+                adw::ComboRow {
+                    set_title: "Primary Subtitle Track",
+                    set_subtitle: "Main subtitle track for learning",
+                    set_factory: Some(&track_factory),
+                    #[track(model.track_list_model.is_dirty())]
+                    set_model: Some(model.track_list_model.get()),
+                    #[track(model.track_list_model.is_dirty())]
+                    set_selected: model.primary_track_ix.map_or(gtk::INVALID_LIST_POSITION, |ix| get_list_ix_from_stream_ix(model.track_list_model.get(), ix)),
+                    connect_selected_notify[sender] => move |combo| {
+                        let stream_index = get_stream_ix_from_combo(combo);
+                        sender.input(SubtitleSelectionDialogMsg::PrimaryTrackChanged(stream_index));
+                    },
+                },
+
+                #[name(secondary_combo)]
+                adw::ComboRow {
+                    set_title: "Secondary Subtitle Track",
+                    set_subtitle: "Optional second track for comparison",
+                    set_factory: Some(&track_factory),
+                    #[track(model.track_list_model.is_dirty())]
+                    set_model: Some(model.track_list_model.get()),
+                    #[track(model.track_list_model.is_dirty())]
+                    set_selected: model.secondary_track_ix.map_or(gtk::INVALID_LIST_POSITION, |ix| get_list_ix_from_stream_ix(model.track_list_model.get(), ix)),
+                    connect_selected_notify[sender] => move |combo| {
+                        let stream_index = get_stream_ix_from_combo(combo);
+                        sender.input(SubtitleSelectionDialogMsg::SecondaryTrackChanged(stream_index));
+                    },
+                },
+            }
+        },
+
+        #[name(track_factory)]
+        gtk::SignalListItemFactory {
+            connect_setup => move |_, list_item| {
+                let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
+                let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
+
+                let language_label = gtk::Label::new(None);
+                language_label.set_halign(gtk::Align::Start);
+                language_label.set_ellipsize(gtk::pango::EllipsizeMode::End);
+
+                let title_label = gtk::Label::new(None);
+                title_label.set_halign(gtk::Align::Start);
+                title_label.set_ellipsize(gtk::pango::EllipsizeMode::End);
+                title_label.add_css_class("subtitle");
+
+                vbox.append(&language_label);
+                vbox.append(&title_label);
+                list_item.set_child(Some(&vbox));
+            },
+            connect_bind => move |_, list_item| {
+                let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
+                let item = list_item.item().unwrap();
+                let track_info = item.downcast_ref::<SubtitleTrackInfo>().unwrap();
+                let vbox = list_item.child().unwrap().downcast::<gtk::Box>().unwrap();
+                let language_label = vbox.first_child().unwrap().downcast::<gtk::Label>().unwrap();
+                let title_label = vbox.last_child().unwrap().downcast::<gtk::Label>().unwrap();
+
+                let language = track_info.language();
+                let title = track_info.title();
+
+                let language_text = if !language.is_empty() {
+                    &language
+                } else {
+                    "Unknown Language"
+                };
+
+                language_label.set_text(&language_text);
+                title_label.set_text(&title);
+                title_label.set_visible(!title.is_empty());
+            },
+        },
+    }
+
+    fn init(
+        parent_window: Self::Init,
+        root: Self::Root,
+        sender: ComponentSender<Self>,
+    ) -> ComponentParts<Self> {
+        let track_list_model = gio::ListStore::new::<SubtitleTrackInfo>();
+
+        let model = Self {
+            parent_window,
+            dialog: root.clone(),
+            track_list_model: Tracker::new(track_list_model),
+            primary_track_ix: None,
+            secondary_track_ix: None,
+        };
+
+        let widgets = view_output!();
+
+        ComponentParts { model, widgets }
+    }
+
+    fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
+        self.track_list_model.reset();
+
+        match msg {
+            SubtitleSelectionDialogMsg::Show => {
+                self.update_combo_models();
+                self.dialog.present(Some(&self.parent_window));
+            }
+            SubtitleSelectionDialogMsg::PrimaryTrackChanged(stream_index) => {
+                self.primary_track_ix = stream_index;
+                sender
+                    .output(SubtitleSelectionDialogOutput::PrimaryTrackSelected(
+                        stream_index,
+                    ))
+                    .unwrap();
+            }
+            SubtitleSelectionDialogMsg::SecondaryTrackChanged(stream_index) => {
+                self.secondary_track_ix = stream_index;
+                sender
+                    .output(SubtitleSelectionDialogOutput::SecondaryTrackSelected(
+                        stream_index,
+                    ))
+                    .unwrap();
+            }
+        }
+    }
+}
+
+impl SubtitleSelectionDialog {
+    fn update_combo_models(&mut self) {
+        let tracks = TRACKS.read();
+
+        // Clear previous entries
+        self.track_list_model.get_mut().remove_all();
+
+        // Add all available tracks
+        for (&stream_index, track) in tracks.iter() {
+            let track_info = SubtitleTrackInfo::new(
+                stream_index,
+                track.language.map(|lang| lang.to_name()),
+                track.title.clone(),
+            );
+            self.track_list_model.get_mut().append(&track_info);
+        }
+    }
+}
+
+fn get_stream_ix_from_combo(combo: &adw::ComboRow) -> Option<StreamIndex> {
+    let ix = combo
+        .selected_item()?
+        .downcast_ref::<SubtitleTrackInfo>()
+        .unwrap()
+        .get_stream_index();
+
+    Some(ix)
+}
+
+fn get_list_ix_from_stream_ix(list_model: &gio::ListStore, stream_ix: StreamIndex) -> u32 {
+    for i in 0..list_model.n_items() {
+        if let Some(item) = list_model.item(i) {
+            if let Some(track_info) = item.downcast_ref::<SubtitleTrackInfo>() {
+                if track_info.get_stream_index() == stream_ix {
+                    return i;
+                }
+            }
+        }
+    }
+    panic!("Stream index {} not found in list model", stream_ix);
+}
diff --git a/src/subtitle_view.rs b/src/subtitle_view.rs
index 30c089c..dc48561 100644
--- a/src/subtitle_view.rs
+++ b/src/subtitle_view.rs
@@ -1,5 +1,6 @@
 use crate::cue_view::{CueView, CueViewMsg, CueViewOutput};
 use crate::util::OptionTracker;
+use gtk::glib;
 use gtk::prelude::*;
 use relm4::prelude::*;
 
@@ -44,7 +45,7 @@ impl SimpleComponent for SubtitleView {
 
                 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_text: model.secondary_cue.get().as_ref().map(|val| val.as_str()).unwrap_or(""),
                     set_justify: gtk::Justification::Center,
                 },
 
diff --git a/src/transcript.rs b/src/transcript.rs
index 2bddb72..eb3459d 100644
--- a/src/transcript.rs
+++ b/src/transcript.rs
@@ -50,7 +50,7 @@ pub struct Transcript {
 #[derive(Debug)]
 pub enum TranscriptMsg {
     NewCue(StreamIndex, SubtitleCue),
-    SelectTrack(StreamIndex),
+    SelectTrack(Option<StreamIndex>),
     ScrollToCue(usize),
 }
 
@@ -116,14 +116,17 @@ impl SimpleComponent for Transcript {
                 }
             }
             TranscriptMsg::SelectTrack(stream_index) => {
-                self.active_stream_index = Some(stream_index);
+                self.active_stream_index = 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());
+
+                if let Some(stream_ix) = stream_index {
+                    let tracks = TRACKS.read();
+                    if let Some(track) = tracks.get(&stream_ix) {
+                        for cue in &track.cues {
+                            self.active_cues.guard().push_back(cue.clone());
+                        }
                     }
                 }
             }
diff --git a/src/util/mod.rs b/src/util/mod.rs
index 5b0c6ac..600d572 100644
--- a/src/util/mod.rs
+++ b/src/util/mod.rs
@@ -1,3 +1,5 @@
 mod option_tracker;
+mod tracker;
 
 pub use option_tracker::OptionTracker;
+pub use tracker::Tracker;
diff --git a/src/util/tracker.rs b/src/util/tracker.rs
new file mode 100644
index 0000000..66c30a9
--- /dev/null
+++ b/src/util/tracker.rs
@@ -0,0 +1,41 @@
+pub struct Tracker<T> {
+    inner: T,
+    dirty: bool,
+}
+
+/// Tracks changes to an inner value T. Any change using `set` will cause the
+/// tracker to be marked as dirty.
+impl<T> Tracker<T> {
+    pub fn new(inner: T) -> Self {
+        Self { inner, dirty: true }
+    }
+
+    pub fn get(&self) -> &T {
+        &self.inner
+    }
+
+    pub fn get_mut(&mut self) -> &mut T {
+        self.dirty = true;
+        &mut self.inner
+    }
+
+    pub fn set(&mut self, value: T) {
+        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> Default for Tracker<T> {
+    fn default() -> Self {
+        Self::new(T::default())
+    }
+}