[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

@ -27,6 +27,9 @@ pub fn key_binds() -> HashMap<KeyBind, Action> {
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
}

View file

@ -80,6 +80,17 @@ fn language_name(code: &str) -> Option<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
#[rustfmt::skip]
fn main() -> Result<(), Box<dyn Error>> {
@ -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<MprisEvent>),
NewFrame,
Reload,
AbRepeat,
ShowControls,
SystemThemeModeChange(cosmic_theme::ThemeMode),
WindowClose,
@ -326,6 +346,7 @@ pub struct App {
current_audio: i32,
text_codes: Vec<TextCode>,
current_text: Option<i32>,
ab_repeat: Option<(Option<f64>, Option<f64>)>,
#[cfg(feature = "xdg-portal")]
inhibit: tokio::sync::watch::Sender<bool>,
}
@ -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,
)]
}

View file

@ -17,6 +17,7 @@ pub fn menu_bar<'a>(
config_state: &ConfigState,
key_binds: &HashMap<KeyBind, Action>,
projects: &[(String, PathBuf)],
ab_repeat: &Option<(Option<f64>, Option<f64>)>,
) -> 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())