Add three-state repeat button

Add a new button to the controls that defines whether the media repeats
at the end of the stream. The states are:

- Disabled: Media does not repeat. This is existing behavior and the
default state.
- Always: Media always repeats.
- Once: Media repeats once. Flag is reset when loading new media.

Tested all three states with both audio and video.

Added a `rustfmt.toml` to avoid reordering imports. They keep moving
around in previous pull requests, best to just define what we want in
the repository.

Fixes: #39
This commit is contained in:
norepro 2025-11-26 18:26:06 -08:00
parent 7c9ec8b423
commit de70009ed2
3 changed files with 60 additions and 3 deletions

1
rustfmt.toml Normal file
View file

@ -0,0 +1 @@
reorder_imports = false

View file

@ -40,10 +40,23 @@ impl Default for Config {
} }
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum RepeatState {
Disabled,
Once,
Always,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PlayerState {
pub repeat: RepeatState,
}
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ConfigState { pub struct ConfigState {
pub recent_files: VecDeque<url::Url>, pub recent_files: VecDeque<url::Url>,
pub recent_projects: VecDeque<PathBuf>, pub recent_projects: VecDeque<PathBuf>,
pub player_state: PlayerState,
} }
impl Default for ConfigState { impl Default for ConfigState {
@ -51,6 +64,9 @@ impl Default for ConfigState {
Self { Self {
recent_files: VecDeque::new(), recent_files: VecDeque::new(),
recent_projects: VecDeque::new(), recent_projects: VecDeque::new(),
player_state: PlayerState {
repeat: RepeatState::Disabled,
},
} }
} }
} }

View file

@ -34,7 +34,7 @@ use std::{
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::{ use crate::{
config::{CONFIG_VERSION, Config, ConfigState}, config::{CONFIG_VERSION, Config, ConfigState, RepeatState},
key_bind::{KeyBind, key_binds}, key_bind::{KeyBind, key_binds},
project::ProjectNode, project::ProjectNode,
}; };
@ -280,6 +280,7 @@ pub enum Message {
Pause, Pause,
Play, Play,
PlayPause, PlayPause,
RepeatToggled(RepeatState),
Scrolled(ScrollDelta), Scrolled(ScrollDelta),
Seek(f64), Seek(f64),
SeekRelative(f64), SeekRelative(f64),
@ -320,6 +321,7 @@ pub struct App {
current_text: Option<i32>, current_text: Option<i32>,
#[cfg(feature = "xdg-portal")] #[cfg(feature = "xdg-portal")]
inhibit: tokio::sync::watch::Sender<bool>, inhibit: tokio::sync::watch::Sender<bool>,
has_media_repeated: bool,
} }
impl App { impl App {
@ -344,6 +346,7 @@ impl App {
self.current_audio = -1; self.current_audio = -1;
self.text_codes.clear(); self.text_codes.clear();
self.current_text = None; self.current_text = None;
self.has_media_repeated = false;
self.update_mpris_meta(); self.update_mpris_meta();
self.update_nav_bar_active(); self.update_nav_bar_active();
self.allow_idle(); self.allow_idle();
@ -375,6 +378,7 @@ impl App {
}; };
self.duration = video.duration().as_secs_f64(); self.duration = video.duration().as_secs_f64();
self.has_media_repeated = false;
let pipeline = video.pipeline(); let pipeline = video.pipeline();
self.video_opt = Some(video); self.video_opt = Some(video);
@ -875,6 +879,7 @@ impl Application for App {
current_text: None, current_text: None,
#[cfg(feature = "xdg-portal")] #[cfg(feature = "xdg-portal")]
inhibit, inhibit,
has_media_repeated: false,
}; };
// Do not show nav bar by default. Will be opened by open_project if needed // Do not show nav bar by default. Will be opened by open_project if needed
@ -1222,6 +1227,11 @@ impl Application for App {
} }
} }
} }
Message::RepeatToggled(state) => {
self.flags.config_state.player_state.repeat = state;
self.update_controls(true);
self.save_config_state();
}
Message::Scrolled(delta) => { Message::Scrolled(delta) => {
let nav_bar_toggled = self.core.nav_bar_active(); let nav_bar_toggled = self.core.nav_bar_active();
if let Some(video) = &mut self.video_opt { if let Some(video) = &mut self.video_opt {
@ -1304,7 +1314,20 @@ impl Application for App {
} }
} }
Message::EndOfStream => { Message::EndOfStream => {
println!("end of stream"); println!(
"end of stream, repeat={:?}, has_media_repeated={:?}",
self.flags.config_state.player_state.repeat, self.has_media_repeated
);
match self.flags.config_state.player_state.repeat {
RepeatState::Always | RepeatState::Once if !self.has_media_repeated => {
if let Some(video) = &mut self.video_opt {
self.has_media_repeated = true;
video.restart_stream().expect("restart_stream");
}
}
_ => {}
}
} }
Message::MissingPlugin(element) => { Message::MissingPlugin(element) => {
if let Some(video) = &mut self.video_opt { if let Some(video) = &mut self.video_opt {
@ -1620,7 +1643,7 @@ impl Application for App {
); );
} }
if self.controls { if self.controls {
let mut row = widget::row::with_capacity(7) let mut row = widget::row::with_capacity(8)
.align_items(Alignment::Center) .align_items(Alignment::Center)
.spacing(space_xxs) .spacing(space_xxs)
.push( .push(
@ -1633,6 +1656,23 @@ impl Application for App {
) )
.on_press(Message::PlayPause), .on_press(Message::PlayPause),
); );
row = row.push(
widget::button::icon(
widget::icon::from_name(match self.flags.config_state.player_state.repeat {
RepeatState::Disabled => "media-playlist-no-repeat-symbolic",
RepeatState::Always => "media-playlist-repeat-symbolic",
RepeatState::Once => "media-playlist-repeat-song-symbolic",
})
.size(16),
)
.on_press(Message::RepeatToggled(
match self.flags.config_state.player_state.repeat {
RepeatState::Disabled => RepeatState::Always,
RepeatState::Always => RepeatState::Once,
RepeatState::Once => RepeatState::Disabled,
},
)),
);
if self.core.is_condensed() { if self.core.is_condensed() {
row = row.push(widget::horizontal_space(Length::Fill)); row = row.push(widget::horizontal_space(Length::Fill));
} else { } else {