corundum-framework-gui 0.9.1

The look shared by sapphire and asteria apps: egui shell, system fonts, Lucide icons, translations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! [`AudioPreview`]: a sound file's tags, read off the UI thread, with a play button and a
//! seek bar over the app's [`Player`].

use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};

use corundum_framework_audio::decode::{Info, probe};
use corundum_framework_audio::{Command, Player, PlayerError, PlayerState, Status};

use crate::{icons, tr};

/// How many probed files are kept.
const KEPT: usize = 8;

/// The music icon's size above the title, in points.
const ICON: f32 = 48.0;

/// The play and pause icons' size, in points.
const BUTTON_ICON: f32 = 16.0;

/// The seek bar's widest, in points.
const SEEK_WIDTH: f32 = 320.0;
/// How far an arrow key on the seek bar moves, in ms.
const SEEK_KEY_STEP_MS: f64 = 5_000.0;

/// What the worker made of a file.
enum Probed {
    /// Audio, with what its tags and container say.
    Audio(Info),
    /// The file could not be opened (gone, or not ours to read): nothing is known, and the
    /// player says why when it is played.
    Unread,
    /// Opened, and not audio the decoder knows.
    NotAudio,
}

/// The worker thread's end: paths out, answers in.
struct Worker {
    requests: Sender<PathBuf>,
    answers: Receiver<(PathBuf, Probed)>,
    /// Requests sent and not yet answered or passed over. The thread holds a clone for as
    /// long as it runs, which is how the tests see that it ended, and when it is idle.
    pending: Arc<AtomicUsize>,
}

impl Worker {
    /// Start the thread. It takes the newest request it has, passing over older ones, and
    /// ends when the [`AudioPreview`] is dropped.
    fn spawn(ctx: egui::Context) -> Worker {
        let (requests, inbox) = channel::<PathBuf>();
        let (outbox, answers) = channel();
        let pending = Arc::new(AtomicUsize::new(0));
        let count = pending.clone();
        std::thread::Builder::new()
            .name("corundum-audio-probe".into())
            .spawn(move || {
                while let Ok(mut path) = inbox.recv() {
                    let mut taken = 1;
                    while let Ok(newer) = inbox.try_recv() {
                        path = newer;
                        taken += 1;
                    }
                    let probed = answer(&path);
                    let sent = outbox.send((path, probed));
                    count.fetch_sub(taken, Ordering::SeqCst);
                    if sent.is_err() {
                        break;
                    }
                    ctx.request_repaint();
                }
            })
            .expect("spawn the audio preview's thread");
        Worker {
            requests,
            answers,
            pending,
        }
    }
}

/// Read the tags and the length of the file at `path`.
fn answer(path: &Path) -> Probed {
    let file = match std::fs::File::open(path) {
        Ok(file) => file,
        Err(error) => {
            tracing::debug!(
                "the audio preview could not open {}: {error}",
                path.display()
            );
            return Probed::Unread;
        }
    };
    let ext = path.extension().and_then(|e| e.to_str());
    // A panic in the demuxer would end the thread, and the next frame would start another to
    // probe the file again: it is not audio instead, once.
    match catch_unwind(AssertUnwindSafe(|| probe(Box::new(file), ext))) {
        Ok(Ok(info)) => Probed::Audio(info),
        Ok(Err(error)) => {
            tracing::debug!("{} is not audio: {error}", path.display());
            Probed::NotAudio
        }
        Err(_) => Probed::NotAudio,
    }
}

/// A sound file in the detail pane: the music icon, its title (else its file name) and its
/// artist and album when the tags have them, a play button, a seek bar and the time, and
/// why it did not play, if it did not.
///
/// The tags are read on a worker thread (the newest file picked wins, the last eight are
/// kept), with a spinner meanwhile; the UI thread never touches the file. Nothing plays on
/// its own: Play replaces what `player` has with this file, and the file that played before
/// keeps playing while another is only shown. A file the decoder does not know says so and
/// offers no play.
///
/// Make one and keep it across frames; the thread it starts ends when it is dropped. The
/// player is the app's, spawned with a repaint of its window, so the time moves.
#[derive(Default)]
pub struct AudioPreview {
    /// Started on the first frame that needs it.
    worker: Option<Worker>,
    /// The last path asked for, until it is answered, so a frame asks again only for
    /// something new.
    asked: Option<PathBuf>,
    /// The probed files, one per path, the most recently shown last.
    kept: Vec<(PathBuf, Probed)>,
    /// Where the seek bar is held, in ms, for the path it is held on, until it is let go.
    held: Option<(PathBuf, f64)>,
    /// Whether the last frame's seek bar was on, `None` when it drew none.
    #[cfg(test)]
    seek_enabled: Option<bool>,
    /// The last seek bar's id: to tell a keyboard step from a drag, and for a test to focus it.
    seek_id: Option<egui::Id>,
}

impl AudioPreview {
    /// An empty preview; its thread starts on the first frame that needs it.
    pub fn new() -> AudioPreview {
        AudioPreview::default()
    }

    /// Draw `path`, played by `player`, in the space `ui` has left.
    pub fn ui(&mut self, ui: &mut egui::Ui, path: &Path, player: &Player<PathBuf>) {
        self.receive();
        #[cfg(test)]
        {
            self.seek_enabled = None;
        }
        let info = match self.kept.iter().position(|(p, _)| p == path) {
            Some(at) => {
                let kept = self.kept.remove(at);
                self.kept.push(kept);
                match &self.kept[self.kept.len() - 1].1 {
                    Probed::Audio(info) => Some(Some(info.clone())),
                    Probed::Unread => Some(None),
                    Probed::NotAudio => {
                        not_audio(ui);
                        return;
                    }
                }
            }
            None => {
                self.ask(ui.ctx(), path);
                None
            }
        };
        // `None` while the worker has not answered; `Some(None)` when it found nothing.
        let probing = info.is_none();
        let info = info.flatten();
        let state = player.state();
        let here = plays(&state, path);

        ui.vertical_centered(|ui| {
            ui.add(icons::image(ui, icons::music(), ICON));
            let title = info
                .as_ref()
                .and_then(|i| i.title.clone())
                .unwrap_or_else(|| file_name(path));
            ui.add(egui::Label::new(egui::RichText::new(title).strong()).wrap());
            // The same height whatever it holds, so the button does not move under a finger
            // when the tags come.
            let row = [ui.available_width(), ui.spacing().interact_size.y];
            if probing {
                ui.add_sized(row, egui::Spinner::new());
            } else {
                let byline = info.as_ref().and_then(byline).unwrap_or_default();
                ui.add_sized(row, egui::Label::new(byline).truncate());
            }

            self.button(ui, path, player, &state, here);

            let probed = info.as_ref().and_then(|i| i.duration_ms);
            let length = shown_length(here, state.duration_ms, probed);
            let position = self.seek_bar(ui, path, player, &state, here, length);
            let time = format!(
                "{} / {}",
                clock(position),
                length.map_or_else(|| "--:--".to_owned(), clock)
            );
            ui.label(time);

            if here && let Some(error) = &state.error {
                ui.add(egui::Label::new(error_text(ui, error)).wrap());
            }
        });
    }

    /// The play / pause button.
    fn button(
        &self,
        ui: &mut egui::Ui,
        path: &Path,
        player: &Player<PathBuf>,
        state: &PlayerState<PathBuf>,
        here: bool,
    ) {
        let active = here
            && matches!(
                state.status,
                Status::Playing | Status::Buffering | Status::Preparing
            );
        let (icon, label) = if active {
            (icons::pause(), tr!(ui, "corundum-preview-pause"))
        } else {
            (icons::play(), tr!(ui, "corundum-preview-play"))
        };
        let button = egui::Button::image_and_text(icons::image(ui, icon, BUTTON_ICON), label);
        if ui.add(button).clicked() {
            if here {
                player.send(Command::Toggle);
            } else {
                player.send(Command::Replace {
                    items: vec![path.to_owned()],
                    start: 0,
                });
            }
        }
    }

