Compare commits

..

5 Commits

Author SHA1 Message Date
Test User 87b98193d8 docs: audit the book and the READMEs, and give Seek its role
Sweep of docs/ and every README against the code, after several features
landed since the last one.

Mechanical checks, which find what reading does not: SUMMARY covers every
page and no more; every relative link resolves (docs/src/providers.md
pointed a directory above the book, twice); every #anchor matches a real
heading; every provider crate's Settings field is documented in both its
README and its book page; the feature table matches Cargo.toml, which it
did not -- `rss` was missing and "seven provider features" is now eight.

Prose that predated recent work: the root README's client-config sample
knew only `spectrum` and still claimed every option has a flag; its
spectrum and web-client sections predated the colors, segments, shadows,
pane tabs, register and seek; cbd-web/README.md likewise; intro.md's
provider tree was missing /rss; clients.md omitted volume and mute from
what the update stream carries; rssdy/README.md did not mention that only
audio enclosures become episodes.

The audit also found a defect the docs were right about: `Seek` was never
added to `minimum_role`, so it fell through to the owner-only default
while auth.md and architecture/roles-auth.md both promise a queue-owner
may control playback. With auth configured a queue-owner could play, skip
and change the volume, but got PermissionDenied on `,`/`.`. Seek now sits
with the other playback verbs.

The test meant to prevent that -- "a new RPC must be added to exactly one
list" -- compared the role lists against a hardcoded 24, so a 25th method
kept the suite green. It now reads the method names out of crabidy.proto
and compares sets: a count copied out of a file is not a check against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:19:59 +02:00
Test User d8bd06219c docs: correct why the spectrum seams stay fixed and the bars vary
The note gave the reasoning for the opposite of what the code does: it
holds the seams at bar_gap and lets the bar widths absorb a pane that
does not divide evenly, not the other way round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:35:15 +02:00
Test User d25620c6a9 cbd-tui: give the spectrum segments, seams and filled shadows
Follow-up on the shading and peaks, plus the knobs to tune them.

The gradient now ends at a configured `spectrum_top_color` -- the secondary
purple -- rather than blending toward white, so the top row *is* the top
color. "none" ramps brightness alone.

A peak now fills the gap between the bar and its held maximum instead of
marking it with a rule, so each bar trails a shadow of where it just was.
`spectrum_peak_fill = false` keeps the rule, which is also the fallback
for a font without U+2594.

The dividers make the segments legible without breaking the field apart:
the seam between two bars is the bar itself at 35% brightness rather than
an empty column, and `spectrum_row_gap` eighths are left unlit at the top
of every cell so a full row draws `▇` and each value row reads on its own.
`spectrum_bar_width` is a minimum -- bars take the spare columns of their
slot and seams stay exactly `spectrum_bar_gap` wide, because an uneven bar
reads as an uneven level while an uneven seam is only untidy. Keeping the
seam inside the bar's own cells means all 24 bins still fit a normal pane.

Peaks fall in seconds now, not frames: `update_spectrum` times the interval
between frames and `spectrum_peak_fall` says how long a full-scale shadow
takes to reach the floor (4s, up from an effective 1.7s). The server's frame
rate is its own business, and a per-frame decay silently retunes itself when
it changes. `advance_spectrum` takes the interval so the fall is testable
without a clock, and the renderer floors the divisor itself rather than
trusting the config clamp -- a zero would freeze every shadow on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:27:28 +02:00
Test User d5b35a4da2 cbd-tui: shade the spectrum bars and hold their peaks
Modelled on BeSpec's LED mode. Bars are now shaded by height rather than
drawn in one flat color, and a peak-hold marker rides above each column.

Shading is a function of the row alone, so it stays cheap: the color is
computed once per row and the row's cells still coalesce into a couple of
spans. `gradient_color` ramps the configured base from 55% brightness at
the floor to 30% toward white at the top, so a loud bar reads as hot and
not merely tall. Only an `Rgb` base can be interpolated -- a color name or
a palette index is a reference into the terminal's own theme, whose RGB
value is not ours to know -- so those render flat, whatever
`spectrum_gradient` says.

The peaks are per-bin state advanced in `update_spectrum`: a bin at or
above its peak raises it at once, otherwise the peak falls 0.03 a frame,
about 1.7s from full scale at the server's 20 fps. The server keeps
streaming zeroed frames while the audio is idle, so the markers fall away
on pause instead of freezing on screen. A marker is drawn only in a row
the bar does not reach: a cell holds one glyph, so one inside the bar's
own top cell would eat the bar to repeat what its top edge already shows.

Config gains `spectrum_gradient` (default true) and `spectrum_peak_color`
(default the primary blue, `"none"`/`"off"` to draw none), resolved with
`spectrum_color` into one `SpectrumStyle` in place of the old
`set_spectrum_color`. Existing config files take the defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:08:43 +02:00
Test User 430c87451b tui: make the spectrum color configurable
The bars were hardcoded to `COLOR_PRIMARY`, the same muted blue as the pane
borders, so the one moving thing on screen read as chrome.

`spectrum_color` in the client config now picks it, parsed by ratatui's
`Color: FromStr`: a `#rrggbb` triple, a color name, or a 0-255 palette index,
the latter two deferring to the terminal theme. It defaults to `COLOR_RED` —
the red the queue marks the playing track with — so the two agree on what
"now" looks like.

The default lives twice, as `COLOR_RED` for the renderer and `COLOR_RED_HEX`
for the config file, one being a `Color` and the other a string a user edits.
`the_default_spectrum_color_is_the_queue_red` stops them drifting.

A config file is user input, so an unparsable value warns on stderr and falls
back to the red instead of failing. It resolves in `run`, before the alternate
screen is entered, or the warning would be drawn over and lost.

Existing config files predate the key: they keep working and take the default,
since every field of the `ClapSerde` opt struct is optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:43:21 +02:00
16 changed files with 1557 additions and 73 deletions

View File

@ -137,13 +137,26 @@ password = ""
# Show the frequency-spectrum bars under the track progress. Default true. # Show the frequency-spectrum bars under the track progress. Default true.
spectrum = true spectrum = true
# How those bars look. Defaults shown; a color is a "#rrggbb" triple, a
# color name, or a 0-255 palette index, and an unusable value warns and
# falls back. See docs/src/clients/tui.md for what each one does.
spectrum_color = "#bf616a" # the bars (the queue's playing-track red)
spectrum_gradient = true # shade them by height
spectrum_top_color = "#b48ead" # what the shading reaches at the top
spectrum_peak_color = "#81a1c1" # peak-hold shadows; "none" draws none
spectrum_peak_fill = true # false: a thin rule at the peak instead
spectrum_peak_fall = 4.0 # seconds for a full-scale shadow to fall
spectrum_bar_width = 1 # least bar width, in cells
spectrum_bar_gap = 1 # seam dividing two bars, in cells
spectrum_row_gap = 1 # line dividing two rows, in eighths
``` ```
Every option is also available as a command-line flag before the Every option except the `spectrum_*` appearance settings is also available
subcommand (`cbd-tui --address ... --user owner`, `cbd --spectrum as a command-line flag before the subcommand (`cbd-tui --address ... --user
false`); a flag overrides the file value. To write the credentials into owner`, `cbd --spectrum false`); a flag overrides the file value. To write
the config once, use the `auth` subcommand (see below) instead of the credentials into the config once, use the `auth` subcommand (see below)
editing the file by hand: instead of editing the file by hand:
```sh ```sh
cbd-tui auth owner 'my-password' # sets user + password cbd-tui auth owner 'my-password' # sets user + password
@ -361,15 +374,32 @@ Toggle it at runtime with `f`, or set the startup default with
`spectrum = false` in the client config. Servers built without the `spectrum = false` in the client config. Servers built without the
`spectrum` feature simply never send bars. `spectrum` feature simply never send bars.
The bars are shaded by height — red at the floor reaching purple at the top
— and divided into segments by a dim seam between bars and a thin line
between value rows. Each bar trails a blue peak-hold shadow marking where it
lately reached, falling away over a few seconds. Every part of that is
configurable, including turning it all off; see
[docs/src/clients/tui.md](docs/src/clients/tui.md).
`,` and `.` seek 15 seconds inside the playing track; `<` and `>` (or
`Ctrl-p`/`Ctrl-n`) skip a whole track. `K`/`J` change the volume and `m`
mutes; the now-playing pane shows the server's level, which tops out at
110%.
## Web client ## Web client
`crabidy-server` serves a browser client with the same functionality as `crabidy-server` serves a browser client with the same functionality as
the TUI at its own address (`http://<server>:50051/`) — same navigation, the TUI at its own address (`http://<server>:50051/`) — same navigation,
same keys (`j`/`k`/`h`/`l`, `%`, `e`, `d`, `w`, `W`, queue and playback same keys (`j`/`k`/`h`/`l`, `%`, `e`, `d`, `w`, `W`, marks and visual mode,
controls, `?` for help), plus clickable equivalents and a light/dark the `y`/`d`/`p` register, `,`/`.` to seek, queue and playback controls, `?`
theme toggle. It talks gRPC-web to the same service the TUI uses, so it for help), plus clickable equivalents for all of it: the progress bar seeks
honors the same `[auth]` roles (it shows a login form when the server where you click, and `library`/`queue` tabs in the top bar switch panes
requires credentials). without a keyboard. On a phone the two panes cannot sit side by side, so
only the focused one is shown and those tabs are the way between them.
There is a light/dark theme toggle. It talks gRPC-web to the same service
the TUI uses, so it honors the same `[auth]` roles (it shows a login form
when the server requires credentials). The `/` live filter is TUI-only for
now.
It is compiled to a WASM bundle and embedded into the server binary, It is compiled to a WASM bundle and embedded into the server binary,
behind the default-on `web-ui` cargo feature. A plain `cargo build` behind the default-on `web-ui` cargo feature. A plain `cargo build`

View File

@ -25,6 +25,7 @@ pub use list::{Filter, StatefulList};
use bindings::Action; use bindings::Action;
use library::Library; use library::Library;
use now_playing::NowPlaying; use now_playing::NowPlaying;
pub use now_playing::SpectrumStyle;
use queue::Queue; use queue::Queue;
pub use register::Register; pub use register::Register;
@ -65,10 +66,22 @@ pub(crate) struct UiItem {
} }
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
/// [`COLOR_PRIMARY`] as a config-file string — the default
/// `spectrum_peak_color`. The pair is checked by
/// `the_default_peak_color_is_the_primary_blue`.
pub const COLOR_PRIMARY_HEX: &str = "#81a1c1";
// const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172); // const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172);
pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82);
pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173);
/// [`COLOR_SECONDARY`] as a config-file string — the default
/// `spectrum_top_color`. The pair is checked by
/// `the_default_top_color_is_the_secondary_purple`.
pub const COLOR_SECONDARY_HEX: &str = "#b48ead";
pub const COLOR_RED: Color = Color::Rgb(191, 97, 106); pub const COLOR_RED: Color = Color::Rgb(191, 97, 106);
/// [`COLOR_RED`] as a config-file string — the default `spectrum_color`,
/// so the bars match the playing queue row out of the box. The pair is
/// checked by `the_default_spectrum_color_is_the_queue_red`.
pub const COLOR_RED_HEX: &str = "#bf616a";
pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140); pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
// const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112); // const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112);
// const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233); // const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233);

View File

@ -1,4 +1,7 @@
use std::{ops::Div, time::Duration}; use std::{
ops::Div,
time::{Duration, Instant},
};
#[cfg(feature = "notifications")] #[cfg(feature = "notifications")]
use notify_rust::Notification; use notify_rust::Notification;
@ -13,34 +16,299 @@ use ratatui::{
Frame, Frame,
}; };
use super::{COLOR_PRIMARY, COLOR_SECONDARY}; use super::{COLOR_PRIMARY, COLOR_RED, COLOR_SECONDARY};
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell. /// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
/// Renders `bins` as full-height vertical bars filling a `width`×`height` /// The unfilled peak-hold shadow: a thin rule at the peak, the width of a
/// area: one `Line` per row, top row first. Each column maps to a bin; /// cell (U+2594 upper one-eighth block).
/// its level in `[0, 1]` fills from the bottom, using partial block const PEAK_MARK: char = '▔';
/// glyphs for the topmost fractional cell. Pure, so it is unit-tested.
fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static>> { /// Brightness of the bottom gradient row, as a fraction of the configured
/// color. Dim enough to read as a floor, light enough to stay visible on a
/// dark background.
const GRADIENT_FLOOR: f32 = 0.55;
/// Brightness of a divider column, as a fraction of the bar it belongs to.
/// The divider carries its bar's own shape at this shade, so the bars stay
/// one connected field with a seam between them rather than becoming
/// separate sticks with holes in between.
const DIVIDER_DIM: f32 = 0.35;
/// Shortest `peak_fall` that still means "falls": below this the shadows
/// would be gone before the next frame, which is what
/// `spectrum_peak_color = "none"` is for.
const MIN_PEAK_FALL: f32 = 0.05;
/// How the spectrum is painted, resolved from the client config once at
/// startup (see `config::spectrum_style`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpectrumStyle {
/// Bar color: the gradient's floor, and the whole bar without one.
pub color: Color,
/// Color the gradient reaches at the top of the area, or `None` to
/// ramp brightness alone.
pub top: Option<Color>,
/// Shade the bars by height instead of one flat color.
pub gradient: bool,
/// Peak-hold shadow color, or `None` to draw no shadows.
pub peak: Option<Color>,
/// Fill the shadow from the bar up to its peak, rather than drawing a
/// thin rule at the peak alone.
pub peak_fill: bool,
/// Seconds a full-scale peak takes to fall to the floor.
pub peak_fall: f32,
/// Least bar width in cells; a bar takes any spare columns of its slot
/// beyond this, so the field stays connected.
pub bar_width: usize,
/// Width of the seam dividing two bars, in cells.
pub bar_gap: usize,
/// Height of the line dividing two value rows, in eighths of a row.
/// 0 stacks the rows into a solid bar.
pub row_gap: usize,
}
impl Default for SpectrumStyle {
fn default() -> Self {
SpectrumStyle {
color: COLOR_RED,
top: Some(COLOR_SECONDARY),
gradient: true,
peak: Some(COLOR_PRIMARY),
peak_fill: true,
peak_fall: 4.0,
bar_width: 1,
bar_gap: 1,
row_gap: 1,
}
}
}
/// A bin level off the wire, clamped into `[0, 1]`. The bins are `float`s
/// from a peer, so NaN and infinity are possible; both read as silence
/// rather than propagating into the glyph arithmetic.
fn level_of(raw: f32) -> f32 {
if raw.is_finite() {
raw.clamp(0.0, 1.0)
} else {
0.0
}
}
/// What one column of the spectrum area shows: a bar's level and the peak
/// held above it, drawn either as the bar itself or as the dim seam that
/// divides it from its neighbour.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Column {
level: f32,
peak: f32,
/// This column is the seam at the right of its bar.
divider: bool,
}
/// Assigns every column of the area to a bar, marking those that are its
/// divider.
///
/// A bar fills its slot except for the last `style.bar_gap` columns, and is
/// never narrower than `style.bar_width`, so spare columns widen the bars
/// rather than the seams. There is one bar per bin unless the area is too
/// narrow to hold that many — then a bar covers a group of neighbouring bins
/// and takes the loudest of them, because a spectrum's news is its peaks and
/// an average would flatten them.
fn spectrum_columns(
bins: &[f32],
peaks: &[f32],
width: usize,
style: SpectrumStyle,
) -> Vec<Column> {
let bar_width = style.bar_width.max(1);
let pitch = bar_width + style.bar_gap;
// Never more bars than bins (they would repeat a neighbour), never
// fewer than one (a very narrow pane still shows something).
let bars = (width / pitch.max(1)).clamp(1, bins.len().max(1));
// Loudest level and peak over a bar's share of the bins.
let loudest = |slice: &[f32]| slice.iter().copied().map(level_of).fold(0.0, f32::max);
// The first column of bar `n`, so that it inverts the column-to-bar
// mapping below exactly: rounding the two the same way instead would
// put a column outside the slot it belongs to.
let first_col = |bar: usize| (bar * width).div_ceil(bars);
(0..width)
.map(|col| {
let bar = (col * bars / width.max(1)).min(bars - 1);
let start = first_col(bar);
let span = first_col(bar + 1).min(width) - start;
// The bar leads its slot and the seam trails it. A slot with no
// room for both keeps the bar: a seam that leaves no bar shows
// nothing at all.
let bar_cells = bar_width.max(span.saturating_sub(style.bar_gap)).min(span);
let lo = bar * bins.len() / bars;
let hi = ((bar + 1) * bins.len() / bars).max(lo + 1).min(bins.len());
Column {
level: loudest(bins.get(lo..hi).unwrap_or_default()),
peak: loudest(peaks.get(lo..hi).unwrap_or_default()),
divider: col - start >= bar_cells,
}
})
.collect()
}
/// Eighths of the cell in row `from_bottom` that a bar at `level` fills: 0
/// for a row the bar does not reach, and at most `8 - row_gap` for one it
/// fills completely — the unfilled eighths at the top of the cell are the
/// line dividing this value row from the one above.
fn cell_eighths(level: f32, height: usize, from_bottom: usize, row_gap: usize) -> usize {
let eighths = (level * height as f32 * 8.0).round() as usize;
eighths
.saturating_sub(from_bottom * 8)
.min(8usize.saturating_sub(row_gap))
}
/// The glyph a peak shadow puts in row `from_bottom`, if any: filled from
/// the row above the bar up to the peak, or — unfilled — a thin rule in the
/// single row the peak reaches.
fn shadow_glyph(
peak: f32,
height: usize,
from_bottom: usize,
style: SpectrumStyle,
) -> Option<char> {
let eighths = (level_of(peak) * height as f32 * 8.0).round() as usize;
if eighths == 0 {
return None;
}
if style.peak_fill {
match cell_eighths(peak, height, from_bottom, style.row_gap) {
0 => None,
cell => Some(BLOCKS[cell]),
}
} else if (eighths - 1) / 8 == from_bottom {
Some(PEAK_MARK)
} else {
None
}
}
/// `color` at `factor` of its brightness, for the seam between two bars.
///
/// Only an `Rgb` color can be dimmed — a name or a palette index is the
/// terminal's to resolve — so those return `None` and the seam falls back to
/// an empty column, the one divider that needs no color.
fn dimmed(color: Color, factor: f32) -> Option<Color> {
let Color::Rgb(r, g, b) = color else {
return None;
};
let dim = |channel: u8| (f32::from(channel) * factor).round().clamp(0.0, 255.0) as u8;
Some(Color::Rgb(dim(r), dim(g), dim(b)))
}
/// The color of the bar row `from_bottom` rows up in a `height`-row area:
/// the base dimmed to [`GRADIENT_FLOOR`] at the bottom, interpolated to
/// `top` (or the undimmed base, without one) at the very top.
///
/// Only `Rgb` colors can be interpolated. A color name or a palette index
/// is a reference into the terminal's own theme — its RGB value is not ours
/// to know — so an `Rgb` base with a named `top` ramps brightness alone,
/// and a named base renders flat.
fn gradient_color(base: Color, top: Option<Color>, from_bottom: usize, height: usize) -> Color {
let Color::Rgb(base_r, base_g, base_b) = base else {
return base;
};
let (top_r, top_g, top_b) = match top {
Some(Color::Rgb(r, g, b)) => (r, g, b),
_ => (base_r, base_g, base_b),
};
let t = if height <= 1 {
1.0
} else {
from_bottom as f32 / (height - 1) as f32
};
let ramp = |base: u8, top: u8| {
let low = f32::from(base) * GRADIENT_FLOOR;
let high = f32::from(top);
(low + (high - low) * t).round().clamp(0.0, 255.0) as u8
};
Color::Rgb(
ramp(base_r, top_r),
ramp(base_g, top_g),
ramp(base_b, top_b),
)
}
/// Renders `bins` as vertical bars filling a `width`×`height` area: one
/// `Line` per row, top row first. A bar's level in `[0, 1]` fills from the
/// bottom, using partial block glyphs for the topmost fractional cell.
/// Pure, so it is unit-tested.
///
/// `peaks` holds the decaying per-bin maxima (see
/// [`NowPlaying::update_spectrum`]). Where a bar has fallen below its peak
/// the gap between the two is filled in the peak color, so each bar trails
/// a shadow of where it just was.
///
/// The bars form one field: neighbours are told apart by a dimmed seam
/// column, and value rows by leaving the top `style.row_gap` eighths of each
/// cell unlit.
fn spectrum_lines(
bins: &[f32],
peaks: &[f32],
width: usize,
height: usize,
style: SpectrumStyle,
) -> Vec<Line<'static>> {
let columns = spectrum_columns(bins, peaks, width, style);
(0..height) (0..height)
.map(|row| { .map(|row| {
// Row 0 is the top; count cells up from the bottom. // Row 0 is the top; count cells up from the bottom.
let from_bottom = height - 1 - row; let from_bottom = height - 1 - row;
let cells: String = (0..width) let bar_color = if style.gradient {
.map(|col| { gradient_color(style.color, style.top, from_bottom, height)
let bin = if bins.is_empty() {
0
} else { } else {
(col * bins.len() / width.max(1)).min(bins.len() - 1) style.color
}; };
let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0); // Runs of same-colored cells, so a row is a handful of spans
let total_eighths = (level * height as f32 * 8.0).round() as usize; // rather than one per column.
let cell = total_eighths.saturating_sub(from_bottom * 8).min(8); let mut runs: Vec<(Color, String)> = Vec::new();
BLOCKS[cell] let seam_color = dimmed(bar_color, DIVIDER_DIM);
}) for column in &columns {
.collect(); // A seam shows its own bar, dimmed. Where the color cannot
Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY))) // be dimmed the seam is an empty column instead.
let (bar_color, peak_color) = match (column.divider, seam_color) {
(false, _) => (Some(bar_color), style.peak),
(true, Some(seam)) => {
(Some(seam), style.peak.and_then(|c| dimmed(c, DIVIDER_DIM)))
}
(true, None) => (None, None),
};
let (glyph, color) = match bar_color {
None => (' ', style.color),
Some(bar_color) => {
let cell = cell_eighths(column.level, height, from_bottom, style.row_gap);
// The bar itself, else its shadow reaching up to
// the held peak, else nothing. A cell holds one
// glyph, so the bar always wins its own rows: a
// shadow inside them would eat bar to repeat what
// the bar's top edge already shows.
match (cell, peak_color) {
(0, Some(peak_color)) => {
match shadow_glyph(column.peak, height, from_bottom, style) {
Some(glyph) => (glyph, peak_color),
None => (' ', bar_color),
}
}
_ => (BLOCKS[cell], bar_color),
}
}
};
match runs.last_mut() {
Some((run_color, text)) if *run_color == color => text.push(glyph),
_ => runs.push((color, glyph.to_string())),
}
}
Line::from(
runs.into_iter()
.map(|(color, text)| Span::styled(text, Style::default().fg(color)))
.collect::<Vec<_>>(),
)
}) })
.collect() .collect()
} }
@ -76,8 +344,17 @@ pub struct NowPlaying {
/// Latest frequency-spectrum bars (architecture/spectrum.md), empty /// Latest frequency-spectrum bars (architecture/spectrum.md), empty
/// until the first frame arrives. /// until the first frame arrives.
spectrum: Vec<f32>, spectrum: Vec<f32>,
/// Per-bin peak hold: the highest level each bin has reached lately,
/// falling at the configured rate. Drawn as the shadow above the bars.
spectrum_peaks: Vec<f32>,
/// When the last spectrum frame arrived, so the peaks fall in seconds
/// rather than in frames of whatever rate the server happens to send.
spectrum_frame_at: Option<Instant>,
/// Whether to draw the spectrum row (config `spectrum`, default on). /// Whether to draw the spectrum row (config `spectrum`, default on).
spectrum_enabled: bool, spectrum_enabled: bool,
/// Bar color, gradient and peak color (config `spectrum_color`,
/// `spectrum_gradient`, `spectrum_peak_color`).
spectrum_style: SpectrumStyle,
/// Whether the server output is muted. /// Whether the server output is muted.
muted: bool, muted: bool,
/// The server's output level, `1.0` = 100%. This is the level playback /// The server's output level, `1.0` = 100%. This is the level playback
@ -94,7 +371,10 @@ impl Default for NowPlaying {
position: None, position: None,
track: None, track: None,
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_peaks: Vec::new(),
spectrum_frame_at: None,
spectrum_enabled: true, spectrum_enabled: true,
spectrum_style: SpectrumStyle::default(),
muted: false, muted: false,
volume: 1.0, volume: 1.0,
} }
@ -118,14 +398,49 @@ impl NowPlaying {
pub fn update_modifiers(&mut self, mods: &QueueModifiers) { pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
self.modifiers = *mods; self.modifiers = *mods;
} }
/// Applies a spectrum frame from the server (already normalized). /// Applies a spectrum frame from the server (already normalized),
/// timing it against the previous frame so the peaks fall at the
/// configured rate whatever the server's frame rate is.
pub fn update_spectrum(&mut self, bins: Vec<f32>) { pub fn update_spectrum(&mut self, bins: Vec<f32>) {
let now = Instant::now();
let elapsed = self
.spectrum_frame_at
.replace(now)
.map_or(Duration::ZERO, |previous| {
now.saturating_duration_since(previous)
});
self.advance_spectrum(bins, elapsed);
}
/// [`NowPlaying::update_spectrum`] with the frame interval given rather
/// than measured — the testable core.
///
/// A bin at or above its peak raises it at once; otherwise the peak
/// falls by whatever share of `peak_fall` this frame took. The bin count
/// comes off the wire, so the peaks follow whatever arrives.
fn advance_spectrum(&mut self, bins: Vec<f32>, elapsed: Duration) {
let fall = elapsed.as_secs_f32() / self.spectrum_style.peak_fall.max(MIN_PEAK_FALL);
self.spectrum_peaks.resize(bins.len(), 0.0);
for (peak, level) in self.spectrum_peaks.iter_mut().zip(&bins) {
let level = level_of(*level);
*peak = if level >= *peak {
level
} else {
(*peak - fall).max(0.0)
};
}
self.spectrum = bins; self.spectrum = bins;
} }
/// Enables/disables the spectrum row (from client config). /// Enables/disables the spectrum row (from client config).
pub fn set_spectrum_enabled(&mut self, enabled: bool) { pub fn set_spectrum_enabled(&mut self, enabled: bool) {
self.spectrum_enabled = enabled; self.spectrum_enabled = enabled;
} }
/// Sets how the bars are painted (from client config). Parsing the
/// config strings is the caller's job, so an unusable value never
/// reaches here.
pub fn set_spectrum_style(&mut self, style: SpectrumStyle) {
self.spectrum_style = style;
}
/// Shows or hides the spectrum row (the `v` keybinding). The server /// Shows or hides the spectrum row (the `v` keybinding). The server
/// keeps streaming the bars; this only gates rendering. /// keeps streaming the bars; this only gates rendering.
pub fn toggle_spectrum(&mut self) { pub fn toggle_spectrum(&mut self) {
@ -277,14 +592,21 @@ impl NowPlaying {
f.render_widget(time_p, elapsed_layout[1]); f.render_widget(time_p, elapsed_layout[1]);
} }
// The spectrum: full-height accent bars filling the region left // The spectrum: full-height bars filling the region left below the
// below the progress. Columns stretch across the pane width // progress, in the configured color, under a peak-hold marker line.
// regardless of the server's bin count. // Columns stretch across the pane width regardless of the server's
// bin count.
if self.spectrum_enabled && !self.spectrum.is_empty() { if self.spectrum_enabled && !self.spectrum.is_empty() {
let area = now_playing_layout[2]; let area = now_playing_layout[2];
let (width, height) = (area.width as usize, area.height as usize); let (width, height) = (area.width as usize, area.height as usize);
if width > 0 && height > 0 { if width > 0 && height > 0 {
let lines = spectrum_lines(&self.spectrum, width, height); let lines = spectrum_lines(
&self.spectrum,
&self.spectrum_peaks,
width,
height,
self.spectrum_style,
);
f.render_widget(Paragraph::new(lines), area); f.render_widget(Paragraph::new(lines), area);
} }
} }
@ -348,12 +670,40 @@ mod tests {
is_captured: false, is_captured: false,
}), }),
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_peaks: Vec::new(),
spectrum_frame_at: None,
spectrum_enabled: true, spectrum_enabled: true,
spectrum_style: SpectrumStyle::default(),
muted: false, muted: false,
volume: 1.0, volume: 1.0,
} }
} }
/// One flat color, no markers: the glyph tests are about the bars'
/// geometry, so they pin the paint down and let the color tests own it.
fn flat() -> SpectrumStyle {
SpectrumStyle {
color: COLOR_RED,
top: None,
gradient: false,
peak: None,
peak_fill: true,
peak_fall: 4.0,
// One solid cell per bin, no seams and no row lines: the
// geometry tests are about the bars' height, and the layout
// tests own the widths and the dividers.
bar_width: 1,
bar_gap: 0,
row_gap: 0,
}
}
/// The glyph a completely filled row uses under `style` — `█` only when
/// no line divides the value rows.
fn full_cell(style: SpectrumStyle) -> char {
BLOCKS[8 - style.row_gap]
}
fn render(pane: &NowPlaying) { fn render(pane: &NowPlaying) {
let backend = TestBackend::new(60, 12); let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal"); let mut terminal = Terminal::new(backend).expect("test terminal");
@ -381,7 +731,7 @@ mod tests {
fn spectrum_lines_fill_full_height_columns() { fn spectrum_lines_fill_full_height_columns() {
// A full-level bin fills every row of its column with the full // A full-level bin fills every row of its column with the full
// block; a zero bin leaves every row blank. // block; a zero bin leaves every row blank.
let lines = spectrum_lines(&[1.0, 0.0], 2, 4); let lines = spectrum_lines(&[1.0, 0.0], &[], 2, 4, flat());
assert_eq!(lines.len(), 4, "one line per row"); assert_eq!(lines.len(), 4, "one line per row");
let text: Vec<String> = lines let text: Vec<String> = lines
.iter() .iter()
@ -395,7 +745,7 @@ mod tests {
#[test] #[test]
fn spectrum_lines_grow_from_the_bottom() { fn spectrum_lines_grow_from_the_bottom() {
// Half level over 4 rows ≈ 16 eighths → the bottom two rows fill. // Half level over 4 rows ≈ 16 eighths → the bottom two rows fill.
let lines = spectrum_lines(&[0.5], 1, 4); let lines = spectrum_lines(&[0.5], &[], 1, 4, flat());
let col: Vec<char> = lines let col: Vec<char> = lines
.iter() .iter()
.map(|l| l.spans[0].content.chars().next().unwrap()) .map(|l| l.spans[0].content.chars().next().unwrap())
@ -410,11 +760,471 @@ mod tests {
let mut pane = now_playing(10_000, 60_000); let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]); pane.update_spectrum(vec![1.0; 24]);
let rows = rendered_rows(&pane); let rows = rendered_rows(&pane);
// Full-level bars fill several rows with the full block. // Full-level bars fill several rows to the row gap.
let full_rows = rows.iter().filter(|r| r.contains('█')).count(); let filled = full_cell(SpectrumStyle::default());
let full_rows = rows.iter().filter(|r| r.contains(filled)).count();
assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}"); assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}");
} }
/// The foreground of the first full-block cell in the buffer — the
/// color the bars are actually drawn in.
fn first_bar_color(pane: &NowPlaying) -> Option<Color> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
let buffer = terminal.backend().buffer().clone();
let filled = full_cell(pane.spectrum_style).to_string();
(0..buffer.area.height)
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
.find(|&(x, y)| buffer[(x, y)].symbol() == filled)
.and_then(|(x, y)| buffer[(x, y)].style().fg)
}
/// Every distinct foreground the full-block cells are drawn in.
fn bar_colors(pane: &NowPlaying) -> Vec<Color> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
let buffer = terminal.backend().buffer().clone();
let filled = full_cell(pane.spectrum_style).to_string();
let mut colors: Vec<Color> = Vec::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
if buffer[(x, y)].symbol() != filled {
continue;
}
if let Some(fg) = buffer[(x, y)].style().fg {
if !colors.contains(&fg) {
colors.push(fg);
}
}
}
}
colors
}
#[test]
fn the_default_style_is_the_queue_red_under_a_blue_peak() {
let style = SpectrumStyle::default();
assert_eq!(style.color, super::super::COLOR_RED);
assert_eq!(style.top, Some(super::super::COLOR_SECONDARY));
assert_eq!(style.peak, Some(super::super::COLOR_PRIMARY));
assert!(style.gradient, "shaded out of the box");
assert!(style.peak_fill, "shadows filled out of the box");
assert_eq!(
(style.bar_width, style.bar_gap, style.row_gap),
(1, 1, 1),
"connected bars, a seam between them, a line between rows"
);
assert_eq!(style.peak_fall, 4.0);
}
#[test]
fn flat_bars_are_drawn_in_the_configured_color() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
pane.set_spectrum_style(SpectrumStyle {
color: Color::Rgb(1, 2, 3),
..flat()
});
assert_eq!(first_bar_color(&pane), Some(Color::Rgb(1, 2, 3)));
assert_eq!(
bar_colors(&pane),
vec![Color::Rgb(1, 2, 3)],
"gradient off: one color for the whole bar"
);
}
#[test]
fn shaded_bars_use_a_different_color_per_row() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
// Full-level bars over several rows, shaded: each row is its own
// shade of the base color.
assert!(
bar_colors(&pane).len() > 1,
"expected a gradient, got {:?}",
bar_colors(&pane)
);
}
/// Channel sum, as a stand-in for how bright a color reads.
fn brightness(color: Color) -> u32 {
match color {
Color::Rgb(r, g, b) => u32::from(r) + u32::from(g) + u32::from(b),
other => panic!("expected rgb, got {other:?}"),
}
}
#[test]
fn the_gradient_runs_dim_at_the_floor_to_bright_at_the_top() {
let base = super::super::COLOR_RED;
let bottom = gradient_color(base, None, 0, 8);
let top = gradient_color(base, None, 7, 8);
assert!(
brightness(bottom) < brightness(base),
"floor dimmer than the base: {bottom:?}"
);
// Without a top color the ramp is brightness alone, so the top row
// is the base itself rather than something hotter.
assert_eq!(top, base, "top of a topless ramp is the base");
// Monotonic in between, so the ramp reads as one gradient.
let ramp: Vec<u32> = (0..8)
.map(|r| brightness(gradient_color(base, None, r, 8)))
.collect();
assert!(ramp.windows(2).all(|w| w[0] <= w[1]), "{ramp:?}");
// A one-row area is all "top": the single row keeps the full color
// rather than being dimmed to the floor.
assert_eq!(gradient_color(base, None, 0, 1), base);
}
#[test]
fn the_top_color_is_where_the_gradient_ends() {
// Red at the floor reaching the secondary purple at the top.
let (base, top) = (super::super::COLOR_RED, super::super::COLOR_SECONDARY);
assert_eq!(gradient_color(base, Some(top), 7, 8), top);
assert!(
brightness(gradient_color(base, Some(top), 0, 8)) < brightness(base),
"the floor is still dimmed"
);
// Mid-way is neither end but lies between them on every channel.
let middle = gradient_color(base, Some(top), 4, 8);
assert_ne!(middle, base);
assert_ne!(middle, top);
assert!(brightness(middle) < brightness(top));
}
#[test]
fn a_named_or_indexed_color_is_never_shaded() {
// Their RGB belongs to the terminal theme, so there is nothing to
// interpolate — they render flat, whatever `gradient` says.
for color in [Color::LightBlue, Color::Indexed(208), Color::Reset] {
assert_eq!(gradient_color(color, None, 0, 8), color);
assert_eq!(gradient_color(color, Some(COLOR_RED), 7, 8), color);
}
// A named *top* cannot be interpolated either, so the ramp falls
// back to brightness alone rather than guessing its RGB.
let base = super::super::COLOR_RED;
assert_eq!(
gradient_color(base, Some(Color::LightBlue), 7, 8),
gradient_color(base, None, 7, 8)
);
}
/// The single column of a one-wide render, bottom row first, as
/// (glyph, color) pairs.
fn column_of(lines: &[Line<'static>]) -> Vec<(char, Option<Color>)> {
lines
.iter()
.rev()
.map(|line| {
let span = &line.spans[0];
(span.content.chars().next().expect("a cell"), span.style.fg)
})
.collect()
}
#[test]
fn a_filled_shadow_reaches_from_the_bar_up_to_the_peak() {
// Bar at an eighth of the area, peak still at full scale: the rows
// between are filled in the peak color, the bar keeps its own.
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
..flat()
};
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style));
assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor");
for (row, cell) in column.iter().enumerate().skip(1) {
assert_eq!(
*cell,
('█', Some(COLOR_PRIMARY)),
"row {row} is filled shadow"
);
}
}
#[test]
fn an_unfilled_shadow_is_a_rule_at_the_peak_alone() {
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
peak_fill: false,
..flat()
};
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style));
assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor");
assert_eq!(column[1].0, ' ', "nothing under the rule");
assert_eq!(column[2].0, ' ', "nothing under the rule");
assert_eq!(column[3], (PEAK_MARK, Some(COLOR_PRIMARY)), "the rule");
}
#[test]
fn a_shadow_inside_the_bar_is_not_drawn() {
// A cell holds one glyph: a shadow in a row the bar occupies would
// eat bar to repeat what its top edge already shows.
for peak_fill in [true, false] {
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
peak_fill,
..flat()
};
let lines = spectrum_lines(&[1.0], &[1.0], 1, 4, style);
assert!(
lines
.iter()
.all(|l| l.spans.iter().all(|s| s.style.fg == Some(COLOR_RED))),
"a peak at the bar top must not overwrite it (fill: {peak_fill})"
);
}
}
#[test]
fn shadows_are_left_out_when_switched_off() {
// `spectrum_peak_color = "none"`: the rows above the bar stay empty.
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, flat()));
assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor");
assert!(
column[1..].iter().all(|(glyph, _)| *glyph == ' '),
"no shadow: {column:?}"
);
}
/// A single rendered row as (glyph, color) per column.
fn row_of(lines: &[Line<'static>], row: usize) -> Vec<(char, Option<Color>)> {
lines[row]
.spans
.iter()
.flat_map(|span| span.content.chars().map(move |c| (c, span.style.fg)))
.collect()
}
#[test]
fn a_seam_divides_the_bars_without_separating_them() {
// Four bins across twelve columns: each bar takes its slot but its
// last column, which carries the same glyph dimmed. The field stays
// connected — no column is empty where a bar stands.
let style = SpectrumStyle {
bar_gap: 1,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 4], &[], 12, 1, style), 0);
assert!(row.iter().all(|(glyph, _)| *glyph == '█'), "{row:?}");
let colors: Vec<Option<Color>> = row.iter().map(|(_, color)| *color).collect();
assert_eq!(
colors,
vec![
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
]
);
}
#[test]
fn bars_take_the_spare_columns_rather_than_the_seams() {
// `bar_width` is a floor, not a width: two bins over nine columns
// give bars of three with a one-cell seam, not bars of one.
let style = SpectrumStyle {
bar_width: 1,
bar_gap: 1,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 2], &[], 8, 1, style), 0);
let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count();
assert_eq!(seams, 2, "one seam per bar: {row:?}");
}
#[test]
fn a_wider_seam_takes_more_columns() {
let style = SpectrumStyle {
bar_gap: 3,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 3], &[], 12, 1, style), 0);
let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count();
assert_eq!(seams, 9, "three cells of seam per bar: {row:?}");
}
#[test]
fn bars_never_outnumber_the_bins() {
// A wide pane and few bins: bars would otherwise repeat their
// neighbours, which reads as data that is not there.
let style = SpectrumStyle {
bar_gap: 1,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 3], &[], 40, 1, style), 0);
let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count();
assert_eq!(seams, 3, "three bars, three seams: {row:?}");
}
#[test]
fn a_bar_too_wide_for_its_slot_keeps_its_cell() {
// Bars wider than the pane can give them: every column is bar, and
// the seam goes rather than the bar.
let style = SpectrumStyle {
bar_width: 40,
bar_gap: 8,
..flat()
};
let row = row_of(&spectrum_lines(&[1.0, 0.0], &[], 4, 1, style), 0);
assert_eq!(row.len(), 4);
assert!(
row.iter()
.any(|(glyph, color)| *glyph == '█' && *color == Some(COLOR_RED)),
"still draws a bar: {row:?}"
);
}
#[test]
fn a_seam_cannot_be_dimmed_from_a_named_color_so_it_empties() {
// A palette color has no RGB of ours to dim, so the seam falls back
// to the one divider that needs no color.
let style = SpectrumStyle {
color: Color::LightBlue,
bar_gap: 1,
..flat()
};
let row = row_of(&spectrum_lines(&[1.0; 2], &[], 6, 1, style), 0);
let blanks = row.iter().filter(|(glyph, _)| *glyph == ' ').count();
assert_eq!(blanks, 2, "one empty seam per bar: {row:?}");
}
#[test]
fn a_row_gap_leaves_the_top_of_each_cell_dark() {
// The line between value rows: a full row stops short of the cell
// top by the gap, so the segments of a bar can be told apart.
for (row_gap, expected) in [(0, '█'), (1, '▇'), (2, '▆'), (4, '▄')] {
let style = SpectrumStyle { row_gap, ..flat() };
let row = row_of(&spectrum_lines(&[1.0], &[], 1, 4, style), 0);
assert_eq!(row[0].0, expected, "row_gap {row_gap}");
}
}
#[test]
fn a_row_gap_lowers_a_shadow_the_same_way() {
// Shadows are bars too: a solid shadow keeps the same line between
// rows, or it would read as a different kind of thing.
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
row_gap: 1,
..flat()
};
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style));
assert_eq!(column[0], ('▇', Some(COLOR_RED)), "bar at the floor");
assert_eq!(column[3], ('▇', Some(COLOR_PRIMARY)), "shadow on top");
}
#[test]
fn a_narrow_pane_folds_bins_together_by_their_loudest() {
// Two bins to one bar: the loud one must survive, since a spectrum's
// news is its peaks.
let lines = spectrum_lines(&[0.0, 1.0], &[], 1, 4, flat());
let column = column_of(&lines);
assert!(
column.iter().all(|(glyph, _)| *glyph == '█'),
"the loud bin wins: {column:?}"
);
}
#[test]
fn a_peak_holds_then_falls_over_the_configured_seconds() {
let mut pane = now_playing(10_000, 60_000);
pane.set_spectrum_style(SpectrumStyle {
peak_fall: 2.0,
..SpectrumStyle::default()
});
pane.advance_spectrum(vec![1.0], Duration::ZERO);
assert_eq!(
pane.spectrum_peaks,
vec![1.0],
"a hit sets the peak at once"
);
// Silence afterwards: half the fall time takes it half way down.
pane.advance_spectrum(vec![0.0], Duration::from_secs(1));
assert!(
(pane.spectrum_peaks[0] - 0.5).abs() < 1e-6,
"{:?}",
pane.spectrum_peaks
);
pane.advance_spectrum(vec![0.0], Duration::from_secs(1));
assert_eq!(pane.spectrum_peaks, vec![0.0], "and reaches the floor");
}
#[test]
fn the_fall_is_paced_in_seconds_not_frames() {
// The server's frame rate is not ours to rely on: twenty frames of
// a twentieth of a second must fall as far as one frame of a second.
let fall_after = |frames: u32, each: Duration| {
let mut pane = now_playing(10_000, 60_000);
pane.set_spectrum_style(SpectrumStyle {
peak_fall: 4.0,
..SpectrumStyle::default()
});
pane.advance_spectrum(vec![1.0], Duration::ZERO);
for _ in 0..frames {
pane.advance_spectrum(vec![0.0], each);
}
pane.spectrum_peaks[0]
};
let many = fall_after(20, Duration::from_millis(50));
let one = fall_after(1, Duration::from_secs(1));
assert!((many - one).abs() < 1e-5, "{many} vs {one}");
}
#[test]
fn an_unusable_fall_time_still_falls() {
// `peak_fall` is clamped in config, but the renderer is not entitled
// to assume that: a zero would divide by zero and freeze the peaks.
let mut pane = now_playing(10_000, 60_000);
pane.set_spectrum_style(SpectrumStyle {
peak_fall: 0.0,
..SpectrumStyle::default()
});
pane.advance_spectrum(vec![1.0], Duration::ZERO);
pane.advance_spectrum(vec![0.0], Duration::from_millis(100));
assert_eq!(pane.spectrum_peaks, vec![0.0]);
}
#[test]
fn a_peak_follows_a_changed_bin_count() {
// The bin count comes off the wire and may change mid-stream.
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 8]);
pane.update_spectrum(vec![0.5; 3]);
assert_eq!(pane.spectrum_peaks.len(), 3);
pane.update_spectrum(vec![0.5; 16]);
assert_eq!(pane.spectrum_peaks.len(), 16);
}
#[test]
fn a_nonsense_bin_level_reads_as_silence() {
// Bins are floats from a peer: NaN and infinity must not become
// bars, peaks, or a panic.
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0]);
assert_eq!(pane.spectrum_peaks, vec![0.0; 4]);
let rows = rendered_rows(&pane);
assert!(
!rows
.iter()
.any(|r| r.contains(full_cell(pane.spectrum_style))),
"nonsense levels must draw nothing: {rows:?}"
);
}
#[test] #[test]
fn the_spectrum_row_is_hidden_when_disabled() { fn the_spectrum_row_is_hidden_when_disabled() {
let mut pane = now_playing(10_000, 60_000); let mut pane = now_playing(10_000, 60_000);
@ -422,7 +1232,9 @@ mod tests {
pane.update_spectrum(vec![1.0; 24]); pane.update_spectrum(vec![1.0; 24]);
let rows = rendered_rows(&pane); let rows = rendered_rows(&pane);
assert!( assert!(
!rows.iter().any(|r| r.contains('█')), !rows
.iter()
.any(|r| r.contains(full_cell(pane.spectrum_style))),
"disabled spectrum must not draw bars: {rows:?}" "disabled spectrum must not draw bars: {rows:?}"
); );
} }
@ -431,13 +1243,14 @@ mod tests {
fn toggle_spectrum_hides_then_shows_the_bars() { fn toggle_spectrum_hides_then_shows_the_bars() {
let mut pane = now_playing(10_000, 60_000); let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]); pane.update_spectrum(vec![1.0; 24]);
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); let filled = full_cell(pane.spectrum_style);
// `v` hides the bars… assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
// `f` hides the bars…
pane.toggle_spectrum(); pane.toggle_spectrum();
assert!(!rendered_rows(&pane).iter().any(|r| r.contains('█'))); assert!(!rendered_rows(&pane).iter().any(|r| r.contains(filled)));
// …and again brings them back. // …and again brings them back.
pane.toggle_spectrum(); pane.toggle_spectrum();
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
} }
#[test] #[test]

