[Feat] AB repeat and frame stepping (#241)

* feat: Add A-B repeat and frame  navigation features with new key binds and menu entries.

* playback options

* fix merge
This commit is contained in:
nz366 2026-04-03 18:16:01 +00:00 committed by GitHub
parent 069337e2ca
commit 6f5040a953
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 139 additions and 21 deletions

View file

@ -37,3 +37,11 @@ quit = Quit
repeat-disabled = Repeat disabled repeat-disabled = Repeat disabled
repeat-track = Repeat track 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

View file

@ -27,6 +27,9 @@ pub fn key_binds() -> HashMap<KeyBind, Action> {
bind!([], Key::Character(" ".into()), PlayPause); bind!([], Key::Character(" ".into()), PlayPause);
bind!([], Key::Named(Named::ArrowLeft), SeekBackward); bind!([], Key::Named(Named::ArrowLeft), SeekBackward);
bind!([], Key::Named(Named::ArrowRight), SeekForward); bind!([], Key::Named(Named::ArrowRight), SeekForward);
bind!([], Key::Character(".".into()), NextFrame);
bind!([], Key::Character(",".into()), PreviousFrame);
bind!([], Key::Character("a".into()), AbRepeat);
key_binds key_binds
} }

View file

@ -80,6 +80,17 @@ fn language_name(code: &str) -> Option<String> {
Some(name.to_string()) Some(name.to_string())
} }
fn get_framerate(video: &Video) -> Option<f64> {
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::<gst::Fraction>("framerate").ok()?;
Some(framerate.numer() as f64 / framerate.denom() as f64)
}
/// Runs application with these settings /// Runs application with these settings
#[rustfmt::skip] #[rustfmt::skip]
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
@ -178,6 +189,9 @@ pub enum Action {
PlayPause, PlayPause,
SeekBackward, SeekBackward,
SeekForward, SeekForward,
NextFrame,
PreviousFrame,
AbRepeat,
WindowClose, WindowClose,
} }
@ -198,6 +212,9 @@ impl MenuAction for Action {
Self::PlayPause => Message::PlayPause, Self::PlayPause => Message::PlayPause,
Self::SeekBackward => Message::SeekRelative(-10.0), Self::SeekBackward => Message::SeekRelative(-10.0),
Self::SeekForward => 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, Self::WindowClose => Message::WindowClose,
} }
} }
@ -292,11 +309,14 @@ pub enum Message {
SeekRelative(f64), SeekRelative(f64),
SeekRelease, SeekRelease,
PlayNext, PlayNext,
NextFrame,
PreviousFrame,
EndOfStream, EndOfStream,
MissingPlugin(gst::Message), MissingPlugin(gst::Message),
MprisChannel(MprisMeta, MprisState, mpsc::UnboundedSender<MprisEvent>), MprisChannel(MprisMeta, MprisState, mpsc::UnboundedSender<MprisEvent>),
NewFrame, NewFrame,
Reload, Reload,
AbRepeat,
ShowControls, ShowControls,
SystemThemeModeChange(cosmic_theme::ThemeMode), SystemThemeModeChange(cosmic_theme::ThemeMode),
WindowClose, WindowClose,
@ -326,6 +346,7 @@ pub struct App {
current_audio: i32, current_audio: i32,
text_codes: Vec<TextCode>, text_codes: Vec<TextCode>,
current_text: Option<i32>, current_text: Option<i32>,
ab_repeat: Option<(Option<f64>, Option<f64>)>,
#[cfg(feature = "xdg-portal")] #[cfg(feature = "xdg-portal")]
inhibit: tokio::sync::watch::Sender<bool>, inhibit: tokio::sync::watch::Sender<bool>,
} }
@ -890,6 +911,7 @@ impl Application for App {
current_audio: -1, current_audio: -1,
text_codes: Vec::new(), text_codes: Vec::new(),
current_text: None, current_text: None,
ab_repeat: None,
#[cfg(feature = "xdg-portal")] #[cfg(feature = "xdg-portal")]
inhibit, inhibit,
}; };
@ -1036,6 +1058,7 @@ impl Application for App {
} }
Message::FileLoad(url) => { Message::FileLoad(url) => {
self.flags.url_opt = Some(url); self.flags.url_opt = Some(url);
self.ab_repeat = None;
return self.load(); return self.load();
} }
Message::FileOpen => { 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 => { Message::EndOfStream => {
println!( if let Some((a, _)) = self.ab_repeat {
"end of stream, repeat={:?}", let target = a.unwrap_or(0.0);
self.flags.config_state.player_state.repeat 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) => { Message::MissingPlugin(element) => {
@ -1458,9 +1533,18 @@ impl Application for App {
self.update_mpris_state(); self.update_mpris_state();
} }
Message::NewFrame => { Message::NewFrame => {
if let Some(video) = &self.video_opt { if let Some(video) = &mut self.video_opt {
if !self.dragging { if !self.dragging {
self.position = video.position().as_secs_f64(); 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()); self.update_controls(self.dropdown_opt.is_some());
} }
} }
@ -1487,6 +1571,7 @@ impl Application for App {
&self.flags.config_state, &self.flags.config_state,
&self.key_binds, &self.key_binds,
&self.projects, &self.projects,
&self.ab_repeat,
)] )]
} }

View file

@ -17,6 +17,7 @@ pub fn menu_bar<'a>(
config_state: &ConfigState, config_state: &ConfigState,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
projects: &[(String, PathBuf)], projects: &[(String, PathBuf)],
ab_repeat: &Option<(Option<f64>, Option<f64>)>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let home_dir_opt = std::env::home_dir(); let home_dir_opt = std::env::home_dir();
let format_path = |path: &PathBuf| -> String { let format_path = |path: &PathBuf| -> String {
@ -87,23 +88,44 @@ pub fn menu_bar<'a>(
)); ));
} }
MenuBar::new(vec![menu::Tree::with_children( MenuBar::new(vec![
RcElementWrapper::new(Element::from(menu::root(fl!("file")))), menu::Tree::with_children(
menu::items( RcElementWrapper::new(Element::from(menu::root(fl!("file")))),
key_binds, menu::items(
vec![ key_binds,
menu::Item::Button(fl!("open-media"), None, Action::FileOpen), vec![
menu::Item::Folder(fl!("open-recent-media"), recent_files), menu::Item::Button(fl!("open-media"), None, Action::FileOpen),
menu::Item::Button(fl!("close-file"), None, Action::FileClose), menu::Item::Folder(fl!("open-recent-media"), recent_files),
menu::Item::Divider, menu::Item::Button(fl!("close-file"), None, Action::FileClose),
menu::Item::Button(fl!("open-media-folder"), None, Action::FolderOpen), menu::Item::Divider,
menu::Item::Folder(fl!("open-recent-media-folder"), recent_projects), menu::Item::Button(fl!("open-media-folder"), None, Action::FolderOpen),
menu::Item::Folder(fl!("close-media-folder"), close_projects), menu::Item::Folder(fl!("open-recent-media-folder"), recent_projects),
menu::Item::Divider, menu::Item::Folder(fl!("close-media-folder"), close_projects),
menu::Item::Button(fl!("quit"), None, Action::WindowClose), 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_height(ItemHeight::Dynamic(40))
.item_width(ItemWidth::Uniform(320)) .item_width(ItemWidth::Uniform(320))
.spacing(theme::active().cosmic().spacing.space_xxxs.into()) .spacing(theme::active().cosmic().spacing.space_xxxs.into())