diff options
Diffstat (limited to 'src/app.rs')
-rw-r--r-- | src/app.rs | 371 |
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 = >k::MenuButton { + set_label: "Select Subtitle Track", + set_popover: Some(>k::PopoverMenu::from_model(Some(&model.subtitle_selection_menu))), + }, + pack_start = >k::Button { + set_label: "Preferences", + connect_clicked => AppMsg::ShowPreferences, + add_css_class: "flat", + } + }, + + #[wrap(Some)] + set_content = >k::Paned { + set_orientation: gtk::Orientation::Vertical, + #[wrap(Some)] + set_start_child = >k::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 + } + } +} |