mimick/library/video_player/
controls.rs1use gstreamer as gst;
2use gtk::prelude::*;
3
4pub(super) const SPEEDS: [f64; 8] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
5pub(super) const NORMAL_SPEED: usize = 3;
6
7pub(super) struct Controls {
8 pub(super) revealer: gtk::Revealer,
9 pub(super) hover: gtk::EventControllerMotion,
10 pub(super) play: gtk::Button,
11 pub(super) stop: gtk::Button,
12 pub(super) back: gtk::Button,
13 pub(super) forward: gtk::Button,
14 pub(super) seek: gtk::Scale,
15 pub(super) elapsed: gtk::Label,
16 pub(super) total: gtk::Label,
17 pub(super) repeat: gtk::ToggleButton,
18 pub(super) tracks: gtk::MenuButton,
19 pub(super) speed: gtk::MenuButton,
20 pub(super) speeds: Vec<gtk::CheckButton>,
21 pub(super) volume_menu: gtk::MenuButton,
22 pub(super) volume: gtk::Scale,
23 pub(super) mute: gtk::ToggleButton,
24 pub(super) fullscreen: gtk::Button,
25}
26
27impl Controls {
28 pub(super) fn new(volume: f64, muted: bool) -> Self {
29 let (speed, speeds) = speed_menu();
30 let (volume_menu, volume_scale, mute) = volume_menu(volume, muted);
31 let tracks = menu_button("Audio and subtitles", >k::Popover::new());
32 tracks.set_icon_name("media-view-subtitles-symbolic");
33 tracks.set_visible(false);
34 let controls = Self {
35 revealer: controls_revealer(),
36 hover: gtk::EventControllerMotion::new(),
37 play: control_button("media-playback-start-symbolic", "Play (Space)"),
38 stop: control_button("media-playback-stop-symbolic", "Stop"),
39 back: control_button("media-seek-backward-symbolic", "Back 10 seconds (J)"),
40 forward: control_button("media-seek-forward-symbolic", "Forward 10 seconds (L)"),
41 seek: seek_scale(),
42 elapsed: time_label(),
43 total: time_label(),
44 repeat: repeat_toggle(),
45 tracks,
46 speed,
47 speeds,
48 volume_menu,
49 volume: volume_scale,
50 mute,
51 fullscreen: control_button("view-fullscreen-symbolic", "Fullscreen (F11)"),
52 };
53 controls.assemble();
54 controls
55 }
56
57 fn assemble(&self) {
58 let seek_row = row(8);
59 seek_row.append(&self.elapsed);
60 seek_row.append(&self.seek);
61 seek_row.append(&self.total);
62 let button_row = row(2);
63 let spacer = gtk::Box::builder().hexpand(true).build();
64 let buttons: [>k::Widget; 10] = [
65 self.back.upcast_ref(),
66 self.play.upcast_ref(),
67 self.forward.upcast_ref(),
68 self.stop.upcast_ref(),
69 spacer.upcast_ref(),
70 self.repeat.upcast_ref(),
71 self.tracks.upcast_ref(),
72 self.speed.upcast_ref(),
73 self.volume_menu.upcast_ref(),
74 self.fullscreen.upcast_ref(),
75 ];
76 for button in buttons {
77 button_row.append(button);
78 }
79 let panel = gtk::Box::builder()
80 .orientation(gtk::Orientation::Vertical)
81 .spacing(2)
82 .css_classes(["osd", "mimick-video-controls"])
83 .build();
84 panel.append(&seek_row);
85 panel.append(&button_row);
86 panel.add_controller(self.hover.clone());
87 self.revealer.set_child(Some(&panel));
88 }
89}
90
91pub(super) fn popover_box() -> gtk::Box {
92 gtk::Box::builder()
93 .orientation(gtk::Orientation::Vertical)
94 .spacing(2)
95 .margin_top(6)
96 .margin_bottom(6)
97 .margin_start(6)
98 .margin_end(6)
99 .build()
100}
101
102pub(super) fn track_section(
103 content: >k::Box,
104 title: &str,
105 labels: &[String],
106 active: usize,
107) -> Vec<gtk::CheckButton> {
108 content.append(
109 >k::Label::builder()
110 .label(title)
111 .xalign(0.0)
112 .css_classes(["heading"])
113 .build(),
114 );
115 let checks = radio_checks(labels, active);
116 for check in &checks {
117 content.append(check);
118 }
119 checks
120}
121
122pub(super) fn format_time(time: gst::ClockTime) -> String {
123 let secs = time.seconds();
124 let (hours, minutes, seconds) = (secs / 3600, secs / 60 % 60, secs % 60);
125 if hours > 0 {
126 format!("{hours}:{minutes:02}:{seconds:02}")
127 } else {
128 format!("{minutes}:{seconds:02}")
129 }
130}
131
132pub(super) fn speed_label(rate: f64) -> String {
133 format!("{rate}×")
134}
135
136pub(super) fn track_label(n: usize, language: Option<&str>) -> String {
137 match language {
138 Some(lang) if !lang.is_empty() => format!("Track {} ({})", n + 1, lang),
139 _ => format!("Track {}", n + 1),
140 }
141}
142
143pub(super) fn volume_icon(volume: f64, muted: bool) -> &'static str {
144 if muted || volume <= 0.0 {
145 "audio-volume-muted-symbolic"
146 } else if volume < 0.34 {
147 "audio-volume-low-symbolic"
148 } else if volume < 0.67 {
149 "audio-volume-medium-symbolic"
150 } else {
151 "audio-volume-high-symbolic"
152 }
153}
154
155fn row(spacing: i32) -> gtk::Box {
156 gtk::Box::builder()
157 .orientation(gtk::Orientation::Horizontal)
158 .spacing(spacing)
159 .build()
160}
161
162fn control_button(icon: &str, tooltip: &str) -> gtk::Button {
163 gtk::Button::builder()
164 .icon_name(icon)
165 .tooltip_text(tooltip)
166 .focus_on_click(false)
167 .css_classes(["flat"])
168 .build()
169}
170
171fn menu_button(tooltip: &str, popover: >k::Popover) -> gtk::MenuButton {
172 gtk::MenuButton::builder()
173 .tooltip_text(tooltip)
174 .popover(popover)
175 .direction(gtk::ArrowType::Up)
176 .focus_on_click(false)
177 .css_classes(["flat"])
178 .build()
179}
180
181fn repeat_toggle() -> gtk::ToggleButton {
182 gtk::ToggleButton::builder()
183 .icon_name("media-playlist-repeat-symbolic")
184 .tooltip_text("Loop")
185 .focus_on_click(false)
186 .css_classes(["flat"])
187 .build()
188}
189
190fn seek_scale() -> gtk::Scale {
191 let seek = gtk::Scale::builder()
192 .orientation(gtk::Orientation::Horizontal)
193 .draw_value(false)
194 .hexpand(true)
195 .focus_on_click(false)
196 .build();
197 seek.set_range(0.0, 1.0);
198 seek
199}
200
201fn time_label() -> gtk::Label {
202 gtk::Label::builder()
203 .label(format_time(gst::ClockTime::ZERO))
204 .css_classes(["numeric", "caption"])
205 .build()
206}
207
208fn controls_revealer() -> gtk::Revealer {
209 gtk::Revealer::builder()
210 .transition_type(gtk::RevealerTransitionType::Crossfade)
211 .transition_duration(180)
212 .reveal_child(true)
213 .valign(gtk::Align::End)
214 .halign(gtk::Align::Fill)
215 .margin_start(12)
216 .margin_end(12)
217 .margin_bottom(12)
218 .visible(false)
219 .build()
220}
221
222fn radio_checks(labels: &[String], active: usize) -> Vec<gtk::CheckButton> {
223 let checks: Vec<gtk::CheckButton> = labels
224 .iter()
225 .enumerate()
226 .map(|(n, label)| {
227 gtk::CheckButton::builder()
228 .label(label.as_str())
229 .active(n == active)
230 .build()
231 })
232 .collect();
233 for check in &checks[1..] {
234 check.set_group(Some(&checks[0]));
235 }
236 checks
237}
238
239fn speed_menu() -> (gtk::MenuButton, Vec<gtk::CheckButton>) {
240 let labels: Vec<String> = SPEEDS.iter().map(|&rate| speed_label(rate)).collect();
241 let checks = radio_checks(&labels, NORMAL_SPEED);
242 let content = popover_box();
243 for check in &checks {
244 content.append(check);
245 }
246 let menu = menu_button(
247 "Playback speed",
248 >k::Popover::builder().child(&content).build(),
249 );
250 menu.set_label(&labels[NORMAL_SPEED]);
251 (menu, checks)
252}
253
254fn volume_menu(volume: f64, muted: bool) -> (gtk::MenuButton, gtk::Scale, gtk::ToggleButton) {
255 let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
256 scale.set_value(volume);
257 scale.set_draw_value(false);
258 scale.set_width_request(140);
259 let mute = gtk::ToggleButton::builder()
260 .icon_name("audio-volume-muted-symbolic")
261 .tooltip_text("Mute (M)")
262 .active(muted)
263 .build();
264 let content = row(6);
265 content.append(&mute);
266 content.append(&scale);
267 let menu = menu_button("Volume", >k::Popover::builder().child(&content).build());
268 menu.set_icon_name(volume_icon(volume, muted));
269 (menu, scale, mute)
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn format_time_switches_to_hours_past_one_hour() {
278 assert_eq!(format_time(gst::ClockTime::ZERO), "0:00");
279 assert_eq!(format_time(gst::ClockTime::from_seconds(75)), "1:15");
280 assert_eq!(format_time(gst::ClockTime::from_seconds(3725)), "1:02:05");
281 }
282
283 #[test]
284 fn speed_labels_drop_trailing_zeroes() {
285 let labels: Vec<String> = SPEEDS.iter().map(|&r| speed_label(r)).collect();
286 assert_eq!(labels[0], "0.25×");
287 assert_eq!(labels[NORMAL_SPEED], "1×");
288 assert_eq!(labels[7], "2×");
289 }
290
291 #[test]
292 fn track_label_includes_language_when_known() {
293 assert_eq!(track_label(0, Some("en")), "Track 1 (en)");
294 assert_eq!(track_label(1, Some("")), "Track 2");
295 assert_eq!(track_label(2, None), "Track 3");
296 }
297
298 #[test]
299 fn volume_icon_reflects_level_and_mute() {
300 assert_eq!(volume_icon(0.8, true), "audio-volume-muted-symbolic");
301 assert_eq!(volume_icon(0.0, false), "audio-volume-muted-symbolic");
302 assert_eq!(volume_icon(0.2, false), "audio-volume-low-symbolic");
303 assert_eq!(volume_icon(0.5, false), "audio-volume-medium-symbolic");
304 assert_eq!(volume_icon(1.0, false), "audio-volume-high-symbolic");
305 }
306}