feat(qt): a Rip button on an open CD, and it asks where to put the album
The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.
The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.
While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.
Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.
Four new strings in all eight locales.
cc
This commit is contained in:
14 files changed
+338
No files matched your search
@@ -32,6 +32,7 @@ log = { workspace = true }
|
||||
# a transitive dep of half the tree; this only makes it direct.
|
||||
libc = "0.2"
|
||||
qbz-disc = { path = "../qbz-disc" }
|
||||
qbz-rip = { path = "../qbz-rip" }
|
||||
rand = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -158,6 +158,34 @@ Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.ephemeralPlayAll(true)
|
||||
}
|
||||
// Rip — a PHYSICAL disc only. An image is already a
|
||||
// file, so offering it there would be offering to copy
|
||||
// something the user already has.
|
||||
QbzCircleAction {
|
||||
visible: QbzLocal.localEphemeralIsCd && !QbzLocal.localRipActive
|
||||
name: "cloud-download"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.ripDisc()
|
||||
}
|
||||
// While it runs the button gives way to its progress —
|
||||
// one control, one state, rather than a button that
|
||||
// looks pressable during a job it cannot start twice.
|
||||
Row {
|
||||
visible: QbzLocal.localRipActive
|
||||
spacing: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
QbzSpinner {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 15
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Ripping", QbzSession.trRev)
|
||||
+ " " + QbzLocal.localRipProgress
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
// Kept: this is the ONLY way to close the session, and
|
||||
// with it the only way to make the tab go away.
|
||||
QbzCircleAction {
|
||||
|
||||
@@ -129,6 +129,16 @@ pub mod qbz_local {
|
||||
/// sequence changes on every open, which is what "take me to what I
|
||||
/// just opened" actually needs.
|
||||
#[qproperty(i32, local_ephemeral_open_seq)]
|
||||
/// The open session came from a physical CD, so it can be RIPPED.
|
||||
/// Derived from the rows themselves rather than passed in — a flag
|
||||
/// somebody has to remember to set is a flag that eventually is not.
|
||||
#[qproperty(bool, local_ephemeral_is_cd)]
|
||||
/// A rip is running; the pane shows progress instead of the button.
|
||||
#[qproperty(bool, local_rip_active)]
|
||||
/// "3/7 · 45%" — already formatted, because the number of things that
|
||||
/// can disagree about how to format it is otherwise the number of
|
||||
/// places that show it.
|
||||
#[qproperty(QString, local_rip_progress)]
|
||||
|
||||
// --- Plex ----------------------------------------------------------
|
||||
/// Master toggle (Settings > Local Library > Plex). Drives whether
|
||||
@@ -303,6 +313,10 @@ pub mod qbz_local {
|
||||
/// `Open > Open SACD image…`: pick a .iso and play its stereo area.
|
||||
#[qinvokable]
|
||||
fn ephemeral_open_sacd(self: Pin<&mut QbzLocal>);
|
||||
/// Rip the open CD into a folder the user picks. No-op unless the
|
||||
/// session is a disc.
|
||||
#[qinvokable]
|
||||
fn rip_disc(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.
|
||||
@@ -486,6 +500,9 @@ pub struct QbzLocalRust {
|
||||
local_ephemeral_json: QString,
|
||||
local_ephemeral_label: QString,
|
||||
local_ephemeral_open_seq: i32,
|
||||
local_ephemeral_is_cd: bool,
|
||||
local_rip_active: bool,
|
||||
local_rip_progress: QString,
|
||||
plex_enabled: bool,
|
||||
plex_available: bool,
|
||||
plex_syncing: bool,
|
||||
@@ -536,6 +553,9 @@ impl Default for QbzLocalRust {
|
||||
local_ephemeral_json: QString::from(""),
|
||||
local_ephemeral_label: QString::from(""),
|
||||
local_ephemeral_open_seq: 0,
|
||||
local_ephemeral_is_cd: false,
|
||||
local_rip_active: false,
|
||||
local_rip_progress: QString::default(),
|
||||
plex_enabled: false,
|
||||
plex_available: false,
|
||||
plex_syncing: false,
|
||||
@@ -1122,6 +1142,10 @@ impl qbz_local::QbzLocal {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn rip_disc(self: Pin<&mut Self>) {
|
||||
crate::rip_qt::start();
|
||||
}
|
||||
|
||||
pub fn ephemeral_play_all(self: Pin<&mut Self>, shuffle: bool) {
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
|
||||
@@ -74,6 +74,35 @@ pub fn get_track(id: i64) -> Option<LocalTrack> {
|
||||
STATE.get_track(id)
|
||||
}
|
||||
|
||||
/// True when the open session is a physical CD — the only medium this app
|
||||
/// can rip, since an image is already a file.
|
||||
pub fn session_is_cd() -> bool {
|
||||
STATE
|
||||
.tracks_snapshot()
|
||||
.first()
|
||||
.map(|t| qbz_disc::CdRef::is_cd_path(&t.file_path))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Where to put a rip. A folder chooser, and the answer is never guessed.
|
||||
pub(crate) fn pick_folder_for_rip() -> Option<String> {
|
||||
let title = qbz_i18n::t("Where should the ripped album go?");
|
||||
let start = dirs::audio_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let start = start.to_string_lossy().into_owned();
|
||||
let candidates: [(&str, Vec<String>); 4] = [
|
||||
("zenity", vec!["--file-selection".into(), "--directory".into(),
|
||||
format!("--title={title}"), format!("--filename={start}/")]),
|
||||
("qarma", vec!["--file-selection".into(), "--directory".into(),
|
||||
format!("--title={title}"), format!("--filename={start}/")]),
|
||||
("kdialog", vec!["--getexistingdirectory".into(), start.clone(),
|
||||
"--title".into(), title.clone()]),
|
||||
("yad", vec!["--file".into(), "--directory".into(), format!("--title={title}")]),
|
||||
];
|
||||
run_chooser(&candidates)
|
||||
}
|
||||
|
||||
/// Every track of the current session, in scan (= display) order.
|
||||
pub fn tracks_snapshot() -> Vec<LocalTrack> {
|
||||
STATE.tracks_snapshot()
|
||||
@@ -287,6 +316,7 @@ fn publish_closed() {
|
||||
b.as_mut().set_local_ephemeral_loading(false);
|
||||
b.as_mut().set_local_ephemeral_json(QString::from(""));
|
||||
b.as_mut().set_local_ephemeral_label(QString::from(""));
|
||||
b.as_mut().set_local_ephemeral_is_cd(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,8 +378,18 @@ pub fn adopt_tracks(label: &str, tracks: Vec<LocalTrack>) {
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_local_ephemeral_open_seq(seq);
|
||||
});
|
||||
// Derived from the rows, not passed in: whoever builds the session
|
||||
// already knows what the tracks are, and a separate flag is one more
|
||||
// thing a future medium can forget to set.
|
||||
let is_cd = tracks
|
||||
.first()
|
||||
.map(|t| qbz_disc::CdRef::is_cd_path(&t.file_path))
|
||||
.unwrap_or(false);
|
||||
match STATE.open_tracks(&label, tracks) {
|
||||
Ok(res) => {
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_local_ephemeral_is_cd(is_cd);
|
||||
});
|
||||
log::info!(
|
||||
"[qbz-qt] ephemeral adopted {} ({} tracks)",
|
||||
label,
|
||||
|
||||
@@ -241,6 +241,7 @@ mod browse_qt;
|
||||
mod cast_qt;
|
||||
mod cdda_qt;
|
||||
mod sacd_qt;
|
||||
mod rip_qt;
|
||||
mod label_qt;
|
||||
mod now_playing;
|
||||
mod output_labels;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! "Rip this CD into my library": the Qt side of the job.
|
||||
//!
|
||||
//! Everything domain-shaped lives in `qbz-rip` (read, encode, tag, name); this
|
||||
//! file only asks the user where to put it, turns the open session into a
|
||||
//! plan, and reports progress.
|
||||
//!
|
||||
//! The DESTINATION is always asked, never guessed. A ripper that decides where
|
||||
//! your music goes is a ripper you have to clean up after, and the app already
|
||||
//! knows several plausible folders — which is exactly why it must not pick one.
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
use crate::local_bridge::ui;
|
||||
|
||||
/// Kick off a rip of the currently open CD session.
|
||||
pub fn start() {
|
||||
if !crate::local_ephemeral::session_is_cd() {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
let picked = tokio::task::spawn_blocking(crate::local_ephemeral::pick_folder_for_rip)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(dest) = picked else { return };
|
||||
|
||||
let Some(plan) = build_plan(std::path::PathBuf::from(dest)) else {
|
||||
crate::toast_qt::error(qbz_i18n::t("Nothing to rip."));
|
||||
return;
|
||||
};
|
||||
let count = plan.tracks.len();
|
||||
let album = plan.album.clone();
|
||||
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_rip_active(true);
|
||||
b.as_mut().set_local_rip_progress(QString::from("0%"));
|
||||
});
|
||||
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
qbz_rip::rip(&plan, |p| {
|
||||
// The bar is per-DISC, not per-track: a listener watching a
|
||||
// 79-minute album wants to know how far the album is, and a
|
||||
// per-track bar that resets seven times reads like a stall.
|
||||
let overall = (p.track_index as f32 + p.fraction) / p.track_count as f32;
|
||||
let text = format!(
|
||||
"{}/{} · {}%",
|
||||
p.track_index + 1,
|
||||
p.track_count,
|
||||
(overall * 100.0) as u32
|
||||
);
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_local_rip_progress(QString::from(text.as_str()));
|
||||
});
|
||||
true
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_rip_active(false);
|
||||
b.as_mut().set_local_rip_progress(QString::default());
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(Ok(files)) => {
|
||||
log::info!("[qbz-qt] rip: {} files written", files.len());
|
||||
crate::toast_qt::success(qbz_i18n::tf(
|
||||
"Ripped {} track to your library",
|
||||
"Ripped {} tracks to your library",
|
||||
count as i64,
|
||||
&[&count.to_string()],
|
||||
));
|
||||
// The folder is outside the index until somebody scans it.
|
||||
// Saying so beats the user wondering why the album is not in
|
||||
// Local Library yet.
|
||||
log::info!("[qbz-qt] rip: {album:?} — run a folder scan to index it");
|
||||
}
|
||||
Ok(Err(qbz_rip::RipError::Cancelled)) => {
|
||||
log::info!("[qbz-qt] rip cancelled");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("[qbz-qt] rip failed: {e}");
|
||||
crate::toast_qt::error(format!("{e}"));
|
||||
}
|
||||
Err(e) => log::warn!("[qbz-qt] rip task failed: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Turn the open session into a plan. Returns `None` when the session is not
|
||||
/// a CD, or holds no track this can read.
|
||||
fn build_plan(destination: std::path::PathBuf) -> Option<qbz_rip::RipPlan> {
|
||||
let rows = crate::local_ephemeral::tracks_snapshot();
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let album = rows[0].album.clone();
|
||||
let album_artist = rows[0]
|
||||
.album_artist
|
||||
.clone()
|
||||
.filter(|a| !a.is_empty())
|
||||
.unwrap_or_else(|| rows[0].artist.clone());
|
||||
|
||||
let tracks: Vec<qbz_rip::RipTrack> = rows
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let r = qbz_disc::CdRef::parse(&t.file_path)?;
|
||||
Some(qbz_rip::RipTrack {
|
||||
number: t.track_number.unwrap_or(0),
|
||||
title: t.title.clone(),
|
||||
artist: if t.artist.is_empty() {
|
||||
album_artist.clone()
|
||||
} else {
|
||||
t.artist.clone()
|
||||
},
|
||||
source: qbz_rip::RipSource::Cd {
|
||||
device: r.device,
|
||||
start_lsn: r.start_lsn,
|
||||
sectors: r.sectors,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
(!tracks.is_empty()).then(|| qbz_rip::RipPlan {
|
||||
destination,
|
||||
album,
|
||||
album_artist,
|
||||
year: rows[0].year,
|
||||
tracks,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user