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::layout::{Dir, Layout, Resize};
|
||||
use crate::music;
|
||||
use crate::net::{self, Session};
|
||||
use crate::sbx;
|
||||
use crate::theme::Theme;
|
||||
@@ -284,6 +285,10 @@ pub struct App {
|
||||
/// and creds aren't cached). While `Some`, keystrokes feed this masked
|
||||
/// buffer instead of chat — the secret never leaves the client.
|
||||
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 {
|
||||
@@ -321,6 +326,7 @@ impl App {
|
||||
layout: Layout::default(),
|
||||
focused_pane: 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;
|
||||
// The local AI agent subprocess this client spawned via `/ai start`, if any.
|
||||
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");
|
||||
|
||||
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,
|
||||
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
||||
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term,
|
||||
&mut agent, ¶ms);
|
||||
&mut agent, &mut music, ¶ms);
|
||||
}
|
||||
KeyCode::Backspace => { app.input.pop(); }
|
||||
// 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(()); }
|
||||
_ = 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.wait();
|
||||
}
|
||||
if let Some(mut p) = music.take() {
|
||||
p.stop();
|
||||
}
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
term.backend_mut(),
|
||||
@@ -1779,6 +1801,7 @@ fn handle_command(
|
||||
session: &Session,
|
||||
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
agent: &mut Option<std::process::Child>,
|
||||
music: &mut Option<music::Player>,
|
||||
params: &net::ConnParams,
|
||||
) {
|
||||
let room = &session.room;
|
||||
@@ -1799,6 +1822,102 @@ fn handle_command(
|
||||
} else {
|
||||
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") {
|
||||
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
|
||||
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
|
||||
/// <question>`) and as the candidate set for the "did you mean" suggester.
|
||||
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",
|
||||
"/ai",
|
||||
];
|
||||
|
||||
@@ -8,6 +8,7 @@ mod app;
|
||||
mod crypto;
|
||||
mod ft;
|
||||
mod layout;
|
||||
mod music;
|
||||
mod net;
|
||||
mod sbx;
|
||||
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 {
|
||||
title: "LAYOUT (resize panes)",
|
||||
items: vec![
|
||||
@@ -655,7 +674,7 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
|
||||
} else {
|
||||
"✖ closed · Ctrl-R to reconnect"
|
||||
};
|
||||
let bar = Line::from(vec![
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!(" {0} hack-house {0} ", theme.sigil),
|
||||
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),
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user