Skip to content

Commit 60eed4b

Browse files
committed
Polish loading screen progress UI
1 parent 6b25acc commit 60eed4b

3 files changed

Lines changed: 392 additions & 104 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Loading Screen Polish Design
2+
3+
## Context
4+
5+
`looper play --url ...` can spend noticeable time resolving long YouTube videos before playback starts. The existing startup renderer shows the logo, status, and boot logs, but it is visually plainer than the playback screen and the log bullets can look ragged on wide terminals.
6+
7+
## Approach
8+
9+
Upgrade the existing startup/loading renderer instead of adding a separate screen. `StartupScreenState` will carry optional download progress, and `draw_startup` will render a centered bordered panel that matches the playback screen's amber accents and blue-gray borders.
10+
11+
Before byte progress exists, the screen shows an indeterminate animated progress bar so the UI feels alive while metadata is resolving. Once `yt-dlp` emits progress, the same bar switches to measured percent, bytes, speed, and ETA.
12+
13+
## Layout
14+
15+
- Centered bordered panel with the ASCII logo at the top.
16+
- Bordered status section with spinner, loading copy, and progress bar.
17+
- Aligned log section with a fixed bullet gutter so the cheeky loading notes scan cleanly.
18+
- Existing sync-warning banner remains above the loading panel.
19+
20+
## Testing
21+
22+
Use ratatui test backends to verify the startup screen still renders the sync-warning banner and now renders loading progress. Run `cargo test` and `cargo build`.

src/play_loop.rs

Lines changed: 91 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::{
1111
io::{stdout, Write},
1212
path::Path,
1313
sync::{
14-
mpsc::{self, Receiver, SyncSender, TrySendError},
14+
mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError},
1515
Arc, Mutex,
1616
},
1717
thread,
@@ -28,7 +28,7 @@ use crate::plugin::{self, ytdlp, TrackInfo};
2828
use crate::storage::{track_record, HistorySortField, SharedStorage, Storage, SyncWarning};
2929
use crate::tui::{
3030
draw, draw_history_browser, draw_startup, restore_terminal, setup_terminal, AppState,
31-
HistoryPanelState, StartupScreenState, N_BANDS,
31+
HistoryPanelState, StartupProgressState, StartupScreenState, N_BANDS,
3232
};
3333

