Skip to content

Commit 5445cdf

Browse files
committed
feat(player): improve radio playback UX, details, and playlist navigation
1 parent 4e7dbdf commit 5445cdf

5 files changed

Lines changed: 551 additions & 56 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,11 @@ Folder groups can be collapsed or expanded in the track list. When a folder play
139139

140140
Use the center track list to browse tracks. Double-click a track to start it immediately.
141141

142-
The top player bar truncates long titles so track names never overlap the technical details or controls. Local track metadata stays separated from the title, and the track list uses compact metadata so the duration, sample rate, bitrate, channels, and size remain readable.
142+
The top player bar keeps the current title left-aligned and truncates long titles so track names never overlap the technical details or controls. Local track metadata stays separated from the title, and player-only mode shows a reduced metadata set with just duration and size.
143143

144144
### Play internet radio
145145

146-
Open the Internet radio tab, paste a stream URL, optionally enter a readable station name, then double-click or press Play on the saved station. If the name is empty, Audio Orbit tries to read it from the stream. The Radio tab hides local-only playback options such as shuffle, repeat, auto-play next, crossfade, playback transitions, and silence skipping. Favorite radio stations can be filtered from the Radio tab or the left Library panel.
146+
Open the Internet radio tab, paste a stream URL, optionally enter a readable station name, then double-click or use the station three-dot menu to play it. If the name is empty, Audio Orbit tries to read it from the stream. Radio rows use the same left-aligned list layout, search behavior, scrollbar gutter, favorite marking, and Details modal style as local tracks. The Radio tab hides local-only playback options such as shuffle, repeat, auto-play next, crossfade, playback transitions, and silence skipping, but the active sound profile's orbit processing can still be applied to the live stream.
147147

148148
### Use repeat modes
149149

@@ -159,7 +159,11 @@ When repeat selection is active, checkboxes appear in the track list so you can
159159

160160
### Search tracks
161161

162-
Use the search button in the track list header to reveal search. Search filters by track title, folder group, and file path. Use **Next result** to jump between matches.
162+
Use the search button in the track list header to reveal search. Search filters by track title, folder group, and file path. Use **Next result** to jump between matches. Folder playlist context remains visible above the list, the folder dropdown grows with available entries, and **Now playing** scrolls the list back to the active track.
163+
164+
### Waveform and silence skip
165+
166+
Local track waveforms mark long quiet sections that silence skipping will bypass. Internet radio uses a live audio visualizer that fills from left to right with decoded stream levels and clips older levels as new audio arrives.
163167

164168
### Manage Favorites
165169

@@ -183,7 +187,7 @@ If an update is available, Audio Orbit can replace its current executable and re
183187

184188
Audio Orbit remembers the window size and position when the app closes and restores the same layout on the next launch. Player-only and full-layout sizes are kept separately, and switching modes restores that mode's own saved width and height.
185189

186-
Settings, release watcher, update, folder import, and About content use responsive modal layouts with internal scrolling on small windows.
190+
Settings, release watcher, update, folder import, About content, and Details dialogs use responsive modal layouts with internal scrolling on small windows.
187191

188192
Only one Audio Orbit instance can run at a time. If the app is already open, starting the executable again exits immediately instead of opening a second player window.
189193

src/audio_player.rs

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ use anyhow::{Context, Result};
33
use cpal::traits::{DeviceTrait, HostTrait};
44
use rodio::{buffer::SamplesBuffer, Decoder, OutputStream, OutputStreamHandle, Sink, Source};
55
use std::{
6+
collections::VecDeque,
7+
f32::consts::PI,
68
fs,
79
fs::File,
810
io::{self, BufReader, Read, Seek, SeekFrom},
911
path::{Path, PathBuf},
10-
sync::Mutex,
12+
sync::{Arc, Mutex},
1113
thread,
1214
time::{Duration, Instant},
1315
};
@@ -21,6 +23,7 @@ pub struct PlaybackInfo {
2123
pub sample_rate: u32,
2224
pub size_bytes: Option<u64>,
2325
pub waveform: Vec<f32>,
26+
pub silence_ranges: Vec<(f32, f32)>,
2427
}
2528

2629
struct RadioStream<R> {
@@ -62,6 +65,165 @@ impl<R> Seek for RadioStream<R> {
6265
}
6366
}
6467

