diff --git a/.gitignore b/.gitignore
index 10de4bd..624d569 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@
/target/
/vendor/
/vendor.tar
+.idea/
diff --git a/res/icons/hicolor/16x16/apps/playback-speed-symbolic.svg b/res/icons/hicolor/16x16/apps/playback-speed-symbolic.svg
new file mode 100644
index 0000000..b0cf5b4
--- /dev/null
+++ b/res/icons/hicolor/16x16/apps/playback-speed-symbolic.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index a08d2bb..c231957 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -54,6 +54,9 @@ const JUMP_BACKWARD_ICON: &[u8] =
const JUMP_FORWARD_ICON: &[u8] =
include_bytes!("../res/icons/hicolor/16x16/apps/jump-forward-10-symbolic.svg");
+const PLAYBACK_SPEED_ICON: &[u8] =
+ include_bytes!("../res/icons/hicolor/16x16/apps/playback-speed-symbolic.svg");
+
use std::error::Error;
fn language_name(code: &str) -> Option {
@@ -226,6 +229,7 @@ pub struct Flags {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DropdownKind {
Audio,
+ Speed,
Subtitle,
}
@@ -312,6 +316,8 @@ pub enum Message {
NewFrame,
Reload,
AbRepeat,
+ VideoAreaClick,
+ PlaybackSpeed(f64),
ShowControls,
SystemThemeModeChange(cosmic_theme::ThemeMode),
WindowClose,
@@ -342,6 +348,7 @@ pub struct App {
text_codes: Vec,
current_text: Option,
ab_repeat: Option<(Option, Option)>,
+ playback_speed: f64,
#[cfg(feature = "xdg-portal")]
inhibit: tokio::sync::watch::Sender,
}
@@ -456,6 +463,9 @@ impl App {
self.set_looping_from_repeat_state();
self.update_flags();
self.update_mpris_meta();
+ if self.playback_speed != 1.0 {
+ self.apply_playback_speed();
+ }
self.update_title()
}
@@ -848,6 +858,22 @@ impl App {
video.set_looping(self.flags.config_state.player_state.repeat == RepeatState::Track);
}
}
+
+ fn apply_playback_speed(&self) {
+ let Some(video) = &self.video_opt else { return };
+ let pipeline = video.pipeline();
+ let position = gst::ClockTime::from_nseconds(video.position().as_nanos() as u64);
+ if let Err(err) = pipeline.seek(
+ self.playback_speed,
+ gst::SeekFlags::FLUSH | gst::SeekFlags::ACCURATE,
+ gst::SeekType::Set,
+ position,
+ gst::SeekType::None,
+ gst::ClockTime::NONE,
+ ) {
+ log::warn!("failed to set playback speed {}: {}", self.playback_speed, err);
+ }
+ }
}
/// Implement [`cosmic::Application`] to integrate with COSMIC.
@@ -907,6 +933,7 @@ impl Application for App {
text_codes: Vec::new(),
current_text: None,
ab_repeat: None,
+ playback_speed: 1.0,
#[cfg(feature = "xdg-portal")]
inhibit,
};
@@ -1481,6 +1508,19 @@ impl Application for App {
self.update_controls(true);
}
}
+ Message::PlaybackSpeed(speed) => {
+ self.playback_speed = speed;
+ self.dropdown_opt = None;
+ self.apply_playback_speed();
+ self.update_controls(true);
+ }
+ Message::VideoAreaClick => {
+ if self.dropdown_opt.is_some() {
+ self.dropdown_opt = None;
+ } else {
+ return self.update(Message::PlayPause);
+ }
+ }
Message::AbRepeat => {
let current_opt = self.video_opt.as_ref().map(|v| v.position().as_secs_f64());
if let Some(current) = current_opt {
@@ -1742,7 +1782,7 @@ impl Application for App {
}
let mouse_area = widget::mouse_area(video_player)
- .on_press(Message::PlayPause)
+ .on_press(Message::VideoAreaClick)
.on_double_press(Message::Fullscreen);
let mut popover = widget::popover(mouse_area).position(widget::popover::Position::Bottom);
@@ -1779,6 +1819,61 @@ impl Application for App {
.into(),
);
}
+ DropdownKind::Speed => {
+ for &speed in &[0.75f64, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25] {
+ let s = format!("{:.2}", speed);
+ let speed_str = format!(
+ "{}x",
+ s.trim_end_matches('0').trim_end_matches('.')
+ );
+ let is_active = (self.playback_speed - speed).abs() < 0.01;
+
+ let icon: Element<_> = if is_active {
+ widget::icon::from_name("object-select-symbolic")
+ .size(16)
+ .into()
+ } else {
+ widget::container(widget::space::horizontal())
+ .width(Length::Fixed(16.0))
+ .into()
+ };
+
+ let label: Element<_> = if is_active {
+ widget::text(speed_str).font(font::bold()).into()
+ } else {
+ widget::text(speed_str).into()
+ };
+
+ let row_content = widget::row::with_children(vec![icon, label])
+ .spacing(space_xs)
+ .align_y(Alignment::Center);
+
+ let content: Element<_> = if is_active {
+ widget::container(row_content)
+ .class(theme::Container::custom(|theme| {
+ let accent: Color =
+ theme.cosmic().accent_color().into();
+ widget::container::Style {
+ icon_color: Some(accent),
+ text_color: Some(accent),
+ ..Default::default()
+ }
+ }))
+ .into()
+ } else {
+ row_content.into()
+ };
+
+ items.push(
+ widget::button::custom(content)
+ .width(Length::Fill)
+ .padding([12.0f32, 8.0f32])
+ .class(theme::Button::MenuItem)
+ .on_press(Message::PlaybackSpeed(speed))
+ .into(),
+ );
+ }
+ }
DropdownKind::Subtitle => {
if !self.audio_codes.is_empty() {
items.push(widget::text::heading(fl!("audio")).into());
@@ -1807,34 +1902,42 @@ impl Application for App {
}
}
+ let use_padding = !matches!(dropdown, DropdownKind::Speed);
let mut column = widget::column::with_capacity(items.len());
for item in items {
- column = column.push(widget::container(item).padding([space_xxs, space_m]));
+ column = column.push(if use_padding {
+ widget::container(item).padding([space_xxs, space_m]).into()
+ } else {
+ item
+ });
}
popup_items.push(
widget::row::with_children(vec![
widget::space::horizontal().into(),
- widget::container(column)
- .padding(1)
- //TODO: move style to libcosmic
- .class(theme::Container::custom(|theme| {
- let cosmic = theme.cosmic();
- let component = &cosmic.background.component;
- widget::container::Style {
- icon_color: Some(component.on.into()),
- text_color: Some(component.on.into()),
- background: Some(Background::Color(component.base.into())),
- border: Border {
- radius: 8.0.into(),
- width: 1.0,
- color: component.divider.into(),
- },
- ..Default::default()
- }
- }))
- .width(Length::Fixed(240.0))
- .into(),
+ widget::mouse_area(
+ widget::container(column)
+ .padding(1)
+ //TODO: move style to libcosmic
+ .class(theme::Container::custom(|theme| {
+ let cosmic = theme.cosmic();
+ let component = &cosmic.background.component;
+ widget::container::Style {
+ icon_color: Some(component.on.into()),
+ text_color: Some(component.on.into()),
+ background: Some(Background::Color(component.base.into())),
+ border: Border {
+ radius: 8.0.into(),
+ width: 1.0,
+ color: component.divider.into(),
+ },
+ ..Default::default()
+ }
+ }))
+ .width(Length::Fixed(240.0)),
+ )
+ .on_press(Message::ShowControls)
+ .into(),
])
.into(),
);
@@ -1930,6 +2033,12 @@ impl Application for App {
)
.on_press(Message::DropdownToggle(DropdownKind::Subtitle)),
)
+ .push(
+ widget::button::icon(
+ widget::icon::from_svg_bytes(PLAYBACK_SPEED_ICON).symbolic(true),
+ )
+ .on_press(Message::DropdownToggle(DropdownKind::Speed)),
+ )
.push(
widget::button::icon(
widget::icon::from_name("view-fullscreen-symbolic").size(16),
@@ -1957,31 +2066,37 @@ impl Application for App {
.on_press(Message::DropdownToggle(DropdownKind::Audio)),
);
popup_items.push(
- widget::container(row)
- .padding([space_xxs, space_xs])
- .class(theme::Container::WindowBackground)
- .into(),
+ widget::mouse_area(
+ widget::container(row)
+ .padding([space_xxs, space_xs])
+ .class(theme::Container::WindowBackground),
+ )
+ .on_press(Message::ShowControls)
+ .into(),
);
if self.core.is_condensed() {
popup_items.push(
- widget::container(
- widget::row::with_capacity(3)
- .align_y(Alignment::Center)
- .spacing(space_xxs)
- .push(widget::text(format_time(self.position)).font(font::mono()))
- .push(
- Slider::new(0.0..=self.duration, self.position, Message::Seek)
- .step(0.1)
- .on_release(Message::SeekRelease),
- )
- .push(
- widget::text(format_time(self.duration - self.position))
- .font(font::mono()),
- ),
+ widget::mouse_area(
+ widget::container(
+ widget::row::with_capacity(3)
+ .align_y(Alignment::Center)
+ .spacing(space_xxs)
+ .push(widget::text(format_time(self.position)).font(font::mono()))
+ .push(
+ Slider::new(0.0..=self.duration, self.position, Message::Seek)
+ .step(0.1)
+ .on_release(Message::SeekRelease),
+ )
+ .push(
+ widget::text(format_time(self.duration - self.position))
+ .font(font::mono()),
+ ),
+ )
+ .padding([space_xxs, space_xs])
+ .class(theme::Container::WindowBackground),
)
- .padding([space_xxs, space_xs])
- .class(theme::Container::WindowBackground)
+ .on_press(Message::ShowControls)
.into(),
);
}
diff --git a/src/video.rs b/src/video.rs
index 743f43e..1953fe5 100644
--- a/src/video.rs
+++ b/src/video.rs
@@ -27,6 +27,11 @@ pub fn new_video(
.downcast::()
.map_err(|_| iced_video_player::Error::Cast)
.unwrap();
+ if let Ok(scaletempo) = gst::ElementFactory::make("scaletempo").build() {
+ pipeline.set_property("audio-filter", &scaletempo);
+ } else {
+ log::warn!("scaletempo element not available; speed changes will affect pitch");
+ }
pipeline.connect("element-setup", false, |vals| {
let Ok(elem) = vals[1].get::() else {
return None;