3434
pub struct PlaybackContext<'a> {
@@ -88,6 +88,7 @@ fn browse_history_session(
8888
status: "db migrations... teaching SQLite to keep a beat".to_string(),
8989
logs: startup_logs(),
9090
frame_count: 0,
91+
progress: None,
9192
sync_warning: None,
9293
};
9394
title_state.set("looper — playlist history".to_string())?;
@@ -178,6 +179,10 @@ fn play_file_session(
178179
status: "db migrations... teaching SQLite to keep a beat".to_string(),
179180
logs: startup_logs(),
180181
frame_count: 0,
182+
progress: Some(StartupProgressState {
183+
label: "opening local history".to_string(),
184+
progress: None,
185+
}),
181186
sync_warning: None,
182187
};
183188
title_state.set("looper — booting".to_string())?;
@@ -187,12 +192,16 @@ fn play_file_session(
187192
startup.sync_warning = sync_warning.clone();
188193

189194
loop {
190-
startup.frame_count += 1;
191-
startup.status = format!("loading song... bribing the aux cord for `{current_url}`");
192-
title_state.set("looper — loading song".to_string())?;
193-
terminal.draw(|frame| draw_startup(frame, &startup))?;
195+
let resolved =
196+
match resolve_url_with_startup(terminal, title_state, &mut startup, &current_url)? {
197+
ResolveStartupOutcome::Resolved(resolved) => resolved,
198+
ResolveStartupOutcome::Quit => {
199+
push_replica_best_effort(&storage);
200+
return Ok(());
201+
}
202+
};
194203

195-
let next = match plugin::resolve_url(&current_url)? {
204+
let next = match resolved {
196205
None => play_tracks(
197206
terminal,
198207
title_state,
@@ -239,6 +248,65 @@ fn play_file_session(
239248
}
240249
}
241250

251+
enum ResolveStartupOutcome {
252+
Resolved(Option<Vec<TrackInfo>>),
253+
Quit,
254+
}
255+
256+
fn resolve_url_with_startup(
257+
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
258+
title_state: &mut TitleState,
259+
startup: &mut StartupScreenState,
260+
current_url: &str,
261+
) -> Result<ResolveStartupOutcome> {
262+
let (sender, receiver) = mpsc::channel();
263+
let url = current_url.to_string();
264+
thread::spawn(move || {
265+
let result = plugin::resolve_url(&url).map_err(|err| err.to_string());
266+
let _ = sender.send(result);
267+
});
268+
269+
loop {
270+
startup.frame_count += 1;
271+
startup.status = format!(
272+
"loading song... bribing the aux cord for `{}`",
273+
truncate_title(current_url, 72)
274+
);
275+
startup.logs = startup_logs();
276+
startup.progress = Some(StartupProgressState {
277+
label: "resolving source".to_string(),
278+
progress: None,
279+
});
280+
title_state.set("looper — loading song".to_string())?;
281+
terminal.draw(|frame| draw_startup(frame, startup))?;
282+
283+
match receiver.try_recv() {
284+
Ok(Ok(resolved)) => {
285+
startup.progress = None;
286+
return Ok(ResolveStartupOutcome::Resolved(resolved));
287+
}
288+
Ok(Err(message)) => return Err(color_eyre::eyre::eyre!(message)),
289+
Err(TryRecvError::Empty) => {}
290+
Err(TryRecvError::Disconnected) => {
291+
return Err(color_eyre::eyre::eyre!(
292+
"remote resolver stopped before returning a result"
293+
));
294+
}
295+
}
296+
297+
if event::poll(Duration::from_millis(30))? {
298+
if let Event::Key(key) = event::read()? {
299+
match (key.code, key.modifiers) {
300+
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
301+
return Ok(ResolveStartupOutcome::Quit);
302+
}
303+
_ => {}
304+
}
305+
}
306+
}
307+
}
308+
}
309+
242310
enum LoopAction {
243311
Quit,
244312
NextTrack,
@@ -1214,18 +1282,15 @@ fn render_track_startup(
12141282
sync_warning: Option<&SyncWarning>,
12151283
) -> Result<()> {
12161284
title_state.set(format_loading_title(frame_count, &track.title))?;
1217-
let status = startup_status(
1218-
track,
1219-
track_index,
1220-
total_tracks,
1221-
is_playlist,
1222-
&phase,
1223-
progress,
1224-
);
1285+
let status = startup_status(track, track_index, total_tracks, is_playlist, &phase);
12251286
let startup = StartupScreenState {
12261287
status,
12271288
logs: track_startup_logs(note),
12281289
frame_count,
1290+
progress: Some(StartupProgressState {
1291+
label: loading_phase_label(&phase).to_string(),
1292+
progress,
1293+
}),
12291294
sync_warning: sync_warning.cloned(),
12301295
};
12311296
terminal.draw(|frame| draw_startup(frame, &startup))?;
@@ -1238,7 +1303,6 @@ fn startup_status(
12381303
total_tracks: usize,
12391304
is_playlist: bool,
12401305
phase: &LoadingPhase,
1241-
progress: Option<crate::download::DownloadProgress>,
12421306
) -> String {
12431307
let position = if is_playlist {
12441308
format!("track {track_index}/{total_tracks}")
@@ -1250,26 +1314,7 @@ fn startup_status(
12501314

12511315
match phase {
12521316
LoadingPhase::Resolving => format!("{position} • {service} • sizing up `{title}`"),
1253-
LoadingPhase::Downloading => {
1254-
let progress_label = progress
1255-
.map(|p| {
1256-
let percent = p
1257-
.fraction()
1258-
.map(|f| format!("{}%", (f * 100.0).round() as u64))
1259-
.unwrap_or_else(|| "warming up".to_string());
1260-
let speed = p
1261-
.speed_bytes_per_sec
1262-
.map(human_speed)
1263-
.unwrap_or_else(|| "--".to_string());
1264-
let eta = p
1265-
.eta_seconds
1266-
.map(human_eta)
1267-
.unwrap_or_else(|| "--:--".to_string());
1268-
format!("{percent} • {speed} • eta {eta}")
1269-
})
1270-
.unwrap_or_else(|| "warming up".to_string());
1271-
format!("{position} • {service} • downloading `{title}` • {progress_label}")
1272-
}
1317+
LoadingPhase::Downloading => format!("{position} • {service} • downloading `{title}`"),
12731318
LoadingPhase::Finalizing => format!("{position} • {service} • polishing `{title}`"),
12741319
LoadingPhase::Ready => format!("{position} • {service} • cueing `{title}`"),
12751320
LoadingPhase::Error(message) => {
@@ -1278,6 +1323,16 @@ fn startup_status(
12781323
}
12791324
}
12801325

1326+
fn loading_phase_label(phase: &LoadingPhase) -> &'static str {
1327+
match phase {
1328+
LoadingPhase::Resolving => "resolving source",
1329+
LoadingPhase::Downloading => "downloading audio",
1330+
LoadingPhase::Finalizing => "finalizing cache",
1331+
LoadingPhase::Ready => "cueing playback",
1332+
LoadingPhase::Error(_) => "download failed",
1333+
}
1334+
}
1335+
12811336
fn track_startup_logs(note: String) -> Vec<String> {
12821337
vec![
12831338
note,
@@ -1286,38 +1341,6 @@ fn track_startup_logs(note: String) -> Vec<String> {
12861341
]
12871342
}
12881343

1289-
fn human_speed(bytes_per_sec: u64) -> String {
1290-
format!("{}/s", human_bytes(bytes_per_sec))
1291-
}
1292-
1293-
fn human_bytes(bytes: u64) -> String {
1294-
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
1295-
let mut value = bytes as f64;
1296-
let mut unit = 0;
1297-
while value >= 1024.0 && unit < UNITS.len() - 1 {
1298-
value /= 1024.0;
1299-
unit += 1;
1300-
}
1301-
1302-
if unit == 0 {
1303-
format!("{bytes} {}", UNITS[unit])
1304-
} else {
1305-
format!("{value:.1} {}", UNITS[unit])
1306-
}
1307-
}
1308-
1309-
fn human_eta(eta: u64) -> String {
1310-
let minutes = eta / 60;
1311-
let seconds = eta % 60;
1312-
if minutes >= 60 {
1313-
let hours = minutes / 60;
1314-
let minutes = minutes % 60;
1315-
format!("{hours}:{minutes:02}:{seconds:02}")
1316-
} else {
1317-
format!("{minutes}:{seconds:02}")
1318-
}
1319-
}
1320-
13211344
fn format_playback_title(frame_count: u64, title: &str, paused: bool) -> String {
13221345
let title = truncate_title(title, 48);
13231346
if paused {

0 commit comments

Comments
 (0)