playr 0.3.2

A minimal TUI music player that plays local files and contacts nothing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
//! Engine tests, against the fake output device in `common`, so they need no
//! audio device. One smoke test plays to the real default device.

mod common;

use common::{fake_player, levels, silence, skip};
use playr::audio::{Cmd, Player, State, Status};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

fn player() -> Player {
    fake_player().0
}

/// Consecutive unplayable files. The recursive skip overflowed at 2,000 in a
/// debug build.
const BAD_RUN: usize = 5_000;

/// Writes `n` one-byte `.mp3` files, which fail to probe.
fn bad_files(dir: &Path, n: usize) -> Vec<PathBuf> {
    (0..n)
        .map(|i| {
            let p = dir.join(format!("{i:05}.mp3"));
            std::fs::write(&p, b"x").unwrap();
            p
        })
        .collect()
}

fn wait_for(player: &Player, done: impl Fn(&Status) -> bool) -> Status {
    let deadline = Instant::now() + Duration::from_secs(30);
    loop {
        let s = player.status();
        if done(&s) || Instant::now() > deadline {
            return s;
        }
        std::thread::sleep(Duration::from_millis(20));
    }
}

#[test]
fn a_long_run_of_unplayable_files_is_skipped_at_start() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    player.send(Cmd::Play(bad_files(dir.path(), BAD_RUN), 0));

    let s = wait_for(&player, |s| s.error_seq == BAD_RUN as u64);
    assert_eq!(
        s.error_seq, BAD_RUN as u64,
        "not every bad file was reported"
    );
    assert_eq!(s.state, State::Stopped);
}

#[test]
fn a_long_run_of_unplayable_files_is_skipped_after_a_track() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let good = dir.path().join("good.wav");
    silence(&good, 44100, 0.2);

    // The good track decodes to its end within the first pump, so the bad
    // run is reached through gapless staging rather than through `start`.
    let mut queue = vec![good];
    queue.extend(bad_files(dir.path(), BAD_RUN));
    player.send(Cmd::Play(queue, 0));

    let s = wait_for(&player, |s| s.error_seq == BAD_RUN as u64);
    assert_eq!(
        s.error_seq, BAD_RUN as u64,
        "not every bad file was reported"
    );
}

/// Plays `rates` in order at +3 semitones and returns the status once the
/// track at `index` is audible.
fn varispeed_status(rates: &[u32], index: usize) -> Status {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    // Every track but the last is short, so the next one is reached quickly.
    let queue: Vec<PathBuf> = rates
        .iter()
        .enumerate()
        .map(|(i, &rate)| {
            let p = dir.path().join(format!("{i}.wav"));
            let secs = if i + 1 == rates.len() { 3.0 } else { 0.2 };
            silence(&p, rate, secs);
            p
        })
        .collect();

    player.send(Cmd::SpeedBy(3));
    player.send(Cmd::Play(queue, 0));
    let s = wait_for(&player, |s| {
        s.state == State::Playing && s.index == index && s.source.is_some()
    });
    assert_eq!(s.index, index, "track {index} never started");
    assert_eq!(s.semitones, 3);
    s
}

#[test]
fn varispeed_applies_to_a_track_started_directly() {
    let s = varispeed_status(&[44100], 0);
    assert!(s.resampling, "track plays at normal speed: {s:?}");
}

#[test]
fn varispeed_survives_a_gapless_track_change() {
    let s = varispeed_status(&[44100, 44100], 1);
    assert!(s.resampling, "next track plays at normal speed: {s:?}");
}

#[test]
fn varispeed_survives_a_track_change_that_reopens_the_output() {
    let s = varispeed_status(&[44100, 48000], 1);
    assert!(s.resampling, "next track plays at normal speed: {s:?}");
}

#[test]
fn publishing_a_status_does_not_copy_the_queue() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    player.send(Cmd::Play(bad_files(dir.path(), 3), 0));
    wait_for(&player, |s| s.error_seq == 3);

    // The engine republishes every few milliseconds; each status must share
    // the engine's queue rather than own a copy of every path.
    let (a, b) = (player.status(), player.status());
    assert_eq!(a.queue.len(), 3);
    assert!(std::sync::Arc::ptr_eq(&a.queue, &b.queue));
}

