diff --git a/i18n/en/cosmic_player.ftl b/i18n/en/cosmic_player.ftl index f3b1e7d..505e312 100644 --- a/i18n/en/cosmic_player.ftl +++ b/i18n/en/cosmic_player.ftl @@ -37,3 +37,11 @@ quit = Quit repeat-disabled = Repeat disabled repeat-track = Repeat track + +playback = Playback +next-frame = Next Frame +previous-frame = Previous Frame +ab-repeat = A-B Repeat +ab-repeat-set-a = A-B Repeat (A) +ab-repeat-set-b = A-B Repeat (B) +ab-repeat-clear = Clear A-B Repeat diff --git a/src/key_bind.rs b/src/key_bind.rs index 6c5a3d8..2750a04 100644 --- a/src/key_bind.rs +++ b/src/key_bind.rs @@ -27,6 +27,9 @@ pub fn key_binds() -> HashMap { bind!([], Key::Character(" ".into()), PlayPause); bind!([], Key::Named(Named::ArrowLeft), SeekBackward); bind!([], Key::Named(Named::ArrowRight), SeekForward); + bind!([], Key::Character(".".into()), NextFrame); + bind!([], Key::Character(",".into()), PreviousFrame); + bind!([], Key::Character("a".into()), AbRepeat); key_binds } diff --git a/src/main.rs b/src/main.rs index 8690718..1d76a3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,17 @@ fn language_name(code: &str) -> Option { Some(name.to_string()) } +fn get_framerate(video: &Video) -> Option { + let pipeline = video.pipeline(); + let video_sink: gst::Element = pipeline.property("video-sink"); + let binding = video_sink.pads(); + let pad = binding.first()?; + let caps = pad.current_caps()?; + let structure = caps.structure(0)?; + let framerate = structure.get::("framerate").ok()?; + Some(framerate.numer() as f64 / framerate.denom() as f64) +} + /// Runs application with these settings #[rustfmt::skip] fn main() -> Result<(), Box> { @@ -178,6 +189,9 @@ pub enum Action { PlayPause, SeekBackward, SeekForward, + NextFrame, + PreviousFrame, + AbRepeat, WindowClose, } @@ -198,6 +212,9 @@ impl MenuAction for Action { Self::PlayPause => Message::PlayPause, Self::SeekBackward => Message::SeekRelative(-10.0), Self::SeekForward => Message::SeekRelative(10.0), + Self::NextFrame => Message::NextFrame, + Self::PreviousFrame => Message::PreviousFrame, + Self::AbRepeat => Message::AbRepeat, Self::WindowClose => Message::WindowClose, } } @@ -292,11 +309,14 @@ pub enum Message { SeekRelative(f64), SeekRelease, PlayNext, + NextFrame, + PreviousFrame, EndOfStream, MissingPlugin(gst::Message), MprisChannel(MprisMeta, MprisState, mpsc::UnboundedSender), NewFrame, Reload, + AbRepeat, ShowControls, SystemThemeModeChange(cosmic_theme::ThemeMode), WindowClose, @@ -326,6 +346,7 @@ pub struct App { current_audio: i32, text_codes: Vec, current_text: Option, + ab_repeat: Option<(Option, Option)>, #[cfg(feature = "xdg-portal")] inhibit: tokio::sync::watch::Sender, } @@ -890,6 +911,7 @@ impl Application for App { current_audio: -1, text_codes: Vec::new(), current_text: None, + ab_repeat: None, #[cfg(feature = "xdg-portal")] inhibit, }; @@ -1036,6 +1058,7 @@ impl Application for App { } Message::FileLoad(url) => { self.flags.url_opt = Some(url); + self.ab_repeat = None; return self.load(); } Message::FileOpen => { @@ -1385,11 +1408,63 @@ impl Application for App { } } + Message::NextFrame => { + if let Some(video) = &mut self.video_opt { + video.pipeline().send_event(gst::event::Step::new( + gst::format::Buffers::from_u64(1), + 1.0, + true, + false, + )); + video.set_paused(true); + self.update_controls(true); + } + } + Message::PreviousFrame => { + if let Some(video) = &mut self.video_opt { + // TODO: Improve Accuracy. + let current = video.position(); + let fps = get_framerate(video).unwrap_or(30.0); + let frame_duration = Duration::from_secs_f64(1.0 / fps); + let target = current.saturating_sub(frame_duration + Duration::from_millis(1)); + + video.seek(target, true).expect("seek"); + video.set_paused(true); + self.update_controls(true); + } + } + Message::AbRepeat => { + let current_opt = self.video_opt.as_ref().map(|v| v.position().as_secs_f64()); + if let Some(current) = current_opt { + match self.ab_repeat { + None => { + self.ab_repeat = Some((Some(current), None)); + } + Some((a, None)) => { + self.ab_repeat = Some((a, Some(current))); + } + Some(_) => { + self.ab_repeat = None; + } + } + self.update_controls(true); + } + } + Message::EndOfStream => { - println!( - "end of stream, repeat={:?}", - self.flags.config_state.player_state.repeat - ); + if let Some((a, _)) = self.ab_repeat { + let target = a.unwrap_or(0.0); + if let Some(video) = &mut self.video_opt { + video + .seek(Duration::from_secs_f64(target), true) + .expect("seek"); + } + } else { + println!( + "end of stream, repeat={:?}", + self.flags.config_state.player_state.repeat + ); + } } Message::MissingPlugin(element) => { @@ -1458,9 +1533,18 @@ impl Application for App { self.update_mpris_state(); } Message::NewFrame => { - if let Some(video) = &self.video_opt { + if let Some(video) = &mut self.video_opt { if !self.dragging { self.position = video.position().as_secs_f64(); + + if let Some((a, b)) = self.ab_repeat { + let target_a = a.unwrap_or(0.0); + let target_b = b.unwrap_or(self.duration); + if self.position >= target_b { + let _ = video.seek(Duration::from_secs_f64(target_a), true); + } + } + self.update_controls(self.dropdown_opt.is_some()); } } @@ -1487,6 +1571,7 @@ impl Application for App { &self.flags.config_state, &self.key_binds, &self.projects, + &self.ab_repeat, )] } diff --git a/src/menu.rs b/src/menu.rs index b893f0a..a2c3b0e 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -17,6 +17,7 @@ pub fn menu_bar<'a>( config_state: &ConfigState, key_binds: &HashMap, projects: &[(String, PathBuf)], + ab_repeat: &Option<(Option, Option)>, ) -> Element<'a, Message> { let home_dir_opt = std::env::home_dir(); let format_path = |path: &PathBuf| -> String { @@ -87,23 +88,44 @@ pub fn menu_bar<'a>( )); } - MenuBar::new(vec![menu::Tree::with_children( - RcElementWrapper::new(Element::from(menu::root(fl!("file")))), - menu::items( - key_binds, - vec![ - menu::Item::Button(fl!("open-media"), None, Action::FileOpen), - menu::Item::Folder(fl!("open-recent-media"), recent_files), - menu::Item::Button(fl!("close-file"), None, Action::FileClose), - menu::Item::Divider, - menu::Item::Button(fl!("open-media-folder"), None, Action::FolderOpen), - menu::Item::Folder(fl!("open-recent-media-folder"), recent_projects), - menu::Item::Folder(fl!("close-media-folder"), close_projects), - menu::Item::Divider, - menu::Item::Button(fl!("quit"), None, Action::WindowClose), - ], + MenuBar::new(vec![ + menu::Tree::with_children( + RcElementWrapper::new(Element::from(menu::root(fl!("file")))), + menu::items( + key_binds, + vec![ + menu::Item::Button(fl!("open-media"), None, Action::FileOpen), + menu::Item::Folder(fl!("open-recent-media"), recent_files), + menu::Item::Button(fl!("close-file"), None, Action::FileClose), + menu::Item::Divider, + menu::Item::Button(fl!("open-media-folder"), None, Action::FolderOpen), + menu::Item::Folder(fl!("open-recent-media-folder"), recent_projects), + menu::Item::Folder(fl!("close-media-folder"), close_projects), + menu::Item::Divider, + menu::Item::Button(fl!("quit"), None, Action::WindowClose), + ], + ), ), - )]) + menu::Tree::with_children( + RcElementWrapper::new(Element::from(menu::root(fl!("playback")))), + menu::items( + key_binds, + vec![ + menu::Item::Button(fl!("next-frame"), None, Action::NextFrame), + menu::Item::Button(fl!("previous-frame"), None, Action::PreviousFrame), + menu::Item::Button( + match ab_repeat { + None => fl!("ab-repeat-set-a"), + Some((_, None)) => fl!("ab-repeat-set-b"), + Some((_, Some(_))) => fl!("ab-repeat-clear"), + }, + None, + Action::AbRepeat, + ), + ], + ), + ), + ]) .item_height(ItemHeight::Dynamic(40)) .item_width(ItemWidth::Uniform(320)) .spacing(theme::active().cosmic().spacing.space_xxxs.into())