View File

@ -1,6 +1,12 @@
use std::path::{Path, PathBuf}; use std::{
path::{Path, PathBuf},
str::FromStr,
};
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde}; use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
use ratatui::style::Color;
use crate::app::SpectrumStyle;
#[derive(ClapSerde, Serialize, Debug)] #[derive(ClapSerde, Serialize, Debug)]
#[clap(author, version, about)] #[clap(author, version, about)]
@ -169,6 +175,162 @@ pub struct ServerConfig {
#[default(true)] #[default(true)]
#[clap(long)] #[clap(long)]
pub spectrum: bool, pub spectrum: bool,
/// Color of the frequency-spectrum bars: a `#rrggbb` hex triple, one
/// of ratatui's names ("red", "light-blue", …), or a 0-255 index into
/// the terminal palette. Defaults to the red the queue marks the
/// playing track with. An unparsable value falls back to that
/// default with a warning — see [`spectrum_style`].
#[default(crate::app::COLOR_RED_HEX.to_string())]
#[clap(long)]
pub spectrum_color: String,
/// Shade the bars from dim at the floor to bright at the top, instead
/// of one flat color. Only a hex `spectrum_color` can be shaded: a
/// name or palette index has no RGB value of ours to interpolate, so
/// those stay flat whatever this says.
#[default(true)]
#[clap(long)]
pub spectrum_gradient: bool,
/// Color the shading reaches at the top of the bars, in the same three
/// forms as `spectrum_color`. Defaults to the secondary purple;
/// "none" (or "off") shades brightness alone, keeping one hue.
#[default(crate::app::COLOR_SECONDARY_HEX.to_string())]
#[clap(long)]
pub spectrum_top_color: String,
/// Color of the peak-hold shadows trailing above the bars, in the same
/// three forms as `spectrum_color`. Defaults to the primary blue;
/// "none" (or "off") draws no shadows.
#[default(crate::app::COLOR_PRIMARY_HEX.to_string())]
#[clap(long)]
pub spectrum_peak_color: String,
/// Fill the shadow from the bar up to its held peak. Set false for a
/// thin rule at the peak alone, leaving the space below it empty.
#[default(true)]
#[clap(long)]
pub spectrum_peak_fill: bool,
/// Seconds a full-scale shadow takes to fall to the floor, 0.05-60
/// (out of range is clamped with a warning). Higher lingers longer.
#[default(4.0)]
#[clap(long)]
pub spectrum_peak_fall: f32,
/// Least bar width in terminal cells, 1-16 (out of range is clamped
/// with a warning). Bars take any spare columns beyond this, so raise
/// it only to force wider bars at the cost of showing fewer bands.
#[default(1)]
#[clap(long)]
pub spectrum_bar_width: usize,
/// Width of the seam dividing two bars, in cells, 0-8 (out of range is
/// clamped with a warning). The seam is the bar itself dimmed, so the
/// bars stay one connected field; 0 removes it.
#[default(1)]
#[clap(long)]
pub spectrum_bar_gap: usize,
/// Height of the line dividing two value rows, in eighths of a row,
/// 0-4 (out of range is clamped with a warning). This is what makes the
/// individual segments of a bar visible; 0 stacks them solid.
#[default(1)]
#[clap(long)]
pub spectrum_row_gap: usize,
}
/// Resolves the spectrum options to a [`SpectrumStyle`].
///
/// A config file is user input: a typo must not take the client down, so an
/// unparsable color is reported on stderr and the default is used. The
/// terminal decides what a name or palette index looks like, so this cannot
/// tell a working color from an invisible one — only a syntactic one from a
/// nonsense one.
pub fn spectrum_style(config: &Config) -> SpectrumStyle {
SpectrumStyle {
color: color_or_default(
&config.server.spectrum_color,
"spectrum_color",
crate::app::COLOR_RED,
),
top: optional_color(
&config.server.spectrum_top_color,
"spectrum_top_color",
crate::app::COLOR_SECONDARY,
),
gradient: config.server.spectrum_gradient,
peak: optional_color(
&config.server.spectrum_peak_color,
"spectrum_peak_color",
crate::app::COLOR_PRIMARY,
),
peak_fill: config.server.spectrum_peak_fill,
peak_fall: seconds(
config.server.spectrum_peak_fall,
"spectrum_peak_fall",
0.05,
60.0,
),
bar_width: cells(
config.server.spectrum_bar_width,
"spectrum_bar_width",
1,
16,
),
bar_gap: cells(config.server.spectrum_bar_gap, "spectrum_bar_gap", 0, 8),
row_gap: cells(config.server.spectrum_row_gap, "spectrum_row_gap", 0, 4),
}
}
/// Parses one color option, falling back to `fallback` with a warning. An
/// empty value is a blank in the file rather than a mistake, so it takes
/// the default silently.
fn color_or_default(raw: &str, key: &str, fallback: Color) -> Color {
let raw = raw.trim();
if raw.is_empty() {
return fallback;
}
Color::from_str(raw).unwrap_or_else(|_| {
eprintln!("invalid {key} {raw:?}, using the default");
fallback
})
}
/// Parses a color option that can be switched off, where "none"/"off" mean
/// "leave this out". Neither word is a color, so there is no value they
/// shadow.
fn optional_color(raw: &str, key: &str, fallback: Color) -> Option<Color> {
let trimmed = raw.trim();
if trimmed.eq_ignore_ascii_case("none") || trimmed.eq_ignore_ascii_case("off") {
return None;
}
Some(color_or_default(trimmed, key, fallback))
}
/// Clamps a cell-count option into `min..=max`, warning when it was out of
/// range. A width of a thousand cells is a typo, not an instruction, and
/// clamping keeps a typo from emptying the pane.
fn cells(value: usize, key: &str, min: usize, max: usize) -> usize {
if value < min || value > max {
eprintln!("{key} {value} is outside {min}-{max}, clamping");
}
value.clamp(min, max)
}
/// Clamps a duration option into `min..=max` seconds, warning when it was
/// out of range. A TOML float can also be NaN or infinite, which no clamp
/// would fix, so those take `min`.
fn seconds(value: f32, key: &str, min: f32, max: f32) -> f32 {
if !value.is_finite() {
eprintln!("{key} {value} is not a number of seconds, using {min}");
return min;
}
if value < min || value > max {
eprintln!("{key} {value} is outside {min}-{max}, clamping");
}
value.clamp(min, max)
} }
#[cfg(test)] #[cfg(test)]
@ -210,6 +372,146 @@ mod tests {
assert!(!config.server.spectrum); assert!(!config.server.spectrum);
} }
#[test]
fn the_default_spectrum_color_is_the_queue_red() {
// The config default is a string and the renderer wants a `Color`;
// this is what keeps the two spellings of the same red together.
let config = Config::default();
assert_eq!(config.server.spectrum_color, crate::app::COLOR_RED_HEX);
assert_eq!(spectrum_style(&config).color, crate::app::COLOR_RED);
}
#[test]
fn the_default_peak_color_is_the_primary_blue() {
let config = Config::default();
assert_eq!(
config.server.spectrum_peak_color,
crate::app::COLOR_PRIMARY_HEX
);
let style = spectrum_style(&config);
assert_eq!(style.peak, Some(crate::app::COLOR_PRIMARY));
assert!(style.gradient, "bars are shaded out of the box");
}
#[test]
fn a_spectrum_color_is_read_as_hex_name_or_palette_index() {
let mut config = Config::default();
for (raw, expected) in [
("#00ff7f", Color::Rgb(0, 255, 127)),
("light-blue", Color::LightBlue),
("208", Color::Indexed(208)),
// Surrounding whitespace is a config-file slip, not a value.
(" #010203 ", Color::Rgb(1, 2, 3)),
] {
config.server.spectrum_color = raw.to_string();
assert_eq!(spectrum_style(&config).color, expected, "parsing {raw:?}");
// The peak color takes the same three forms.
config.server.spectrum_peak_color = raw.to_string();
assert_eq!(spectrum_style(&config).peak, Some(expected));
}
}
#[test]
fn an_unusable_spectrum_color_falls_back_to_the_default() {
// A config file is user input: a typo warns and keeps the client
// running rather than taking it down.
let mut config = Config::default();
for raw in ["", " ", "puce", "#12345", "300", "#"] {
config.server.spectrum_color = raw.to_string();
config.server.spectrum_peak_color = raw.to_string();
let style = spectrum_style(&config);
assert_eq!(style.color, crate::app::COLOR_RED, "rejecting {raw:?}");
assert_eq!(
style.peak,
Some(crate::app::COLOR_PRIMARY),
"rejecting {raw:?}"
);
}
}
#[test]
fn a_peak_color_of_none_switches_the_markers_off() {
// "none" and "off" are not colors, so neither shadows a real value.
let mut config = Config::default();
for raw in ["none", "NONE", " off ", "Off"] {
config.server.spectrum_peak_color = raw.to_string();
assert_eq!(spectrum_style(&config).peak, None, "for {raw:?}");
}
}
#[test]
fn the_default_top_color_is_the_secondary_purple() {
let config = Config::default();
assert_eq!(
config.server.spectrum_top_color,
crate::app::COLOR_SECONDARY_HEX
);
assert_eq!(
spectrum_style(&config).top,
Some(crate::app::COLOR_SECONDARY)
);
}
#[test]
fn a_top_color_of_none_shades_brightness_alone() {
let mut config = Config::default();
for raw in ["none", "OFF"] {
config.server.spectrum_top_color = raw.to_string();
assert_eq!(spectrum_style(&config).top, None, "for {raw:?}");
}
}
#[test]
fn the_default_layout_is_connected_bars_with_thin_dividers() {
let style = spectrum_style(&Config::default());
assert_eq!(style.bar_width, 1, "bars take the spare columns");
assert_eq!(style.bar_gap, 1, "a one-cell seam");
assert_eq!(style.row_gap, 1, "an eighth of a row between segments");
assert!(style.peak_fill, "shadows filled");
assert_eq!(style.peak_fall, 4.0);
}
#[test]
fn the_widths_and_the_fall_are_clamped_not_obeyed() {
// A config file is user input: a nonsense width must not empty the
// pane, and a nonsense fall time must not freeze or divide by zero.
let mut config = Config::default();
config.server.spectrum_bar_width = 0;
config.server.spectrum_bar_gap = 999;
config.server.spectrum_row_gap = 8;
config.server.spectrum_peak_fall = 0.0;
let style = spectrum_style(&config);
assert_eq!(style.bar_width, 1);
assert_eq!(style.bar_gap, 8);
assert_eq!(style.row_gap, 4, "a whole cell of gap would draw nothing");
assert_eq!(style.peak_fall, 0.05);
// The other end, and a float that is no duration at all.
config.server.spectrum_bar_width = 999;
config.server.spectrum_peak_fall = f32::NAN;
let style = spectrum_style(&config);
assert_eq!(style.bar_width, 16);
assert_eq!(style.peak_fall, 0.05);
config.server.spectrum_peak_fall = 900.0;
assert_eq!(spectrum_style(&config).peak_fall, 60.0);
}
#[test]
fn the_shadow_fill_and_fall_come_from_the_file() {
let mut config = Config::default();
config.server.spectrum_peak_fill = false;
config.server.spectrum_peak_fall = 1.5;
let style = spectrum_style(&config);
assert!(!style.peak_fill);
assert_eq!(style.peak_fall, 1.5);
}
#[test]
fn the_gradient_can_be_switched_off() {
let mut config = Config::default();
config.server.spectrum_gradient = false;
assert!(!spectrum_style(&config).gradient);
}
#[test] #[test]
fn write_auth_round_trips_user_password_address() { fn write_auth_round_trips_user_password_address() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");

View File

@ -43,8 +43,11 @@ pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() }); tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
let spectrum_enabled = config.server.spectrum; let spectrum_enabled = config.server.spectrum;
// Resolved here rather than in the UI thread so a bad config string is
// reported on stderr before the alternate screen swallows it.
let spectrum_style = config::spectrum_style(config);
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
run_ui(ui_tx, ui_rx, spectrum_enabled); run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_style);
}) })
.await?; .await?;
@ -209,7 +212,12 @@ async fn poll(
Ok(()) Ok(())
} }
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled: bool) { fn run_ui(
tx: Sender<MessageFromUi>,
rx: Receiver<MessageToUi>,
spectrum_enabled: bool,
spectrum_style: app::SpectrumStyle,
) {
// setup terminal // setup terminal
enable_raw_mode().unwrap(); enable_raw_mode().unwrap();
let mut stdout = io::stdout(); let mut stdout = io::stdout();
@ -220,6 +228,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
// create app and run it // create app and run it
let mut app = App::new(tx); let mut app = App::new(tx);
app.now_playing.set_spectrum_enabled(spectrum_enabled); app.now_playing.set_spectrum_enabled(spectrum_enabled);
app.now_playing.set_spectrum_style(spectrum_style);
let tick_rate = Duration::from_millis(100); let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now(); let mut last_tick = Instant::now();

View File

@ -25,12 +25,25 @@ functionality as `cbd-tui`, served by `crabidy-server` itself.
## Functionality ## Functionality
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click), Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click), marks
marks, create/rename/delete nodes (`%`/`e`/`d`), bookmark and capture and visual mode (`s`, `v`/`V`) in both panes, the one-slot register (`y`
(`w`/`W`, with live progress lines and skipped-track marking), the full queue yanks, `d`/`c`/`C` fill it as they remove, `p`/`P` paste), create/rename/
and playback controls, volume, shuffle/repeat, and a `?` help overlay listing delete nodes (`%`/`e`/`d`), bookmark and capture (`w`/`W`, with live progress
the keys. Keys mirror the TUI; every key also has a clickable control. A lines and skipped-track marking), the full queue and playback controls,
light/dark theme follows the OS and can be toggled (persisted). The accent seeking (`,`/`.` for 15 seconds, `<`/`>` for a whole track, or a click on the
progress bar), volume, shuffle/repeat, and a `?` help overlay listing the
keys. Keys mirror the TUI; every key also has a clickable control. The `/`
live filter is the one thing that is TUI-only so far.
Two layout details earn their own note:
- `library`/`queue` **tabs** in the top bar switch panes and show which one
the keys go to — what `Tab` does, reachable by thumb.
- Below 700px the panes cannot sit side by side, so **only the focused pane
is rendered**. It is not collapsed to a strip: a strip's truncated rows
still take taps, which sent them to the wrong pane.
A light/dark theme follows the OS and can be toggled (persisted). The accent
color is the crab orange-red. color is the crab orange-red.
When the server requires credentials, a login form collects the role When the server requires credentials, a login form collects the role

