Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "media-ls"
version = "0.0.2"
version = "0.0.3"
edition = "2024"
description = "Media LS — terminal-native audio/video file browser with metadata columns, TUI preview, and structured JSON output"
license = "MIT"
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,30 @@ mls ~/Videos | jq . # streaming NDJSON
brew install thepushkarp/tap/mls
```

The Homebrew formula installs `ffmpeg` and `mpv`, so probing and playback work
after a fresh Brew install. Install `trash` separately if you want safe delete
in triage mode:

```bash
brew install trash
```

### Cargo

```bash
cargo install media-ls
```

`cargo install` only installs the `mls` binary. Install runtime tools
separately for probe/playback support:

```bash
brew install ffmpeg mpv

# Optional: safe delete in triage mode
brew install trash
```

### Build from source

```bash
Expand All @@ -36,10 +54,13 @@ cargo build --release # requires Rust 1.85+
cp target/release/mls ~/.local/bin/ # or anywhere on PATH
```

### Prerequisites
For source builds, install the same runtime tools as the Cargo route:

```bash
brew install ffmpeg mpv trash
brew install ffmpeg mpv

# Optional: safe delete in triage mode
brew install trash
```

| Dependency | Required | Purpose |
Expand Down
4 changes: 4 additions & 0 deletions src/deps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
use std::fmt::Write;
use std::process::Command as StdCommand;

pub const PLAYBACK_REQUIRES_MPV: &str = "Playback requires mpv. Install: brew install mpv";
pub const PLAYBACK_DISABLED_WARNING: &str =
"Warning: mpv not found. Playback features disabled. Install: brew install mpv";

/// Result of checking external dependencies.
#[derive(Debug)]
pub struct DepCheck {
Expand Down
17 changes: 11 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,17 @@ async fn run() -> Result<()> {
.into());
}

// Warn about optional deps (mpv)
if dep_check.mpv.is_none() && !cli.quiet {
let _ = writeln!(
std::io::stderr(),
"Warning: mpv not found. Playback features disabled. Install: brew install mpv"
);
if dep_check.mpv.is_none() {
if matches!(&cli.command, Some(Command::Play { .. })) {
return Err(ExitCodeError {
code: exit_code::DEPENDENCY,
msg: deps::PLAYBACK_REQUIRES_MPV.into(),
}
.into());
}
if !cli.quiet {
let _ = writeln!(std::io::stderr(), "{}", deps::PLAYBACK_DISABLED_WARNING);
}
}

// Route to subcommand
Expand Down
18 changes: 18 additions & 0 deletions src/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ pub struct MpvController {
conn: Option<IpcConn>,
}

/// Convert playback startup failures into a user-facing status string.
#[must_use]
pub fn playback_error_message(error: &anyhow::Error) -> String {
let is_mpv_missing = error
.to_string()
.contains(crate::deps::PLAYBACK_REQUIRES_MPV)
|| error
.chain()
.filter_map(|cause| cause.downcast_ref::<std::io::Error>())
.any(|io_error| io_error.kind() == std::io::ErrorKind::NotFound);

if is_mpv_missing {
crate::deps::PLAYBACK_REQUIRES_MPV.to_string()
} else {
format!("Playback failed: {error}")
}
}

impl MpvController {
/// Create a new controller (mpv not yet spawned).
#[must_use]
Expand Down
58 changes: 46 additions & 12 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,27 @@ impl App {
self.status_ticks = 30;
}

fn clear_playback_state(&mut self) {
self.playback_file_name = None;
self.playback_position = None;
self.playback_duration = None;
}

fn apply_playback_start_result(&mut self, result: Result<()>, name: &str) {
match result {
Ok(()) => {
self.playback_file_name = Some(name.to_string());
self.playback_position = None;
self.playback_duration = None;
self.set_status(format!("Playing: {name}"));
}
Err(error) => {
self.clear_playback_state();
self.set_status(crate::playback::playback_error_message(&error));
}
}
}

/// Remove the currently selected media entry from the entries list.
///
/// Called after successful delete or move in triage mode so the
Expand Down Expand Up @@ -726,9 +747,7 @@ async fn event_loop(
// Update mpv state — detect process exit
if app.mpv.state() != PlaybackState::Stopped && !app.mpv.is_alive() {
app.mpv.stop().await;
app.playback_position = None;
app.playback_duration = None;
app.playback_file_name = None;
app.clear_playback_state();
}

// Poll playback position only while actively playing (not paused/stopped)
Expand Down Expand Up @@ -760,7 +779,6 @@ async fn event_loop(
Ok(())
}

#[expect(clippy::too_many_lines, reason = "match arms for key handling")]
async fn handle_key(app: &mut App, key: KeyEvent) {
// Handle filter input mode
if app.filter_active {
Expand Down Expand Up @@ -840,9 +858,7 @@ async fn handle_key(app: &mut App, key: KeyEvent) {
(KeyCode::Char('p'), _) => handle_playback(app).await,
(KeyCode::Char('P'), _) => {
app.mpv.stop().await;
app.playback_position = None;
app.playback_duration = None;
app.playback_file_name = None;
app.clear_playback_state();
app.set_status("Stopped playback".to_string());
}
(KeyCode::Char(']'), _) => {
Expand Down Expand Up @@ -946,11 +962,8 @@ async fn handle_playback(app: &mut App) {
let _ = app.mpv.toggle_pause().await;
} else {
// Stopped or different file — start playing selected
let _ = app.mpv.play(&path, audio_only).await;
app.playback_file_name = Some(name.clone());
app.playback_position = None;
app.playback_duration = None;
app.set_status(format!("Playing: {name}"));
let result = app.mpv.play(&path, audio_only).await;
app.apply_playback_start_result(result, &name);
}
}

Expand Down Expand Up @@ -1448,6 +1461,27 @@ mod tests {
assert!(app.playback_file_name.is_none());
}

#[test]
fn playback_start_failure_keeps_state_empty_and_sets_error_status() {
let mut app = make_test_app(&["a.mp4"]);
let name = "a.mp4";

app.apply_playback_start_result(
Err(anyhow::anyhow!(
"Playback requires mpv. Install: brew install mpv"
)),
name,
);

assert!(app.playback_file_name.is_none());
assert!(app.playback_position.is_none());
assert!(app.playback_duration.is_none());
assert_eq!(
app.status_message.as_deref(),
Some("Playback requires mpv. Install: brew install mpv")
);
}

#[test]
fn thumb_skips_audio_only_files() {
// make_entry creates entries with video: None (audio-only)
Expand Down
15 changes: 10 additions & 5 deletions src/tui/triage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,16 @@ pub async fn handle_triage_key(app: &mut App, key: KeyEvent) {
// Playback in triage
KeyCode::Char('p') => {
if app.mpv.state() == crate::playback::PlaybackState::Stopped {
let info = app
.selected_entry()
.map(|entry| (entry.path.clone(), entry.media.video.is_none()));
if let Some((path, audio_only)) = info {
let _ = app.mpv.play(&path, audio_only).await;
let info = app.selected_entry().map(|entry| {
(
entry.path.clone(),
entry.media.video.is_none(),
entry.file_name.clone(),
)
});
if let Some((path, audio_only, name)) = info {
let result = app.mpv.play(&path, audio_only).await;
app.apply_playback_start_result(result, &name);
}
} else {
let _ = app.mpv.toggle_pause().await;
Expand Down
14 changes: 14 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ fn missing_ffprobe_exits_4() {
.code(4);
}

#[test]
fn play_without_mpv_exits_4_with_install_hint() {
let tmp = setup_media_dir();
Command::new(cargo_bin("mls"))
.env("PATH", mock_bin_dir())
.arg("--quiet")
.arg("play")
.arg(tmp.path().join("song.mp3"))
.assert()
.code(4)
.stderr(predicate::str::contains("Playback requires mpv"))
.stderr(predicate::str::contains("brew install mpv"));
}

// --- Validation errors ---

#[test]
Expand Down