use adw::prelude::*; use relm4::{WorkerController, prelude::*}; use crate::{ open_dialog::{OpenDialog, OpenDialogMsg, OpenDialogOutput}, player::{Player, PlayerMsg, PlayerOutput}, preferences::{Preferences, PreferencesMsg}, subtitle_extraction::{SubtitleExtractor, SubtitleExtractorMsg, SubtitleExtractorOutput}, subtitle_selection_dialog::{ SubtitleSelectionDialog, SubtitleSelectionDialogMsg, SubtitleSelectionDialogOutput, }, subtitle_view::{SubtitleView, SubtitleViewMsg, SubtitleViewOutput}, tracks::{SUBTITLE_TRACKS, StreamIndex, SubtitleCue}, transcript::{Transcript, TranscriptMsg, TranscriptOutput}, util::Tracker, }; pub struct App { transcript: Controller, player: Controller, subtitle_view: Controller, extractor: WorkerController, preferences: Controller, open_url_dialog: Controller, subtitle_selection_dialog: Controller, primary_stream_ix: Option, primary_cue: Tracker>, primary_last_cue_ix: Tracker>, secondary_cue: Tracker>, secondary_stream_ix: Option, secondary_last_cue_ix: Tracker>, // for auto-pausing autopaused: bool, hovering_primary_cue: bool, } #[derive(Debug)] pub enum AppMsg { NewCue(StreamIndex, SubtitleCue), SubtitleExtractionComplete, PrimarySubtitleTrackSelected(Option), SecondarySubtitleTrackSelected(Option), PositionUpdate(gst::ClockTime), SetHoveringSubtitleCue(bool), ShowUrlOpenDialog, ShowPreferences, ShowSubtitleSelectionDialog, Play { url: String, whisper_stream_index: Option, }, } #[relm4::component(pub)] impl SimpleComponent for App { type Init = (); type Input = AppMsg; type Output = (); view! { #[root] adw::ApplicationWindow { set_title: Some("lleap"), set_default_width: 800, set_default_height: 600, adw::ToolbarView { add_top_bar = &adw::HeaderBar { pack_start = >k::Button { set_label: "Open...", connect_clicked => AppMsg::ShowUrlOpenDialog, }, pack_end = >k::Button { set_icon_name: "settings-symbolic", connect_clicked => AppMsg::ShowPreferences, } }, #[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( _init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { 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), PlayerOutput::SubtitleSelectionButtonPressed => AppMsg::ShowSubtitleSelectionDialog, }); 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::NewCue(stream_index, cue) => { AppMsg::NewCue(stream_index, cue) } SubtitleExtractorOutput::ExtractionComplete => AppMsg::SubtitleExtractionComplete, }, ); let preferences = Preferences::builder().launch(root.clone().into()).detach(); let open_url_dialog = OpenDialog::builder().launch(root.clone().into()).forward( sender.input_sender(), |output| match output { OpenDialogOutput::Play { url, whisper_stream_index, } => AppMsg::Play { url, whisper_stream_index, }, }, ); 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 { player, transcript, subtitle_view, extractor, preferences, open_url_dialog, subtitle_selection_dialog, primary_stream_ix: None, primary_cue: Tracker::new(None), primary_last_cue_ix: Tracker::new(None), secondary_stream_ix: None, secondary_cue: Tracker::new(None), secondary_last_cue_ix: Tracker::new(None), autopaused: false, hovering_primary_cue: false, }; let widgets = view_output!(); ComponentParts { model, widgets } } fn update(&mut self, message: Self::Input, _sender: ComponentSender) { match message { AppMsg::NewCue(stream_index, cue) => { self.transcript .sender() .send(TranscriptMsg::NewCue(stream_index, cue)) .unwrap(); } AppMsg::SubtitleExtractionComplete => { log::info!("Subtitle extraction complete"); } 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 { // sometimes we get a few position update messages after // auto-pausing; this prevents us from immediately un-autopausing // again if self.autopaused { return; } let cue_was_some = self.primary_cue.get().is_some(); Self::update_cue( stream_ix, pos, &mut self.primary_cue, &mut self.primary_last_cue_ix, ); if self.primary_cue.is_dirty() { // last cue just ended -> auto-pause if cue_was_some && self.hovering_primary_cue { self.player.sender().send(PlayerMsg::Pause).unwrap(); self.autopaused = true; return; } self.subtitle_view .sender() .send(SubtitleViewMsg::SetPrimaryCue( self.primary_cue.get().clone(), )) .unwrap(); self.primary_cue.reset(); } if self.primary_last_cue_ix.is_dirty() { if let Some(ix) = self.primary_last_cue_ix.get() { self.transcript .sender() .send(TranscriptMsg::ScrollToCue(*ix)) .unwrap(); } self.primary_last_cue_ix.reset(); } } if let Some(stream_ix) = self.secondary_stream_ix { Self::update_cue( stream_ix, pos, &mut self.secondary_cue, &mut self.secondary_last_cue_ix, ); if !self.autopaused && self.secondary_cue.is_dirty() { self.subtitle_view .sender() .send(SubtitleViewMsg::SetSecondaryCue( self.secondary_cue.get().clone(), )) .unwrap(); self.secondary_cue.reset(); } } } AppMsg::SetHoveringSubtitleCue(hovering) => { self.hovering_primary_cue = hovering; if !hovering && self.autopaused { self.player.sender().send(PlayerMsg::Play).unwrap(); self.autopaused = false; } } AppMsg::ShowUrlOpenDialog => { self.open_url_dialog .sender() .send(OpenDialogMsg::Show) .unwrap(); } AppMsg::ShowPreferences => { self.preferences .sender() .send(PreferencesMsg::Show) .unwrap(); } AppMsg::ShowSubtitleSelectionDialog => { self.subtitle_selection_dialog .sender() .send(SubtitleSelectionDialogMsg::Show) .unwrap(); } AppMsg::Play { url, whisper_stream_index, } => { self.player .sender() .send(PlayerMsg::SetUrl(url.clone())) .unwrap(); self.extractor .sender() .send(SubtitleExtractorMsg::ExtractFromUrl { url, whisper_stream_index, }) .unwrap(); } } } } impl App { fn update_cue( stream_ix: StreamIndex, position: gst::ClockTime, cue: &mut Tracker>, last_cue_ix: &mut Tracker>, ) { let lock = SUBTITLE_TRACKS.read(); let track = lock.get(&stream_ix).unwrap(); // 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).unwrap(); if last_cue.start <= position && position <= last_cue.end { // still at current cue return; } else if let Some(next_cue) = track.cues.get(ix + 1) { if last_cue.end < position && position < next_cue.start { // strictly between cues cue.set(None); return; } if next_cue.start <= position && position <= next_cue.end { // already in next cue (this happens when one cue immediately // follows the previous one) cue.set(Some(next_cue.text.clone())); last_cue_ix.set(Some(ix + 1)); return; } } } // if we are before the first subtitle, no need to look further if track.cues.is_empty() || position < track.cues.first().unwrap().start { cue.set(None); last_cue_ix.set(None); return; } // otherwise, search the whole track (e.g. after seeking) match track .cues .iter() .enumerate() .rev() .find(|(_ix, cue)| cue.start <= position) { Some((ix, new_cue)) => { last_cue_ix.set(Some(ix)); if position <= new_cue.end { cue.set(Some(new_cue.text.clone())); } else { cue.set(None); } } None => { cue.set(None); last_cue_ix.set(None); } }; } }