Compare commits
No commits in common. "d2f687983efe38c63af313c7944a679d8018343e" and "1e8afd5f41c22a84e7c0eb8cf1a7b18a0db87c70" have entirely different histories.
d2f687983e
...
1e8afd5f41
|
|
@ -158,16 +158,6 @@ impl Player {
|
|||
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<()> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine
|
||||
|
|
|
|||
|
|
@ -93,9 +93,6 @@ pub enum PlayerEngineCommand {
|
|||
SeekBy(i64, Sender<Result<Duration>>),
|
||||
GetVolume(Sender<f32>),
|
||||
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>>),
|
||||
/// End of stream for the source started by the given generation.
|
||||
/// 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::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::Eos(generation) => self.handle_eos(generation),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,28 +45,6 @@ fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static
|
|||
.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 {
|
||||
play_state: PlayState,
|
||||
duration: Option<Duration>,
|
||||
|
|
@ -80,9 +58,6 @@ pub struct NowPlaying {
|
|||
spectrum_enabled: bool,
|
||||
/// Whether the server output is muted.
|
||||
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 {
|
||||
|
|
@ -96,7 +71,6 @@ impl Default for NowPlaying {
|
|||
spectrum: Vec::new(),
|
||||
spectrum_enabled: true,
|
||||
muted: false,
|
||||
volume: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -135,10 +109,6 @@ impl NowPlaying {
|
|||
pub fn update_mute(&mut self, muted: bool) {
|
||||
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) {
|
||||
// With the spectrum on, the info block takes exactly the height
|
||||
|
|
@ -162,17 +132,6 @@ impl NowPlaying {
|
|||
.constraints(constraints)
|
||||
.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 play_text = match self.play_state {
|
||||
PlayState::Loading => "▼",
|
||||
|
|
@ -184,6 +143,12 @@ impl NowPlaying {
|
|||
Some(album) => album.title.to_string(),
|
||||
None => "No album".to_string(),
|
||||
};
|
||||
let mods = format!(
|
||||
"Shuffle: {}, Repeat: {}{}",
|
||||
self.modifiers.shuffle,
|
||||
self.modifiers.repeat,
|
||||
if self.muted { ", Muted" } else { "" },
|
||||
);
|
||||
vec![
|
||||
Line::from(Span::raw(mods)),
|
||||
Line::from(Span::raw(play_text)),
|
||||
|
|
@ -202,7 +167,7 @@ impl NowPlaying {
|
|||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::from(Span::raw(mods)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::raw("No track playing")),
|
||||
]
|
||||
|
|
@ -350,7 +315,6 @@ mod tests {
|
|||
spectrum: Vec::new(),
|
||||
spectrum_enabled: true,
|
||||
muted: false,
|
||||
volume: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -440,58 +404,6 @@ mod tests {
|
|||
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,
|
||||
/// hand-written track files); the gauge must clamp instead of hitting
|
||||
/// ratatui's `ratio should be between 0 and 1` panic.
|
||||
|
|
|
|||
|
|
@ -243,11 +243,6 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
|
|||
if let Some(mods) = init_data.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 {
|
||||
StreamUpdate::Queue(queue) => {
|
||||
|
|
@ -267,7 +262,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
|
|||
app.now_playing.update_modifiers(&mods);
|
||||
}
|
||||
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) => {
|
||||
app.captures.apply(progress);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,6 @@ use crate::state::{
|
|||
|
||||
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
|
||||
/// 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
|
||||
|
|
@ -807,30 +800,9 @@ fn TopBar(store: Store) -> impl IntoView {
|
|||
save_pref("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! {
|
||||
<header class="topbar">
|
||||
<span class="brand">"crabidy"</span>
|
||||
<span class="tabs">
|
||||
{tab(Focus::Library, "library")}
|
||||
{tab(Focus::Queue, "queue")}
|
||||
</span>
|
||||
<span
|
||||
class="conn"
|
||||
class:offline=move || !store.connected.get()
|
||||
|
|
@ -1216,18 +1188,12 @@ fn Transport(store: Store) -> impl IntoView {
|
|||
<div class="controls">
|
||||
<button class="ghost" title="previous track (<)"
|
||||
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 (,)"
|
||||
on:click=move |_| store.dispatch(Action::SeekBackward)>"«"</button>
|
||||
on:click=move |_| store.dispatch(Action::SeekBackward)>"⏪"</button>
|
||||
<button class="ghost big" title="play/pause (Space)"
|
||||
on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button>
|
||||
<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 (>)"
|
||||
on:click=move |_| store.dispatch(Action::NextTrack)>"⏭"</button>
|
||||
<button class="ghost" title="restart track (r)"
|
||||
|
|
@ -1300,10 +1266,10 @@ fn Transport(store: Store) -> impl IntoView {
|
|||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max=MAX_VOLUME.to_string()
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -126,30 +126,12 @@ input {
|
|||
& .conn {
|
||||
font-size: 0.85rem;
|
||||
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 {
|
||||
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 {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
|
|
@ -297,11 +279,6 @@ input {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 1.3rem;
|
||||
|
|
@ -485,23 +462,19 @@ input {
|
|||
/* ---- phone ------------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 700px) {
|
||||
/* One pane at a time, switched by the topbar tabs (or `Tab`). The other
|
||||
pane is gone rather than collapsed to a strip: a strip is a row of
|
||||
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. */
|
||||
/* One pane at a time; Tab (or tapping a pane edge) switches — the
|
||||
unfocused pane collapses to a slim strip acting as its tab. */
|
||||
.panes {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.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;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||
#[cfg(feature = "fs")]
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
#[cfg(feature = "fs")]
|
||||
use tracing::info;
|
||||
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
|
||||
|
|
@ -134,10 +132,6 @@ impl Playback {
|
|||
trace!("handling playback command");
|
||||
match command {
|
||||
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 Ok(queue) = self.queue.lock() else {
|
||||
error!("queue lock poisoned");
|
||||
|
|
@ -147,6 +141,10 @@ impl Playback {
|
|||
queue_position: queue.current_position() as u32,
|
||||
track: queue.current_track(),
|
||||
};
|
||||
let position = TrackPosition {
|
||||
duration: 0,
|
||||
position: 0,
|
||||
};
|
||||
let play_state = {
|
||||
let Ok(play_state) = self.state.lock() else {
|
||||
error!("play state lock poisoned");
|
||||
|
|
@ -160,8 +158,8 @@ impl Playback {
|
|||
queue: Some(self.queue_snapshot(&queue)),
|
||||
queue_track: Some(queue_track),
|
||||
play_state: play_state as i32,
|
||||
volume,
|
||||
mute: muted,
|
||||
volume: 0.0,
|
||||
mute: false,
|
||||
position: Some(position),
|
||||
mods: Some(QueueModifiers {
|
||||
repeat: queue.repeat,
|
||||
|
|
@ -382,22 +380,12 @@ impl Playback {
|
|||
|
||||
PlaybackCommand::ChangeVolume { delta } => {
|
||||
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) => {
|
||||
debug!(volume, delta, "changed volume");
|
||||
self.broadcast(StreamUpdate::Volume(volume));
|
||||
self.broadcast(StreamUpdate::Mute(false));
|
||||
debug!(volume, delta, "changing volume");
|
||||
if let Err(err) = self.player.set_volume(volume + delta).await {
|
||||
warn!("set_volume failed: {err:?}");
|
||||
}
|
||||
}
|
||||
Err(err) => warn!("set_volume failed: {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
|
||||
/// normal and not an error.
|
||||
fn broadcast(&self, update: StreamUpdate) {
|
||||
|
|
|
|||
|
|
@ -20,11 +20,7 @@ The screen has two focusable panes side by side and a now-playing pane:
|
|||
track highlighted.
|
||||
- **Now playing** — the current track, a progress gauge, and the
|
||||
frequency-spectrum bars below it (see [Spectrum](#frequency-spectrum),
|
||||
fills the rest of the right column). Its top line reports the server's
|
||||
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.
|
||||
fills the rest of the right column).
|
||||
|
||||
`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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
the queue toolbar shows how many entries are waiting). Seek is there too —
|
||||
`,` and `.` move 15 seconds back and forward (`<` and `>` skip a whole
|
||||
track), as do the `«`/`»` buttons in the transport bar, and the progress
|
||||
bar is clickable — a click seeks to
|
||||
that point in the track. 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.
|
||||
track), as do the `⏪`/`⏩`
|
||||
buttons in
|
||||
the transport bar. The `/` live filter is TUI-only for now.
|
||||
|
||||
## How it is served
|
||||
|
||||
|
|
|
|||
128
plan/summary.md
128
plan/summary.md
|
|
@ -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
|
||||
clippy never compiles it. Second time this session that the wasm build was the
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue