Compare commits

..

No commits in common. "d2f687983efe38c63af313c7944a679d8018343e" and "1e8afd5f41c22a84e7c0eb8cf1a7b18a0db87c70" have entirely different histories.

10 changed files with 35 additions and 436 deletions

View File

@ -158,16 +158,6 @@ impl Player {
Ok(rx.recv_async().await?) Ok(rx.recv_async().await?)
} }
/// Whether output is muted, for telling a connecting client the truth
/// instead of assuming it is not.
pub async fn is_muted(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetMuted(tx))
.await?;
Ok(rx.recv_async().await?)
}
pub async fn pause(&self) -> Result<()> { pub async fn pause(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine

View File

@ -93,9 +93,6 @@ pub enum PlayerEngineCommand {
SeekBy(i64, Sender<Result<Duration>>), SeekBy(i64, Sender<Result<Duration>>),
GetVolume(Sender<f32>), GetVolume(Sender<f32>),
ToggleMute(Sender<bool>), ToggleMute(Sender<bool>),
/// The current muted state, so a connecting client can be told it
/// rather than assuming "not muted".
GetMuted(Sender<bool>),
GetPaused(Sender<Result<bool>>), GetPaused(Sender<Result<bool>>),
/// End of stream for the source started by the given generation. /// End of stream for the source started by the given generation.
/// Stale generations are ignored so an old track finishing can never /// Stale generations are ignored so an old track finishing can never
@ -296,7 +293,6 @@ impl PlayerEngine {
} }
PlayerEngineCommand::GetVolume(tx) => send_reply(tx, self.volume()), PlayerEngineCommand::GetVolume(tx) => send_reply(tx, self.volume()),
PlayerEngineCommand::ToggleMute(tx) => send_reply(tx, self.toggle_mute()), PlayerEngineCommand::ToggleMute(tx) => send_reply(tx, self.toggle_mute()),
PlayerEngineCommand::GetMuted(tx) => send_reply(tx, self.muted),
PlayerEngineCommand::GetPaused(tx) => send_reply(tx, self.is_paused()), PlayerEngineCommand::GetPaused(tx) => send_reply(tx, self.is_paused()),
PlayerEngineCommand::Eos(generation) => self.handle_eos(generation), PlayerEngineCommand::Eos(generation) => self.handle_eos(generation),
} }

View File