68+
#[derive(Default)]
69+
struct RadioVisualizerState {
70+
peaks: VecDeque<f32>,
71+
current_peak: f32,
72+
sample_counter: usize,
73+
}
74+
75+
type RadioVisualizerHandle = Arc<Mutex<RadioVisualizerState>>;
76+
77+
struct LiveRadioSource<S> {
78+
inner: S,
79+
settings: DspSettings,
80+
input_channels: u16,
81+
sample_rate: u32,
82+
frame_index: u64,
83+
output_frame: [f32; 2],
84+
output_channel: usize,
85+
visualizer: RadioVisualizerHandle,
86+
}
87+
88+
impl<S: Source<Item = f32>> LiveRadioSource<S> {
89+
fn new(inner: S, settings: DspSettings, visualizer: RadioVisualizerHandle) -> Self {
90+
let input_channels = inner.channels().max(1);
91+
let sample_rate = inner.sample_rate().max(1);
92+
Self {
93+
inner,
94+
settings,
95+
input_channels,
96+
sample_rate,
97+
frame_index: 0,
98+
output_frame: [0.0, 0.0],
99+
output_channel: 2,
100+
visualizer,
101+
}
102+
}
103+
104+
fn read_input_frame(&mut self) -> Option<([f32; 2], f32)> {
105+
let channels = self.input_channels.max(1) as usize;
106+
let mut sum = 0.0_f32;
107+
let mut count = 0usize;
108+
let mut left = 0.0_f32;
109+
let mut right = 0.0_f32;
110+
111+
for channel in 0..channels {
112+
match self.inner.next() {
113+
Some(sample) => {
114+
if channel == 0 {
115+
left = sample;
116+
} else if channel == 1 {
117+
right = sample;
118+
}
119+
sum += sample;
120+
count += 1;
121+
}
122+
None if count == 0 => return None,
123+
None => break,
124+
}
125+
}
126+
127+
if count == 0 {
128+
None
129+
} else {
130+
if count == 1 {
131+
right = left;
132+
}
133+
Some(([left, right], sum / count as f32))
134+
}
135+
}
136+
137+
fn process_frame(&mut self, stereo: [f32; 2], mono: f32) -> [f32; 2] {
138+
record_radio_peak(&self.visualizer, stereo[0].abs().max(stereo[1].abs()).max(mono.abs()));
139+
let output_level = self.settings.output_level_percent.clamp(1, 100) as f32 / 100.0;
140+
if !self.settings.orbit_enabled {
141+
return [
142+
soft_limit_radio(stereo[0] * output_level),
143+
soft_limit_radio(stereo[1] * output_level),
144+
];
145+
}
146+
147+
let width = self.settings.stereo_width_percent.min(100) as f32 / 100.0;
148+
let speed = self.settings.orbit_speed_percent.clamp(10, 200) as f32 / 100.0;
149+
let time = self.frame_index as f32 / self.sample_rate as f32;
150+
let pan = (2.0 * PI * 0.20 * speed * time).sin() * width;
151+
let angle = (pan.clamp(-1.0, 1.0) + 1.0) * PI / 4.0;
152+
let mut left_gain = angle.cos();
153+
let mut right_gain = angle.sin();
154+
155+
if matches!(self.settings.mode, crate::dsp::OrbitMode::VirtualEightDirectionOrbit) {
156+
let depth = (2.0 * PI * 0.20 * speed * time).cos();
157+
let rear = (-depth).max(0.0) * (self.settings.depth_cue_percent.min(100) as f32 / 100.0);
158+
let shade = 1.0 - rear * 0.22;
159+
left_gain *= shade;
160+
right_gain *= shade;
161+
}
162+
163+
[
164+
soft_limit_radio(mono * left_gain * output_level),
165+
soft_limit_radio(mono * right_gain * output_level),
166+
]
167+
}
168+
}
169+
170+
impl<S: Source<Item = f32>> Iterator for LiveRadioSource<S> {
171+
type Item = f32;
172+
173+
fn next(&mut self) -> Option<Self::Item> {
174+
if self.output_channel < 2 {
175+
let sample = self.output_frame[self.output_channel];
176+
self.output_channel += 1;
177+
return Some(sample);
178+
}
179+
180+
let (stereo, mono) = self.read_input_frame()?;
181+
self.output_frame = self.process_frame(stereo, mono);
182+
self.output_channel = 1;
183+
self.frame_index = self.frame_index.saturating_add(1);
184+
Some(self.output_frame[0])
185+
}
186+
}
187+
188+
impl<S: Source<Item = f32>> Source for LiveRadioSource<S> {
189+
fn current_frame_len(&self) -> Option<usize> {
190+
None
191+
}
192+
193+
fn channels(&self) -> u16 {
194+
2
195+
}
196+
197+
fn sample_rate(&self) -> u32 {
198+
self.sample_rate
199+
}
200+
201+
fn total_duration(&self) -> Option<Duration> {
202+
None
203+
}
204+
}
205+
206+
fn record_radio_peak(visualizer: &RadioVisualizerHandle, peak: f32) {
207+
let Ok(mut state) = visualizer.lock() else {
208+
return;
209+
};
210+
state.current_peak = state.current_peak.max(peak.min(1.0));
211+
state.sample_counter += 1;
212+
if state.sample_counter >= 768 {
213+
let peak = state.current_peak;
214+
state.peaks.push_back(peak);
215+
while state.peaks.len() > 4096 {
216+
state.peaks.pop_front();
217+
}
218+
state.current_peak = 0.0;
219+
state.sample_counter = 0;
220+
}
221+
}
222+
223+
fn soft_limit_radio(value: f32) -> f32 {
224+
(value / (1.0 + value.abs() * 0.12)).clamp(-1.0, 1.0)
225+
}
226+
65227
pub struct AudioPlayer {
66228
_stream: OutputStream,
67229
stream_handle: OutputStreamHandle,
@@ -75,6 +237,7 @@ pub struct AudioPlayer {
75237
current_path: Option<PathBuf>,
76238
current_settings: Option<DspSettings>,
77239
volume_percent: u8,
240+
radio_visualizer: RadioVisualizerHandle,
78241
}
79242

80243
impl AudioPlayer {
@@ -96,6 +259,7 @@ impl AudioPlayer {
96259
current_path: None,
97260
current_settings: None,
98261
volume_percent: 100,
262+
radio_visualizer: Arc::new(Mutex::new(RadioVisualizerState::default())),
99263
})
100264
}
101265