/// A player that has started `secs` of silence, and a second file to enqueue.
fn playing(secs: f32) -> (Player, PathBuf, tempfile::TempDir) {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let (first, second) = (dir.path().join("1.wav"), dir.path().join("2.wav"));
    silence(&first, 44100, secs);
    silence(&second, 44100, 1.0);
    player.send(Cmd::Play(vec![first], 0));
    let s = wait_for(&player, |s| s.state == State::Playing);
    assert_eq!(s.state, State::Playing);
    (player, second, dir)
}

#[test]
fn enqueueing_after_the_queue_ends_starts_playback() {
    let (player, second, _dir) = playing(0.1);
    let s = wait_for(&player, |s| s.state == State::Stopped);
    assert_eq!(s.state, State::Stopped, "first track never ended");

    player.send(Cmd::Enqueue(vec![second]));
    let s = wait_for(&player, |s| s.state == State::Playing);
    assert_eq!((s.state, s.index), (State::Playing, 1));
}

#[test]
fn enqueueing_while_the_last_track_plays_out_continues_into_it() {
    // The first track decodes to its end at once, then plays out of the ring
    // for about a second. The enqueue lands in that window.
    let (player, second, _dir) = playing(1.0);
    std::thread::sleep(Duration::from_millis(200));
    player.send(Cmd::Enqueue(vec![second]));

    let s = wait_for(&player, |s| s.index == 1 || s.state == State::Stopped);
    assert_eq!((s.state, s.index), (State::Playing, 1), "playback stopped");
}

/// Plays `secs` of silence per track, and returns once the first track's
/// position reaches 1.4 s. The decoder leads by just over a second, so a 2 s
/// first track has been read to its end by then.
fn near_the_end_of_a_track(secs: &[f32]) -> (Player, tempfile::TempDir) {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let queue: Vec<PathBuf> = secs
        .iter()
        .enumerate()
        .map(|(i, &s)| {
            let p = dir.path().join(format!("{i}.wav"));
            silence(&p, 44100, s);
            p
        })
        .collect();
    player.send(Cmd::Play(queue, 0));
    let deadline = Instant::now() + Duration::from_secs(10);
    while player.position() < Duration::from_millis(1400) && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(10));
    }
    assert_eq!(player.status().index, 0, "first track ended too soon");
    (player, dir)
}

#[test]
fn a_seek_near_the_end_of_a_track_stays_in_that_track() {
    // Each track holds its own level, so what played can be counted from the
    // device's output rather than timed: the fake plays slower under load.
    let (player, control) = fake_player();
    let dir = tempfile::tempdir().unwrap();
    let (first, second) = (dir.path().join("0.wav"), dir.path().join("1.wav"));
    levels(&first, 44100, &[(2.0, 0.25)]);
    levels(&second, 44100, &[(6.0, -0.25)]);
    player.send(Cmd::Play(vec![first, second], 0));
    // The decoder leads by just over a second, so by 1.4 s the first track
    // has been read to its end and the second is staged.
    let deadline = Instant::now() + Duration::from_secs(10);
    while player.position() < Duration::from_millis(1400) && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(10));
    }
    assert_eq!(player.status().index, 0, "first track ended too soon");

    player.send(Cmd::Seek(Duration::from_millis(500)));
    std::thread::sleep(Duration::from_millis(100));
    let s = player.status();
    assert_eq!(s.index, 0);
    assert!(
        s.duration.is_some_and(|d| d < Duration::from_secs(3)),
        "the first track shows the second track's duration: {:?}",
        s.duration
    );
    assert!(player.position() < Duration::from_secs(1));

    let s = wait_for(&player, |s| s.state == State::Stopped);
    assert_eq!(s.state, State::Stopped);
    // Seeking the second track instead played 5.5 s of it, then all 6 s again.
    let played = control.played.lock().unwrap();
    let second_secs = played.iter().filter(|v| **v < -0.2).count() as f64 / 2.0 / 44100.0;
    assert!(
        (second_secs - 6.0).abs() < 0.1,
        "the second track played for {second_secs:.2} s"
    );
}

#[test]
fn a_seek_in_the_last_track_after_it_is_decoded_is_not_ignored() {
    let (player, _dir) = near_the_end_of_a_track(&[2.0]);
    player.send(Cmd::Seek(Duration::from_millis(500)));
    // Ignored, the track would have ended 0.6 s from now.
    std::thread::sleep(Duration::from_secs(1));

    let s = player.status();
    assert_eq!(s.state, State::Playing, "the seek was ignored");
    let pos = player.position();
    assert!(pos < Duration::from_millis(1800), "position {pos:?}");
}

/// Repeats of one unplayable file: several seconds of skipping in a debug build.
const LONG_RUN: usize = 300_000;

fn long_run_of_unplayable_files(dir: &Path) -> Vec<PathBuf> {
    vec![bad_files(dir, 1).remove(0); LONG_RUN]
}

#[test]
fn stop_interrupts_a_long_run_of_unplayable_files() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    player.send(Cmd::Play(long_run_of_unplayable_files(dir.path()), 0));
    player.send(Cmd::Stop);

    std::thread::sleep(Duration::from_millis(200));
    let before = player.status().error_seq;
    std::thread::sleep(Duration::from_millis(200));
    let after = player.status().error_seq;
    assert_eq!(before, after, "still skipping after stop");
    assert!(after < LONG_RUN as u64);
}

#[test]
fn quitting_does_not_wait_for_a_long_run_of_unplayable_files() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    player.send(Cmd::Play(long_run_of_unplayable_files(dir.path()), 0));
    std::thread::sleep(Duration::from_millis(50));

    let quit = Instant::now();
    drop(player);
    let took = quit.elapsed();
    assert!(took < Duration::from_secs(1), "quit took {took:?}");
}

/// Plays `queue` from `index` and waits until that track is playing.
fn playing_at(player: &Player, queue: Vec<PathBuf>, index: usize) {
    player.send(Cmd::Play(queue, index));
    let s = wait_for(player, |s| s.state == State::Playing && s.index == index);
    assert_eq!((s.state, s.index), (State::Playing, index));
}

/// The status once an error past `errors` is reported and the command that
/// raised it has finished.
///
/// `fail` records an error at once, but state and index are published only
/// after the command, so reading them as soon as the error shows is a race.
fn settled_after_an_error(player: &Player, errors: u64) -> Status {
    wait_for(player, |s| s.error_seq > errors);
    std::thread::sleep(Duration::from_millis(300));
    player.status()
}

#[test]
fn previous_steps_back_over_an_unplayable_track() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let (first, last) = (dir.path().join("0.wav"), dir.path().join("2.wav"));
    silence(&first, 44100, 5.0);
    silence(&last, 44100, 5.0);
    let bad = bad_files(dir.path(), 1).remove(0);
    playing_at(&player, vec![first, bad, last], 2);
    let errors = player.status().error_seq;

    // Within three seconds of the start, so this steps back, not restarts.
    player.send(Cmd::Prev);
    let s = settled_after_an_error(&player, errors);
    assert!(s.error_seq > errors, "the unplayable track was never tried");
    assert_eq!(
        (s.state, s.index),
        (State::Playing, 0),
        "landed on {} after {:?}",
        s.index,
        s.error
    );
}

#[test]
fn previous_with_only_unplayable_tracks_before_restarts_the_track() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let good = dir.path().join("1.wav");
    silence(&good, 44100, 5.0);
    let bad = bad_files(dir.path(), 1).remove(0);
    playing_at(&player, vec![bad, good], 1);
    let errors = player.status().error_seq;

    player.send(Cmd::Prev);
    let s = settled_after_an_error(&player, errors);
    assert!(s.error_seq > errors, "the earlier track was never tried");
    assert_eq!((s.state, s.index), (State::Playing, 1));
}

