Skip to main content

mimick/library/video_player/
controls.rs

1use 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", &gtk::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: [&gtk::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: &gtk::Box,
104    title: &str,
105    labels: &[String],
106    active: usize,
107) -> Vec<gtk::CheckButton> {
108    content.append(
109        &gtk::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: &gtk::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        &gtk::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", &gtk::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}