Compare commits
No commits in common. "87b98193d8a42080f0448b3ded84b3f1ae80510a" and "d2f687983efe38c63af313c7944a679d8018343e" have entirely different histories.
87b98193d8
...
d2f687983e
50
README.md
50
README.md
|
|
@ -137,26 +137,13 @@ password = ""
|
|||
|
||||
# Show the frequency-spectrum bars under the track progress. Default 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 except the `spectrum_*` appearance settings is also available
|
||||
as a command-line flag before the subcommand (`cbd-tui --address ... --user
|
||||
owner`, `cbd --spectrum false`); a flag overrides the file value. To write
|
||||
the credentials into the config once, use the `auth` subcommand (see below)
|
||||
instead of editing the file by hand:
|
||||
Every option is also available as a command-line flag before the
|
||||
subcommand (`cbd-tui --address ... --user owner`, `cbd --spectrum
|
||||
false`); a flag overrides the file value. To write the credentials into
|
||||
the config once, use the `auth` subcommand (see below) instead of
|
||||
editing the file by hand:
|
||||
|
||||
```sh
|
||||
cbd-tui auth owner 'my-password' # sets user + password
|
||||
|
|
@ -374,32 +361,15 @@ Toggle it at runtime with `f`, or set the startup default with
|
|||
`spectrum = false` in the client config. Servers built without the
|
||||
`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
|
||||
|
||||
`crabidy-server` serves a browser client with the same functionality as
|
||||
the TUI at its own address (`http://<server>:50051/`) — same navigation,
|
||||
same keys (`j`/`k`/`h`/`l`, `%`, `e`, `d`, `w`, `W`, marks and visual mode,
|
||||
the `y`/`d`/`p` register, `,`/`.` to seek, queue and playback controls, `?`
|
||||
for help), plus clickable equivalents for all of it: the progress bar seeks
|
||||
where you click, and `library`/`queue` tabs in the top bar switch panes
|
||||
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.
|
||||
same keys (`j`/`k`/`h`/`l`, `%`, `e`, `d`, `w`, `W`, queue and playback
|
||||
controls, `?` for help), plus clickable equivalents and 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).
|
||||
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ pub use list::{Filter, StatefulList};
|
|||
use bindings::Action;
|
||||
use library::Library;
|
||||
use now_playing::NowPlaying;
|
||||
pub use now_playing::SpectrumStyle;
|
||||
use queue::Queue;
|
||||
pub use register::Register;
|
||||
|
||||
|
|
@ -66,22 +65,10 @@ pub(crate) struct UiItem {
|
|||
}
|
||||
|
||||
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);
|
||||
pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82);
|
||||
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);
|
||||
/// [`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);
|
||||
// const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112);
|
||||
// const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
use std::{
|
||||
ops::Div,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{ops::Div, time::Duration};
|
||||
|
||||
#[cfg(feature = "notifications")]
|
||||
use notify_rust::Notification;
|
||||
|
|
@ -16,299 +13,34 @@ use ratatui::{
|
|||
Frame,
|
||||
};
|
||||
|
||||
use super::{COLOR_PRIMARY, COLOR_RED, COLOR_SECONDARY};
|
||||
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
|
||||
|
||||
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
|
||||
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
|
||||
/// The unfilled peak-hold shadow: a thin rule at the peak, the width of a
|
||||
/// cell (U+2594 upper one-eighth block).
|
||||
const PEAK_MARK: char = '▔';
|
||||
|
||||
/// 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);
|
||||
/// Renders `bins` as full-height vertical bars filling a `width`×`height`
|
||||
/// area: one `Line` per row, top row first. Each column maps to a bin;
|
||||
/// its level in `[0, 1]` fills from the bottom, using partial block
|
||||
/// glyphs for the topmost fractional cell. Pure, so it is unit-tested.
|
||||
fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static>> {
|
||||
(0..height)
|
||||
.map(|row| {
|
||||
// Row 0 is the top; count cells up from the bottom.
|
||||
let from_bottom = height - 1 - row;
|
||||
let bar_color = if style.gradient {
|
||||
gradient_color(style.color, style.top, from_bottom, height)
|
||||
let cells: String = (0..width)
|
||||
.map(|col| {
|
||||
let bin = if bins.is_empty() {
|
||||
0
|
||||
} else {
|
||||
style.color
|
||||
(col * bins.len() / width.max(1)).min(bins.len() - 1)
|
||||
};
|
||||
// Runs of same-colored cells, so a row is a handful of spans
|
||||
// rather than one per column.
|
||||
let mut runs: Vec<(Color, String)> = Vec::new();
|
||||
let seam_color = dimmed(bar_color, DIVIDER_DIM);
|
||||
for column in &columns {
|
||||
// A seam shows its own bar, dimmed. Where the color cannot
|
||||
// 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<_>>(),
|
||||
)
|
||||
let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let total_eighths = (level * height as f32 * 8.0).round() as usize;
|
||||
let cell = total_eighths.saturating_sub(from_bottom * 8).min(8);
|
||||
BLOCKS[cell]
|
||||
})
|
||||
.collect();
|
||||
Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -344,17 +76,8 @@ pub struct NowPlaying {
|
|||
/// Latest frequency-spectrum bars (architecture/spectrum.md), empty
|
||||
/// until the first frame arrives.
|
||||
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).
|
||||
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.
|
||||
muted: bool,
|
||||
/// The server's output level, `1.0` = 100%. This is the level playback
|
||||
|
|
@ -371,10 +94,7 @@ impl Default for NowPlaying {
|
|||
position: None,
|
||||
track: None,
|
||||
spectrum: Vec::new(),
|
||||
spectrum_peaks: Vec::new(),
|
||||
spectrum_frame_at: None,
|
||||
spectrum_enabled: true,
|
||||
spectrum_style: SpectrumStyle::default(),
|
||||
muted: false,
|
||||
volume: 1.0,
|
||||
}
|
||||
|
|
@ -398,49 +118,14 @@ impl NowPlaying {
|
|||
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
||||
self.modifiers = *mods;
|
||||
}
|
||||
/// 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.
|
||||
/// Applies a spectrum frame from the server (already normalized).
|
||||
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;
|
||||
}
|
||||
/// Enables/disables the spectrum row (from client config).
|
||||
pub fn set_spectrum_enabled(&mut self, enabled: bool) {
|
||||
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
|
||||
/// keeps streaming the bars; this only gates rendering.
|
||||
pub fn toggle_spectrum(&mut self) {
|
||||
|
|
@ -592,21 +277,14 @@ impl NowPlaying {
|
|||
f.render_widget(time_p, elapsed_layout[1]);
|
||||
}
|
||||
|
||||
// The spectrum: full-height bars filling the region left below the
|
||||
// progress, in the configured color, under a peak-hold marker line.
|
||||
// Columns stretch across the pane width regardless of the server's
|
||||
// bin count.
|
||||
// The spectrum: full-height accent bars filling the region left
|
||||
// below the progress. Columns stretch across the pane width
|
||||
// regardless of the server's bin count.
|
||||
if self.spectrum_enabled && !self.spectrum.is_empty() {
|
||||
let area = now_playing_layout[2];
|
||||
let (width, height) = (area.width as usize, area.height as usize);
|
||||
if width > 0 && height > 0 {
|
||||
let lines = spectrum_lines(
|
||||
&self.spectrum,
|
||||
&self.spectrum_peaks,
|
||||
width,
|
||||
height,
|
||||
self.spectrum_style,
|
||||
);
|
||||
let lines = spectrum_lines(&self.spectrum, width, height);
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
}
|
||||
|
|
@ -670,40 +348,12 @@ mod tests {
|
|||
is_captured: false,
|
||||
}),
|
||||
spectrum: Vec::new(),
|
||||
spectrum_peaks: Vec::new(),
|
||||
spectrum_frame_at: None,
|
||||
spectrum_enabled: true,
|
||||
spectrum_style: SpectrumStyle::default(),
|
||||
muted: false,
|
||||
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) {
|
||||
let backend = TestBackend::new(60, 12);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
|
|
@ -731,7 +381,7 @@ mod tests {
|
|||
fn spectrum_lines_fill_full_height_columns() {
|
||||
// A full-level bin fills every row of its column with the full
|
||||
// block; a zero bin leaves every row blank.
|
||||
let lines = spectrum_lines(&[1.0, 0.0], &[], 2, 4, flat());
|
||||
let lines = spectrum_lines(&[1.0, 0.0], 2, 4);
|
||||
assert_eq!(lines.len(), 4, "one line per row");
|
||||
let text: Vec<String> = lines
|
||||
.iter()
|
||||
|
|
@ -745,7 +395,7 @@ mod tests {
|
|||
#[test]
|
||||
fn spectrum_lines_grow_from_the_bottom() {
|
||||
// Half level over 4 rows ≈ 16 eighths → the bottom two rows fill.
|
||||
let lines = spectrum_lines(&[0.5], &[], 1, 4, flat());
|
||||
let lines = spectrum_lines(&[0.5], 1, 4);
|
||||
let col: Vec<char> = lines
|
||||
.iter()
|
||||
.map(|l| l.spans[0].content.chars().next().unwrap())
|
||||
|
|
@ -760,471 +410,11 @@ mod tests {
|
|||
let mut pane = now_playing(10_000, 60_000);
|
||||
pane.update_spectrum(vec![1.0; 24]);
|
||||
let rows = rendered_rows(&pane);
|
||||
// Full-level bars fill several rows to the row gap.
|
||||
let filled = full_cell(SpectrumStyle::default());
|
||||
let full_rows = rows.iter().filter(|r| r.contains(filled)).count();
|
||||
// Full-level bars fill several rows with the full block.
|
||||
let full_rows = rows.iter().filter(|r| r.contains('█')).count();
|
||||
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]
|
||||
fn the_spectrum_row_is_hidden_when_disabled() {
|
||||
let mut pane = now_playing(10_000, 60_000);
|
||||
|
|
@ -1232,9 +422,7 @@ mod tests {
|
|||
pane.update_spectrum(vec![1.0; 24]);
|
||||
let rows = rendered_rows(&pane);
|
||||
assert!(
|
||||
!rows
|
||||
.iter()
|
||||
.any(|r| r.contains(full_cell(pane.spectrum_style))),
|
||||
!rows.iter().any(|r| r.contains('█')),
|
||||
"disabled spectrum must not draw bars: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1243,14 +431,13 @@ mod tests {
|
|||
fn toggle_spectrum_hides_then_shows_the_bars() {
|
||||
let mut pane = now_playing(10_000, 60_000);
|
||||
pane.update_spectrum(vec![1.0; 24]);
|
||||
let filled = full_cell(pane.spectrum_style);
|
||||
assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
|
||||
// `f` hides the bars…
|
||||
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
||||
// `v` hides the bars…
|
||||
pane.toggle_spectrum();
|
||||
assert!(!rendered_rows(&pane).iter().any(|r| r.contains(filled)));
|
||||
assert!(!rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
||||
// …and again brings them back.
|
||||
pane.toggle_spectrum();
|
||||
assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
|
||||
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::app::SpectrumStyle;
|
||||
|
||||
#[derive(ClapSerde, Serialize, Debug)]
|
||||
#[clap(author, version, about)]
|
||||
|
|
@ -175,162 +169,6 @@ pub struct ServerConfig {
|
|||
#[default(true)]
|
||||
#[clap(long)]
|
||||
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)]
|
||||
|
|
@ -372,146 +210,6 @@ mod tests {
|
|||
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]
|
||||
fn write_auth_round_trips_user_password_address() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
|
|
|
|||
|
|
@ -43,11 +43,8 @@ pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
|
|||
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
||||
|
||||
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 || {
|
||||
run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_style);
|
||||
run_ui(ui_tx, ui_rx, spectrum_enabled);
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
@ -212,12 +209,7 @@ async fn poll(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn run_ui(
|
||||
tx: Sender<MessageFromUi>,
|
||||
rx: Receiver<MessageToUi>,
|
||||
spectrum_enabled: bool,
|
||||
spectrum_style: app::SpectrumStyle,
|
||||
) {
|
||||
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled: bool) {
|
||||
// setup terminal
|
||||
enable_raw_mode().unwrap();
|
||||
let mut stdout = io::stdout();
|
||||
|
|
@ -228,7 +220,6 @@ fn run_ui(
|
|||
// create app and run it
|
||||
let mut app = App::new(tx);
|
||||
app.now_playing.set_spectrum_enabled(spectrum_enabled);
|
||||
app.now_playing.set_spectrum_style(spectrum_style);
|
||||
let tick_rate = Duration::from_millis(100);
|
||||
let mut last_tick = Instant::now();
|
||||
|
||||
|
|
|
|||
|
|
@ -25,25 +25,12 @@ functionality as `cbd-tui`, served by `crabidy-server` itself.
|
|||
|
||||
## Functionality
|
||||
|
||||
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click), marks
|
||||
and visual mode (`s`, `v`/`V`) in both panes, the one-slot register (`y`
|
||||
yanks, `d`/`c`/`C` fill it as they remove, `p`/`P` paste), create/rename/
|
||||
delete nodes (`%`/`e`/`d`), bookmark and capture (`w`/`W`, with live progress
|
||||
lines and skipped-track marking), the full queue and playback controls,
|
||||
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
|
||||
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click),
|
||||
marks, create/rename/delete nodes (`%`/`e`/`d`), bookmark and capture
|
||||
(`w`/`W`, with live progress lines and skipped-track marking), the full queue
|
||||
and playback controls, volume, shuffle/repeat, and a `?` help overlay listing
|
||||
the keys. Keys mirror the TUI; every key also has a clickable control. A
|
||||
light/dark theme follows the OS and can be toggled (persisted). The accent
|
||||
color is the crab orange-red.
|
||||
|
||||
When the server requires credentials, a login form collects the role
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ pub fn minimum_role(grpc_path: &str) -> Role {
|
|||
// Every other queue and playback verb.
|
||||
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent"
|
||||
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
|
||||
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" | "Seek" => Role::QueueOwner,
|
||||
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" => Role::QueueOwner,
|
||||
// Library writes (CaptureLibraryNode, SaveQueue,
|
||||
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
|
||||
_ => 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
|
||||
/// (argon2id, default parameters, fresh random salt). Backs the
|
||||
/// `crabidy-server guard` subcommand.
|
||||
/// `crabidy-server hash-password` helper.
|
||||
pub fn hash_password(password: &str) -> Result<String, String> {
|
||||
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
||||
use argon2::PasswordHasher;
|
||||
|
|
@ -355,7 +355,6 @@ mod tests {
|
|||
"Next",
|
||||
"Prev",
|
||||
"RestartTrack",
|
||||
"Seek",
|
||||
];
|
||||
let owner = [
|
||||
"CaptureLibraryNode",
|
||||
|
|
@ -363,28 +362,10 @@ mod tests {
|
|||
"RenameLibraryNode",
|
||||
"DeleteLibraryNode",
|
||||
];
|
||||
// Every RPC must appear in exactly one list above. Taken from the
|
||||
// proto itself rather than a count copied out of it: a hardcoded
|
||||
// total let `Seek` be added to the service and land on the
|
||||
// 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)"
|
||||
);
|
||||
// The full service, from crabidy.proto — 24 methods. A new RPC
|
||||
// must be added to exactly one list (and the layer keeps it
|
||||
// owner-only until then).
|
||||
assert_eq!(appender.len() + queue_owner.len() + owner.len(), 24);
|
||||
for method in appender {
|
||||
let path = format!("{SERVICE_PREFIX}{method}");
|
||||
assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}");
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ below it (owner ⊃ queue-owner ⊃ queue-appender):
|
|||
- **owner** — the normal user: everything, including all library
|
||||
writes.
|
||||
- **queue-owner** — anything on the queue and playback (append, remove,
|
||||
reorder, clear, shuffle, repeat, play/stop, next/prev, seek, volume,
|
||||
mute), but **no library writes**: no bookmarks (`w`), no captures (`W`),
|
||||
reorder, clear, shuffle, repeat, play/stop, next/prev, volume, mute,
|
||||
…), but **no library writes**: no bookmarks (`w`), no captures (`W`),
|
||||
no saving, renaming, or deleting.
|
||||
- **queue-appender** — may browse and search the library and **append**
|
||||
tracks to the queue; nothing else. (Searching is allowed because
|
||||
|
|
|
|||
|
|
@ -36,13 +36,12 @@ The same list goes into the server's startup log, so a support question
|
|||
| `abs` | the `/abs` audiobookshelf provider |
|
||||
| `soundcloud` | the `/soundcloud` provider |
|
||||
| `jamendo` | the `/jamendo` provider |
|
||||
| `rss` | the `/rss` podcast-subscription provider |
|
||||
| `fs` | local files **and persistent state** — see below |
|
||||
| `opus` | Ogg-Opus decoding (`symphonia` + a bundled libopus C build) |
|
||||
| `spectrum` | the server-side FFT feeding clients' spectrum bars |
|
||||
| `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) |
|
||||
|
||||
Plus two conveniences: `all-providers` enables the eight provider features
|
||||
Plus two conveniences: `all-providers` enables the seven provider features
|
||||
at once, and `cbd` (the bundle) mirrors every feature above and adds
|
||||
`notifications` for the TUI's desktop "now playing" popups (`notify-rust`,
|
||||
which on Linux pulls a D-Bus stack).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
**commands** (browse the library, change the queue, control playback)
|
||||
and subscribes to the **update stream** that pushes the current queue,
|
||||
play state, track position, output level and mute, capture progress, and
|
||||
the frequency spectrum as they change. Nothing is polled — each client redraws from
|
||||
play state, track position, capture progress, and the frequency
|
||||
spectrum as they change. Nothing is polled — each client redraws from
|
||||
the pushed updates, so several clients driving one server always agree
|
||||
on what is playing.
|
||||
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ its visible set is recomputed.
|
|||
## Frequency spectrum
|
||||
|
||||
A row of frequency bars is drawn under the track progress while audio
|
||||
plays, as block glyphs (`▁▂▃▄▅▆▇█`). The bars are
|
||||
plays, as block glyphs (`▁▂▃▄▅▆▇█`) in the accent color. The bars are
|
||||
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
|
||||
them on the update stream like every other bit of live state. So the
|
||||
|
|
@ -147,71 +147,6 @@ config sets the startup default (`spectrum = true` is the default;
|
|||
`false` starts hidden). Either way it is only a display choice — the
|
||||
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
|
||||
|
||||
Global keys work in either pane. Pane keys apply only while that pane is
|
||||
|
|
|
|||
|
|
@ -105,50 +105,11 @@ password = ""
|
|||
|
||||
# Show the frequency-spectrum bars under the track progress.
|
||||
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 except the `spectrum_*` appearance settings (everything below
|
||||
`spectrum` itself) is also a command-line flag, given before the subcommand
|
||||
(`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum false`).
|
||||
A provided flag overrides the file value; an omitted flag
|
||||
Every option is also a command-line flag, given before the subcommand
|
||||
(`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
|
||||
once instead of editing by hand, use the `auth` subcommand (see
|
||||
[Command line](./clients/cli.md)):
|
||||
|
|
@ -164,8 +125,8 @@ plaintext credential, not a hash. Keep the client config file private.
|
|||
The password is never written to logs.
|
||||
```
|
||||
|
||||
For the client's `spectrum` options in context — shading, and the
|
||||
peak-hold markers — see [The terminal client](./clients/tui.md).
|
||||
For the client's `spectrum` option in context, see
|
||||
[The terminal client](./clients/tui.md).
|
||||
|
||||
## Where the server's own data lives
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ segment is a **provider** mounted as a subtree:
|
|||
├── fs a local music folder
|
||||
├── fyyd podcast search
|
||||
├── jamendo Creative-Commons music
|
||||
├── rss podcast subscriptions
|
||||
├── soundcloud SoundCloud search, likes, and playlists
|
||||
├── tidal Tidal streaming
|
||||
├── youtube YouTube search & playlists
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ independent things decide it:
|
|||
|
||||
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
|
||||
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.
|
||||
2. **Is it enabled?** The `providers` list in `crabidy-server.toml` turns
|
||||
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.
|
||||
|
||||
```admonish note
|
||||
|
|
|
|||
172
plan/summary.md
172
plan/summary.md
|
|
@ -1640,175 +1640,3 @@ a nudge next to a skip.
|
|||
`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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -43,13 +43,6 @@ 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,
|
||||
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
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue