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?)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
Ok(volume) => {
|
||||||
// Nothing was broadcast here, so no client ever
|
debug!(volume, delta, "changing volume");
|
||||||
// learned the level had changed. The level sent is
|
if let Err(err) = self.player.set_volume(volume + delta).await {
|
||||||
// the one the engine actually took, so a client
|
warn!("set_volume failed: {err:?}");
|
||||||
// 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));
|
|
||||||
}
|
}
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
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
|
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.
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue