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};
const KEPT: usize = 8;
const ICON: f32 = 48.0;
const BUTTON_ICON: f32 = 16.0;
const SEEK_WIDTH: f32 = 320.0;
const SEEK_KEY_STEP_MS: f64 = 5_000.0;
enum Probed {
Audio(Info),
Unread,
NotAudio,
}
struct Worker {
requests: Sender<PathBuf>,
answers: Receiver<(PathBuf, Probed)>,
pending: Arc<AtomicUsize>,
}
impl Worker {
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,
}
}
}
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());
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,
}
}
#[derive(Default)]
pub struct AudioPreview {
worker: Option<Worker>,
asked: Option<PathBuf>,
kept: Vec<(PathBuf, Probed)>,
held: Option<(PathBuf, f64)>,
#[cfg(test)]
seek_enabled: Option<bool>,
seek_id: Option<egui::Id>,
}
impl AudioPreview {
pub fn new() -> AudioPreview {
AudioPreview::default()
}
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
}
};
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());
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());
}
});
}
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,
});
}
}
}
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);
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)
}
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() {
self.worker = None;
self.asked = None;
return;
}
self.asked = Some(path.to_owned());
}
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;
}
}
}
}
#[cfg(test)]
pub(super) fn seek_enabled(&self) -> Option<bool> {
self.seek_enabled
}
#[cfg(test)]
pub(super) fn seek_id(&self) -> Option<egui::Id> {
self.seek_id
}
#[cfg(test)]
pub(super) fn thread_token(&self) -> Option<std::sync::Weak<AtomicUsize>> {
self.worker.as_ref().map(|w| Arc::downgrade(&w.pending))
}
#[cfg(test)]
pub(super) fn idle(&self) -> bool {
self.worker
.as_ref()
.is_none_or(|w| w.pending.load(Ordering::SeqCst) == 0)
}
}
fn plays(state: &PlayerState<PathBuf>, path: &Path) -> bool {
state.queue.len() == 1 && state.queue[0] == path && state.current == Some(0)
}
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());
});
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
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(" · "))
}
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:?}")),
}
}
fn clock(ms: u64) -> String {
let secs = ms / 1000;
format!("{}:{:02}", secs / 60, secs % 60)
}
pub(super) fn shown_length(here: bool, player: Option<u64>, probed: Option<u64>) -> Option<u64> {
if here { player.or(probed) } else { probed }
}
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
}
}
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,
}
}
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)
}
}