feat(qbzd): add Scrobbler setup-TUI screen (Last.fm / ListenBrainz)

Add an eighth setup-TUI section (`qbzd setup`) for scrobbler auth, mirroring
the Account browser-login methodology: the App suspends the alt-screen and runs
the CLI connect flow on the plain terminal, then reloads the screen's settings
snapshot on resume. Last.fm prints an authorize URL and waits for Enter (the CLI
verb owns that); ListenBrainz prompts for a pasted user token, then hands it to
the CLI verb. Both write the canonical ScrobblerSettingsStore, so enable/disable
stays a CLI verb and the screen only shows connection state.

Scrobbler is appended LAST in SCREENS so no existing section index or number-key
jump shifts; the number-key jump range widens 1-7 -> 1-8. The D7 six-screen cap
was already lifted for FB4's Wizard; this is the second owner-sanctioned break.

cc
This commit is contained in:
vicrodh committed 2026-07-19 20:13:39 -06:00
1 parent 1c5c44b904
commit 09a46b694d
5 files changed
+210 -14

No files matched your search

+45 -7
View File
@@ -1,6 +1,7 @@
// crates/qbzd/src/tui/app.rs — the setup-TUI state machine.
//
// Owns the six screens (D7 hard cap: `const SCREENS: [Screen; 6]`), the route,
// Owns the eight screens (the D7 six-screen cap was lifted for FB4's Wizard and
// the CONSOLE ext's Scrobbler, both owner-sanctioned), the route,
// the dirty-save model (§4), the App-level overlays (help / result panel /
// dirty-leave modal), and the worker plumbing (§5.5: NO I/O on keystrokes — disk
// and HTTP happen only at screen entry, `r`, save, and the immediate actions, on
@@ -32,6 +33,7 @@ use super::screens::bundle::{BundleState, PendingImport};
use super::screens::network::{self as network_screen, NetworkState};
use super::screens::playback::PlaybackState;
use super::screens::qconnect::QConnectState;
use super::screens::scrobbler::ScrobblerState;
use super::screens::wizard::WizardState;
use super::strings as s;
use super::theme;
@@ -56,12 +58,14 @@ pub enum Screen {
Network,
Bundle,
Wizard,
Scrobbler,
}
/// Seven sections. The original D7 six-screen cap was broken deliberately for
/// FB4's HiFi Wizard (owner-sanctioned); a further screen still needs an owner
/// decision, not a PR.
pub const SCREENS: [Screen; 7] = [
/// Eight sections. The original D7 six-screen cap was broken deliberately for
/// FB4's HiFi Wizard, and again for the CONSOLE ext's Scrobbler (Last.fm /
/// ListenBrainz auth) — both owner-sanctioned. Scrobbler is appended LAST so no
/// existing section index (or number-key jump) shifts.
pub const SCREENS: [Screen; 8] = [
Screen::Account,
Screen::Audio,
Screen::Playback,
@@ -69,6 +73,7 @@ pub const SCREENS: [Screen; 7] = [
Screen::Network,
Screen::Bundle,
Screen::Wizard,
Screen::Scrobbler,
];
// Sidebar width is now responsive (FB5): `widgets::sidebar_width(term_width)` —
@@ -118,6 +123,7 @@ fn section_title(screen: Screen) -> &'static str {
Screen::Network => s::NETWORK_TITLE,
Screen::Bundle => s::BUNDLE_TITLE,
Screen::Wizard => s::WIZARD_TITLE,
Screen::Scrobbler => s::SCROBBLER_TITLE,
}
}
@@ -167,10 +173,10 @@ enum NavIntent {
/// is open in the content (it owns every key); `uses_horizontal` = the focused
/// content field consumes ←/→ (Audio's Buffer slider), so ← must NOT drop focus.
fn classify_key(focus: Focus, code: KeyCode, editing: bool, uses_horizontal: bool) -> NavIntent {
// Number keys 1-7 jump from ANY focus — but only when no field editor is
// Number keys 1-8 jump from ANY focus — but only when no field editor is
// capturing input (a port/token/name field must receive its digits).
if !editing {
if let KeyCode::Char(c @ '1'..='7') = code {
if let KeyCode::Char(c @ '1'..='8') = code {
return NavIntent::JumpSection(c as usize - '1' as usize);
}
}
@@ -229,6 +235,12 @@ pub enum ScreenAction {
WizardTestPoll,
/// Esc mid-wizard — open the confirm-abandon modal.
WizardAbandon,
// ---- Scrobbler (CONSOLE ext) ----
/// Suspend the alt-screen and run the Last.fm connect flow on the plain
/// terminal (same methodology as the browser login).
ScrobbleConnectLastfm,
/// Suspend the alt-screen and run the ListenBrainz token connect flow.
ScrobbleConnectListenbrainz,
}
/// Read-only context passed to every screen's `draw` (the live status body for
@@ -243,6 +255,10 @@ pub enum LoopCmd {
/// Suspend the alt-screen and run the T5 browser-login engine on the plain
/// terminal, then resume (see the task report for this deliberate divergence).
BrowserLogin,
/// Suspend the alt-screen and run the Last.fm scrobbler connect flow.
ScrobbleLastfm,
/// Suspend the alt-screen and run the ListenBrainz scrobbler connect flow.
ScrobbleListenbrainz,
}
// ============================ worker messages ============================
@@ -275,6 +291,7 @@ enum Active {
Network(NetworkState),
Bundle(BundleState),
Wizard(WizardState),
Scrobbler(ScrobblerState),
}
enum Overlay {
@@ -449,6 +466,7 @@ impl App {
}
Screen::Bundle => Active::Bundle(BundleState::new(desktop_profile_present())),
Screen::Wizard => Active::Wizard(WizardState::new()),
Screen::Scrobbler => Active::Scrobbler(ScrobblerState::new(&self.roots)),
};
}
@@ -520,6 +538,7 @@ impl App {
Active::Network(s) => s.is_editing(),
Active::Bundle(s) => s.is_editing(),
Active::Wizard(s) => s.is_editing(),
Active::Scrobbler(s) => s.is_editing(),
}
}
@@ -534,6 +553,7 @@ impl App {
Active::Network(s) => s.editing_label(),
Active::Bundle(s) => s.editing_label(),
Active::Wizard(s) => s.editing_label(),
Active::Scrobbler(s) => s.editing_label(),
}
}
@@ -647,6 +667,7 @@ impl App {
Active::Network(s) => s.handle_key(key),
Active::Bundle(s) => s.handle_key(key),
Active::Wizard(s) => s.handle_key(key),
Active::Scrobbler(s) => s.handle_key(key),
}
}
@@ -715,6 +736,8 @@ impl App {
self.overlay = Overlay::ConfirmAbandon;
LoopCmd::None
}
ScreenAction::ScrobbleConnectLastfm => LoopCmd::ScrobbleLastfm,
ScreenAction::ScrobbleConnectListenbrainz => LoopCmd::ScrobbleListenbrainz,
}
}
@@ -1103,6 +1126,14 @@ impl App {
&self.roots
}
/// Reload the Scrobbler screen's settings snapshot after a connect flow ran
/// on the suspended plain terminal (the CLI auth wrote the store directly).
pub fn refresh_scrobbler(&mut self) {
if matches!(self.active_section, Screen::Scrobbler) {
self.active = Active::Scrobbler(ScrobblerState::new(&self.roots));
}
}
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame) {
@@ -1161,6 +1192,7 @@ impl App {
Active::Network(sc) => sc.draw(f, inner, &ctx),
Active::Bundle(sc) => sc.draw(f, inner, &ctx),
Active::Wizard(sc) => sc.draw(f, inner, &ctx),
Active::Scrobbler(sc) => sc.draw(f, inner, &ctx),
}
self.draw_footer(f, rows[3]);
@@ -1309,6 +1341,7 @@ impl App {
}
}
Active::Wizard(sc) => sc.help_text(),
Active::Scrobbler(_) => s::HELP_SCROBBLER,
_ => {
if self.active_is_dirty() {
s::HELP_CONTENT_DIRTY
@@ -1754,6 +1787,11 @@ mod tests {
Screen::Network => Active::Network(NetworkState::new(&QbzdConfig::default(), Vec::new())),
Screen::Bundle => Active::Bundle(BundleState::new(false)),
Screen::Wizard => Active::Wizard(WizardState::new()),
Screen::Scrobbler => Active::Scrobbler(ScrobblerState::new(&ProfileRoots {
config: PathBuf::from("/nonexistent"),
data: PathBuf::from("/nonexistent"),
cache: PathBuf::from("/nonexistent"),
})),
};
App {
roots: ProfileRoots {
+55 -1
View File
@@ -18,7 +18,7 @@ pub mod wizard_core;
mod screens;
use std::io::IsTerminal;
use std::io::{IsTerminal, Write};
use std::time::Duration;
use ratatui::crossterm::event::{self, Event, KeyEventKind};
@@ -71,6 +71,12 @@ fn event_loop(terminal: &mut DefaultTerminal, app: &mut App, handle: &Handle) ->
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match app.on_key(key) {
LoopCmd::None => {}
LoopCmd::BrowserLogin => run_browser_login(terminal, app, handle),
LoopCmd::ScrobbleLastfm => {
run_scrobble_login(terminal, app, handle, ScrobbleProvider::Lastfm)
}
LoopCmd::ScrobbleListenbrainz => {
run_scrobble_login(terminal, app, handle, ScrobbleProvider::Listenbrainz)
}
},
Ok(_) => {} // resize/mouse/etc. — the next draw re-lays-out (§5.4)
Err(_) => return 1,
@@ -98,3 +104,51 @@ fn run_browser_login(terminal: &mut DefaultTerminal, app: &mut App, handle: &Han
.map_err(|e| e.to_string());
app.after_browser_login(mapped);
}
/// Which scrobbler provider a suspended connect flow targets.
enum ScrobbleProvider {
Lastfm,
Listenbrainz,
}
/// Suspend the alt-screen and run the scrobbler connect flow on the plain
/// terminal — the SAME methodology as the browser login. Last.fm prints an
/// authorize URL and waits for Enter (the CLI verb owns that); ListenBrainz
/// prompts for a pasted user token here, then hands it to the CLI verb. Both
/// write the canonical ScrobblerSettingsStore, so the screen is reloaded on
/// resume.
fn run_scrobble_login(
terminal: &mut DefaultTerminal,
app: &mut App,
handle: &Handle,
provider: ScrobbleProvider,
) {
ratatui::restore();
let roots = app.roots().clone();
match provider {
ScrobbleProvider::Lastfm => {
println!("{}", strings::SCROBBLE_LASTFM_HANDOFF);
let _ = handle.block_on(async { crate::cli::scrobble::login_lastfm(None, &roots).await });
}
ScrobbleProvider::Listenbrainz => {
println!("{}", strings::SCROBBLE_LISTENBRAINZ_HANDOFF);
print!("token: ");
let _ = std::io::stdout().flush();
let mut token = String::new();
if std::io::stdin().read_line(&mut token).is_ok() {
let token = token.trim().to_string();
if token.is_empty() {
println!("no token entered — skipped.");
} else {
let _ = handle
.block_on(async { crate::cli::scrobble::login_listenbrainz(None, token, &roots).await });
}
}
}
}
println!("{}", strings::SCROBBLE_RETURN_HINT);
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
*terminal = ratatui::init();
app.refresh_scrobbler();
}
+4 -2
View File
@@ -1,9 +1,11 @@
// crates/qbzd/src/tui/screens/mod.rs — the seven setup screens (FB4 added the
// HiFi Wizard over the old six-screen D7 cap, owner-sanctioned).
// crates/qbzd/src/tui/screens/mod.rs — the eight setup screens (FB4 added the
// HiFi Wizard over the old six-screen D7 cap; the CONSOLE ext added Scrobbler —
// both owner-sanctioned).
pub mod account;
pub mod audio;
pub mod bundle;
pub mod network;
pub mod playback;
pub mod qconnect;
pub mod scrobbler;
pub mod wizard;
+87
View File
@@ -0,0 +1,87 @@
// crates/qbzd/src/tui/screens/scrobbler.rs — the Scrobbler setup screen
// (CONSOLE ext, owner-sanctioned 8th section). Shows Last.fm / ListenBrainz
// connection state and runs the connect flows with the SAME methodology as the
// Account login: the App suspends the alt-screen and runs the CLI auth flow on
// the plain terminal (Last.fm prints an authorize URL; ListenBrainz prompts for
// a pasted token), then resumes. No inline save — the auth flows write the
// canonical ScrobblerSettingsStore directly; enable/disable is a CLI verb.
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsStore};
use crate::paths::ProfileRoots;
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::theme;
pub struct ScrobblerState {
settings: ScrobblerSettings,
}
impl ScrobblerState {
pub fn new(roots: &ProfileRoots) -> Self {
let settings = ScrobblerSettingsStore::new_at(&roots.data)
.and_then(|s| s.get_settings())
.unwrap_or_default();
Self { settings }
}
/// This screen has no inline field editor (its actions suspend to the plain
/// terminal), so it never captures the keyboard for editing.
pub fn is_editing(&self) -> bool {
false
}
pub fn editing_label(&self) -> Option<&'static str> {
None
}
pub fn handle_key(&mut self, key: KeyEvent) -> ScreenAction {
match key.code {
KeyCode::Char('l') | KeyCode::Char('L') => ScreenAction::ScrobbleConnectLastfm,
KeyCode::Char('b') | KeyCode::Char('B') => ScreenAction::ScrobbleConnectListenbrainz,
KeyCode::Esc => ScreenAction::Back,
_ => ScreenAction::Consumed,
}
}
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let s = &self.settings;
let lines: Vec<Line> = vec![
Line::from(Span::styled("Scrobbling", theme::accent_bold())),
Line::from(""),
provider_line("Last.fm", s.lastfm_is_authed(), s.lastfm_active(), &s.lastfm_username),
provider_line(
"ListenBrainz",
s.listenbrainz_is_authed(),
s.listenbrainz_active(),
&s.listenbrainz_username,
),
Line::from(""),
Line::from(Span::styled(
" L connect Last.fm B connect ListenBrainz",
theme::dim(),
)),
Line::from(Span::styled(
" enable/disable: qbzd scrobble enable|disable <provider>",
theme::dim(),
)),
];
f.render_widget(Paragraph::new(lines), area);
}
}
fn provider_line(name: &str, authed: bool, active: bool, user: &str) -> Line<'static> {
let status = match (authed, active) {
(false, _) => "not connected".to_string(),
(true, true) => format!("on · {user}"),
(true, false) => format!("off · connected as {user}"),
};
Line::from(vec![
Span::styled(format!(" {name:<14}"), theme::accent()),
Span::raw(status),
])
}
+19 -4
View File
@@ -32,7 +32,7 @@ pub const BREADCRUMB_ROOT: &str = "Setup";
/// so a trailing `*` fits the 14-col sidebar; Account/Import/Wizard are never
/// dirty. Seven since FB4 added the HiFi Wizard (owner-sanctioned cap break over
/// the old six-screen D7 cap).
pub const SIDEBAR_LABELS: [&str; 7] = [
pub const SIDEBAR_LABELS: [&str; 8] = [
"Account",
"Audio",
"Playback",
@@ -40,11 +40,12 @@ pub const SIDEBAR_LABELS: [&str; 7] = [
"Network",
"Import/Exp",
"Wizard",
"Scrobbler",
];
/// Left-nav labels, WIDE tier (≥ 100 cols → the 28-col sidebar, FB5). The extra
/// room lets `Import / Export` spell itself out; everything else is already whole.
pub const SIDEBAR_LABELS_WIDE: [&str; 7] = [
pub const SIDEBAR_LABELS_WIDE: [&str; 8] = [
"Account",
"Audio",
"Playback",
@@ -52,12 +53,13 @@ pub const SIDEBAR_LABELS_WIDE: [&str; 7] = [
"Network",
"Import / Export",
"Wizard",
"Scrobbler",
];
/// A terse, static one-line summary shown dim UNDER each name in the wide sidebar
/// only (FB5). Static (not live device state) — it names what the section is for
/// so the roomy sidebar reads intentionally; it never needs the section loaded.
pub const SIDEBAR_SUMMARIES: [&str; 7] = [
pub const SIDEBAR_SUMMARIES: [&str; 8] = [
"sign in",
"output · bit-perfect",
"quality · behavior",
@@ -65,10 +67,11 @@ pub const SIDEBAR_SUMMARIES: [&str; 7] = [
"http control",
"settings bundle",
"DAC setup",
"last.fm · listenbrainz",
];
// Global help-bar hints (context-sensitive; assembled per focus + screen).
pub const HELP_NAV: &str = "up/down move · Enter open · 1-7 jump · Tab content · ? help · q quit";
pub const HELP_NAV: &str = "up/down move · Enter open · 1-8 jump · Tab content · ? help · q quit";
pub const HELP_CONTENT_CLEAN: &str = "up/down move · Enter edit · Tab nav · Esc nav · ? help · q quit";
pub const HELP_CONTENT_DIRTY: &str = "up/down move · Enter edit · s SAVE* · Tab nav · Esc nav · q quit";
pub const HELP_AUDIO_CLEAN: &str =
@@ -419,3 +422,15 @@ pub fn wiz_done_summary(dacs: usize) -> String {
pub const WIZ_ABANDON_TITLE: &str = "Quit the wizard?";
pub const WIZ_ABANDON_BODY: &str = "Your selections and generated config will be discarded.";
pub const WIZ_ABANDON_HINT: &str = "y quit · Esc stay";
// ============================ Scrobbler (CONSOLE ext) ============================
pub const SCROBBLER_TITLE: &str = "Scrobbler";
pub const HELP_SCROBBLER: &str = "L connect Last.fm · B connect ListenBrainz · Tab nav · Esc nav · q quit";
// Alt-screen handoffs — printed on the plain terminal before the CLI auth flow
// runs (same methodology as the Account browser login).
pub const SCROBBLE_LASTFM_HANDOFF: &str =
"Connecting Last.fm — a browser authorize step follows below.\n";
pub const SCROBBLE_LISTENBRAINZ_HANDOFF: &str =
"Connecting ListenBrainz — paste your user token below.\n";
pub const SCROBBLE_RETURN_HINT: &str = "\nPress Enter to return to setup…";