    /// The seek bar, on only while this file plays and its length is known; letting it go,
    /// or a key on it, seeks there. Returns the position to show, in ms: the player's (or
    /// where the bar is held), whatever the bar can show.
    fn seek_bar(
        &mut self,
        ui: &mut egui::Ui,
        path: &Path,
        player: &Player<PathBuf>,
        state: &PlayerState<PathBuf>,
        here: bool,
        length: Option<u64>,
    ) -> u64 {
        let enabled = here && length.is_some();
        if !enabled {
            self.held = None;
        }
        let held = self
            .held
            .as_ref()
            .filter(|(p, _)| p == path)
            .map(|(_, ms)| *ms);
        let position = shown_position(here, held, state.position_ms);
        let end = length.filter(|_| enabled).unwrap_or(0).max(1) as f64;
        let mut value = seek_value(enabled, position, end as u64);
        ui.spacing_mut().slider_width = (ui.available_width() - 16.0).clamp(0.0, SEEK_WIDTH);
        let mut slider = egui::Slider::new(&mut value, 0.0..=end).show_value(false);
        // An arrow key on the focused bar moves five seconds, not a point of the rail. The
        // step also snaps the value, so it applies only to a keyboard (the bar has focus and
        // no pointer is down): dragging stays smooth.
        let focused = self
            .seek_id
            .is_some_and(|id| ui.memory(|m| m.has_focus(id)));
        if focused && !ui.input(|i| i.pointer.any_down()) {
            slider = slider.step_by(SEEK_KEY_STEP_MS);
        }
        let response = ui.add_enabled(enabled, slider);
        self.seek_id = Some(response.id);
        #[cfg(test)]
        {
            self.seek_enabled = Some(response.enabled());
        }
        let (held, seek) = seek_step(
            held,
            value,
            response.dragged(),
            response.changed(),
            response.drag_stopped(),
        );
        self.held = held.map(|ms| (path.to_owned(), ms));
        if let Some(ms) = seek {
            player.send(Command::Seek(ms));
        }
        held.map_or(position, |ms| ms.max(0.0) as u64)
    }

    /// Send the worker `path`, unless it is the one still unanswered.
    fn ask(&mut self, ctx: &egui::Context, path: &Path) {
        if self.asked.as_deref() == Some(path) {
            return;
        }
        let worker = self
            .worker
            .get_or_insert_with(|| Worker::spawn(ctx.clone()));
        worker.pending.fetch_add(1, Ordering::SeqCst);
        if worker.requests.send(path.to_owned()).is_err() {
            // The thread is gone: start another on the next frame.
            self.worker = None;
            self.asked = None;
            return;
        }
        self.asked = Some(path.to_owned());
    }

    /// Keep what the worker has finished; whether it is shown is the frame's to decide.
    fn receive(&mut self) {
        let Some(worker) = &self.worker else {
            return;
        };
        loop {
            match worker.answers.try_recv() {
                Ok((path, probed)) => {
                    if self.asked.as_ref() == Some(&path) {
                        self.asked = None;
                    }
                    self.kept.retain(|(p, _)| *p != path);
                    self.kept.push((path, probed));
                    if self.kept.len() > KEPT {
                        self.kept.remove(0);
                    }
                }
                Err(TryRecvError::Empty) => break,
                Err(TryRecvError::Disconnected) => {
                    self.worker = None;
                    self.asked = None;
                    break;
                }
            }
        }
    }

    /// Whether the last frame's seek bar was on, `None` when it drew none, for a test.
    #[cfg(test)]
    pub(super) fn seek_enabled(&self) -> Option<bool> {
        self.seek_enabled
    }

