Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
19 changes: 19 additions & 0 deletions src/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ 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 {
if error
.to_string()
.contains(crate::deps::PLAYBACK_REQUIRES_MPV)
{
return crate::deps::PLAYBACK_REQUIRES_MPV.to_string();
}
if error
.chain()
.filter_map(|cause| cause.downcast_ref::<std::io::Error>())
.any(|io_error| io_error.kind() == std::io::ErrorKind::NotFound)
{
return crate::deps::PLAYBACK_REQUIRES_MPV.to_string();
}
format!("Playback failed: {error}")
}
Comment thread
thepushkarp marked this conversation as resolved.

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