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, player: Controller, subtitle_view: Controller, extractor: WorkerController, preferences: Controller, subtitle_selection_menu: Menu, subtitle_selection_action_group: SimpleActionGroup, primary_stream_ix: Option, primary_last_cue_ix: OptionTracker, secondary_stream_ix: Option, secondary_last_cue_ix: OptionTracker, // 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, ) -> ComponentParts { 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.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.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 = "".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, ) -> Option { 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 } } }