diff --git a/docs/music-licensing.md b/docs/music-licensing.md new file mode 100644 index 0000000..b3767f7 --- /dev/null +++ b/docs/music-licensing.md @@ -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/.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//`. + +## Imported albums (`/music import`) + +`/music import ` 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. diff --git a/hh/music/crypt.json b/hh/music/crypt.json new file mode 100644 index 0000000..0a2962c --- /dev/null +++ b/hh/music/crypt.json @@ -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 } + ] +} diff --git a/hh/music/crypt/01-ossuary-5-rest.mp3 b/hh/music/crypt/01-ossuary-5-rest.mp3 new file mode 100644 index 0000000..fca3721 Binary files /dev/null and b/hh/music/crypt/01-ossuary-5-rest.mp3 differ diff --git a/hh/music/crypt/02-ghostpocalypse-crossing-the-threshold.mp3 b/hh/music/crypt/02-ghostpocalypse-crossing-the-threshold.mp3 new file mode 100644 index 0000000..2e07bb4 Binary files /dev/null and b/hh/music/crypt/02-ghostpocalypse-crossing-the-threshold.mp3 differ diff --git a/hh/music/crypt/03-crypto.mp3 b/hh/music/crypt/03-crypto.mp3 new file mode 100644 index 0000000..d99e2ea Binary files /dev/null and b/hh/music/crypt/03-crypto.mp3 differ diff --git a/hh/music/crypt/04-killers.mp3 b/hh/music/crypt/04-killers.mp3 new file mode 100644 index 0000000..cbd8211 Binary files /dev/null and b/hh/music/crypt/04-killers.mp3 differ diff --git a/hh/music/crypt/05-hitman.mp3 b/hh/music/crypt/05-hitman.mp3 new file mode 100644 index 0000000..daf834e Binary files /dev/null and b/hh/music/crypt/05-hitman.mp3 differ diff --git a/hh/music/terminal.json b/hh/music/terminal.json new file mode 100644 index 0000000..26985fc --- /dev/null +++ b/hh/music/terminal.json @@ -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 } + ] +} diff --git a/hh/music/terminal/01-neon-laser-horizon.mp3 b/hh/music/terminal/01-neon-laser-horizon.mp3 new file mode 100644 index 0000000..79f89f1 Binary files /dev/null and b/hh/music/terminal/01-neon-laser-horizon.mp3 differ diff --git a/hh/music/terminal/02-cyborg-ninja.mp3 b/hh/music/terminal/02-cyborg-ninja.mp3 new file mode 100644 index 0000000..52e8765 Binary files /dev/null and b/hh/music/terminal/02-cyborg-ninja.mp3 differ diff --git a/hh/music/terminal/03-space-fighter-loop.mp3 b/hh/music/terminal/03-space-fighter-loop.mp3 new file mode 100644 index 0000000..10d4fdc Binary files /dev/null and b/hh/music/terminal/03-space-fighter-loop.mp3 differ diff --git a/hh/music/terminal/04-voltaic.mp3 b/hh/music/terminal/04-voltaic.mp3 new file mode 100644 index 0000000..151f643 Binary files /dev/null and b/hh/music/terminal/04-voltaic.mp3 differ diff --git a/hh/music/terminal/05-blip-stream.mp3 b/hh/music/terminal/05-blip-stream.mp3 new file mode 100644 index 0000000..57bab4b Binary files /dev/null and b/hh/music/terminal/05-blip-stream.mp3 differ diff --git a/hh/music/terminal/06-pixelland.mp3 b/hh/music/terminal/06-pixelland.mp3 new file mode 100644 index 0000000..9abed33 Binary files /dev/null and b/hh/music/terminal/06-pixelland.mp3 differ diff --git a/hh/src/app.rs b/hh/src/app.rs index 1ae3979..29e5ee8 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -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, + /// "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, } 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 = None; + // Background-music session this client started via `/music play`, if any. + let mut music: Option = 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>, agent: &mut Option, + music: &mut Option, 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, 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 [as ]", + 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 "), + }, + "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 [as ]` + 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 [as ]"), + other => app.err(format!( + "unknown /music '{other}' — list · play · stop · next · random · import " + )), + } } else if let Some(rest) = line.strip_prefix("/theme") { // Live vestment switch: `/theme `, or bare `/theme` to list options. let name = rest.trim(); @@ -2821,7 +2940,7 @@ fn local_ollama_models() -> Result, String> { /// known command family that legitimately falls through to chat (e.g. `/ai /// `) 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", ]; diff --git a/hh/src/main.rs b/hh/src/main.rs index 3be6f56..c41f03e 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -8,6 +8,7 @@ mod app; mod crypto; mod ft; mod layout; +mod music; mod net; mod sbx; mod theme; diff --git a/hh/src/music.rs b/hh/src/music.rs new file mode 100644 index 0000000..2f6380d --- /dev/null +++ b/hh/src/music.rs @@ -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 ` 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 `. +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, +} + +/// 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 `.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 { + 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("!!!"), ""); + } +} diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 2bdcd18..2e44b0f 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -396,6 +396,25 @@ fn help_clusters(theme: &Theme) -> Vec { ), ], }, + 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 ", "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 ", + "add a file/folder of your own audio as a new album (… as )", + ), + ], + }, 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