#[test]
fn the_default_output_device_plays() {
    let player = match Player::new() {
        Ok(p) => p,
        Err(e) => return skip("PLAYR_REQUIRE_DEVICE", &e.to_string()),
    };
    player.send(Cmd::SetVolume(0.0));
    let dir = tempfile::tempdir().unwrap();
    let track = dir.path().join("a.wav");
    silence(&track, 44100, 5.0);
    player.send(Cmd::Play(vec![track], 0));

    let deadline = Instant::now() + Duration::from_secs(5);
    while player.position() < Duration::from_millis(300) && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(20));
    }
    let s = player.status();
    assert_eq!(s.state, State::Playing, "error: {:?}", s.error);
    assert!(
        player.position() >= Duration::from_millis(300),
        "the device did not play"
    );

    // The device's callback discards the buffer, well inside the timeout that
    // would reopen the device instead.
    player.send(Cmd::Seek(Duration::from_secs(3)));
    std::thread::sleep(Duration::from_millis(300));
    let pos = player.position();
    assert!(
        pos >= Duration::from_secs(3) && pos < Duration::from_millis(3400),
        "position {pos:?} 300 ms after seeking to 3 s"
    );
}

#[test]
fn playing_an_empty_queue_stops() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let track = dir.path().join("a.wav");
    silence(&track, 44100, 5.0);
    playing_at(&player, vec![track], 0);

    player.send(Cmd::Play(Vec::new(), 0));
    let s = wait_for(&player, |s| s.state == State::Stopped);
    assert_eq!(s.state, State::Stopped, "playing with nothing queued");
}

#[test]
fn a_track_that_opens_but_fails_its_first_packet_is_skipped() {
    if !common::have_ffmpeg() {
        return;
    }
    let dir = tempfile::tempdir().unwrap();
    // Faststart puts the index first, so a truncated file still opens.
    let broken = dir.path().join("broken.m4a");
    let ok = std::process::Command::new("ffmpeg")
        .args([
            "-y",
            "-v",
            "error",
            "-f",
            "lavfi",
            "-i",
            "anoisesrc=d=2:r=44100:seed=3",
        ])
        .args(["-ac", "2", "-c:a", "aac", "-movflags", "+faststart"])
        .arg(&broken)
        .status()
        .is_ok_and(|s| s.success());
    if !ok {
        return skip("PLAYR_REQUIRE_FFMPEG", "this ffmpeg cannot encode AAC");
    }
    let bytes = std::fs::read(&broken).unwrap();
    std::fs::write(&broken, &bytes[..bytes.len() * 6 / 10]).unwrap();
    let mut probe = playr::audio::decode::AudioStream::open(&broken).expect("no longer opens");
    assert!(
        probe.next_chunk().is_err(),
        "the first packet no longer fails"
    );

    let good = dir.path().join("good.wav");
    silence(&good, 44100, 3.0);
    let player = player();
    player.send(Cmd::Play(vec![broken, good], 0));

    let s = settled_after_an_error(&player, 0);
    assert!(
        s.error
            .as_deref()
            .unwrap_or_default()
            .contains("broken.m4a"),
        "not reported: {:?}",
        s.error
    );
    assert_eq!((s.state, s.index), (State::Playing, 1));
}

#[test]
fn the_queue_is_current_as_soon_as_send_returns() {
    // The engine publishes its status every few milliseconds. It must never
    // put back a queue older than the one `send` installed, so each change is
    // watched for a while, not checked once.
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let track = dir.path().join("a.wav");
    // Resampling 192 kHz keeps the engine decoding between messages, which
    // is when a stale publish would happen.
    silence(&track, 192_000, 30.0);
    player.send(Cmd::SpeedBy(1));
    for n in 1..=60 {
        player.send(Cmd::Enqueue(vec![track.clone()]));
        assert_eq!(player.queue().len(), n);
        let until = Instant::now() + Duration::from_millis(20);
        while Instant::now() < until {
            let len = player.status().queue.len();
            assert_eq!(len, n, "a stale queue of {len} was published");
        }
    }
}

#[test]
fn jumping_plays_a_queued_track_and_keeps_the_queue() {
    let player = player();
    let dir = tempfile::tempdir().unwrap();
    let (a, b) = (dir.path().join("a.wav"), dir.path().join("b.wav"));
    silence(&a, 44100, 5.0);
    silence(&b, 44100, 5.0);
    playing_at(&player, vec![a, b], 0);
    let queue = player.queue();

    player.send(Cmd::Jump(1));
    let s = wait_for(&player, |s| s.index == 1);
    assert_eq!((s.state, s.index), (State::Playing, 1));
    assert!(
        std::sync::Arc::ptr_eq(&queue, &player.queue()),
        "the queue was replaced"
    );
}