feat(music): bundled CC-BY session soundtrack + /music player
Add a session background-music feature to the TUI. Ships two bundled CC BY 4.0 albums (Kevin MacLeod / incompetech.com) under hh/music/: 'crypt' (dark ambient, 5 tracks) and 'terminal' (synth/chiptune, 6). - hh/src/music.rs: playlist model + subprocess player (ffplay/mpv/cvlc fallback chain), album discovery (user ~/.hh/music shadows bundled), random shuffle, and /music import of the operator's own audio. - /music [list] · play [album] (blank/random shuffles) · stop · next · import <path> [as <name>]; now-playing shown in the top bar + help. - docs/music-licensing.md: CC-BY provenance/attribution register. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
# Session-music licensing & provenance
|
||||||
|
|
||||||
|
_Generated 2026-07-07 for the bundled albums under `hh/music/`._
|
||||||
|
|
||||||
|
This is the provenance + attribution register for the background-music albums that
|
||||||
|
the Rust client plays during a session (`/music`, see `hh/src/music.rs`). Each album
|
||||||
|
is a `hh/music/<name>.json` manifest pointing at the `.mp3` files shipped alongside it.
|
||||||
|
|
||||||
|
## Verdict: ALL bundled tracks CLEARED — CC BY 4.0, attribution required
|
||||||
|
|
||||||
|
- **2 albums / 11 tracks**, every one composed by **Kevin MacLeod** and published on
|
||||||
|
**incompetech.com** under **Creative Commons Attribution 4.0 International (CC BY 4.0)**.
|
||||||
|
- CC BY 4.0 **permits** redistribution, bundling, and commercial use **provided the
|
||||||
|
author is credited**. That makes these safe to commit to the repo, push to any
|
||||||
|
remote, demo publicly, and ship — unlike the quarantined character art
|
||||||
|
(`character-art-licensing.md`).
|
||||||
|
- Attribution is carried in two machine-readable places already: the `license` field
|
||||||
|
of each `hh/music/*.json` manifest, and this register. Keep both intact.
|
||||||
|
|
||||||
|
### Required attribution (CC BY 4.0)
|
||||||
|
|
||||||
|
> Music by **Kevin MacLeod** (incompetech.com) — licensed under
|
||||||
|
> **Creative Commons: By Attribution 4.0** — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
Surface this credit wherever the music is used publicly (demo video description,
|
||||||
|
release notes, an About/Credits screen). Do not remove the `license` fields from the
|
||||||
|
manifests, and do not relabel these tracks as original or royalty-free-without-credit.
|
||||||
|
|
||||||
|
## Per-track register
|
||||||
|
|
||||||
|
| Album | Track | Composer | Source | Licence | Status |
|
||||||
|
|-------|-------|----------|--------|---------|--------|
|
||||||
|
| crypt | Ossuary 5 - Rest | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| crypt | Ghostpocalypse - 6 Crossing the Threshold | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| crypt | Crypto | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| crypt | Killers | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| crypt | Hitman | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| terminal | Neon Laser Horizon | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| terminal | Cyborg Ninja | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| terminal | Space Fighter Loop | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| terminal | Voltaic | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| terminal | Blip Stream | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
| terminal | Pixelland | Kevin MacLeod | incompetech.com | CC BY 4.0 | CLEARED |
|
||||||
|
|
||||||
|
> `crypt` is the dark-ambient album (matches the default "crypt" vestment); `terminal`
|
||||||
|
> is the hacker synth/chiptune album. Files live under `hh/music/<album>/`.
|
||||||
|
|
||||||
|
## Imported albums (`/music import`)
|
||||||
|
|
||||||
|
`/music import <path>` writes user albums to `~/.hh/music/*.json`, referencing the
|
||||||
|
operator's own files **in place** (absolute paths — nothing is copied into the repo).
|
||||||
|
Those files are the operator's responsibility and are **out of scope** for this
|
||||||
|
register; only the bundled `hh/music/` albums above are shipped and covered here.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "crypt",
|
||||||
|
"about": "Dark ambient for the small hours — occult dread, deep focus.",
|
||||||
|
"license": "CC BY 4.0 — Kevin MacLeod (incompetech.com)",
|
||||||
|
"tracks": [
|
||||||
|
{ "title": "Ossuary 5 - Rest", "artist": "Kevin MacLeod", "src": "crypt/01-ossuary-5-rest.mp3", "secs": 235 },
|
||||||
|
{ "title": "Ghostpocalypse - Crossing the Threshold", "artist": "Kevin MacLeod", "src": "crypt/02-ghostpocalypse-crossing-the-threshold.mp3", "secs": 108 },
|
||||||
|
{ "title": "Crypto", "artist": "Kevin MacLeod", "src": "crypt/03-crypto.mp3", "secs": 204 },
|
||||||
|
{ "title": "Killers", "artist": "Kevin MacLeod", "src": "crypt/04-killers.mp3", "secs": 305 },
|
||||||
|
{ "title": "Hitman", "artist": "Kevin MacLeod", "src": "crypt/05-hitman.mp3", "secs": 200 }
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "terminal",
|
||||||
|
"about": "Hacker synth & chiptune — neon arcades and late-night shells.",
|
||||||
|
"license": "CC BY 4.0 — Kevin MacLeod (incompetech.com)",
|
||||||
|
"tracks": [
|
||||||
|
{ "title": "Neon Laser Horizon", "artist": "Kevin MacLeod", "src": "terminal/01-neon-laser-horizon.mp3", "secs": 178 },
|
||||||
|
{ "title": "Cyborg Ninja", "artist": "Kevin MacLeod", "src": "terminal/02-cyborg-ninja.mp3", "secs": 180 },
|
||||||
|
{ "title": "Space Fighter Loop", "artist": "Kevin MacLeod", "src": "terminal/03-space-fighter-loop.mp3", "secs": 101 },
|
||||||
|
{ "title": "Voltaic", "artist": "Kevin MacLeod", "src": "terminal/04-voltaic.mp3", "secs": 196 },
|
||||||
|
{ "title": "Blip Stream", "artist": "Kevin MacLeod", "src": "terminal/05-blip-stream.mp3", "secs": 284 },
|
||||||
|
{ "title": "Pixelland", "artist": "Kevin MacLeod", "src": "terminal/06-pixelland.mp3", "secs": 233 }
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+122
-3
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::ft;
|
use crate::ft;
|
||||||
use crate::layout::{Dir, Layout, Resize};
|
use crate::layout::{Dir, Layout, Resize};
|
||||||
|
use crate::music;
|
||||||
use crate::net::{self, Session};
|
use crate::net::{self, Session};
|
||||||
use crate::sbx;
|
use crate::sbx;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
@@ -284,6 +285,10 @@ pub struct App {
|
|||||||
/// and creds aren't cached). While `Some`, keystrokes feed this masked
|
/// and creds aren't cached). While `Some`, keystrokes feed this masked
|
||||||
/// buffer instead of chat — the secret never leaves the client.
|
/// buffer instead of chat — the secret never leaves the client.
|
||||||
pub sudo_prompt: Option<SudoPrompt>,
|
pub sudo_prompt: Option<SudoPrompt>,
|
||||||
|
/// "album ▸ Track" for the music now-playing indicator, or None when no
|
||||||
|
/// background music is playing. Mirrors the `music::Player` label so the UI
|
||||||
|
/// never has to touch the player's process handle.
|
||||||
|
pub now_playing: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -321,6 +326,7 @@ impl App {
|
|||||||
layout: Layout::default(),
|
layout: Layout::default(),
|
||||||
focused_pane: None,
|
focused_pane: None,
|
||||||
sudo_prompt: None,
|
sudo_prompt: None,
|
||||||
|
now_playing: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,6 +968,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
let mut send_seq: u64 = 0;
|
let mut send_seq: u64 = 0;
|
||||||
// The local AI agent subprocess this client spawned via `/ai start`, if any.
|
// The local AI agent subprocess this client spawned via `/ai start`, if any.
|
||||||
let mut agent: Option<std::process::Child> = None;
|
let mut agent: Option<std::process::Child> = None;
|
||||||
|
// Background-music session this client started via `/music play`, if any.
|
||||||
|
let mut music: Option<music::Player> = None;
|
||||||
let downloads = PathBuf::from("./downloads");
|
let downloads = PathBuf::from("./downloads");
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
@@ -1371,7 +1379,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
handle_command(&line, &mut app, &mut theme, &mut send_seq,
|
handle_command(&line, &mut app, &mut theme, &mut send_seq,
|
||||||
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
||||||
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term,
|
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term,
|
||||||
&mut agent, ¶ms);
|
&mut agent, &mut music, ¶ms);
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => { app.input.pop(); }
|
KeyCode::Backspace => { app.input.pop(); }
|
||||||
// Scroll: ↑/↓ scroll the sandbox terminal if one is up,
|
// Scroll: ↑/↓ scroll the sandbox terminal if one is up,
|
||||||
@@ -1676,7 +1684,18 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
}
|
}
|
||||||
_ = sigterm.recv() => { break Ok(()); }
|
_ = sigterm.recv() => { break Ok(()); }
|
||||||
_ = sighup.recv() => { break Ok(()); }
|
_ = sighup.recv() => { break Ok(()); }
|
||||||
_ = tick.tick() => { app.spin = app.spin.wrapping_add(1); }
|
_ = tick.tick() => {
|
||||||
|
app.spin = app.spin.wrapping_add(1);
|
||||||
|
// Advance background music when the current track ends. Compute
|
||||||
|
// the event before touching `music`/`app` so the player borrow
|
||||||
|
// is released first.
|
||||||
|
let ev = music.as_mut().and_then(|p| p.tick());
|
||||||
|
match ev {
|
||||||
|
Some(Ok(label)) => app.now_playing = Some(label),
|
||||||
|
Some(Err(e)) => { music = None; app.now_playing = None; app.err(e); }
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1690,6 +1709,9 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
|
if let Some(mut p) = music.take() {
|
||||||
|
p.stop();
|
||||||
|
}
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
term.backend_mut(),
|
term.backend_mut(),
|
||||||
@@ -1779,6 +1801,7 @@ fn handle_command(
|
|||||||
session: &Session,
|
session: &Session,
|
||||||
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
|
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||||
agent: &mut Option<std::process::Child>,
|
agent: &mut Option<std::process::Child>,
|
||||||
|
music: &mut Option<music::Player>,
|
||||||
params: &net::ConnParams,
|
params: &net::ConnParams,
|
||||||
) {
|
) {
|
||||||
let room = &session.room;
|
let room = &session.room;
|
||||||
@@ -1799,6 +1822,102 @@ fn handle_command(
|
|||||||
} else {
|
} else {
|
||||||
app.sys(format!("† room password: {}", app.password));
|
app.sys(format!("† room password: {}", app.password));
|
||||||
}
|
}
|
||||||
|
} else if let Some(rest) = line.strip_prefix("/music") {
|
||||||
|
// Background music for the session. Plays bundled CC-BY albums or the
|
||||||
|
// operator's imported files through an external player (ffplay/mpv/cvlc);
|
||||||
|
// the run loop's tick auto-advances tracks. Local to this client — never
|
||||||
|
// broadcast, so each member scores their own session.
|
||||||
|
let rest = rest.trim();
|
||||||
|
let (cmd, arg) = rest
|
||||||
|
.split_once(char::is_whitespace)
|
||||||
|
.map(|(c, a)| (c, a.trim()))
|
||||||
|
.unwrap_or((rest, ""));
|
||||||
|
// Start (or restart into) an album by name, replacing any current session.
|
||||||
|
let play = |app: &mut App, music: &mut Option<music::Player>, name: &str| {
|
||||||
|
if let Some(mut p) = music.take() {
|
||||||
|
p.stop();
|
||||||
|
}
|
||||||
|
match music::Player::start(name) {
|
||||||
|
Ok(p) => {
|
||||||
|
let label = p.label();
|
||||||
|
*music = Some(p);
|
||||||
|
app.now_playing = Some(label.clone());
|
||||||
|
app.sys(format!("♪ playing {label}"));
|
||||||
|
}
|
||||||
|
Err(e) => app.err(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match cmd {
|
||||||
|
"" | "list" | "ls" => {
|
||||||
|
app.sys(format!(
|
||||||
|
"♪ albums: {} — /music play [album] (blank/random = shuffle) · stop · next · import <path> [as <name>]",
|
||||||
|
music::once_or_none(music::available())
|
||||||
|
));
|
||||||
|
if let Some(np) = &app.now_playing {
|
||||||
|
app.sys(format!("♪ now playing: {np}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"play" | "start" => {
|
||||||
|
// Bare `/music play`, or `/music play random|shuffle`, rolls a
|
||||||
|
// random album; otherwise play the named one.
|
||||||
|
let pick = if arg.is_empty()
|
||||||
|
|| arg.eq_ignore_ascii_case("random")
|
||||||
|
|| arg.eq_ignore_ascii_case("shuffle")
|
||||||
|
{
|
||||||
|
music::random()
|
||||||
|
} else {
|
||||||
|
Some(arg.to_string())
|
||||||
|
};
|
||||||
|
match pick {
|
||||||
|
Some(name) => play(app, music, &name),
|
||||||
|
None => app.err("no albums installed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stop" | "off" | "pause" => {
|
||||||
|
if let Some(mut p) = music.take() {
|
||||||
|
p.stop();
|
||||||
|
app.now_playing = None;
|
||||||
|
app.sys("♪ music stopped");
|
||||||
|
} else {
|
||||||
|
app.sys("♪ nothing playing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"next" | "skip" => match music.as_mut() {
|
||||||
|
Some(p) => match p.skip() {
|
||||||
|
Ok(label) => {
|
||||||
|
app.now_playing = Some(label.clone());
|
||||||
|
app.sys(format!("♪ playing {label}"));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
*music = None;
|
||||||
|
app.now_playing = None;
|
||||||
|
app.err(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => app.sys("♪ nothing playing — /music play <album>"),
|
||||||
|
},
|
||||||
|
"random" | "shuffle" => match music::random() {
|
||||||
|
Some(name) => play(app, music, &name),
|
||||||
|
None => app.err("no albums installed"),
|
||||||
|
},
|
||||||
|
"import" | "add" if !arg.is_empty() => {
|
||||||
|
// `/music import <path> [as <name>]`
|
||||||
|
let (path, as_name) = match arg.rsplit_once(" as ") {
|
||||||
|
Some((p, n)) => (p.trim(), Some(n.trim())),
|
||||||
|
None => (arg, None),
|
||||||
|
};
|
||||||
|
match music::import(path, as_name) {
|
||||||
|
Ok((name, n)) => app.sys(format!(
|
||||||
|
"♪ imported {n} track(s) as album '{name}' — /music play {name}"
|
||||||
|
)),
|
||||||
|
Err(e) => app.err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"import" | "add" => app.err("usage: /music import <file-or-dir> [as <name>]"),
|
||||||
|
other => app.err(format!(
|
||||||
|
"unknown /music '{other}' — list · play <album> · stop · next · random · import <path>"
|
||||||
|
)),
|
||||||
|
}
|
||||||
} else if let Some(rest) = line.strip_prefix("/theme") {
|
} else if let Some(rest) = line.strip_prefix("/theme") {
|
||||||
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
|
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
|
||||||
let name = rest.trim();
|
let name = rest.trim();
|
||||||
@@ -2821,7 +2940,7 @@ fn local_ollama_models() -> Result<Vec<String>, String> {
|
|||||||
/// known command family that legitimately falls through to chat (e.g. `/ai
|
/// known command family that legitimately falls through to chat (e.g. `/ai
|
||||||
/// <question>`) and as the candidate set for the "did you mean" suggester.
|
/// <question>`) and as the candidate set for the "did you mean" suggester.
|
||||||
const KNOWN_COMMANDS: &[&str] = &[
|
const KNOWN_COMMANDS: &[&str] = &[
|
||||||
"/help", "/?", "/clear", "/cls", "/pw", "/password", "/theme", "/layout", "/drive",
|
"/help", "/?", "/clear", "/cls", "/pw", "/password", "/theme", "/layout", "/music", "/drive",
|
||||||
"/sendroom", "/send", "/accept", "/reject", "/sbx", "/unsudo", "/sudo", "/grant", "/revoke",
|
"/sendroom", "/send", "/accept", "/reject", "/sbx", "/unsudo", "/sudo", "/grant", "/revoke",
|
||||||
"/ai",
|
"/ai",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod app;
|
|||||||
mod crypto;
|
mod crypto;
|
||||||
mod ft;
|
mod ft;
|
||||||
mod layout;
|
mod layout;
|
||||||
|
mod music;
|
||||||
mod net;
|
mod net;
|
||||||
mod sbx;
|
mod sbx;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
|||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
//! Background music for terminal sessions — "hacker vibes". Plays bundled
|
||||||
|
//! CC-BY albums (see `hh/music/*.json`) or the operator's own imported files
|
||||||
|
//! through an *external* player (mpv / ffplay / cvlc), shelled out like the
|
||||||
|
//! sandbox backends so the Rust binary itself stays codec- and audio-device
|
||||||
|
//! free. Driven by `/music` in `app::handle_command`; the run loop's tick calls
|
||||||
|
//! `Player::tick` to auto-advance between tracks.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
|
||||||
|
/// Where the bundled albums live, so `/music play <name>` resolves a bare name
|
||||||
|
/// to a manifest at runtime (mirrors theme.rs's `THEMES_DIR`). Each `*.json`
|
||||||
|
/// here is one playlist; its `src`s are paths relative to this directory.
|
||||||
|
pub const MUSIC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/music");
|
||||||
|
|
||||||
|
/// File extensions we treat as importable audio for `/music import <dir>`.
|
||||||
|
const AUDIO_EXTS: &[&str] = &[
|
||||||
|
"mp3", "ogg", "oga", "flac", "wav", "m4a", "aac", "opus", "wma",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Track {
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
/// A stream URL (`scheme://…`), an absolute file path, or a path relative to
|
||||||
|
/// the playlist's own directory.
|
||||||
|
pub src: String,
|
||||||
|
/// Track length in seconds, if known (0 = unknown). Display-only.
|
||||||
|
pub secs: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub name: String,
|
||||||
|
pub about: String,
|
||||||
|
pub license: String,
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External players we know how to drive, in preference order. Each plays a
|
||||||
|
/// single source to its end and then exits, which is exactly the signal
|
||||||
|
/// `Player::tick` waits on to advance to the next track.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Engine {
|
||||||
|
Mpv,
|
||||||
|
Ffplay,
|
||||||
|
Cvlc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
/// First installed player, searching PATH. `None` means the host has no
|
||||||
|
/// audio backend and `/music` can't play anything.
|
||||||
|
fn detect() -> Option<Engine> {
|
||||||
|
for (bin, eng) in [
|
||||||
|
("mpv", Engine::Mpv),
|
||||||
|
("ffplay", Engine::Ffplay),
|
||||||
|
("cvlc", Engine::Cvlc),
|
||||||
|
] {
|
||||||
|
if in_path(bin) {
|
||||||
|
return Some(eng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bin(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Engine::Mpv => "mpv",
|
||||||
|
Engine::Ffplay => "ffplay",
|
||||||
|
Engine::Cvlc => "cvlc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A headless, quiet, play-once command for a single `target` source.
|
||||||
|
fn command(self, target: &str) -> Command {
|
||||||
|
let mut c = Command::new(self.bin());
|
||||||
|
match self {
|
||||||
|
Engine::Mpv => {
|
||||||
|
c.args(["--no-video", "--really-quiet", "--no-terminal"]).arg(target);
|
||||||
|
}
|
||||||
|
Engine::Ffplay => {
|
||||||
|
c.args(["-nodisp", "-autoexit", "-hide_banner", "-loglevel", "error"])
|
||||||
|
.arg(target);
|
||||||
|
}
|
||||||
|
Engine::Cvlc => {
|
||||||
|
c.args(["--intf", "dummy", "--play-and-exit", "--quiet"]).arg(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live playback session: an album, a cursor into it, and the child player
|
||||||
|
/// process rendering the current track. Kept out of `App` (like the `/ai`
|
||||||
|
/// agent child) so the UI never touches a process handle; `App::now_playing`
|
||||||
|
/// mirrors `label()` for display. Dropping it always stops the audio.
|
||||||
|
pub struct Player {
|
||||||
|
playlist: Playlist,
|
||||||
|
/// Directory the playlist's relative `src`s resolve against.
|
||||||
|
base: PathBuf,
|
||||||
|
idx: usize,
|
||||||
|
engine: Engine,
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Player {
|
||||||
|
/// Load `name` (user library shadows bundled) and start playing its first
|
||||||
|
/// track. Errors carry a chat-ready message.
|
||||||
|
pub fn start(name: &str) -> Result<Player, String> {
|
||||||
|
let engine = Engine::detect().ok_or_else(|| {
|
||||||
|
"no audio player found — install ffplay (ffmpeg), mpv, or vlc".to_string()
|
||||||
|
})?;
|
||||||
|
let (playlist, base) = load(name)?;
|
||||||
|
if playlist.tracks.is_empty() {
|
||||||
|
return Err(format!("album '{name}' has no tracks"));
|
||||||
|
}
|
||||||
|
let target = resolve(&base, &playlist.tracks[0].src);
|
||||||
|
let child = spawn(engine, &target)?;
|
||||||
|
Ok(Player {
|
||||||
|
playlist,
|
||||||
|
base,
|
||||||
|
idx: 0,
|
||||||
|
engine,
|
||||||
|
child,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the player once (call on the run-loop tick). Returns:
|
||||||
|
/// - `None` — the current track is still playing.
|
||||||
|
/// - `Some(Ok(label))` — the track finished; advanced to a new one.
|
||||||
|
/// - `Some(Err(e))` — couldn't continue; caller should drop the player.
|
||||||
|
pub fn tick(&mut self) -> Option<Result<String, String>> {
|
||||||
|
match self.child.try_wait() {
|
||||||
|
Ok(Some(_)) => Some(self.advance()),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(_) => None, // transient wait error — retry next tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill the current track and jump to the next (wrapping). Backs `/music
|
||||||
|
/// next` / `/music skip`.
|
||||||
|
pub fn skip(&mut self) -> Result<String, String> {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
self.advance()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop playback and reap the child.
|
||||||
|
pub fn stop(&mut self) {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "album ▸ Track Title" — mirrored into `App::now_playing` for the top bar.
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
format!("{} ▸ {}", self.playlist.name, self.playlist.tracks[self.idx].title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(&mut self) -> Result<String, String> {
|
||||||
|
self.idx = (self.idx + 1) % self.playlist.tracks.len();
|
||||||
|
let target = resolve(&self.base, &self.playlist.tracks[self.idx].src);
|
||||||
|
self.child = spawn(self.engine, &target)?;
|
||||||
|
Ok(self.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Player {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the chosen player on one source, muting its stdio into a temp log so it
|
||||||
|
/// can never scribble on the TUI.
|
||||||
|
fn spawn(engine: Engine, target: &str) -> Result<Child, String> {
|
||||||
|
let log = std::env::temp_dir().join("hh-music.log");
|
||||||
|
let (out, err) = match std::fs::File::create(&log) {
|
||||||
|
Ok(f) => {
|
||||||
|
let dup = f.try_clone().map_err(|e| e.to_string())?;
|
||||||
|
(Stdio::from(f), Stdio::from(dup))
|
||||||
|
}
|
||||||
|
Err(_) => (Stdio::null(), Stdio::null()),
|
||||||
|
};
|
||||||
|
engine
|
||||||
|
.command(target)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(out)
|
||||||
|
.stderr(err)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("could not start {} ({e})", engine.bin()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a track `src` into something the player can open: URLs and absolute
|
||||||
|
/// paths pass through; a bare/relative path resolves against the playlist dir.
|
||||||
|
fn resolve(base: &Path, src: &str) -> String {
|
||||||
|
if src.contains("://") {
|
||||||
|
return src.to_string();
|
||||||
|
}
|
||||||
|
let p = Path::new(src);
|
||||||
|
if p.is_absolute() {
|
||||||
|
src.to_string()
|
||||||
|
} else {
|
||||||
|
base.join(src).to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's personal album library, where `/music import` writes. `None` if
|
||||||
|
/// `$HOME` is unset.
|
||||||
|
pub fn user_dir() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".hh/music"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album search path: the user library first (so it can shadow a bundled name),
|
||||||
|
/// then the shipped albums.
|
||||||
|
fn dirs() -> Vec<PathBuf> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
if let Some(u) = user_dir() {
|
||||||
|
v.push(u);
|
||||||
|
}
|
||||||
|
v.push(PathBuf::from(MUSIC_DIR));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every album name (`*.json` stem) across the search path, deduped + sorted.
|
||||||
|
pub fn available() -> Vec<String> {
|
||||||
|
let mut set = std::collections::BTreeSet::new();
|
||||||
|
for dir in dirs() {
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let path = e.path();
|
||||||
|
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||||
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
set.insert(stem.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load an album by name (user library shadows bundled). Returns the parsed
|
||||||
|
/// playlist and the directory its relative `src`s resolve against.
|
||||||
|
fn load(name: &str) -> Result<(Playlist, PathBuf), String> {
|
||||||
|
for dir in dirs() {
|
||||||
|
let file = dir.join(format!("{name}.json"));
|
||||||
|
if file.is_file() {
|
||||||
|
let s = std::fs::read_to_string(&file).map_err(|e| e.to_string())?;
|
||||||
|
let mut pl: Playlist =
|
||||||
|
serde_json::from_str(&s).map_err(|e| format!("parse {}: {e}", file.display()))?;
|
||||||
|
if pl.name.is_empty() {
|
||||||
|
pl.name = name.to_string();
|
||||||
|
}
|
||||||
|
return Ok((pl, dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("no album '{name}' — try: {}", once_or_none(available())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll a random installed album name (backs `/music random`).
|
||||||
|
pub fn random() -> Option<String> {
|
||||||
|
let all = available();
|
||||||
|
if all.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as usize)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some(all[n % all.len()].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a file or a directory of audio into the user library as a new album,
|
||||||
|
/// referencing the files in place (absolute paths — nothing is copied).
|
||||||
|
/// Returns the album name and track count.
|
||||||
|
pub fn import(path: &str, as_name: Option<&str>) -> Result<(String, usize), String> {
|
||||||
|
let p = Path::new(path);
|
||||||
|
if !p.exists() {
|
||||||
|
return Err(format!("no such path: {path}"));
|
||||||
|
}
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
if p.is_dir() {
|
||||||
|
let mut files: Vec<PathBuf> = std::fs::read_dir(p)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|q| is_audio(q))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
for f in &files {
|
||||||
|
tracks.push(track_from(f));
|
||||||
|
}
|
||||||
|
} else if is_audio(p) {
|
||||||
|
tracks.push(track_from(p));
|
||||||
|
} else {
|
||||||
|
return Err(format!("not an audio file: {path}"));
|
||||||
|
}
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return Err(format!("no audio files found under {path}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_stem = p
|
||||||
|
.file_stem()
|
||||||
|
.or_else(|| p.file_name())
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("import");
|
||||||
|
let name = as_name
|
||||||
|
.map(slugify)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| slugify(default_stem));
|
||||||
|
let name = if name.is_empty() { "import".to_string() } else { name };
|
||||||
|
|
||||||
|
let dir = user_dir().ok_or_else(|| "no $HOME to store the album".to_string())?;
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let count = tracks.len();
|
||||||
|
let pl = Playlist {
|
||||||
|
name: name.clone(),
|
||||||
|
about: format!("imported from {path}"),
|
||||||
|
license: "user-provided".into(),
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
let file = dir.join(format!("{name}.json"));
|
||||||
|
let body = serde_json::to_string_pretty(&pl).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(&file, body).map_err(|e| format!("write {}: {e}", file.display()))?;
|
||||||
|
Ok((name, count))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_from(p: &Path) -> Track {
|
||||||
|
let abs = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||||
|
let title = p
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("track")
|
||||||
|
.to_string();
|
||||||
|
Track {
|
||||||
|
title,
|
||||||
|
artist: String::new(),
|
||||||
|
src: abs.to_string_lossy().into_owned(),
|
||||||
|
secs: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_audio(p: &Path) -> bool {
|
||||||
|
p.is_file()
|
||||||
|
&& p.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `bin` an executable name reachable on `$PATH`? Dependency-free stand-in
|
||||||
|
/// for the `which` crate — good enough to pick an installed player.
|
||||||
|
fn in_path(bin: &str) -> bool {
|
||||||
|
std::env::var_os("PATH")
|
||||||
|
.map(|path| std::env::split_paths(&path).any(|d| d.join(bin).is_file()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce a free-form album name to a safe `<slug>.json` filename.
|
||||||
|
fn slugify(name: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut dash = false;
|
||||||
|
for c in name.trim().chars() {
|
||||||
|
if c.is_ascii_alphanumeric() {
|
||||||
|
if dash && !out.is_empty() {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
out.extend(c.to_lowercase());
|
||||||
|
dash = false;
|
||||||
|
} else {
|
||||||
|
dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join names with " · ", or say "(none)" so an empty list still reads cleanly.
|
||||||
|
pub fn once_or_none(items: Vec<String>) -> String {
|
||||||
|
if items.is_empty() {
|
||||||
|
"(none)".to_string()
|
||||||
|
} else {
|
||||||
|
items.join(" · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_albums_are_discoverable() {
|
||||||
|
// The shipped albums must be found by name so `/music play crypt` works.
|
||||||
|
let names = available();
|
||||||
|
assert!(names.contains(&"crypt".to_string()), "albums: {names:?}");
|
||||||
|
assert!(names.contains(&"terminal".to_string()), "albums: {names:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_albums_parse_and_have_tracks() {
|
||||||
|
for name in ["crypt", "terminal"] {
|
||||||
|
let (pl, base) = load(name).expect("bundled album loads");
|
||||||
|
assert_eq!(pl.name, name);
|
||||||
|
assert!(!pl.tracks.is_empty(), "{name} has tracks");
|
||||||
|
// Every relative src must resolve to a file that actually shipped.
|
||||||
|
for t in &pl.tracks {
|
||||||
|
let path = resolve(&base, &t.src);
|
||||||
|
assert!(
|
||||||
|
Path::new(&path).is_file(),
|
||||||
|
"{name}: missing track file {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_passes_urls_and_absolutes_through() {
|
||||||
|
let base = Path::new("/tmp/albums");
|
||||||
|
assert_eq!(resolve(base, "http://x/y.mp3"), "http://x/y.mp3");
|
||||||
|
assert_eq!(resolve(base, "/abs/a.mp3"), "/abs/a.mp3");
|
||||||
|
assert_eq!(resolve(base, "sub/a.mp3"), "/tmp/albums/sub/a.mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slugify_makes_safe_names() {
|
||||||
|
assert_eq!(slugify(" My Mix!! "), "my-mix");
|
||||||
|
assert_eq!(slugify("late/night"), "late-night");
|
||||||
|
assert_eq!(slugify("!!!"), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-3
@@ -396,6 +396,25 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
HelpCluster {
|
||||||
|
title: "MUSIC (session soundtrack)",
|
||||||
|
items: vec![
|
||||||
|
kv(
|
||||||
|
"/music · /music list",
|
||||||
|
"list albums (bundled 'crypt'/'terminal' + your imports) and what's playing",
|
||||||
|
),
|
||||||
|
kv("/music play <album>", "start an album — tracks auto-advance and loop"),
|
||||||
|
kv(
|
||||||
|
"/music play · random",
|
||||||
|
"blank or 'random' shuffles to a random album",
|
||||||
|
),
|
||||||
|
kv("/music stop · next", "stop playback · skip to the next track"),
|
||||||
|
kv(
|
||||||
|
"/music import <path>",
|
||||||
|
"add a file/folder of your own audio as a new album (… as <name>)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
HelpCluster {
|
HelpCluster {
|
||||||
title: "LAYOUT (resize panes)",
|
title: "LAYOUT (resize panes)",
|
||||||
items: vec![
|
items: vec![
|
||||||
@@ -655,7 +674,7 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
|
|||||||
} else {
|
} else {
|
||||||
"✖ closed · Ctrl-R to reconnect"
|
"✖ closed · Ctrl-R to reconnect"
|
||||||
};
|
};
|
||||||
let bar = Line::from(vec![
|
let mut spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {0} hack-house {0} ", theme.sigil),
|
format!(" {0} hack-house {0} ", theme.sigil),
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -667,8 +686,15 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
|
|||||||
format!("· house {}/{} ", app.users.len(), cap),
|
format!("· house {}/{} ", app.users.len(), cap),
|
||||||
Style::default().fg(theme.title),
|
Style::default().fg(theme.title),
|
||||||
),
|
),
|
||||||
]);
|
];
|
||||||
f.render_widget(Paragraph::new(bar), area);
|
// Now-playing indicator — shown only while background music is running.
|
||||||
|
if let Some(np) = &app.now_playing {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("· ♪ {np} "),
|
||||||
|
Style::default().fg(theme.accent),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render one chat record into one-or-more visual lines. A record may carry
|
/// Render one chat record into one-or-more visual lines. A record may carry
|
||||||
|
|||||||
Reference in New Issue
Block a user