@ -45,28 +45,6 @@ fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static
.collect() .collect()
} }
/// Formats the server's output level for the status line: `1.0` reads as
/// `100%`, and since the server clamps to 1.1 the display can reach
/// `110%`. While muted the level is kept on screen — the server reports
/// the level it would unmute to, which is what the user wants to see —
/// with the mute called out rather than the number replaced.
///
/// A volume off the wire is a `float`, so it can arrive as NaN or
/// infinity from a wrong or malicious peer; that reads as `--` instead of
/// `NaN%`, and negative zero as `0%`.
fn format_volume(volume: f32, muted: bool) -> String {
let level = if volume.is_finite() {
format!("{:.0}%", (volume * 100.0).max(0.0))
} else {
"--".to_string()
};
if muted {
format!("{level} (muted)")
} else {
level
}
}
pub struct NowPlaying { pub struct NowPlaying {
play_state: PlayState, play_state: PlayState,
duration: Option<Duration>, duration: Option<Duration>,
@ -80,9 +58,6 @@ pub struct NowPlaying {
spectrum_enabled: bool, spectrum_enabled: bool,
/// 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
/// would resume at, so it stays meaningful while muted.
volume: f32,
} }
impl Default for NowPlaying { impl Default for NowPlaying {
@ -96,7 +71,6 @@ impl Default for NowPlaying {
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_enabled: true, spectrum_enabled: true,
muted: false, muted: false,
volume: 1.0,
} }
} }
} }
@ -135,10 +109,6 @@ impl NowPlaying {
pub fn update_mute(&mut self, muted: bool) { pub fn update_mute(&mut self, muted: bool) {
self.muted = muted; self.muted = muted;
} }
/// Reflects the server's output level (see [`format_volume`]).
pub fn update_volume(&mut self, volume: f32) {
self.volume = volume;
}
pub fn render(&self, f: &mut Frame, area: Rect) { pub fn render(&self, f: &mut Frame, area: Rect) {
// With the spectrum on, the info block takes exactly the height // With the spectrum on, the info block takes exactly the height
@ -162,17 +132,6 @@ impl NowPlaying {
.constraints(constraints) .constraints(constraints)
.split(area); .split(area);
// Shuffle, repeat and volume describe the *server*, not the track,
// so this line is drawn whether or not something is loaded — the
// volume you are about to press `K` on should be on screen before
// playback starts.
let mods = format!(
"Shuffle: {}, Repeat: {}, Volume: {}",
self.modifiers.shuffle,
self.modifiers.repeat,
format_volume(self.volume, self.muted),
);
let media_info_text = if let Some(track) = &self.track { let media_info_text = if let Some(track) = &self.track {
let play_text = match self.play_state { let play_text = match self.play_state {
PlayState::Loading => "", PlayState::Loading => "",
@ -184,6 +143,12 @@ impl NowPlaying {
Some(album) => album.title.to_string(), Some(album) => album.title.to_string(),
None => "No album".to_string(), None => "No album".to_string(),
}; };
let mods = format!(
"Shuffle: {}, Repeat: {}{}",
self.modifiers.shuffle,
self.modifiers.repeat,
if self.muted { ", Muted" } else { "" },
);
vec![ vec![
Line::from(Span::raw(mods)), Line::from(Span::raw(mods)),
Line::from(Span::raw(play_text)), Line::from(Span::raw(play_text)),
@ -202,7 +167,7 @@ impl NowPlaying {
] ]
} else { } else {
vec![ vec![
Line::from(Span::raw(mods)), Line::from(Span::raw("")),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::raw("No track playing")), Line::from(Span::raw("No track playing")),
] ]
@ -350,7 +315,6 @@ mod tests {
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_enabled: true, spectrum_enabled: true,
muted: false, muted: false,
volume: 1.0,
} }
} }
@ -440,58 +404,6 @@ mod tests {
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
} }
#[test]
fn the_volume_reads_as_a_percentage() {
assert_eq!(format_volume(1.0, false), "100%");
assert_eq!(format_volume(0.85, false), "85%");
assert_eq!(format_volume(0.0, false), "0%");
// The server clamps to 1.1, so the display tops out at 110%.
assert_eq!(format_volume(1.1, false), "110%");
}
/// Muting must not hide the level: the server reports the level it
/// would unmute to, which is what the user is about to adjust.
#[test]
fn a_muted_server_still_shows_its_level() {
assert_eq!(format_volume(0.4, true), "40% (muted)");
}
/// The wire carries a `float`, so NaN/infinity are reachable from a
/// wrong peer and must not render as `NaN%`.
#[test]
fn a_broken_volume_renders_as_unknown() {
assert_eq!(format_volume(f32::NAN, false), "--");
assert_eq!(format_volume(f32::INFINITY, false), "--");
assert_eq!(format_volume(f32::NEG_INFINITY, true), "-- (muted)");
// Negative zero would otherwise print as `-0%`.
assert_eq!(format_volume(-0.0, false), "0%");
}
#[test]
fn the_volume_is_on_screen_while_playing() {
let mut pane = now_playing(10_000, 60_000);
pane.update_volume(0.6);
let rows = rendered_rows(&pane);
assert!(
rows.iter().any(|r| r.contains("Volume: 60%")),
"expected the level in the status line: {rows:?}"
);
}
/// Volume describes the server, not the track, so it is visible before
/// anything is loaded — that is when you reach for `K` blind.
#[test]
fn the_volume_is_on_screen_without_a_track() {
let mut pane = NowPlaying::default();
pane.update_volume(0.25);
pane.update_mute(true);
let rows = rendered_rows(&pane);
assert!(
rows.iter().any(|r| r.contains("Volume: 25% (muted)")),
"expected the level with no track loaded: {rows:?}"
);
}
/// The position can overrun a stale or wrong duration (streams, /// The position can overrun a stale or wrong duration (streams,
/// hand-written track files); the gauge must clamp instead of hitting /// hand-written track files); the gauge must clamp instead of hitting
/// ratatui's `ratio should be between 0 and 1` panic. /// ratatui's `ratio should be between 0 and 1` panic.

View File

@ -243,11 +243,6 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
if let Some(mods) = init_data.mods { if let Some(mods) = init_data.mods {
app.now_playing.update_modifiers(&mods); app.now_playing.update_modifiers(&mods);
} }
// Both were dropped here, so the pane showed a default
// volume and an unmuted server until the user first
// touched either — a display that starts out wrong.
app.now_playing.update_volume(init_data.volume);
app.now_playing.update_mute(init_data.mute);
} }
MessageToUi::Update(update) => match update { MessageToUi::Update(update) => match update {
StreamUpdate::Queue(queue) => { StreamUpdate::Queue(queue) => {
@ -267,7 +262,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
app.now_playing.update_modifiers(&mods); app.now_playing.update_modifiers(&mods);
} }
StreamUpdate::Mute(muted) => app.now_playing.update_mute(muted), StreamUpdate::Mute(muted) => app.now_playing.update_mute(muted),
StreamUpdate::Volume(volume) => app.now_playing.update_volume(volume), StreamUpdate::Volume(_) => { /* FIXME: implement */ }
StreamUpdate::CaptureProgress(progress) => { StreamUpdate::CaptureProgress(progress) => {
app.captures.apply(progress); app.captures.apply(progress);
} }

View File

@ -21,13 +21,6 @@ use crate::state::{
const VOLUME_STEP: f32 = 0.1; const VOLUME_STEP: f32 = 0.1;
/// The highest level the server will accept — it clamps to this in
/// `PlayerEngine::set_volume`, and now reports back the level it took, so a
/// slider that ran past this point had a dead stretch at its right end that
/// filled and then sprang back. Must track the engine's clamp; the audio
/// crate is native-only, so the value cannot be imported from it.
const MAX_VOLUME: f32 = 1.1;
/// How far one seek press moves the playing position, in milliseconds — the /// How far one seek press moves the playing position, in milliseconds — the
/// unit the wire speaks (architecture/seek.md D3). The step lives in the /// unit the wire speaks (architecture/seek.md D3). The step lives in the
/// client; the server only ever receives an offset and applies it to the live /// client; the server only ever receives an offset and applies it to the live
@ -807,30 +800,9 @@ fn TopBar(store: Store) -> impl IntoView {
save_pref("theme", next); save_pref("theme", next);
apply_theme(next); apply_theme(next);
}; };
// What `Tab` does, as a tap target. On a phone the panes stack and only
// the focused one is open, so switching panes has to be reachable without
// a keyboard. It stays on screen at every width, where it doubles as the
// readout of which pane the keys are going to.
let tab = move |target: Focus, label: &'static str| {
view! {
<button
class="ghost"
class:active=move || store.focus.get() == target
aria-pressed=move || (store.focus.get() == target).to_string()
title="switch pane (Tab)"
on:click=move |_| store.focus.set(target)
>
{label}
</button>
}
};
view! { view! {
<header class="topbar"> <header class="topbar">
<span class="brand">"crabidy"</span> <span class="brand">"crabidy"</span>
<span class="tabs">
{tab(Focus::Library, "library")}
{tab(Focus::Queue, "queue")}
</span>
<span <span
class="conn" class="conn"
class:offline=move || !store.connected.get() class:offline=move || !store.connected.get()
@ -1216,18 +1188,12 @@ fn Transport(store: Store) -> impl IntoView {
<div class="controls"> <div class="controls">
<button class="ghost" title="previous track (<)" <button class="ghost" title="previous track (<)"
on:click=move |_| store.dispatch(Action::PrevTrack)>""</button> on:click=move |_| store.dispatch(Action::PrevTrack)>""</button>
// Guillemets, not U+23EA/U+23E9: those two carry emoji
// presentation, so a browser with a color emoji font drew them
// full-color and oversized next to a row of monochrome type.
// These are plain text glyphs at the weight of the `` in the
// library toolbar, and read as a nudge against the heavier
// prev/next marks either side of them.
<button class="ghost" title="back 15 seconds (,)" <button class="ghost" title="back 15 seconds (,)"
on:click=move |_| store.dispatch(Action::SeekBackward)>"«"</button> on:click=move |_| store.dispatch(Action::SeekBackward)>""</button>
<button class="ghost big" title="play/pause (Space)" <button class="ghost big" title="play/pause (Space)"
on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button> on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button>
<button class="ghost" title="forward 15 seconds (.)" <button class="ghost" title="forward 15 seconds (.)"
on:click=move |_| store.dispatch(Action::SeekForward)>"»"</button> on:click=move |_| store.dispatch(Action::SeekForward)>""</button>
<button class="ghost" title="next track (>)" <button class="ghost" title="next track (>)"
on:click=move |_| store.dispatch(Action::NextTrack)>""</button> on:click=move |_| store.dispatch(Action::NextTrack)>""</button>
<button class="ghost" title="restart track (r)" <button class="ghost" title="restart track (r)"
@ -1300,10 +1266,10 @@ fn Transport(store: Store) -> impl IntoView {
<input <input
type="range" type="range"
min="0" min="0"
max=MAX_VOLUME.to_string() max="1.5"
step="0.05" step="0.05"
prop:value=move || store.volume.get().to_string() prop:value=move || store.volume.get().to_string()
title=move || format!("volume {:.0}% (J/K)", store.volume.get() * 100.0) title="volume (J/K)"
on:input=on_volume on:input=on_volume
/> />
</div> </div>

View File

@ -126,30 +126,12 @@ input {
& .conn { & .conn {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--fg-dim); color: var(--fg-dim);
/* Truncate rather than push the pane tabs off a narrow screen. */
min-inline-size: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&.offline { &.offline {
color: var(--danger); color: var(--danger);
} }
} }
& .tabs {
display: flex;
gap: 0.1rem;
padding: 0.1rem;
border: 1px solid var(--border);
border-radius: 8px;
/* Comfortable for a thumb — this is the phone's only pane switch. */
& button {
padding: 0.3rem 0.7rem;
}
}
& .topbar-actions { & .topbar-actions {
margin-inline-start: auto; margin-inline-start: auto;
display: flex; display: flex;
@ -297,11 +279,6 @@ input {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.1rem; gap: 0.1rem;
/* The prev/next/pause marks carry emoji presentation too, so a browser
with a color emoji font renders them as pictures rather than type.
Ask for the text form; ignored where unsupported, which costs
nothing the glyphs are the fallback either way. */
font-variant-emoji: text;
& .big { & .big {
font-size: 1.3rem; font-size: 1.3rem;
@ -485,23 +462,19 @@ input {
/* ---- phone ------------------------------------------------------------- */ /* ---- phone ------------------------------------------------------------- */
@media (max-width: 700px) { @media (max-width: 700px) {
/* One pane at a time, switched by the topbar tabs (or `Tab`). The other /* One pane at a time; Tab (or tapping a pane edge) switches the
pane is gone rather than collapsed to a strip: a strip is a row of unfocused pane collapses to a slim strip acting as its tab. */
truncated rows and toolbar buttons that still take taps, so aiming at
it hit the wrong pane's list or its "play". The tabs replace it. */
.panes { .panes {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 1fr; grid-template-rows: 1fr auto;
}
.pane {
/* No second column to divide, so the border would sit on the screen
edge and `:last-child` only spares the queue. */
border-inline-end: none;
} }
.pane:not(.focused) { .pane:not(.focused) {
display: none; grid-template-rows: auto;
max-block-size: 2.4rem;
overflow: hidden;
border-block-start: 1px solid var(--border);
opacity: 0.75;
} }
.transport { .transport {

View File

@ -19,8 +19,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(feature = "fs")] #[cfg(feature = "fs")]
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration;
use tokio::time::timeout;
#[cfg(feature = "fs")] #[cfg(feature = "fs")]
use tracing::info; use tracing::info;
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument}; use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
@ -134,10 +132,6 @@ impl Playback {
trace!("handling playback command"); trace!("handling playback command");
match command { match command {
PlaybackCommand::Init { result_tx } => { PlaybackCommand::Init { result_tx } => {
// Asked before the queue lock is taken: that lock is a std
// `Mutex`, so it must not be held across an await, which is
// why these three were hardcoded below in the first place.
let (volume, muted, position) = self.player_snapshot().await;
let response = { let response = {
let Ok(queue) = self.queue.lock() else { let Ok(queue) = self.queue.lock() else {
error!("queue lock poisoned"); error!("queue lock poisoned");
@ -147,6 +141,10 @@ impl Playback {
queue_position: queue.current_position() as u32, queue_position: queue.current_position() as u32,
track: queue.current_track(), track: queue.current_track(),
}; };
let position = TrackPosition {
duration: 0,
position: 0,
};
let play_state = { let play_state = {
let Ok(play_state) = self.state.lock() else { let Ok(play_state) = self.state.lock() else {
error!("play state lock poisoned"); error!("play state lock poisoned");
@ -160,8 +158,8 @@ impl Playback {
queue: Some(self.queue_snapshot(&queue)), queue: Some(self.queue_snapshot(&queue)),
queue_track: Some(queue_track), queue_track: Some(queue_track),
play_state: play_state as i32, play_state: play_state as i32,
volume, volume: 0.0,
mute: muted, mute: false,
position: Some(position), position: Some(position),
mods: Some(QueueModifiers { mods: Some(QueueModifiers {
repeat: queue.repeat, repeat: queue.repeat,
@ -382,22 +380,12 @@ impl Playback {
PlaybackCommand::ChangeVolume { delta } => { PlaybackCommand::ChangeVolume { delta } => {
match self.player.volume().await { match self.player.volume().await {
Ok(volume) => match self.player.set_volume(volume + delta).await {
// Nothing was broadcast here, so no client ever
// learned the level had changed. The level sent is
// the one the engine actually took, so a client
// also sees the clamp at 1.1 rather than the value
// it asked for. `set_volume` unmutes as well
// ("reaching for the volume is an intent to hear
// something"), so the mute indicator needs the
// same news or it stays stuck on muted.
Ok(volume) => { Ok(volume) => {
debug!(volume, delta, "changed volume"); debug!(volume, delta, "changing volume");
self.broadcast(StreamUpdate::Volume(volume)); if let Err(err) = self.player.set_volume(volume + delta).await {
self.broadcast(StreamUpdate::Mute(false)); warn!("set_volume failed: {err:?}");
}
} }
Err(err) => warn!("set_volume failed: {err:?}"),
},
Err(err) => warn!("could not read volume: {err:?}"), Err(err) => warn!("could not read volume: {err:?}"),
}; };
} }
@ -506,84 +494,6 @@ impl Playback {
} }
} }
/// The player-owned half of the init snapshot: output level, mute
/// state, and the position within the current track. All three were
/// hardcoded to neutral values, so every client showed 0% volume, an
/// unmuted server and 0:00 until something happened to change them —
/// and while *paused* nothing does, since the position tick skips a
/// paused sink.
///
/// Every read is bounded. The engine thread is single-threaded and can
/// be busy for up to 30 s opening a network stream, and this is the
/// **connect** path: a client must get a usable snapshot rather than
/// hang waiting for one. On a timeout each field falls back to what the
/// engine starts at, and the next update on the stream corrects it. A
/// late reply lands in a dropped receiver, which the engine only logs.
async fn player_snapshot(&self) -> (f32, bool, TrackPosition) {
/// Deliberately short. The engine has no middle gear here: these
/// are plain field reads, so an idle engine answers in
/// microseconds, while a busy one is mid-`Play` and will not answer
/// for up to 30 s no matter how long we wait. So the budget only
/// decides how long the playback loop — which handles commands one
/// at a time — stalls before giving up on a busy engine. Volume is
/// read first, being the field a user actually looks at.
const READ_TIMEOUT: Duration = Duration::from_secs(1);
let volume = match timeout(READ_TIMEOUT, self.player.volume()).await {
Ok(Ok(volume)) => volume,
Ok(Err(err)) => {
warn!("could not read volume for init: {err:?}");
1.0
}
Err(_) => {
warn!("timed out reading volume for init");
1.0
}
};
let muted = match timeout(READ_TIMEOUT, self.player.is_muted()).await {
Ok(Ok(muted)) => muted,
Ok(Err(err)) => {
warn!("could not read mute state for init: {err:?}");
false
}
Err(_) => {
warn!("timed out reading mute state for init");
false
}
};
// Both error when nothing is loaded, which is the ordinary idle
// case rather than a fault — hence zeros without a warning.
let position = match timeout(READ_TIMEOUT, self.player_position()).await {
Ok(position) => position,
Err(_) => {
warn!("timed out reading position for init");
TrackPosition {
duration: 0,
position: 0,
}
}
};
(volume, muted, position)
}
/// The current position, as milliseconds for the wire. Zeros when
/// nothing is playing.
async fn player_position(&self) -> TrackPosition {
let position = self
.player
.elapsed()
.await
.map(|elapsed| elapsed.as_millis().min(u32::MAX.into()) as u32)
.unwrap_or(0);
let duration = self
.player
.duration()
.await
.map(|duration| duration.as_millis().min(u32::MAX.into()) as u32)
.unwrap_or(0);
TrackPosition { duration, position }
}
/// Sends an update to all connected clients. Having no subscribers is /// Sends an update to all connected clients. Having no subscribers is
/// normal and not an error. /// normal and not an error.
fn broadcast(&self, update: StreamUpdate) { fn broadcast(&self, update: StreamUpdate) {

View File

@ -20,11 +20,7 @@ The screen has two focusable panes side by side and a now-playing pane:
track highlighted. track highlighted.
- **Now playing** — the current track, a progress gauge, and the - **Now playing** — the current track, a progress gauge, and the
frequency-spectrum bars below it (see [Spectrum](#frequency-spectrum), frequency-spectrum bars below it (see [Spectrum](#frequency-spectrum),
fills the rest of the right column). Its top line reports the server's fills the rest of the right column).
shuffle, repeat and output level — `Volume: 85%`, or
`Volume: 85% (muted)`, which keeps the level you would unmute to on
screen. The level tops out at 110%, and it is shown even with nothing
loaded, since it belongs to the server rather than the track.
`Tab` cycles focus between the library and the queue; keys are routed to `Tab` cycles focus between the library and the queue; keys are routed to
whichever pane has focus (plus the global keys, which apply in either). whichever pane has focus (plus the global keys, which apply in either).

View File

@ -16,20 +16,9 @@ before the cursor (see [the TUI's register
section](./tui.md#the-register-y-d-and-pp), which behaves identically here; section](./tui.md#the-register-y-d-and-pp), which behaves identically here;
the queue toolbar shows how many entries are waiting). Seek is there too — the queue toolbar shows how many entries are waiting). Seek is there too —
`,` and `.` move 15 seconds back and forward (`<` and `>` skip a whole `,` and `.` move 15 seconds back and forward (`<` and `>` skip a whole
track), as do the `«`/`»` buttons in the transport bar, and the progress track), as do the `⏪`/`⏩`
bar is clickable — a click seeks to buttons in
that point in the track. The `/` live filter is TUI-only for now. the transport bar. The `/` live filter is TUI-only for now.
A pair of `library` / `queue` tabs in the top bar switches panes and shows
which one the keys go to — the same thing `Tab` does, reachable without a
keyboard. It matters on a phone: below 700px the two panes cannot sit side
by side, so only the focused one is rendered and the tabs are the way
between them.
The volume slider spans the server's whole accepted range, ending at 110%
— the server clamps there, so a slider that went further would have a
dead stretch at its right end. Its tooltip reports the current level as a
percentage; the TUI shows the same figure in its now-playing pane.
## How it is served ## How it is served

View File

@ -1512,131 +1512,3 @@ Also: enabling the web-sys `DomRect` feature, without which
that — `cbd-web`'s `mod app` is `#[cfg(target_arch = "wasm32")]`, so native that — `cbd-web`'s `mod app` is `#[cfg(target_arch = "wasm32")]`, so native
clippy never compiles it. Second time this session that the wasm build was the clippy never compiles it. Second time this session that the wasm build was the
only gate that would have caught a web-client mistake. only gate that would have caught a web-client mistake.
## TUI volume display (2026-07-27)
The now-playing pane now reports the output level — `Volume: 85%`, or
`Volume: 85% (muted)`.
The display was the small half of this. `cbd-tui` was **discarding** the volume
it was already being sent — `StreamUpdate::Volume(_)` was a
`/* FIXME: implement */`.
`Init` dropped `volume` *and* `mute` too, so even after wiring the stream the
pane would have started out wrong and only corrected itself once the user
touched either control. Both are wired now; the web client had been reading all
three fields all along.
Muting keeps the number on screen rather than replacing it, because the server
reports the level it would unmute to (`PlayerEngine::volume` returns
`pre_mute_volume` while muted) — that is the level the user is about to adjust,
so it is the useful one. This retires the old `, Muted` suffix.
The line is drawn whether or not a track is loaded — it was previously inside
the `if let Some(track)` branch. Shuffle, repeat and volume describe the
*server*, and an idle player is exactly when you reach for `K` blind.
Formatting is a pure function so its edges are unit-tested: the wire carries a
`float`, so NaN and infinity are reachable from a wrong peer and read as `--`
rather than `NaN%`, and negative zero reads as `0%`.
Noted, not fixed — the web volume slider is `max="1.5"` while the engine clamps
to `1.1`, so the top third of its travel silently snaps back. A one-character
fix in `cbd-web`, but it is the web client's bug, not this change's.
## TUI volume display — the server was the bug (2026-07-27)
The display shipped and read `Volume: 0%` always. The TUI was fine; the level
never existed to be shown. Three separate holes in `crabidy-server`, each of
which alone was enough to break it:
1. **`Init` hardcoded `volume: 0.0`** (and `mute: false`, and a zeroed
`TrackPosition`). This is the whole reason it read 0% rather than a stale
number.
2. **`PlaybackCommand::ChangeVolume` broadcast nothing.** It read the volume,
set the new one, and told no one — so no client ever learned the level had
changed, and the display could never recover from the bad init value. The
web slider had the same silence, and never tracked `J`/`K` either.
3. **`PlaybackCommand::VolumeChanged` / `MuteChanged` are dead variants** —
handled in `playback.rs`, sent by nobody. They look like the volume
broadcast path while doing nothing, which is presumably how (2) went
unnoticed. Left in place (removing them is unrelated cleanup) but worth
knowing about.
`ChangeVolume` now broadcasts the level the engine *took*, so clients see the
clamp at 1.1 rather than what they asked for, plus `Mute(false)`, because
`set_volume` unmutes and the indicator would otherwise stick on muted.
The hardcoding had a cause worth recording: the init response is built while
holding the queue's `std::sync::Mutex` guard, so the block cannot await the
player. The player reads now happen *before* the lock is taken.
**Bounded, because `Init` is the connect path.** The engine thread is
single-threaded and can be 30 s deep in opening a network stream, so each read
gets a 1 s budget and falls back to what the engine starts at. The budget is
short on purpose: the engine either answers in microseconds (idle) or not for
tens of seconds (mid-`Play`), so the number only decides how long the playback
loop stalls before giving up. A late reply lands in a dropped receiver, which
the engine only logs.
Fixing the init **position** also fixes click-to-seek against a *paused*
server: no position ticks flow while paused, so the web client's position sat
at the hardcoded 0, and a gauge click sent `target - 0` — which the engine then
added to the real position, seeking to roughly twice the intended point.
Not covered by a test: `Player::new` is only reachable through `Playback::new`,
and the engine thread opens an audio device, so there is no device-free way to
construct one here. `is_muted()` (a new engine getter — only `toggle_mute`
existed, which cannot be used to *ask*) is likewise verified by reading.
## Web volume slider bound (2026-07-27)
The slider was `max="1.5"` while the engine clamps to `1.1`, so its right third
was unreachable: the fill stopped at 73% of the track and the rest stayed empty
no matter how far the thumb was dragged. Fixing the server to broadcast the
level it actually took (previous entry) is what made this visible — the thumb
now springs back from that dead stretch instead of sitting where it was
dropped.
`max` is now a named `MAX_VOLUME` constant that has to track
`PlayerEngine::set_volume`'s clamp. It cannot be imported: `audio-player` is
native-only and the web client is wasm, so the two ends of this pair are kept
in agreement by the comment on each.
Raising the engine's clamp instead was the alternative — 1.1 is deliberate
headroom, and more gain risks clipping — so the slider was the side to move.
The tooltip also reports the level as a percentage now, which is the web
counterpart of the TUI's `Volume: 85%`.
## Web pane tabs (2026-07-27)
Below 700px the panes cannot sit side by side, so the layout already stacked
them and collapsed the unfocused one to a title strip. Switching, though, was
only bound to `Tab` and to a tap on that strip — awkward on a phone, where
there is no `Tab` key.
The top bar now carries a `library` / `queue` pair of buttons that set the
focus. They are shown at every width rather than behind a media query: on
desktop they double as the readout of which pane the keys go to, which the
inset border says only faintly.
The unfocused pane is not rendered at all below that width. It used to
collapse to a strip, which was its toolbar and the top of its list with the
rest clipped — all still taking taps, so aiming at the strip hit the wrong
pane's list, or the library's "play", which replaces the queue. With the tabs
carrying the switch, the strip had nothing left to earn.
## Web seek buttons (2026-07-27)
The 15-second seek buttons were `⏪`/`⏩` (U+23EA/U+23E9), which carry emoji
presentation: a browser with a color emoji font drew them as full-color
pictures, taller than the line and off its baseline, in a row that is
otherwise monochrome type.
They are `«`/`»` now — plain text glyphs at the weight of the `` in the
library toolbar, and lighter than the prev/next marks either side, which suits
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.