use adw::prelude::*; use relm4::{WorkerController, prelude::*}; use crate::{ player::{Player, PlayerMsg, PlayerOutput}, preferences::{Preferences, PreferencesMsg}, subtitle_extractor::{ StreamIndex, SubtitleExtractor, SubtitleExtractorMsg, SubtitleExtractorOutput, TRACKS, }, subtitle_selection_dialog::{ SubtitleSelectionDialog, SubtitleSelectionDialogMsg, SubtitleSelectionDialogOutput, }, subtitle_view::{SubtitleView, SubtitleViewMsg, SubtitleViewOutput}, transcript::{Transcript, TranscriptMsg, TranscriptOutput}, util::OptionTracker, }; pub struct App { url: String, transcript: Controller, player: Controller, subtitle_view: Controller, extractor: WorkerController, preferences: Controller, subtitle_selection_dialog: Controller, 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), SubtitleExtractionComplete, PrimarySubtitleTrackSelected(Option), SecondarySubtitleTrackSelected(Option), PositionUpdate(gst::ClockTime), SetHoveringSubtitleCue(bool), ShowPreferences, ShowSubtitleSelectionDialog, } #[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::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( url: 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::NewOrUpdatedTrackMetadata(stream_index) => { AppMsg::NewOrUpdatedTrackMetadata(stream_index) } SubtitleExtractorOutput::NewCue(stream_index, cue) => { AppMsg::NewCue(stream_index, cue) } SubtitleExtractorOutput::ExtractionComplete => AppMsg::SubtitleExtractionComplete, }, ); let preferences = Preferences::builder().launch(root.clone().into()).detach(); 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 { url: url.clone(), // TODO remove clone player, transcript, subtitle_view, extractor, preferences, subtitle_selection_dialog, 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) => {} 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 { 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 { if !self.autopaused { self.subtitle_view .sender() .send(SubtitleViewMsg::SetSecondaryCue( Self::get_cue_and_update_ix( stream_ix, pos, &mut self.secondary_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(); } AppMsg::ShowSubtitleSelectionDialog => { self.subtitle_selection_dialog .sender() .send(SubtitleSelectionDialogMsg::Show) .unwrap(); } } } } impl App { 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 } } }