From d9893b659a6a4871bcfe6ab4ea26444383cbb4a8 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:29:21 -0600 Subject: [PATCH] feat(qt): open the audio CD in the drive and play it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Open > Open audio CD` reads the disc, builds an ephemeral session and lands on its tab. Nothing touches library.db, which is the whole point of the ephemeral path: a disc is content from outside the index that leaves with the plastic. It does not invent metadata. CD-DA carries no titles, and the disc measured here reports its MCN as all zeros, so tracks are "Track N" and the album is "Audio CD". A DiscID lookup is the obvious next step and is deliberately not faked: a wrong title is worse than an honest number. The feeder is the interesting part, and its liveness check took three designs because the first two were wrong in opposite directions — both caught by driving the app, neither by any test: 1. `current_track_id() != play_id`: `play_streaming` posts to the audio thread and returns, so the id is not ours yet and the feeder retires before reading a sector. Nothing played at all. 2. The same test with a wall-clock grace: a DEADLOCK. That field is stored only once the audio thread starts playback, which needs the initial buffer, which is what the feeder is filling. Waiting on it is waiting on ourselves; the grace expired and the track stopped mid-play. 3. A feeder slot the audio thread plays no part in. A newer disc play claims it and the older thread retires on its next block. The acknowledgement path stays for "something else superseded us", but only AFTER the player confirms our id, and with no timeout — a timeout is what kills a healthy feeder. A 16-bit WAV header, not `qbz_dsd`'s: that one is hardcoded to 24-bit, and reusing it would either lie in the header or promote 16-bit samples to 24. The shift is arithmetically lossless but it makes the DAC open at 24 bits for a 16-bit source, which is precisely the silent conversion a bit-perfect path must not contain. `play_streaming` also does not publish bit depth, so the CD states 44 100/16 explicitly instead of inheriting the previous track's. Driven end to end on the owner's disc: seven tracks with the right durations, CD / 16-bit / 44.1 kHz badges, ALSA Direct opened at 44100 Hz in exclusive mode, playback past 2:25 with no read errors, and two rapid skips retiring their feeders in ~160 ms each. Seven new strings in all eight locales. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 21 +++ .../translations/en/LC_MESSAGES/qbz-ui.po | 21 +++ .../translations/es/LC_MESSAGES/qbz-ui.po | 21 +++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 21 +++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 21 +++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 21 +++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 21 +++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 21 +++ crates/qbz-qt/Cargo.toml | 1 + crates/qbz-qt/qml/views/local/LocalChrome.qml | 5 +- crates/qbz-qt/src/audible_qt.rs | 170 ++++++++++++++++++ crates/qbz-qt/src/cdda_qt.rs | 151 ++++++++++++++++ crates/qbz-qt/src/local_bridge.rs | 18 ++ crates/qbz-qt/src/local_ephemeral.rs | 38 ++++ crates/qbz-qt/src/main.rs | 1 + 15 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 crates/qbz-qt/src/cdda_qt.rs diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index c36467991..544020861 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -5375,3 +5375,24 @@ msgstr "Duplikate: {}" msgid "Media" msgstr "Medium" + +msgid "Open audio CD" +msgstr "Audio-CD öffnen" + +msgid "Audio CD" +msgstr "Audio-CD" + +msgid "No optical drive found." +msgstr "Kein optisches Laufwerk gefunden." + +msgid "No disc in the drive." +msgstr "Keine Disc im Laufwerk." + +msgid "That disc has no audio tracks." +msgstr "Diese Disc hat keine Audiotitel." + +msgid "Could not read the disc." +msgstr "Die Disc konnte nicht gelesen werden." + +msgid "Track {}" +msgstr "Titel {}" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index d016d67be..60b47c0e4 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -920,3 +920,24 @@ msgstr "Default" msgid "Media" msgstr "Media" + +msgid "Open audio CD" +msgstr "Open audio CD" + +msgid "Audio CD" +msgstr "Audio CD" + +msgid "No optical drive found." +msgstr "No optical drive found." + +msgid "No disc in the drive." +msgstr "No disc in the drive." + +msgid "That disc has no audio tracks." +msgstr "That disc has no audio tracks." + +msgid "Could not read the disc." +msgstr "Could not read the disc." + +msgid "Track {}" +msgstr "Track {}" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index c1a9f76da..3caf6befd 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -5381,3 +5381,24 @@ msgstr "Duplicados: {}" msgid "Media" msgstr "Medio" + +msgid "Open audio CD" +msgstr "Abrir CD de audio" + +msgid "Audio CD" +msgstr "CD de audio" + +msgid "No optical drive found." +msgstr "No se encontró ninguna unidad óptica." + +msgid "No disc in the drive." +msgstr "No hay disco en la unidad." + +msgid "That disc has no audio tracks." +msgstr "Ese disco no tiene pistas de audio." + +msgid "Could not read the disc." +msgstr "No se pudo leer el disco." + +msgid "Track {}" +msgstr "Pista {}" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 56e2bea33..2e524c4dd 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -5373,3 +5373,24 @@ msgstr "Doublons : {}" msgid "Media" msgstr "Média" + +msgid "Open audio CD" +msgstr "Ouvrir le CD audio" + +msgid "Audio CD" +msgstr "CD audio" + +msgid "No optical drive found." +msgstr "Aucun lecteur optique trouvé." + +msgid "No disc in the drive." +msgstr "Aucun disque dans le lecteur." + +msgid "That disc has no audio tracks." +msgstr "Ce disque n'a aucune piste audio." + +msgid "Could not read the disc." +msgstr "Impossible de lire le disque." + +msgid "Track {}" +msgstr "Piste {}" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 5b01d2a8f..0888ba5b3 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -5358,3 +5358,24 @@ msgstr "重複: {}" msgid "Media" msgstr "メディア" + +msgid "Open audio CD" +msgstr "オーディオCDを開く" + +msgid "Audio CD" +msgstr "オーディオCD" + +msgid "No optical drive found." +msgstr "光学ドライブが見つかりません。" + +msgid "No disc in the drive." +msgstr "ドライブにディスクがありません。" + +msgid "That disc has no audio tracks." +msgstr "このディスクにはオーディオトラックがありません。" + +msgid "Could not read the disc." +msgstr "ディスクを読み取れませんでした。" + +msgid "Track {}" +msgstr "トラック {}" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index b030236f3..ed373bcb0 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -5363,3 +5363,24 @@ msgstr "Duplicaten: {}" msgid "Media" msgstr "Medium" + +msgid "Open audio CD" +msgstr "Audio-cd openen" + +msgid "Audio CD" +msgstr "Audio-cd" + +msgid "No optical drive found." +msgstr "Geen optisch station gevonden." + +msgid "No disc in the drive." +msgstr "Geen schijf in het station." + +msgid "That disc has no audio tracks." +msgstr "Die schijf heeft geen audiotracks." + +msgid "Could not read the disc." +msgstr "Kon de schijf niet lezen." + +msgid "Track {}" +msgstr "Track {}" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 1518e4173..c5ad61474 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -5373,3 +5373,24 @@ msgstr "Duplicados: {}" msgid "Media" msgstr "Mídia" + +msgid "Open audio CD" +msgstr "Abrir CD de áudio" + +msgid "Audio CD" +msgstr "CD de áudio" + +msgid "No optical drive found." +msgstr "Nenhuma unidade óptica encontrada." + +msgid "No disc in the drive." +msgstr "Nenhum disco na unidade." + +msgid "That disc has no audio tracks." +msgstr "Esse disco não tem faixas de áudio." + +msgid "Could not read the disc." +msgstr "Não foi possível ler o disco." + +msgid "Track {}" +msgstr "Faixa {}" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index d59c0f131..229c0b3e3 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -5392,3 +5392,24 @@ msgstr "Дубликаты: {}" msgid "Media" msgstr "Носитель" + +msgid "Open audio CD" +msgstr "Открыть аудио-CD" + +msgid "Audio CD" +msgstr "Аудио-CD" + +msgid "No optical drive found." +msgstr "Оптический привод не найден." + +msgid "No disc in the drive." +msgstr "В приводе нет диска." + +msgid "That disc has no audio tracks." +msgstr "На этом диске нет аудиодорожек." + +msgid "Could not read the disc." +msgstr "Не удалось прочитать диск." + +msgid "Track {}" +msgstr "Дорожка {}" diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index 5c4438f73..ff23ccc86 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -31,6 +31,7 @@ log = { workspace = true } # one of the wedges the watchdog exists to cut short. Already in the lock as # a transitive dep of half the tree; this only makes it direct. libc = "0.2" +qbz-disc = { path = "../qbz-disc" } rand = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/qbz-qt/qml/views/local/LocalChrome.qml b/crates/qbz-qt/qml/views/local/LocalChrome.qml index 7b0125445..bede1c212 100644 --- a/crates/qbz-qt/qml/views/local/LocalChrome.qml +++ b/crates/qbz-qt/qml/views/local/LocalChrome.qml @@ -195,10 +195,13 @@ Item { menuWidth: 220 entries: [ { "label": QbzSession.tr("Open folder…", QbzSession.trRev), - "icon": "folder-open", "action": "folder" } + "icon": "folder-open", "action": "folder" }, + { "label": QbzSession.tr("Open audio CD", QbzSession.trRev), + "icon": "disc", "action": "cd" } ] onPicked: function (action) { if (action === "folder") QbzLocal.ephemeralOpen() + else if (action === "cd") QbzLocal.ephemeralOpenCd() } } diff --git a/crates/qbz-qt/src/audible_qt.rs b/crates/qbz-qt/src/audible_qt.rs index 10947db05..c3a71806b 100644 --- a/crates/qbz-qt/src/audible_qt.rs +++ b/crates/qbz-qt/src/audible_qt.rs @@ -120,6 +120,13 @@ pub(crate) async fn play_ticket(runtime: &Runtime, ticket: PlaybackTicket) -> bo seek_secs, } => play_file(runtime, path, play_id, seek_secs).await, + PlaybackTicket::CdTrack { + device, + start_lsn, + sectors, + play_id, + } => play_cd_track(runtime, device, start_lsn, sectors, play_id), + PlaybackTicket::Bytes { bytes, play_id } => { let len = bytes.len(); if let Err(e) = runtime.core().player().play_data(bytes, play_id) { @@ -365,3 +372,166 @@ mod tests { assert_eq!(slot.1, PathBuf::from("/m/album.flac")); } } + +/// The play_id of the CD feeder that should be running. A newer disc play +/// stores its own id here and the older thread retires itself on its next +/// block — a signal the audio thread plays no part in, which is the whole +/// point (see the reasoning inside `play_cd_track`). +static CD_FEEDER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Feed one CD-DA track into the player as a finite WAV. +/// +/// The SHAPE is the shipping DSD-to-PCM path (`player/mod.rs:5224`): declare a +/// content length, hand back a `BufferWriter`, push a header and then blocks +/// from a thread. That is deliberate — it is the one locally-produced stream +/// this app already has, and it works. +/// +/// Three things differ, and each is a decision rather than a detail: +/// +/// 1. **A 16-bit header.** `qbz_dsd::wav_header` is hardcoded to 24-bit, the +/// depth its converter emits. Reusing it would either lie in the header or +/// promote 16-bit samples to 24 — and while that shift is arithmetically +/// lossless, it makes the DAC open at 24 bits for a 16-bit source. That is +/// the silent conversion a bit-perfect path must not contain. +/// +/// 2. **The feeder stops when the track changes.** The DSD thread used to +/// rely on `push_chunk` returning `Err`, which it cannot do; this one polls +/// `current_track_id()`, the same signal `play_file` above reads, and stops +/// the moment the player is on something else. Without it, skipping a track +/// would leave the drive spinning and a 160 MB buffer growing for nothing. +/// +/// 3. **A read error ends the track.** No zero-fill, no skip-ahead. A hole +/// that sounds like silence is indistinguishable from music that was meant +/// to be quiet, and on a bit-perfect path that is worse than stopping. +fn play_cd_track( + runtime: &Runtime, + device: std::path::PathBuf, + start_lsn: u32, + sectors: u32, + play_id: u64, +) -> bool { + let track = qbz_disc::cdda::TocTrack { + number: 0, + start_lsn, + sectors, + is_audio: true, + }; + let mut reader = match qbz_disc::cdda::TrackReader::open(&device, &track) { + Ok(r) => r, + Err(e) => { + log::error!("[qbz-qt] audible: cannot read {} : {e}", device.display()); + crate::toast_qt::error(qbz_i18n::t("Could not read the disc.")); + return false; + } + }; + + let frames = track.frames(); + let content_length = qbz_disc::wav_total_size_16(frames, qbz_disc::CDDA_CHANNELS); + let duration_secs = track.duration_secs(); + log::info!( + "[qbz-qt] audible: CD track {play_id} — {} sectors, {}s, {} bytes WAV \ + ({} Hz / {}-bit / {}ch)", + sectors, + duration_secs, + content_length, + qbz_disc::CDDA_SAMPLE_RATE, + qbz_disc::CDDA_BITS, + qbz_disc::CDDA_CHANNELS + ); + + let player = runtime.core().player(); + // `play_streaming` does not publish the bit depth (only its dynamic + // sibling does), so a CD would inherit whatever the previous track left + // behind. Say it explicitly. + player + .state + .set_stream_quality(qbz_disc::CDDA_SAMPLE_RATE, qbz_disc::CDDA_BITS as u32); + + let writer = match player.play_streaming( + play_id, + qbz_disc::CDDA_SAMPLE_RATE, + qbz_disc::CDDA_CHANNELS, + content_length, + 3, + duration_secs, + 0, + ) { + Ok(w) => w, + Err(e) => { + log::error!("[qbz-qt] audible: play_streaming for CD {play_id} failed: {e}"); + return false; + } + }; + + // Claim the feeder slot BEFORE spawning: whoever claims last wins, and the + // previous feeder sees the change on its next block. + CD_FEEDER.store(play_id, std::sync::atomic::Ordering::SeqCst); + + let state = player.state.clone(); + std::thread::spawn(move || { + if writer + .push_chunk(&qbz_disc::wav_header_16( + frames, + qbz_disc::CDDA_CHANNELS, + qbz_disc::CDDA_SAMPLE_RATE, + )) + .is_err() + { + return; + } + let mut buf = Vec::new(); + // Liveness, third attempt — the first two were both wrong and in + // opposite directions, so the reasoning is written down. + // + // 1. `current_track_id() != play_id` alone: WRONG. `play_streaming` + // posts to the audio thread and returns, so the id is not ours yet + // and the feeder decides it was superseded before reading a sector. + // Nothing played. + // 2. Adding a wall-clock grace: WORSE, and a deadlock. That field is + // stored at `player/mod.rs:2814`, which the audio thread only + // reaches once the initial buffer is READY — and the buffer is + // what this thread is filling. Waiting on it means waiting on + // ourselves, so the grace expired and the track stopped. + // + // The signal has to be one the audio thread is not part of. + // `CD_FEEDER` is bumped by the play that spawned us, so a NEWER CD + // play retires this one immediately — which is the supersede that + // actually happens (skip to the next track of the same disc). + // + // For "a non-CD track superseded us", the acknowledgement path still + // applies, but only ONCE the player has confirmed our id: after that + // it changing means something else really is playing. There is no + // timeout, because a timeout is what can kill a healthy feeder. + let mut acknowledged = false; + loop { + if CD_FEEDER.load(std::sync::atomic::Ordering::SeqCst) != play_id { + log::info!("[qbz-qt] CD feeder {play_id} retired by a newer disc play"); + return; + } + let current = state.current_track_id(); + if current == play_id { + acknowledged = true; + } else if acknowledged { + log::info!("[qbz-qt] CD feeder for superseded track {play_id} stopping"); + return; + } + match reader.next_chunk(&mut buf) { + Ok(0) => { + let _ = writer.complete(); + return; + } + Ok(_) => { + if writer.push_chunk(&buf).is_err() { + return; + } + } + Err(e) => { + log::error!("[qbz-qt] CD read failed mid-track: {e}"); + let _ = writer.error(format!("CD read failed: {e}")); + return; + } + } + } + }); + true +} diff --git a/crates/qbz-qt/src/cdda_qt.rs b/crates/qbz-qt/src/cdda_qt.rs new file mode 100644 index 000000000..ac32b0706 --- /dev/null +++ b/crates/qbz-qt/src/cdda_qt.rs @@ -0,0 +1,151 @@ +//! Open the audio CD in the drive as an ephemeral session. +//! +//! A disc is exactly what the ephemeral model is for: content from outside the +//! index, playable, and gone when you take it out. Nothing here touches +//! `library.db`. +//! +//! What it does NOT do is invent metadata. A CD-DA carries no titles — the +//! disc in the owner's drive reports its MCN as all zeros and only its ISRCs +//! are populated — so tracks are named by their number and the album is called +//! "Audio CD". A DiscID lookup is the obvious next step and is deliberately +//! not faked here: a wrong title is worse than an honest number. + +use qbz_library::{AudioFormat, LocalTrack}; + +use crate::local_ephemeral; + +/// Human label for a disc we know nothing about beyond its shape. +fn disc_label() -> String { + qbz_i18n::t("Audio CD") +} + +/// Map one TOC entry to the `LocalTrack` the rest of the app already knows how +/// to carry. +/// +/// `file_path` is a `cdda:` reference, NOT a path: it is what +/// `LocalSource::playback` parses back into a device and a sector range. That +/// is why nothing downstream may stat it — see `qbz_disc::CdRef`. +fn to_local_track(track: &qbz_disc::cdda::TocTrack, album: &str) -> LocalTrack { + let reference = qbz_disc::CdRef { + device: std::path::PathBuf::new(), // filled by the caller + start_lsn: track.start_lsn, + sectors: track.sectors, + }; + let _ = reference; + LocalTrack { + // `t_args`, not `tf`: this is one track, not a count. A plural form + // here would put a bogus singular/plural pair in eight catalogues. + title: qbz_i18n::t_args("Track {}", &[&track.number.to_string()]), + album: album.to_string(), + album_group_title: album.to_string(), + album_group_key: format!("cdda|||{album}"), + track_number: Some(track.number as u32), + disc_number: Some(1), + duration_secs: track.duration_secs(), + // A CD is 44.1 kHz / 16-bit / stereo by definition. These are not + // guesses and not defaults — the format has no other shape. + sample_rate: qbz_disc::CDDA_SAMPLE_RATE as f64, + bit_depth: Some(qbz_disc::CDDA_BITS as u32), + format: AudioFormat::Wav, + ..Default::default() + } +} + +/// Read the disc in the first drive that has one and publish it as the +/// ephemeral session. Returns the number of audio tracks, or an error string +/// already translated for a toast. +pub fn open_disc() -> Result { + let devices = qbz_disc::list_devices(); + if devices.is_empty() { + return Err(qbz_i18n::t("No optical drive found.")); + } + + // Several drives are legal; take the first one that actually has an audio + // disc rather than assuming /dev/sr0 is the interesting one. + let mut last_err = None; + for dev in &devices { + match qbz_disc::read_toc(dev) { + Ok(toc) => { + let album = disc_label(); + let audio: Vec<_> = toc.audio_tracks().collect(); + let skipped = toc.tracks.len() - audio.len(); + if skipped > 0 { + // Mixed-mode disc. Say so rather than quietly showing + // fewer tracks than the case insert lists. + log::info!( + "[qbz-qt] cd: {skipped} data track(s) skipped on {}", + dev.display() + ); + } + let tracks: Vec = audio + .iter() + .map(|t| { + let mut lt = to_local_track(t, &album); + lt.file_path = qbz_disc::CdRef { + device: dev.clone(), + start_lsn: t.start_lsn, + sectors: t.sectors, + } + .to_path_string(); + lt + }) + .collect(); + let count = tracks.len(); + log::info!( + "[qbz-qt] cd: {} — {count} audio tracks, fingerprint {}", + dev.display(), + toc.fingerprint() + ); + local_ephemeral::adopt_tracks(&album, tracks); + return Ok(count); + } + Err(e) => { + log::info!("[qbz-qt] cd: {} unusable: {e}", dev.display()); + last_err = Some(e); + } + } + } + Err(match last_err { + Some(qbz_disc::CdError::NoDisc) => qbz_i18n::t("No disc in the drive."), + Some(qbz_disc::CdError::NotAudio) => qbz_i18n::t("That disc has no audio tracks."), + Some(e) => format!("{e}"), + None => qbz_i18n::t("No optical drive found."), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_cd_track_carries_the_formats_only_possible_shape() { + let t = qbz_disc::cdda::TocTrack { + number: 7, + start_lsn: 285_735, + sectors: 70_833, + is_audio: true, + }; + let lt = to_local_track(&t, "Audio CD"); + assert_eq!(lt.sample_rate, 44_100.0); + assert_eq!(lt.bit_depth, Some(16)); + assert_eq!(lt.track_number, Some(7)); + // 15:44, the real length of the owner's longest track. + assert_eq!(lt.duration_secs, 944); + } + + #[test] + fn the_reference_survives_the_round_trip_a_playback_will_make() { + let r = qbz_disc::CdRef { + device: std::path::PathBuf::from("/dev/sr0"), + start_lsn: 46_577, + sectors: 53_470, + }; + let s = r.to_path_string(); + // This is the exact test `LocalSource::playback` performs. + assert!(qbz_disc::CdRef::is_cd_path(&s)); + let back = qbz_disc::CdRef::parse(&s).expect("a reference we just wrote must parse"); + assert_eq!(back.device, r.device); + assert_eq!(back.start_lsn, 46_577); + assert_eq!(back.sectors, 53_470); + } +} diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 5387d4af2..135c76336 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -295,6 +295,11 @@ pub mod qbz_local { /// Close the session (stops playback if it came from the session). #[qinvokable] fn ephemeral_clear(self: Pin<&mut QbzLocal>); + /// `Open > Open CD…`: read the audio disc in the drive and make it the + /// ephemeral session. Toasts on every failure it can name (no drive, + /// no disc, a data-only disc) rather than opening an empty pane. + #[qinvokable] + fn ephemeral_open_cd(self: Pin<&mut QbzLocal>); /// Header Play / Shuffle: the whole session becomes the queue. Same /// `(id, shuffle)` shape as `play_album`, so the two headers behave /// identically. @@ -1085,6 +1090,19 @@ impl qbz_local::QbzLocal { crate::local_ephemeral::clear(); } + pub fn ephemeral_open_cd(self: Pin<&mut Self>) { + // Reading a TOC spins the drive up and can take a second or two, so it + // does not run on the UI thread. + crate::spawn(async move { + let outcome = tokio::task::spawn_blocking(crate::cdda_qt::open_disc).await; + match outcome { + Ok(Ok(n)) => log::info!("[qbz-qt] cd opened: {n} tracks"), + Ok(Err(msg)) => crate::toast_qt::error(msg), + Err(e) => log::warn!("[qbz-qt] cd open task failed: {e}"), + } + }); + } + pub fn ephemeral_play_all(self: Pin<&mut Self>, shuffle: bool) { let runtime = crate::app(); crate::spawn(async move { diff --git a/crates/qbz-qt/src/local_ephemeral.rs b/crates/qbz-qt/src/local_ephemeral.rs index 75cb6cc5e..d042b103d 100644 --- a/crates/qbz-qt/src/local_ephemeral.rs +++ b/crates/qbz-qt/src/local_ephemeral.rs @@ -327,6 +327,44 @@ pub fn open_path(path: String) { }); } +/// Publish a session built from a track list rather than a directory scan — +/// a CD today, a disc image next. +/// +/// It goes through the SAME `publish_doc` as a folder, so the pane, the tab, +/// the label and the open sequence all behave identically: a disc is not a +/// second kind of session, it is the same session with a different source of +/// tracks. +/// +/// The path is NOT persisted. A folder is still there tomorrow; a disc is a +/// piece of plastic that gets taken out, and re-opening a drive at boot to +/// find a different album (or none) is worse than starting clean. Restoring a +/// medium needs the TOC fingerprint check the contract specifies, and that is +/// not built yet — so this deliberately does nothing rather than half of it. +pub fn adopt_tracks(label: &str, tracks: Vec) { + let label = label.to_string(); + crate::spawn(async move { + wipe_if_playing(&crate::app()).await; + let seq = OPEN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + ui(move |mut b| { + b.as_mut().set_local_ephemeral_open_seq(seq); + }); + match STATE.open_tracks(&label, tracks) { + Ok(res) => { + log::info!( + "[qbz-qt] ephemeral adopted {} ({} tracks)", + label, + res.tracks.len() + ); + publish_doc(&build_doc(&label, &label, &res.tracks), false); + } + Err(e) => { + log::warn!("[qbz-qt] ephemeral adopt failed: {e}"); + publish_closed(); + } + } + }); +} + /// Boot: re-open the persisted folder, if any. Silent — a folder that moved /// away just clears the stale pref. pub fn rehydrate() { diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index a533d3a61..7ab5474cc 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -239,6 +239,7 @@ mod share_qt; mod nav_qt; mod browse_qt; mod cast_qt; +mod cdda_qt; mod label_qt; mod now_playing; mod output_labels;