@@ -114,7 +278,7 @@ impl AudioPlayer {
114278
self.volume_percent as f32 / 100.0
115279
}
116280

117-
pub fn play_radio_stream(&mut self, url: &str) -> Result<()> {
281+
pub fn play_radio_stream(&mut self, url: &str, settings: DspSettings) -> Result<()> {
118282
let response = reqwest::blocking::Client::builder()
119283
.user_agent("Audio-Orbit-Radio")
120284
.build()?
@@ -129,10 +293,13 @@ impl AudioPlayer {
129293
.with_context(|| format!("failed to decode internet radio stream: {url}"))?;
130294

131295
self.stop();
296+
self.radio_visualizer = Arc::new(Mutex::new(RadioVisualizerState::default()));
297+
let visualizer = Arc::clone(&self.radio_visualizer);
298+
let radio_source = LiveRadioSource::new(decoder.convert_samples::<f32>(), settings, visualizer);
132299
let sink = Sink::try_new(&self.stream_handle)
133300
.context("failed to create audio playback sink")?;
134301
sink.set_volume(self.volume_gain());
135-
sink.append(decoder.convert_samples::<f32>());
302+
sink.append(radio_source);
136303
sink.play();
137304

138305
self.sink = Some(sink);
@@ -147,6 +314,17 @@ impl AudioPlayer {
147314
Ok(())
148315
}
149316

317+
pub fn radio_visualizer_peaks(&self, requested_points: usize) -> Vec<f32> {
318+
let Ok(state) = self.radio_visualizer.lock() else {
319+
return Vec::new();
320+
};
321+
if requested_points == 0 || state.peaks.is_empty() {
322+
return Vec::new();
323+
}
324+
let take = requested_points.min(state.peaks.len());
325+
state.peaks.iter().skip(state.peaks.len() - take).copied().collect()
326+
}
327+
150328
pub fn play_file_with_orbit_from(
151329
&mut self,
152330
path: &Path,
@@ -393,5 +571,6 @@ fn playback_info(path: &Path, render_info: RenderInfo) -> PlaybackInfo {
393571
sample_rate: render_info.sample_rate,
394572
size_bytes: fs::metadata(path).ok().map(|metadata| metadata.len()),
395573
waveform: render_info.waveform,
574+
silence_ranges: render_info.silence_ranges,
396575
}
397576
}

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,8 @@ pub struct UiSettings {
440440
pub full_layout_window_geometry: Option<WindowGeometry>,
441441
#[serde(default)]
442442
pub player_only_window_geometry: Option<WindowGeometry>,
443+
#[serde(default)]
444+
pub playlist_scroll_offset_y: f32,
443445
}
444446

445447
impl Default for UiSettings {
@@ -452,6 +454,7 @@ impl Default for UiSettings {
452454
window_geometry: None,
453455
full_layout_window_geometry: None,
454456
player_only_window_geometry: None,
457+
playlist_scroll_offset_y: 0.0,
455458
}
456459
}
457460
}

0 commit comments

Comments
 (0)