summary refs log tree commit diff
path: root/src/app.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.rs')
-rw-r--r--src/app.rs371
1 files changed, 371 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
+        }
+    }
+}