    /// The last seek bar's id, for a test.
    #[cfg(test)]
    pub(super) fn seek_id(&self) -> Option<egui::Id> {
        self.seek_id
    }

    /// Something the worker thread holds while it runs.
    #[cfg(test)]
    pub(super) fn thread_token(&self) -> Option<std::sync::Weak<AtomicUsize>> {
        self.worker.as_ref().map(|w| Arc::downgrade(&w.pending))
    }

    /// Whether the worker has nothing left to do.
    #[cfg(test)]
    pub(super) fn idle(&self) -> bool {
        self.worker
            .as_ref()
            .is_none_or(|w| w.pending.load(Ordering::SeqCst) == 0)
    }
}

/// Whether `state` plays `path`, alone, as this preview's play leaves it.
fn plays(state: &PlayerState<PathBuf>, path: &Path) -> bool {
    state.queue.len() == 1 && state.queue[0] == path && state.current == Some(0)
}

/// A file the decoder does not know: the music icon and why there is nothing to play.
fn not_audio(ui: &mut egui::Ui) {
    ui.vertical_centered(|ui| {
        ui.add(icons::image(ui, icons::music(), ICON));
        ui.add(egui::Label::new(tr!(ui, "corundum-preview-unsupported")).wrap());
    });
}

/// The file name of `path`, for a file without a title.
fn file_name(path: &Path) -> String {
    path.file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.display().to_string())
}

/// "artist · album", or whichever of them is known.
fn byline(info: &Info) -> Option<String> {
    let parts: Vec<&str> = [&info.artist, &info.album]
        .into_iter()
        .filter_map(|p| p.as_deref())
        .filter(|p| !p.is_empty())
        .collect();
    (!parts.is_empty()).then(|| parts.join(" · "))
}

/// Why this file did not play.
fn error_text(ui: &egui::Ui, error: &PlayerError) -> String {
    match error {
        PlayerError::Unplayable { reason, .. } => {
            tr!(ui, "corundum-preview-failed", reason = reason.as_str())
        }
        PlayerError::Output(reason) => {
            tr!(
                ui,
                "corundum-preview-audio-device",
                reason = reason.as_str()
            )
        }
        other => tr!(ui, "corundum-preview-failed", reason = format!("{other:?}")),
    }
}

/// `ms` as m:ss.
fn clock(ms: u64) -> String {
    let secs = ms / 1000;
    format!("{}:{:02}", secs / 60, secs % 60)
}

/// The length to show: while this file plays, the player's, else the probe's (the player
/// learns it only once the decoder has opened the file); otherwise the probe's.
pub(super) fn shown_length(here: bool, player: Option<u64>, probed: Option<u64>) -> Option<u64> {
    if here { player.or(probed) } else { probed }
}

/// The seek bar's value in ms: the position within `end`, or 0 while the bar is off.
pub(super) fn seek_value(enabled: bool, position_ms: u64, end_ms: u64) -> f64 {
    if enabled {
        position_ms.min(end_ms) as f64
    } else {
        0.0
    }
}

/// The position to show, in ms: where the bar is held, else the player's while this file
/// plays, else its head.
pub(super) fn shown_position(here: bool, held: Option<f64>, position_ms: u64) -> u64 {
    match held {
        Some(ms) => ms.max(0.0) as u64,
        None if here => position_ms,
        None => 0,
    }
}

/// What a frame of the seek bar does: where it is now held, and where to seek, if anywhere.
/// A drag holds the bar and seeks when it is let go; a change without a drag (an arrow key,
/// a press on the rail) seeks at once.
pub(super) fn seek_step(
    held: Option<f64>,
    value: f64,
    dragged: bool,
    changed: bool,
    released: bool,
) -> (Option<f64>, Option<u64>) {
    let ms = |v: f64| v.max(0.0) as u64;
    if dragged {
        (Some(value), None)
    } else if released {
        (None, held.or(changed.then_some(value)).map(ms))
    } else if changed {
        (None, Some(ms(value)))
    } else {
        (held, None)
    }
}