View File

@ -75,7 +75,7 @@ pub fn minimum_role(grpc_path: &str) -> Role {
// Every other queue and playback verb. // Every other queue and playback verb.
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent" "Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent"
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume" | "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" => Role::QueueOwner, | "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => Role::QueueOwner,
// Library writes (CaptureLibraryNode, SaveQueue, // Library writes (CaptureLibraryNode, SaveQueue,
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped. // RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
_ => Role::Owner, _ => Role::Owner,
@ -84,7 +84,7 @@ pub fn minimum_role(grpc_path: &str) -> Role {
/// Hashes a password into the PHC string `crabidy-server.toml` expects /// Hashes a password into the PHC string `crabidy-server.toml` expects
/// (argon2id, default parameters, fresh random salt). Backs the /// (argon2id, default parameters, fresh random salt). Backs the
/// `crabidy-server hash-password` helper. /// `crabidy-server guard` subcommand.
pub fn hash_password(password: &str) -> Result<String, String> { pub fn hash_password(password: &str) -> Result<String, String> {
use argon2::password_hash::{rand_core::OsRng, SaltString}; use argon2::password_hash::{rand_core::OsRng, SaltString};
use argon2::PasswordHasher; use argon2::PasswordHasher;
@ -355,6 +355,7 @@ mod tests {
"Next", "Next",
"Prev", "Prev",
"RestartTrack", "RestartTrack",
"Seek",
]; ];
let owner = [ let owner = [
"CaptureLibraryNode", "CaptureLibraryNode",
@ -362,10 +363,28 @@ mod tests {
"RenameLibraryNode", "RenameLibraryNode",
"DeleteLibraryNode", "DeleteLibraryNode",
]; ];
// The full service, from crabidy.proto — 24 methods. A new RPC // Every RPC must appear in exactly one list above. Taken from the
// must be added to exactly one list (and the layer keeps it // proto itself rather than a count copied out of it: a hardcoded
// owner-only until then). // total let `Seek` be added to the service and land on the
assert_eq!(appender.len() + queue_owner.len() + owner.len(), 24); // owner-only fallthrough with the suite still green, which is the
// one thing this test exists to prevent.
let proto = include_str!("../../crabidy-core/crabidy/v1/crabidy.proto");
let service: std::collections::BTreeSet<&str> = proto
.lines()
.filter_map(|line| line.trim().strip_prefix("rpc "))
.filter_map(|rest| rest.split('(').next())
.map(str::trim)
.collect();
let mapped: std::collections::BTreeSet<&str> = appender
.iter()
.chain(queue_owner.iter())
.chain(owner.iter())
.copied()
.collect();
assert_eq!(
mapped, service,
"every RPC of the service needs a role (left: mapped, right: the proto)"
);
for method in appender { for method in appender {
let path = format!("{SERVICE_PREFIX}{method}"); let path = format!("{SERVICE_PREFIX}{method}");
assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}"); assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}");

View File

@ -22,8 +22,8 @@ below it (owner ⊃ queue-owner ⊃ queue-appender):
- **owner** — the normal user: everything, including all library - **owner** — the normal user: everything, including all library
writes. writes.
- **queue-owner** — anything on the queue and playback (append, remove, - **queue-owner** — anything on the queue and playback (append, remove,
reorder, clear, shuffle, repeat, play/stop, next/prev, volume, mute, reorder, clear, shuffle, repeat, play/stop, next/prev, seek, volume,
), but **no library writes**: no bookmarks (`w`), no captures (`W`), mute), but **no library writes**: no bookmarks (`w`), no captures (`W`),
no saving, renaming, or deleting. no saving, renaming, or deleting.
- **queue-appender** — may browse and search the library and **append** - **queue-appender** — may browse and search the library and **append**
tracks to the queue; nothing else. (Searching is allowed because tracks to the queue; nothing else. (Searching is allowed because

View File

@ -36,12 +36,13 @@ The same list goes into the server's startup log, so a support question
| `abs` | the `/abs` audiobookshelf provider | | `abs` | the `/abs` audiobookshelf provider |
| `soundcloud` | the `/soundcloud` provider | | `soundcloud` | the `/soundcloud` provider |
| `jamendo` | the `/jamendo` provider | | `jamendo` | the `/jamendo` provider |
| `rss` | the `/rss` podcast-subscription provider |
| `fs` | local files **and persistent state** — see below | | `fs` | local files **and persistent state** — see below |
| `opus` | Ogg-Opus decoding (`symphonia` + a bundled libopus C build) | | `opus` | Ogg-Opus decoding (`symphonia` + a bundled libopus C build) |
| `spectrum` | the server-side FFT feeding clients' spectrum bars | | `spectrum` | the server-side FFT feeding clients' spectrum bars |
| `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) | | `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) |
Plus two conveniences: `all-providers` enables the seven provider features Plus two conveniences: `all-providers` enables the eight provider features
at once, and `cbd` (the bundle) mirrors every feature above and adds at once, and `cbd` (the bundle) mirrors every feature above and adds
`notifications` for the TUI's desktop "now playing" popups (`notify-rust`, `notifications` for the TUI's desktop "now playing" popups (`notify-rust`,
which on Linux pulls a D-Bus stack). which on Linux pulls a D-Bus stack).

View File

@ -4,8 +4,8 @@ A client is anything that drives the server. Every client speaks the
same gRPC service (see [Architecture](./architecture.md)): it sends same gRPC service (see [Architecture](./architecture.md)): it sends
**commands** (browse the library, change the queue, control playback) **commands** (browse the library, change the queue, control playback)
and subscribes to the **update stream** that pushes the current queue, and subscribes to the **update stream** that pushes the current queue,
play state, track position, capture progress, and the frequency play state, track position, output level and mute, capture progress, and
spectrum as they change. Nothing is polled — each client redraws from the frequency spectrum as they change. Nothing is polled — each client redraws from
the pushed updates, so several clients driving one server always agree the pushed updates, so several clients driving one server always agree
on what is playing. on what is playing.

View File

@ -135,7 +135,7 @@ its visible set is recomputed.
## Frequency spectrum ## Frequency spectrum
A row of frequency bars is drawn under the track progress while audio A row of frequency bars is drawn under the track progress while audio
plays, as block glyphs (`▁▂▃▄▅▆▇█`) in the accent color. The bars are plays, as block glyphs (`▁▂▃▄▅▆▇█`). The bars are
produced on the **server**: it taps its own audio output, runs an FFT produced on the **server**: it taps its own audio output, runs an FFT
(~20 fps), folds the result into a few log-spaced bins, and streams (~20 fps), folds the result into a few log-spaced bins, and streams
them on the update stream like every other bit of live state. So the them on the update stream like every other bit of live state. So the
@ -147,6 +147,71 @@ config sets the startup default (`spectrum = true` is the default;
`false` starts hidden). Either way it is only a display choice — the `false` starts hidden). Either way it is only a display choice — the
server always computes and streams the bars while audio flows. server always computes and streams the bars while audio flows.
`spectrum_color` in the client config picks their color, as a `#rrggbb`
hex triple, a color name (`red`, `light-blue`, …), or a 0-255 index into
the terminal palette. It defaults to the red the queue marks the playing
track with, so the two agree on what "now" looks like. A palette index or
a name leaves the shade to your terminal theme, which is the point of
them; a hex triple asks for one exact color, which a 256-color terminal
will approximate. An unparsable value warns on stderr and falls back to
the default rather than stopping the client.
### Shading
The bars are **shaded** by height: dim at the floor, brightening toward the
top, so a loud bar reads as hot and not merely tall. Each row gets its own
shade, interpolated from 55% of `spectrum_color` at the bottom to
`spectrum_top_color` — the secondary purple — at the very top. Set
`spectrum_top_color = "none"` to ramp brightness alone and keep one hue, or
`spectrum_gradient = false` for one flat color.
Shading needs colors it can compute with, so it applies only to **hex**
values. A name or a palette index is a reference into your terminal's own
theme — its actual RGB value is the terminal's business, not the client's —
so a named `spectrum_color` renders flat whatever `spectrum_gradient` says,
and a named `spectrum_top_color` is ignored in favour of a brightness ramp.
### Segments and seams
The bars form one connected field, divided two ways so the individual
segments stand out:
- **Between bars**, a seam: the last `spectrum_bar_gap` columns of each bar
are the same bar drawn at 35% brightness, so neighbours are told apart
without the field breaking into separate sticks. `spectrum_bar_gap = 0`
removes it. (A named `spectrum_color` cannot be dimmed, so its seam is an
empty column instead.)
- **Between value rows**, a line: the top `spectrum_row_gap` eighths of every
cell are left unlit, so a full row draws `▇` rather than `█` and each
segment of a bar reads separately. `spectrum_row_gap = 0` stacks them
solid; 2 or more make the line heavier, at a little vertical resolution.
`spectrum_bar_width` is a **minimum**, not a width: bars take any spare
columns beyond it, which is what keeps the field connected. Raise it to force
wider bars — at the cost of showing fewer bands, since there is then room for
fewer bars than the server sends and each covers a group of neighbouring
bands (taking the loudest of them).
### Peak shadows
Each bar trails a **peak-hold shadow**: the gap between the bar and the
highest level it lately reached, filled in `spectrum_peak_color` — the
primary blue by default, so it reads as a shadow of the red bar rather than
part of it. It shows what a transient reached after the bar has dropped away.
- `spectrum_peak_fall` is how long, in seconds, a full-scale shadow takes to
fall to the floor (4 by default). It is real time, not frames, so the
server's frame rate does not change the feel.
- `spectrum_peak_fill = false` draws a thin rule (`▔`) at the peak instead of
filling the space below it — also the thing to do if your terminal font
lacks `▔`… in which case leave it `true`.
- `spectrum_peak_color = "none"` (or `"off"`) leaves shadows out entirely.
A shadow never appears in a row the bar itself occupies: a terminal cell
holds one glyph, so a shadow there would eat bar to repeat what the bar's top
edge already shows. Shadows fall away when playback stops, since the server
keeps streaming zeroed bars while the audio is idle.
## Key bindings ## Key bindings
Global keys work in either pane. Pane keys apply only while that pane is Global keys work in either pane. Pane keys apply only while that pane is

View File

@ -105,11 +105,50 @@ password = ""
# Show the frequency-spectrum bars under the track progress. # Show the frequency-spectrum bars under the track progress.
spectrum = true spectrum = true
# Color of those bars: a "#rrggbb" hex triple, a color name ("red",
# "light-blue", …), or a 0-255 index into the terminal palette. The
# default is the red the queue marks the playing track with. An
# unparsable value warns on stderr and falls back to that default.
spectrum_color = "#bf616a"
# Shade the bars from dim at the floor to bright at the top instead of
# one flat color. Only hex colors can be shaded — a name or a palette
# index has no RGB value the client may interpolate.
spectrum_gradient = true
# Color the shading reaches at the top of the bars, in the same three
# forms as spectrum_color. "none" (or "off") shades brightness alone.
spectrum_top_color = "#b48ead"
# Color of the peak-hold shadows trailing above the bars, in the same
# three forms as spectrum_color. "none" (or "off") draws no shadows.
spectrum_peak_color = "#81a1c1"
# Fill the shadow from the bar up to its peak. false draws a thin rule at
# the peak alone.
spectrum_peak_fill = true
# Seconds a full-scale shadow takes to fall to the floor, 0.05-60.
spectrum_peak_fall = 4.0
# Least bar width in cells, 1-16. Bars take any spare columns beyond
# this, so raise it only to force wider bars and fewer bands.
spectrum_bar_width = 1
# Width in cells of the seam dividing two bars, 0-8. The seam is the bar
# itself dimmed, so the bars stay one connected field; 0 removes it.
spectrum_bar_gap = 1
# Height of the line dividing two value rows, in eighths of a row, 0-4.
# This is what makes the segments of a bar visible; 0 stacks them solid.
spectrum_row_gap = 1
``` ```
Every option is also a command-line flag, given before the subcommand Every option except the `spectrum_*` appearance settings (everything below
(`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum `spectrum` itself) is also a command-line flag, given before the subcommand
false`). A provided flag overrides the file value; an omitted flag (`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum false`).
A provided flag overrides the file value; an omitted flag
leaves the file value in place. To write credentials into the config leaves the file value in place. To write credentials into the config
once instead of editing by hand, use the `auth` subcommand (see once instead of editing by hand, use the `auth` subcommand (see
[Command line](./clients/cli.md)): [Command line](./clients/cli.md)):
@ -125,8 +164,8 @@ plaintext credential, not a hash. Keep the client config file private.
The password is never written to logs. The password is never written to logs.
``` ```
For the client's `spectrum` option in context, see For the client's `spectrum` options in context — shading, and the
[The terminal client](./clients/tui.md). peak-hold markers — see [The terminal client](./clients/tui.md).
## Where the server's own data lives ## Where the server's own data lives

View File

@ -64,6 +64,7 @@ segment is a **provider** mounted as a subtree:
├── fs a local music folder ├── fs a local music folder
├── fyyd podcast search ├── fyyd podcast search
├── jamendo Creative-Commons music ├── jamendo Creative-Commons music
├── rss podcast subscriptions
├── soundcloud SoundCloud search, likes, and playlists ├── soundcloud SoundCloud search, likes, and playlists
├── tidal Tidal streaming ├── tidal Tidal streaming
├── youtube YouTube search & playlists ├── youtube YouTube search & playlists

View File

@ -55,11 +55,11 @@ independent things decide it:
1. **Was it built in?** Every provider sits behind a Cargo feature (all on by 1. **Was it built in?** Every provider sits behind a Cargo feature (all on by
default). A binary built without one can never mount it — see [Tailored default). A binary built without one can never mount it — see [Tailored
builds](../build-features.md), and `crabidy-server features` to see what a builds](./build-features.md), and `crabidy-server features` to see what a
binary has. binary has.
2. **Is it enabled?** The `providers` list in `crabidy-server.toml` turns 2. **Is it enabled?** The `providers` list in `crabidy-server.toml` turns
compiled-in providers on and off without a rebuild — see compiled-in providers on and off without a rebuild — see
[Configuration](../config.md#enabling-and-disabling-providers). [Configuration](./config.md#enabling-and-disabling-providers).
3. **Did it initialize?** Only a client that came up successfully is mounted. 3. **Did it initialize?** Only a client that came up successfully is mounted.
```admonish note ```admonish note

View File

@ -1640,3 +1640,175 @@ a nudge next to a skip.
`font-variant-emoji: text` on the controls row asks for the text form of the `font-variant-emoji: text` on the controls row asks for the text form of the
prev/next/pause marks, which have the same emoji presentation. It is ignored prev/next/pause marks, which have the same emoji presentation. It is ignored
where unsupported, at no cost: those glyphs are the fallback anyway. where unsupported, at no cost: those glyphs are the fallback anyway.
## TUI spectrum color (2026-07-27)
The bars were hardcoded to `COLOR_PRIMARY`, the same muted blue as the pane
borders, so they read as chrome rather than as the thing that is moving.
`spectrum_color` in the client config now picks the color, parsed by ratatui's
`Color: FromStr` — so a `#rrggbb` triple, a color name, or a 0-255 palette
index all work, and a name or index defers to the terminal theme. It defaults
to `COLOR_RED`, the red the queue marks the playing track with, so the two
agree on what "now" looks like.
The default lives twice, as `COLOR_RED` for the renderer and `COLOR_RED_HEX`
for the config file, since one is a `Color` and the other a string a user
edits. `the_default_spectrum_color_is_the_queue_red` is what stops them
drifting.
A config file is user input, so `config::spectrum_color` reports an unparsable
value on stderr and falls back to the red rather than failing. It resolves in
`run`, before the alternate screen is entered, or the warning would be drawn
over and lost. Empty counts as unset for the same reason a missing key does.
No CLI flag: the other options have one because they are things you override
per invocation, and a color is not.
Existing config files predate the key. They keep working — every field of the
`ClapSerde` opt struct is optional — and take the default, but the new key
only appears in files written from now on.
## TUI spectrum shading and peak hold (2026-07-27)
Modelled on BeSpec's LED mode: bars shaded by height, with a peak marker riding
above each one.
Gradients are possible in a TUI because every cell carries its own foreground.
The useful accident is that shading by *height* makes the color a function of
the row alone, so it costs nothing per column — the paint is decided once per
row and the row's cells still coalesce into a couple of spans.
`gradient_color` interpolates the configured base from 55% brightness at the
floor to 30% toward white at the top. Only a `Color::Rgb` base can be
interpolated: a color name or a palette index is a reference into the
terminal's own theme, whose RGB value is not the client's to know, so those
render flat whatever `spectrum_gradient` says. Documented rather than
worked around, since guessing an RGB for `red` would defeat the point of
naming it.
The peak hold is per-bin state in `NowPlaying`, advanced in `update_spectrum`:
a bin at or above its peak raises it instantly, otherwise the peak falls
`PEAK_DECAY` (0.03) a frame — about 1.7s from full scale at the server's 20 fps.
The server keeps streaming zeroed frames while audio is idle, so markers fall
away on pause instead of freezing on screen. Bin count comes off the wire, so
the peaks `resize` to whatever arrives.
A marker is drawn only in a row the bar does not reach at all. A cell holds one
glyph, so a marker inside the bar's own top cell would replace up to seven
eighths of bar to repeat what the bar's top edge already shows. Skipping it
loses nothing: a peak that close to the bar is not news.
The three spectrum options (`spectrum_color`, `spectrum_gradient`,
`spectrum_peak_color`) now resolve together into a `SpectrumStyle`, which
replaces `set_spectrum_color`. `spectrum_peak_color = "none"` (or `"off"`)
draws no markers — also the escape hatch for a terminal font without U+2594.
Neither word is a color, so neither shadows a value someone might have meant.
`level_of` now guards every bin: they are wire floats, so NaN and infinity read
as silence in one place rather than being left to the arithmetic. Neither could
panic before — `clamp` propagates NaN and a NaN float cast saturates to 0 — and
a NaN can never become a held peak, since `level >= *peak` is false for it. The
guard is so the peak comparison and the glyph arithmetic agree on what a
nonsense level means instead of each shrugging it off differently.
## TUI spectrum: segments, seams and shadows (2026-07-27)
A second pass over the shading and peaks, after seeing them run.
**Purple, not white.** The gradient now interpolates from the dimmed base at
the floor to a configured `spectrum_top_color``COLOR_SECONDARY`, the
secondary purple — at the top, instead of blending toward white by a
fraction. Naming the far end is both what was wanted and simpler to explain
than a blend factor: the top row *is* the top color. `"none"` ramps
brightness alone. A named top color cannot be interpolated any more than a
named base can, so it falls back to the brightness ramp rather than the
client inventing an RGB for `light-blue`.
**Filled shadows.** A peak now fills the gap between the bar and its held
maximum rather than marking it with a rule, so each bar trails a shadow of
where it just was. The rule is kept as `spectrum_peak_fill = false`, since it
is also the fallback for a font without U+2594. The old "never inside the
bar's own rows" constraint carries over unchanged, and expresses itself
naturally now that the shadow is drawn with the same `cell_eighths` the bar
uses.
**Seams, not gaps.** The first attempt at "narrower bars, clearer dividers"
made the bars *separate* — one-cell sticks with blank columns between them —
which was wrong. What was wanted is one connected field with the segments
legible. So:
- The divider between bars is now the bar itself at 35% brightness rather
than an empty column. Neighbours are distinguishable, the field stays
continuous, and — because the seam is a cell of the bar, not a hole — the
full 24 bins still fit a normal pane at a pitch of two.
- `spectrum_bar_width` became a *minimum*: bars take the spare columns of
their slot, seams stay exactly `spectrum_bar_gap` wide. A pane rarely
divides evenly by the bin count — 58 columns over 24 bands gives slots of
two and three — and the slack has to go somewhere. It goes to the bars,
which then vary (1 and 2 cells at that width) while the seams stay
uniform, because the alternative is uniform bars separated by seams of
one and two cells, and a seam that varies pulls the eye harder than a bar
that does: the seams are a repeating rhythm, the bars are data and expected
to differ. It is a real trade either way — an uneven bar width is a
slightly misleading canvas for a level.
- The horizontal divider is `spectrum_row_gap` eighths left unlit at the top
of every cell, so a full row draws `▇` and each value row reads as its own
segment. One eighth costs 1/8 of a row's resolution and is the subtlest
line a cell can hold; a whole row of gap would cost half the resolution,
which is why the clamp stops at 4.
A cell is the smallest thing a terminal can color, so the two dividers cannot
be made literally identical: the vertical one is a dimmed column, the
horizontal one a fraction of a row. Both are thin and neither is black.
**Time, not frames.** `spectrum_peak_fall` is seconds for a full-scale shadow
to reach the floor, and `update_spectrum` measures the interval between frames
rather than assuming the server's ~20 fps. The frame rate is the server's
business and may change; a per-frame decay silently retunes itself when it
does. `advance_spectrum` takes the interval as an argument so the fall is
testable without a clock, and the renderer floors the divisor at
`MIN_PEAK_FALL` rather than trusting the config layer's clamp — a zero there
would divide by zero and freeze every shadow on screen.
Seven appearance keys is a lot of config surface, justified by these being
pure appearance with no right answer: each one is a question that came back
with a different answer than the default assumed.
## Documentation audit (2026-07-27)
A sweep of `docs/` and every README against the code, prompted by several
features landing after the last one. Mechanical checks first — they find what
reading cannot:
- every `docs/src/**.md` appears in `SUMMARY.md` and vice versa;
- every relative link in the book and the READMEs resolves (two did not:
`docs/src/providers.md` pointed at `../build-features.md` and `../config.md`,
which from `src/providers.md` lands a directory above the book);
- every `#anchor` in an internal link matches a heading that exists;
- every provider crate's `Settings` field appears in its README and its book
page (all did, except Tidal's endpoint and token fields, which its page
deliberately delegates to `tidaldy/README.md`);
- the `crabidy-server` feature table against `Cargo.toml` (`rss` was missing,
and "seven provider features" had become eight).
Stale prose, all of it from work that landed after the previous sweep: the
root README's client-config sample knew only `spectrum`, still claimed every
option has a command-line flag, and described the spectrum before it had
colors, segments or shadows; its web section predated the pane tabs, the
register and seek; `cbd-web/README.md` predated the same; `docs/src/intro.md`'s
provider tree was missing `/rss`; `docs/src/clients.md` listed what the update
stream carries without volume and mute.
The audit also turned up a **defect the docs were right about**: `Seek` was
never added to `minimum_role`, so it fell through to the owner-only default
while `auth.md` and `architecture/roles-auth.md` both promise a queue-owner
may control playback. With auth configured, a queue-owner could play, skip and
change the volume but got `PermissionDenied` on `,`/`.`.
The test meant to prevent exactly that — "a new RPC must be added to exactly
one list" — compared the three role lists against a hardcoded 24. `Seek` made
the service 25 methods and the suite stayed green. It now reads the method
names out of `crabidy.proto` with `include_str!` and compares sets, so the
next RPC cannot be forgotten. A count copied out of a file is not a check
against that file.

View File

@ -43,6 +43,13 @@ A subscription is queueable and downloadable, so you can queue or `W`-capture
a whole feed. Episodes stream directly from the enclosure URL — no sidecar, a whole feed. Episodes stream directly from the enclosure URL — no sidecar,
no helper binary. no helper binary.
Only **audio** enclosures become episodes: an item is taken when its
enclosure type says audio, or when the type is missing or generic and the URL
looks like audio (including extensionless `/feed/mp3`-style URLs). Video and
image enclosures are skipped, so a show that publishes both plays its audio
and a blog feed does not list its featured images as tracks. See
`docs/src/providers/rss.md` for the exact rule.
**Nothing is cached.** Every visit to a subscription fetches the feed, so an **Nothing is cached.** Every visit to a subscription fetches the feed, so an
episode published a minute ago is there. One memo exists purely so that episode published a minute ago is there. One memo exists purely so that
listing a feed and then queueing its 40 episodes costs one fetch rather than listing a feed and then queueing its 40 episodes costs one fetch rather than