aboutsummaryrefslogtreecommitdiff
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
+ }
+ }
+}