aboutsummaryrefslogtreecommitdiff
path: root/src/app.rs
diff options
context:
space:
mode:
authorMalte Voos <git@mal.tc>2025-12-05 15:35:38 +0100
committerMalte Voos <git@mal.tc>2025-12-05 15:43:58 +0100
commitc347b6133365dcf1b7da4e77890b20d04d6cfba4 (patch)
treec83aac6f7d1e6edc57e607f01e5d3eeee8da4a0e /src/app.rs
parent652b1c2a0ce7db4885ebc51f7f09133a43401442 (diff)
downloadlleap-c347b6133365dcf1b7da4e77890b20d04d6cfba4.tar.gz
lleap-c347b6133365dcf1b7da4e77890b20d04d6cfba4.zip
implement machine translation; various fixes and refactorings
Diffstat (limited to 'src/app.rs')
-rw-r--r--src/app.rs335
1 files changed, 172 insertions, 163 deletions
diff --git a/src/app.rs b/src/app.rs
index 951392e..bdb2ef9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5,33 +5,36 @@ use crate::{
icon_names,
open_dialog::{OpenDialog, OpenDialogMsg, OpenDialogOutput},
player::{Player, PlayerMsg, PlayerOutput},
- preferences::{Preferences, PreferencesMsg},
- subtitle_extraction::{SubtitleExtractor, SubtitleExtractorMsg, SubtitleExtractorOutput},
+ preferences_dialog::{PreferencesDialog, PreferencesDialogMsg},
subtitle_selection_dialog::{
SubtitleSelectionDialog, SubtitleSelectionDialogMsg, SubtitleSelectionDialogOutput,
+ SubtitleSettings,
},
subtitle_view::{SubtitleView, SubtitleViewMsg, SubtitleViewOutput},
- tracks::{SUBTITLE_TRACKS, StreamIndex, SubtitleCue},
+ subtitles::{
+ MetadataCollection, SUBTITLE_TRACKS, StreamIndex, SubtitleCue, SubtitleTrack,
+ extraction::{SubtitleExtractor, SubtitleExtractorMsg, SubtitleExtractorOutput},
+ state::SubtitleState,
+ },
transcript::{Transcript, TranscriptMsg, TranscriptOutput},
+ translation::{DeeplTranslator, deepl::DeeplTranslatorMsg},
util::Tracker,
};
pub struct App {
+ root: adw::ApplicationWindow,
transcript: Controller<Transcript>,
player: Controller<Player>,
subtitle_view: Controller<SubtitleView>,
extractor: WorkerController<SubtitleExtractor>,
+ deepl_translator: AsyncController<DeeplTranslator>,
- preferences: Controller<Preferences>,
+ preferences: Controller<PreferencesDialog>,
open_url_dialog: Controller<OpenDialog>,
- subtitle_selection_dialog: Controller<SubtitleSelectionDialog>,
+ subtitle_selection_dialog: Option<Controller<SubtitleSelectionDialog>>,
- primary_stream_ix: Option<StreamIndex>,
- primary_cue: Tracker<Option<String>>,
- primary_last_cue_ix: Tracker<Option<usize>>,
- secondary_cue: Tracker<Option<String>>,
- secondary_stream_ix: Option<StreamIndex>,
- secondary_last_cue_ix: Tracker<Option<usize>>,
+ primary_subtitle_state: SubtitleState,
+ secondary_subtitle_state: SubtitleState,
// for auto-pausing
autopaused: bool,
@@ -40,10 +43,9 @@ pub struct App {
#[derive(Debug)]
pub enum AppMsg {
- NewCue(StreamIndex, SubtitleCue),
+ AddCue(StreamIndex, SubtitleCue),
SubtitleExtractionComplete,
- PrimarySubtitleTrackSelected(Option<StreamIndex>),
- SecondarySubtitleTrackSelected(Option<StreamIndex>),
+ ApplySubtitleSettings(SubtitleSettings),
PositionUpdate(gst::ClockTime),
SetHoveringSubtitleCue(bool),
ShowUrlOpenDialog,
@@ -51,6 +53,7 @@ pub enum AppMsg {
ShowSubtitleSelectionDialog,
Play {
url: String,
+ metadata: MetadataCollection,
whisper_stream_index: Option<StreamIndex>,
},
}
@@ -123,52 +126,46 @@ impl SimpleComponent for App {
sender.input_sender(),
|output| match output {
SubtitleExtractorOutput::NewCue(stream_index, cue) => {
- AppMsg::NewCue(stream_index, cue)
+ AppMsg::AddCue(stream_index, cue)
}
SubtitleExtractorOutput::ExtractionComplete => AppMsg::SubtitleExtractionComplete,
},
);
- let preferences = Preferences::builder().launch(root.clone().into()).detach();
+ let deepl_translator = DeeplTranslator::builder().launch(()).detach();
+
+ let preferences = PreferencesDialog::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,
+ metadata,
whisper_stream_index,
} => AppMsg::Play {
url,
+ metadata,
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 {
+ root: root.clone(),
player,
transcript,
subtitle_view,
extractor,
+ deepl_translator,
preferences,
open_url_dialog,
- subtitle_selection_dialog,
+ subtitle_selection_dialog: None,
- 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),
+ primary_subtitle_state: SubtitleState::default(),
+ secondary_subtitle_state: SubtitleState::default(),
autopaused: false,
hovering_primary_cue: false,
@@ -179,94 +176,45 @@ impl SimpleComponent for App {
ComponentParts { model, widgets }
}
- fn update(&mut self, message: Self::Input, _sender: ComponentSender<Self>) {
+ fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
match message {
- AppMsg::NewCue(stream_index, cue) => {
+ AppMsg::AddCue(stream_ix, cue) => {
+ SUBTITLE_TRACKS
+ .write()
+ .get_mut(&stream_ix)
+ .unwrap()
+ .push_cue(cue.clone());
+
self.transcript
.sender()
- .send(TranscriptMsg::NewCue(stream_index, cue))
+ .send(TranscriptMsg::NewCue(stream_ix, cue))
.unwrap();
}
AppMsg::SubtitleExtractionComplete => {
log::info!("Subtitle extraction complete");
}
- AppMsg::PrimarySubtitleTrackSelected(stream_index) => {
- self.primary_stream_ix = stream_index;
+ AppMsg::ApplySubtitleSettings(settings) => {
+ self.primary_subtitle_state
+ .set_stream_ix(settings.primary_track_ix);
+ self.secondary_subtitle_state
+ .set_stream_ix(settings.secondary_track_ix);
self.transcript
.sender()
- .send(TranscriptMsg::SelectTrack(stream_index))
+ .send(TranscriptMsg::SelectTrack(settings.primary_track_ix))
+ .unwrap();
+ self.deepl_translator
+ .sender()
+ .send(DeeplTranslatorMsg::SelectTrack(settings.primary_track_ix))
.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();
- }
- }
+ self.subtitle_view
+ .sender()
+ .send(SubtitleViewMsg::ApplySubtitleSettings(settings))
+ .unwrap();
+ }
+ AppMsg::PositionUpdate(position) => {
+ self.update_subtitle_states(position);
}
AppMsg::SetHoveringSubtitleCue(hovering) => {
self.hovering_primary_cue = hovering;
@@ -284,17 +232,20 @@ impl SimpleComponent for App {
AppMsg::ShowPreferences => {
self.preferences
.sender()
- .send(PreferencesMsg::Show)
+ .send(PreferencesDialogMsg::Show)
.unwrap();
}
AppMsg::ShowSubtitleSelectionDialog => {
- self.subtitle_selection_dialog
- .sender()
- .send(SubtitleSelectionDialogMsg::Show)
- .unwrap();
+ if let Some(ref dialog) = self.subtitle_selection_dialog {
+ dialog
+ .sender()
+ .send(SubtitleSelectionDialogMsg::Show)
+ .unwrap();
+ }
}
AppMsg::Play {
url,
+ metadata,
whisper_stream_index,
} => {
self.player
@@ -308,70 +259,128 @@ impl SimpleComponent for App {
whisper_stream_index,
})
.unwrap();
+
+ let subtitle_selection_dialog = SubtitleSelectionDialog::builder()
+ .launch((self.root.clone().into(), metadata))
+ .forward(sender.input_sender(), |output| match output {
+ SubtitleSelectionDialogOutput::ApplySubtitleSettings(settings) => {
+ AppMsg::ApplySubtitleSettings(settings)
+ }
+ });
+ self.subtitle_selection_dialog = Some(subtitle_selection_dialog);
}
}
}
}
impl App {
- fn update_cue(
- stream_ix: StreamIndex,
- position: gst::ClockTime,
- cue: &mut Tracker<Option<String>>,
- last_cue_ix: &mut Tracker<Option<usize>>,
- ) {
- let lock = SUBTITLE_TRACKS.read();
- let track = lock.get(&stream_ix).unwrap();
+ fn update_subtitle_states(&mut self, position: gst::ClockTime) {
+ self.update_primary_subtitle_state(position);
+ self.update_secondary_subtitle_state(position);
+ }
- // 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;
- }
+ fn update_primary_subtitle_state(&mut self, position: gst::ClockTime) {
+ // sometimes we get a few position update messages after
+ // auto-pausing
+ if self.autopaused {
+ return;
+ }
+
+ update_subtitle_state(&mut self.primary_subtitle_state, position);
+
+ // last cue just ended -> auto-pause
+ if self.primary_subtitle_state.last_ended_cue_ix.is_dirty() && self.hovering_primary_cue {
+ self.player.sender().send(PlayerMsg::Pause).unwrap();
+ self.autopaused = true;
+ return;
+ }
+
+ if self.primary_subtitle_state.is_dirty() {
+ let cue = self.primary_subtitle_state.active_cue();
+
+ self.subtitle_view
+ .sender()
+ .send(SubtitleViewMsg::SetPrimaryCue(cue))
+ .unwrap();
+ }
+
+ if self.primary_subtitle_state.last_started_cue_ix.is_dirty() {
+ if let Some(ix) = *self.primary_subtitle_state.last_started_cue_ix {
+ self.transcript
+ .sender()
+ .send(TranscriptMsg::ScrollToCue(ix))
+ .unwrap();
}
}
- // 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);
+ self.primary_subtitle_state.reset();
+ }
+
+ fn update_secondary_subtitle_state(&mut self, position: gst::ClockTime) {
+ // sometimes we get a few position update messages after
+ // auto-pausing
+ if self.autopaused {
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);
- }
+ update_subtitle_state(&mut self.secondary_subtitle_state, position);
+
+ if self.secondary_subtitle_state.is_dirty() {
+ let cue = self.secondary_subtitle_state.active_cue();
+
+ self.subtitle_view
+ .sender()
+ .send(SubtitleViewMsg::SetSecondaryCue(cue))
+ .unwrap();
+ }
+
+ self.secondary_subtitle_state.reset();
+ }
+}
+
+fn update_subtitle_state(state: &mut SubtitleState, position: gst::ClockTime) {
+ if let Some(stream_ix) = state.stream_ix {
+ let lock = SUBTITLE_TRACKS.read();
+ let track = lock.get(&stream_ix).unwrap();
+
+ update_last_time_ix(&track.start_times, &mut state.last_started_cue_ix, position);
+ update_last_time_ix(&track.end_times, &mut state.last_ended_cue_ix, position);
+ }
+}
+
+fn update_last_time_ix(
+ times: &Vec<gst::ClockTime>,
+ last_time_ix: &mut Tracker<Option<usize>>,
+ current_time: gst::ClockTime,
+) {
+ // try to find index quickly (should succeed during normal playback)
+ if let Some(ix) = last_time_ix.get() {
+ let t0 = times.get(*ix).unwrap();
+ match (times.get(ix + 1), times.get(ix + 2)) {
+ (None, _) if current_time >= *t0 => {
+ return;
}
- None => {
- cue.set(None);
- last_cue_ix.set(None);
+ (Some(t1), _) if current_time >= *t0 && current_time < *t1 => {
+ return;
}
- };
+ (Some(t1), None) if current_time >= *t1 => {
+ last_time_ix.set(Some(ix + 1));
+ return;
+ }
+ (Some(t1), Some(t2)) if current_time >= *t1 && current_time < *t2 => {
+ last_time_ix.set(Some(ix + 1));
+ return;
+ }
+ _ => {}
+ }
+ }
+
+ // if we are before the first timestamp, no need to look further
+ if times.is_empty() || current_time < *times.first().unwrap() {
+ last_time_ix.set_if_ne(None);
+ return;
}
+
+ // otherwise, search the whole array (e.g. after seeking)
+ last_time_ix.set(times.iter().rposition(|time| *time <= current_time));
}