diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b53e6b..5a6f38e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,18 +15,19 @@ jobs: - uses: mlugg/setup-zig@v2 with: - version: 0.16.0 - - - name: install libmpv - run: sudo apt-get update && sudo apt-get install -y libmpv-dev + version: 0.17.0 - name: unit tests run: | - zig build test -Dmpv-prefix= - zig build test -Doptimize=ReleaseSafe -Dmpv-prefix= + zig build test + zig build test -Doptimize=ReleaseSafe - # Type-checks freebsd/linux/macos in Debug and ReleaseSafe without - # linking. The FreeBSD release leg has broken twice on translate-c; this - # catches that on every push instead of at tag time inside a VM. - name: cross-target check run: zig build check + + - name: libmpv runtime load + run: | + sudo apt-get update && sudo apt-get install -y libmpv2 + zig build + ./zig-out/bin/hum doctor | tee doctor.txt + grep -E '^ libmpv +libmpv\.so' doctor.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be495d9..ea7089f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,46 +8,8 @@ permissions: contents: write jobs: - build-linux: - name: ${{ matrix.name }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - { name: linux-arm64, os: ubuntu-24.04-arm } - - { name: linux-x86_64, os: ubuntu-24.04 } - steps: - - uses: actions/checkout@v5 - - - uses: mlugg/setup-zig@v2 - with: - version: 0.16.0 - - - name: install libmpv - run: sudo apt-get update && sudo apt-get install -y libmpv-dev - - - name: build - run: | - zig build test -Dmpv-prefix= - zig build -Doptimize=ReleaseSafe -Dmpv-prefix= - - - name: package - run: tar -czf "hum-${{ matrix.name }}.tar.gz" -C zig-out/bin hum - - - uses: softprops/action-gh-release@v3 - with: - files: hum-${{ matrix.name }}.tar.gz - - # Single universal (arm64 + x86_64) binary, cross-built on one Apple-silicon - # runner and lipo'd — avoids the scarce macos-13 Intel runner entirely. - # Apple-silicon only. The universal build died when Homebrew stopped - # shipping x86_64 bottles for mpv (Tier 3 under Rosetta): the x86_64 slice - # needs an x86_64 libmpv to link against, and building mpv from source in CI - # means building ffmpeg too. Intel Macs build from source via the tap. - build-macos: - name: macos-arm64 + build: + name: all targets runs-on: macos-14 timeout-minutes: 30 steps: @@ -55,74 +17,30 @@ jobs: - uses: mlugg/setup-zig@v2 with: - version: 0.16.0 + version: 0.17.0 - - name: install libmpv - run: brew install mpv + - name: test + run: zig build test - name: build run: | - MPV="$(brew --prefix mpv)" - zig build test -Dmpv-prefix="$MPV" - zig build -Doptimize=ReleaseSafe -Dtarget=aarch64-macos -Dmpv-prefix="$MPV" - file zig-out/bin/hum + set -e + for t in aarch64-macos x86_64-macos x86_64-linux-gnu aarch64-linux-gnu x86_64-freebsd; do + zig build -Doptimize=ReleaseSafe -Dtarget=$t --prefix out/$t + done + mkdir -p out/macos-universal/bin + lipo -create -output out/macos-universal/bin/hum out/aarch64-macos/bin/hum out/x86_64-macos/bin/hum + file out/*/bin/hum - name: package - run: tar -czf hum-macos-arm64.tar.gz -C zig-out/bin hum - - - uses: softprops/action-gh-release@v3 - with: - files: hum-macos-arm64.tar.gz - - # Everything here runs inside an emulated FreeBSD VM, so every step is - # 5-10x its native cost. The 30-minute cap was being hit; the work was - # a 55MB in-VM toolchain download plus two full compiles. - build-freebsd: - name: freebsd-x86_64 - runs-on: ubuntu-latest - timeout-minutes: 60 - continue-on-error: true - steps: - - uses: actions/checkout@v5 - - # Pull the toolchain on the fast host (and cache it between releases) - # instead of over the VM's emulated network. The VM sees the workspace. - - name: cache zig tarball - id: zigcache - uses: actions/cache@v4 - with: - path: zig-freebsd.tar.xz - key: zig-freebsd-0.16.0 - - - name: fetch zig (host) - if: steps.zigcache.outputs.cache-hit != 'true' - run: curl -fsSL -o zig-freebsd.tar.xz "https://ziglang.org/download/0.16.0/zig-x86_64-freebsd-0.16.0.tar.xz" - - - uses: vmactions/freebsd-vm@v1 - with: - usesh: true - prepare: | - set -e - # mpv drags in ffmpeg/X11/vulkan; we only ever link libmpv, but the - # port is how FreeBSD ships it. Keep the set otherwise minimal. - pkg install -y mpv pkgconf curl - run: | - set -e - # Unpacked here rather than in `prepare`: this block is the one that - # is guaranteed to run inside the synced workspace, where the - # host-fetched tarball actually is. - mkdir -p /opt/zig - tar -xJf zig-freebsd.tar.xz -C /opt/zig --strip-components=1 - ln -sf /opt/zig/zig /usr/local/bin/zig - zig version - # Tests already run on the linux and macos legs of this same - # workflow. Repeating them here bought a second full compile in the - # slowest environment we have, for no extra coverage. - zig build -Doptimize=ReleaseSafe -Dmpv-prefix=/usr/local - - - name: package - run: tar -czf hum-freebsd-x86_64.tar.gz -C zig-out/bin hum + run: | + set -e + pack() { tar -czf "hum-$1.tar.gz" -C "out/$2/bin" hum; } + pack macos-universal macos-universal + pack linux-x86_64 x86_64-linux-gnu + pack linux-arm64 aarch64-linux-gnu + pack freebsd-x86_64 x86_64-freebsd - uses: softprops/action-gh-release@v3 with: - files: hum-freebsd-x86_64.tar.gz + files: hum-*.tar.gz diff --git a/.zigversion b/.zigversion index 04a373e..c5523bd 100644 --- a/.zigversion +++ b/.zigversion @@ -1 +1 @@ -0.16.0 +0.17.0 diff --git a/README.md b/README.md index 50223bb..91cae89 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,33 @@ your local music library, streamed rss/urls, and yt music, one zig binary that r MIT

-zig 0.16, single binary -- `libmpv` for audio; plays local files, streams, and youtube +zig 0.17, single binary +- `libmpv` for audio, loaded at runtime; plays local files, streams, and youtube - `hum` now has it's own tag reader (id3, vorbis/flac, ogg/opus, mp4) | no taglib, no ffprobe - shells out to `curl` and `yt-dlp` **for youtube obviously**; local playback needs neither -- astats lavfi filter for visualizer via `ffmpeg` +- astats lavfi filter for visualizer (ffmpeg inside mpv) - outbound calls only: never listens on a socket ## version -v0.1.7 dropping out the `ytcli` name; it's a media player now and youtube is only one source +v0.1.8 visualizers, mouse, and installs anywhere mpv does +- **libmpv loads at runtime** instead of being linked: one binary per platform, and intel macs get a release again (universal macOS binary) +- no mpv? `hum` still starts, searches and browses, and tells you to install mpv instead of failing to launch +- **mp3 durations**: local mp3s without a `TLEN` tag showed `00:00`; length now comes from the Xing/Info header, or bytes ÷ bitrate for CBR. existing library rows refresh on the next scan +- **linux media keys / status bars** (MPRIS): install `mpv-mpris` and hum shows up in waybar, polybar, gnome, kde +- now-playing widgets (macOS Now Playing, MPRIS) show the track title instead of a stream URL +- `hum doctor` reports where libmpv was found and flags a stale `yt-dlp`; the TUI says so too when a youtube track fails because of it +- **visualizer view** (`Ctrl+V`): the visualizer takes over the main box while now playing stays below, and the queue sits beside it on wide terminals. five built in: bars, cube, orbit, rings, plasma. `v`/`V` cycles them. they follow your theme and react to loudness, left/right balance and brightness (treble). resize all you want; it lays out fresh every frame +- **your own visualizers**: drop a `.vis` text file (ascii frames + a few `key = value` lines) in `~/.config/hum/visualizers/` and it joins the cycle. see [`examples/visualizers/pulse.vis`](examples/visualizers/pulse.vis) +- **mouse**: wheel scrolls lists, click selects, click again plays, click the progress bar to seek, click `both`/`youtube`/`library` and `all`/`songs`/… to switch scope and filter. in the visualizer: left/right click cycles, the queue panel plays on click and reorders by drag. `mouse=off` in the config turns it off (hold `shift`, or `option` in macOS Terminal, to select text while it's on) +- **paste**: pasting into search goes in as text (bracketed paste), so a pasted newline or escape code can't fire keys +- **zig 0.17**: builds with current zig (and homebrew's), no more C header imports anywhere +- `brew install zblauser/tap/hum` installs the prebuilt binary instead of compiling: intel macs no longer build zig (and LLVM) from source first + +
+previous
+ +v0.1.7
+dropping out the `ytcli` name; it's a media player now and youtube is only one source - **renamed**: the binary is `hum`. your existing history, playlists and config keep working; it does read the old `ytcli/` config/data dirs when the new `hum/` ones aren't present - **local files**: `hum ~/Music/album/01.flac` plays a file; `http(s)://` URL streams - **local library**: `hum library dir ~/Music`, then `Ctrl+L` in the TUI browses artists → albums → tracks @@ -30,9 +48,6 @@ zig 0.16, single binary - fix: search rows showed a duration where the artist belongs (top-result rows come from a card shelf that carries the artist in its header) - dropped: cookie/SAPISIDHASH personalization. not worth the credential surface -
-previous
- v0.1.6
- fix crash on non-UTF-8 autocomplete (the suggest endpoint answers in latin-1 without `ie`/`oe=utf-8`) - remote text is scrubbed of control bytes before it reaches your terminal or your history/playlist files @@ -84,24 +99,28 @@ zig 0.16, single binary + handful of color themes
-## build/install +## install ```sh -zig build # → zig-out/bin/hum -zig build install --prefix ~/.local # → ~/.local/bin/hum +brew install zblauser/tap/hum # macOS / linuxbrew: prebuilt binary + mpv + yt-dlp, nothing to compile ``` +or grab a binary from [releases](../../releases): macOS (universal), Linux (arm64/x86_64), FreeBSD. windows: run under WSL (no native build just yet). + ## dependencies ```sh -brew install mpv ffmpeg # macOS (mpv pulls in yt-dlp) -apt install libmpv-dev yt-dlp ffmpeg # Debian/Ubuntu +brew install mpv # macOS (mpv pulls in yt-dlp) +apt install libmpv2 yt-dlp # Debian/Ubuntu +apt install mpv-mpris # optional: media keys + status bar widgets on linux ```
-> **[ ! ]** currently requires `mpv`, `ffmpeg` and `yt-dlp` particularly on PATH (try and keep your `yt-dlp` updated, youtube breaks old versions) +> **[ ! ]** `libmpv` plays everything; `yt-dlp` is only for youtube (try and keep it updated, youtube breaks old versions). `hum doctor` checks both -## releases - -macOS (universal: arm64 + x86_64), Linux (arm64/x86_64), and FreeBSD binaries are attached to each [release](../../releases). windows: run under WSL (no native build just yet). +## build +```sh +zig build # → zig-out/bin/hum (needs only zig 0.17, not mpv) +zig build install --prefix ~/.local # → ~/.local/bin/hum +``` ## run ```sh @@ -171,6 +190,17 @@ made an effort to use commands that felt fairly intuitive | | `m` | Mute | | | `Ctrl+R` | Cycle repeat mode (off/track/queue) | | | `Ctrl+Y` | Cycle theme | +| | `Ctrl+V` | Visualizer view (`esc` or `Ctrl+V` again to leave) | +| **Visualizer (`Ctrl+V`)** | `v` / `V` | Next / previous visualizer (remembered) | +| | `↑` / `↓` | Volume up / down | +| | `←` / `→` | Seek backward / forward 10 seconds | +| **Mouse** | wheel | Move the selection (volume in the visualizer) | +| | click | Select a row; click it again to play or open it | +| | click the progress bar | Jump to that point in the track | +| | left / right click the visualizer | Next / previous visualizer | +| | click a queue-panel track | Play it | +| | drag a track onto another | Move it there (queue panel and `Ctrl+Q`) | +| | click a scope or filter label | Switch to it |
@@ -179,7 +209,8 @@ made an effort to use commands that felt fairly intuitive - `$XDG_DATA_HOME/hum/log` - timestamped failures (search/album/stream) with the underlying error and any `curl`/`yt-dlp` stderr. check here first when something says `(see log)`. - `$XDG_DATA_HOME/hum/playlists` - saved playlists: `[name]` header, then one `target⇥title⇥artist⇥kind⇥source⇥duration` line per track (`target` is a video id, or a path for local files). tab-separated, editable by hand. files written by older versions still load. - `$XDG_CACHE_HOME/hum/library` - the local index. rebuildable. delete it and `hum library scan`. rows are reused when a file's mtime and size are unchanged, so a rescan only re-reads what actually changed. -- `$XDG_CONFIG_HOME/hum/config` - `key=value` settings (theme, volume, `music_dir`). +- `$XDG_CONFIG_HOME/hum/config` - `key=value` settings (theme, volume, `music_dir`, `visualizer`, `mouse=off`, `splash=off`). +- `$XDG_CONFIG_HOME/hum/visualizers/*.vis` - your own visualizers. header lines `name`, `fps` (1-30), `speed` (`fixed`/`level`), `color` (`level`/`dim`/`accent`/`strong`), then frames separated by `---` lines. up to 32 files, 64KB each; a file that doesn't parse is skipped and noted in the log - history is just newline delimited text - `grep`/`cat` it, or seed it so the TUI autocompletes your favorites from the first keystroke: ```sh printf '%s\n' "elephant gym" "autechre" "john zorn" >> ~/.local/share/hum/history @@ -192,8 +223,15 @@ grep -i jazz ~/.local/share/hum/history > [ ! ] requests written to `/tmp/hum_body*` (mkstemp, 0600, unlinked after) per call + audio streamed/buffered via mpv; not written to disk. ## visualizer -simple spectrum bars via `astats` lavfi filter
-dB to linear/modulated per bar | *not a true per-band FFT* +

+ hum's visualizer view cycling bars, cube, orbit, rings, plasma and a custom flipbook +

+ +full screen the visualizer using `Ctrl+V` while something plays. `v`/`V` cycles bars, cube, orbit, rings, plasma and any `.vis` files you've added; left/right click on the visualizer does the same. the queue on the right is clickable (play) and draggable (reorder)
+ +mpv's `astats` lavfi filter reports loudness, per-channel level and the zero-crossing rate (a stand-in for brightness/treble) every 50ms; the visualizers are driven by those
+ +*not a true per-band FFT*: the bars are modulated by loudness, not split by frequency ## contribution please feel free to contribute, not a guarantee it will be merged, but a guarantee a human will look at it
diff --git a/assets/hum.gif b/assets/hum.gif index 0997264..7267dc3 100644 Binary files a/assets/hum.gif and b/assets/hum.gif differ diff --git a/assets/visualizers.gif b/assets/visualizers.gif new file mode 100644 index 0000000..52ec744 Binary files /dev/null and b/assets/visualizers.gif differ diff --git a/build.zig b/build.zig index dbc2f29..673fe8a 100644 --- a/build.zig +++ b/build.zig @@ -6,7 +6,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const mpv_prefix = b.option([]const u8, "mpv-prefix", "libmpv install prefix (default /usr/local)") orelse "/usr/local"; + const mpv_prefix = b.option([]const u8, "mpv-prefix", "libmpv prefix searched first at runtime") orelse ""; const mod = b.createModule(.{ .root_source_file = b.path("src/main.zig"), @@ -14,10 +14,10 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - linkMpv(b, mod, mpv_prefix); const options = b.addOptions(); options.addOption([]const u8, "version", version); + options.addOption([]const u8, "mpv_prefix", mpv_prefix); mod.addOptions("build_options", options); const exe = b.addExecutable(.{ @@ -29,17 +29,20 @@ pub fn build(b: *std.Build) void { const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); - if (b.args) |args| run_cmd.addArgs(args); + run_cmd.addPassthruArgs(); const run_step = b.step("run", "Run hum"); run_step.dependOn(&run_cmd.step); + const clean_cmd = b.addSystemCommand(&.{ "rm", "-rf", "zig-out", ".zig-cache" }); + const clean_step = b.step("clean", "Remove zig-out and the local build cache"); + clean_step.dependOn(&clean_cmd.step); + const test_mod = b.createModule(.{ .root_source_file = b.path("src/tests.zig"), .target = target, .optimize = optimize, .link_libc = true, }); - linkMpv(b, test_mod, mpv_prefix); test_mod.addOptions("build_options", options); const unit_tests = b.addTest(.{ .root_module = test_mod }); @@ -47,28 +50,12 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_unit_tests.step); - addCheckStep(b, mpv_prefix, options); + addCheckStep(b, options); } -/// `zig build check` type-checks every release target without linking, so a -/// platform-specific break (FreeBSD translate-c, most often) surfaces in seconds -/// on any machine instead of at tag time in a VM. -fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step.Options) void { +fn addCheckStep(b: *std.Build, options: *std.Build.Step.Options) void { const check = b.step("check", "Type-check all release targets (no link)"); - // Zig ships libc headers for every target below, so the ONLY header this - // needs from the host is mpv/client.h. Copy that one file into a generated - // directory rather than putting a system include path on the command line: - // -I /usr/include would feed the host's glibc headers to a macOS or FreeBSD - // cross-compile, which fails on glibc internals. - const header = findMpvHeader(b, mpv_prefix) orelse { - std.log.warn("check: mpv/client.h not found; skipping (pass -Dmpv-prefix)", .{}); - return; - }; - const shim = b.addWriteFiles(); - _ = shim.addCopyFile(.{ .cwd_relative = header }, "mpv/client.h"); - const include_dir = shim.getDirectory(); - const targets = [_][]const u8{ "x86_64-freebsd", "x86_64-linux-gnu", @@ -76,8 +63,6 @@ fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step. "x86_64-macos", "aarch64-macos", }; - // ReleaseSafe as well as Debug: FreeBSD's __ssp fortify wrappers only appear - // in optimized builds. const modes = [_]std.builtin.OptimizeMode{ .Debug, .ReleaseSafe }; for (targets) |triple| { @@ -89,7 +74,6 @@ fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step. .optimize = mode, .link_libc = true, }); - mod.addIncludePath(include_dir); mod.addOptions("build_options", options); const obj = b.addObject(.{ .name = b.fmt("check-{s}-{s}", .{ triple, @tagName(mode) }), @@ -99,34 +83,3 @@ fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step. } } } - -/// Locate mpv/client.h: the configured prefix, then the usual system locations. -fn findMpvHeader(b: *std.Build, prefix: []const u8) ?[]const u8 { - const dirs = [_][]const u8{ - if (prefix.len > 0) b.fmt("{s}/include", .{prefix}) else "", - "/usr/include", - "/usr/local/include", - "/opt/homebrew/include", - }; - for (dirs) |dir| { - if (dir.len == 0) continue; - const path = b.fmt("{s}/mpv/client.h", .{dir}); - std.Io.Dir.cwd().access(b.graph.io, path, .{}) catch continue; - return path; - } - return null; -} - -fn linkMpv(b: *std.Build, mod: *std.Build.Module, prefix: []const u8) void { - if (prefix.len > 0) { - // Explicit prefix wins: skip pkg-config, which resolves to the HOST - // arch's mpv and breaks cross-compiles (e.g. the x86_64 slice of the - // universal macOS build linking the arm libmpv). Empty prefix (Linux) - // still uses pkg-config to find the system libmpv. - mod.addIncludePath(.{ .cwd_relative = b.fmt("{s}/include", .{prefix}) }); - mod.addLibraryPath(.{ .cwd_relative = b.fmt("{s}/lib", .{prefix}) }); - mod.linkSystemLibrary("mpv", .{ .use_pkg_config = .no }); - } else { - mod.linkSystemLibrary("mpv", .{}); - } -} diff --git a/build.zig.zon b/build.zig.zon index b337bf0..b0f6e54 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,7 +9,7 @@ .name = .hum, // This is a [Semantic Version](https://semver.org/). // In a future version of Zig it will be used for package deduplication. - .version = "0.1.7", + .version = "0.1.8", // Together with name, this represents a globally unique package // identifier. This field is generated by the Zig toolchain when the // package is first created, and then *never changes*. This allows @@ -25,7 +25,7 @@ .fingerprint = 0xd8ced3dc97af74f5, // Changing this has security and trust implications. // Tracks the earliest Zig version that the package considers to be a // supported use case. - .minimum_zig_version = "0.16.0", + .minimum_zig_version = "0.17.0", // This field is optional. // Each dependency must either provide a `url` and `hash`, or a `path`. // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. diff --git a/examples/visualizers/pulse.vis b/examples/visualizers/pulse.vis new file mode 100644 index 0000000..fd26385 --- /dev/null +++ b/examples/visualizers/pulse.vis @@ -0,0 +1,49 @@ +# copy into ~/.config/hum/visualizers/ and press v in the visualizer view (^V) +# +# name shown in the header; defaults to the file name +# fps 1-30 frames per second +# speed fixed | level (level: louder music plays the frames faster) +# color level | dim | accent | strong (level: dim when quiet, bright when loud) +# +# frames follow, each starting with a line that is just --- + +name = pulse +fps = 6 +speed = level +color = level +--- + + + · + + +--- + + ╭─╮ + │·│ + ╰─╯ + +--- + ╭───╮ + ╭╯ ╰╮ + │ · │ + ╰╮ ╭╯ + ╰───╯ +--- + ╭───────╮ + ╭─╯ ╰─╮ + │ · │ + ╰─╮ ╭─╯ + ╰───────╯ +--- + ╭───╮ + ╭╯ ╰╮ + │ · │ + ╰╮ ╭╯ + ╰───╯ +--- + + ╭─╮ + │·│ + ╰─╯ + diff --git a/src/api.zig b/src/api.zig index 7e62f77..fa98a49 100644 --- a/src/api.zig +++ b/src/api.zig @@ -4,19 +4,16 @@ const txt = @import("text.zig"); const fsutil = @import("fsutil.zig"); const track_mod = @import("track.zig"); -// Public WEB_REMIX client key, served in every YouTube Music page. Not a secret; secret scanners flag it by pattern. const INNERTUBE_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"; const SEARCH_URL = "https://music.youtube.com/youtubei/v1/search?key=" ++ INNERTUBE_KEY ++ "&prettyPrint=false"; -const TMP_TEMPLATE = "/tmp/hum_bodyXXXXXX"; -// Embedded verbatim into request bodies, so format strings add only the surrounding object. const CLIENT_CONTEXT = \\"context":{"client":{"clientName":"WEB_REMIX","clientVersion":"1.20240101.00.00","hl":"en","gl":"US"},"user":{}} ; const c = fsutil.c; +const SEARCH_TIMEOUT_S = "20"; -// Track is not an api type; re-exported so `api.Track` references keep working. pub const Track = track_mod.Track; pub const Source = track_mod.Source; @@ -78,7 +75,6 @@ pub fn searchFiltered( return out.toOwnedSlice(arena); } -/// Rows inside a card shelf drop the artist from their subtitle because the card header carries it. fn collectTracks( arena: std.mem.Allocator, v: std.json.Value, @@ -114,7 +110,6 @@ fn collectTracks( } } -/// The card's artist is the first UC-linked run in its title, else in its subtitle. fn cardArtist(card: std.json.Value) ?[]const u8 { if (card != .object) return null; inline for (.{ "title", "subtitle" }) |key| { @@ -218,7 +213,6 @@ pub fn browseAlbum(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, return out.toOwnedSlice(arena); } -/// Songs put the runtime in a fixedColumn, videos in the subtitle; an unparseable one means unknown. fn durationOf(obj: std.json.ObjectMap) u32 { if (obj.get("fixedColumns")) |fixed| { if (fixed == .array) { @@ -249,7 +243,6 @@ fn durationOf(obj: std.json.ObjectMap) u32 { return 0; } -/// Album browse ids are the MPRE namespace; matching that beats guessing by run position. fn albumOfRuns(runs: []std.json.Value) ?[]const u8 { for (runs) |run| { const bid = browseIdOfRun(run) orelse continue; @@ -290,7 +283,6 @@ fn firstLinkedRunText(runs: []std.json.Value) ?[]const u8 { return null; } -/// Positional fallback that skips kind words, separators and runtimes — top-result rows have no artist run at all. fn positionalArtist(runs: []std.json.Value) ?[]const u8 { for (runs) |run| { const t = textOfRun(run) orelse continue; @@ -302,7 +294,6 @@ fn positionalArtist(runs: []std.json.Value) ?[]const u8 { return null; } -/// Requires the colon: "1979" is a plausible name, only m:ss is unambiguously a runtime. fn looksLikeDuration(t: []const u8) bool { return std.mem.indexOfScalar(u8, t, ':') != null and track_mod.parseDuration(t) > 0; } @@ -394,15 +385,14 @@ fn buildBodyFiltered(arena: std.mem.Allocator, query: []const u8, filter: Filter return std.fmt.allocPrint(arena, "{{{s},\"query\":{s},\"params\":\"{s}\"}}", .{ CLIENT_CONTEXT, escaped, params }); } -/// Body goes through a 0600 temp file, never argv: queries are user text and argv is world-readable. fn postJson(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, url: []const u8, body: []const u8) ![]u8 { - const body_path = try writeTempFile(arena, body); + const body_path = try fsutil.makeTemp(arena, "hum_body", body); defer _ = c.unlink(body_path.ptr); const data_arg = try std.fmt.allocPrint(arena, "@{s}", .{body_path}); return proc.runCapture(gpa, io, &.{ "curl", "-sS", - "--max-time", "20", + "--max-time", SEARCH_TIMEOUT_S, "-H", "Content-Type: application/json", "-H", "User-Agent: Mozilla/5.0", "-X", "POST", @@ -411,20 +401,6 @@ fn postJson(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, url: [ }); } -fn writeTempFile(arena: std.mem.Allocator, body: []const u8) ![:0]const u8 { - const path = try arena.dupeZ(u8, TMP_TEMPLATE); - const fd = c.mkstemp(path.ptr); - if (fd < 0) return error.TempFileOpen; - defer _ = c.close(fd); - var off: usize = 0; - while (off < body.len) { - const n = c.write(fd, body[off..].ptr, body.len - off); - if (n <= 0) return error.TempFileWrite; - off += @intCast(n); - } - return path; -} - const testing = std.testing; test "Track.isPlayable, Filter labels/params, isKindWord" { @@ -531,7 +507,6 @@ test "findAlbumArtist reads legacy musicDetailHeaderRenderer subtitle" { } test "a subtitle of `Song • 5:38` yields no artist and a duration" { - // top-result song rows carry no artist run at all const json = \\{"musicResponsiveListItemRenderer":{ \\ "playlistItemData":{"videoId":"abc123"}, @@ -591,7 +566,6 @@ test "extractTrack keeps a linked artist and picks up album and duration" { } test "card-shelf rows inherit the artist the card header names" { - // the card header carries the artist its rows omit const json = \\{"musicCardShelfRenderer":{ \\ "title":{"runs":[{"text":"Daft Punk","navigationEndpoint":{"browseEndpoint":{"browseId":"UCRr1"}}}]}, diff --git a/src/config.zig b/src/config.zig index dd142d3..b9ad07b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -5,22 +5,16 @@ pub fn path(arena: std.mem.Allocator, env: *std.process.Environ.Map) ![]const u8 return fsutil.xdgPath(arena, env, "XDG_CONFIG_HOME", ".config", "config"); } -pub fn loadTheme(arena: std.mem.Allocator, file_path: []const u8) ?[]const u8 { - return loadKey(arena, file_path, "theme"); -} - -pub fn saveTheme(arena: std.mem.Allocator, file_path: []const u8, name: []const u8) void { - saveKey(arena, file_path, "theme", name) catch {}; -} +pub const Key = enum { theme, volume, scope, splash, music_dir, visualizer, mouse }; -pub fn loadKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8) ?[]const u8 { +pub fn loadKey(arena: std.mem.Allocator, file_path: []const u8, key: Key) ?[]const u8 { const bytes = fsutil.readFileAlloc(arena, file_path) orelse return null; var it = std.mem.splitScalar(u8, bytes, '\n'); while (it.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t\r"); const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; - if (std.mem.eql(u8, trimmed[0..eq], key)) { + if (std.mem.eql(u8, trimmed[0..eq], @tagName(key))) { const val = std.mem.trim(u8, trimmed[eq + 1 ..], " \t\r"); if (val.len == 0) return null; return arena.dupe(u8, val) catch null; @@ -29,7 +23,7 @@ pub fn loadKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8) return null; } -pub fn saveKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8, value: []const u8) !void { +pub fn saveKey(arena: std.mem.Allocator, file_path: []const u8, key: Key, value: []const u8) !void { var keys: std.ArrayList([]const u8) = .empty; var vals: std.ArrayList([]const u8) = .empty; @@ -39,12 +33,12 @@ pub fn saveKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8, const trimmed = std.mem.trim(u8, line, " \t\r"); const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; const k = trimmed[0..eq]; - if (std.mem.eql(u8, k, key)) continue; + if (std.mem.eql(u8, k, @tagName(key))) continue; try keys.append(arena, k); try vals.append(arena, std.mem.trim(u8, trimmed[eq + 1 ..], " \t\r")); } } - try keys.append(arena, key); + try keys.append(arena, @tagName(key)); try vals.append(arena, value); var buf: std.ArrayList(u8) = .empty; diff --git a/src/feed.zig b/src/feed.zig index 6182f80..b00631c 100644 --- a/src/feed.zig +++ b/src/feed.zig @@ -4,10 +4,8 @@ const track_mod = @import("track.zig"); const fsutil = @import("fsutil.zig"); const proc = @import("proc.zig"); -/// One playable episode from a feed. pub const Episode = struct { title: []const u8, - /// The enclosure. Always http(s) — see `validUrl`. url: []const u8, published: []const u8 = "", duration_s: u32 = 0, @@ -18,14 +16,13 @@ pub const Feed = struct { episodes: []Episode = &.{}, }; -// A feed is a stranger's XML; these are the ceilings it cannot exceed. pub const MAX_BYTES: usize = 16 * 1024 * 1024; const MAX_ITEMS: usize = 500; const MAX_TEXT: usize = 512; const MAX_URL: usize = 2048; const MAX_ENTITY: usize = 12; +const FETCH_TIMEOUT_S = "25"; -/// Deliberately not a general XML parser: it scans for the few elements a feed must have and ignores the rest. No DTDs, entity definitions, namespaces, recursion or external references, so expansion bombs have nothing to expand. pub fn parse(arena: std.mem.Allocator, bytes: []const u8) ?Feed { if (bytes.len == 0 or bytes.len > MAX_BYTES) return null; const is_rss = std.mem.indexOf(u8, bytes, "; Atom uses a link with rel="enclosure" or an audio type. fn enclosureUrl(chunk: []const u8) ?[]const u8 { if (findTag(chunk, " element, CDATA unwrapped. Never crosses into a nested element. fn tagText(hay: []const u8, name: []const u8) ?[]const u8 { var buf: [64]u8 = undefined; if (name.len + 2 > buf.len) return null; @@ -146,10 +138,9 @@ fn tagText(hay: []const u8, name: []const u8) ?[]const u8 { var from: usize = 0; while (findTagPos(hay, open, from)) |found| { from = found.end; - // " 0 and after[0] != ' ' and after[0] != '\t' and after[0] != '/') continue; - if (std.mem.endsWith(u8, found.tag, "/")) continue; // self-closing: no text + if (std.mem.endsWith(u8, found.tag, "/")) continue; var close_buf: [64]u8 = undefined; const close = std.fmt.bufPrint(&close_buf, "", .{name}) catch return null; @@ -167,7 +158,6 @@ fn tagText(hay: []const u8, name: []const u8) ?[]const u8 { return null; } -/// Only the five predefined entities and bounded numeric refs; definitions are never looked up. fn decodeEntities(arena: std.mem.Allocator, s: []const u8) ?[]const u8 { if (std.mem.indexOfScalar(u8, s, '&') == null) return s; var out: std.ArrayList(u8) = .empty; @@ -221,14 +211,12 @@ fn decodeEntities(arena: std.mem.Allocator, s: []const u8) ?[]const u8 { } else |_| {} } else |_| {} } - // unknown entity: keep the text, expand nothing out.append(arena, s[i]) catch return null; i += 1; } return out.items; } -/// Decode, sanitize, cap: feed text is remote text like any other. fn clean(arena: std.mem.Allocator, s: []const u8) ?[]const u8 { const decoded = decodeEntities(arena, s) orelse return null; const safe = txt.sanitize(arena, decoded) catch return null; @@ -240,7 +228,6 @@ fn clean(arena: std.mem.Allocator, s: []const u8) ?[]const u8 { return trimmed[0..end]; } -/// Handed to mpv, so http(s) only — never a path, a scheme, or anything quotable. pub fn validUrl(u: []const u8) bool { if (u.len == 0 or u.len > MAX_URL) return false; if (!std.mem.startsWith(u8, u, "http://") and !std.mem.startsWith(u8, u, "https://")) return false; @@ -251,13 +238,11 @@ pub fn validUrl(u: []const u8) bool { return true; } -/// `` is seconds, or mm:ss, or hh:mm:ss. fn parseDuration(s: []const u8) u32 { if (std.mem.indexOfScalar(u8, s, ':') != null) return track_mod.parseDuration(s); return std.fmt.parseInt(u32, s, 10) catch 0; } -/// Episode → queue row. pub fn toTrack(ep: Episode, feed_title: []const u8) track_mod.Track { return .{ .source = .url, @@ -271,10 +256,8 @@ pub fn toTrack(ep: Episode, feed_title: []const u8) track_mod.Track { // --------------------------------------------------------- subscriptions -/// One subscribed feed: the URL we fetch and the title it reported. pub const Sub = struct { url: []const u8, title: []const u8 = "" }; -/// $XDG_DATA_HOME/hum/feeds — data worth keeping, so not the cache. pub fn path(arena: std.mem.Allocator, env: *std.process.Environ.Map) ![:0]const u8 { return fsutil.xdgPath(arena, env, "XDG_DATA_HOME", ".local/share", "feeds"); } @@ -288,7 +271,7 @@ pub fn loadSubs(arena: std.mem.Allocator, file_path: []const u8) []Sub { if (line.len == 0 or line[0] == '#') continue; var f = std.mem.splitScalar(u8, line, '\t'); const url = f.next() orelse continue; - if (!validUrl(url)) continue; // a hand-edited file is still input + if (!validUrl(url)) continue; const title = f.next() orelse ""; out.append(arena, .{ .url = url, .title = title }) catch return out.items; } @@ -304,15 +287,14 @@ pub fn saveSubs(arena: std.mem.Allocator, file_path: []const u8, subs: []const S return fsutil.writeFile(arena, file_path, buf.items); } -/// Redirects followed (feed hosts move), time-boxed, size-capped, URL validated before argv. pub fn fetch(gpa: std.mem.Allocator, io: std.Io, url: []const u8) ![]u8 { if (!validUrl(url)) return error.BadUrl; return proc.runCapture(gpa, io, &.{ - "curl", "-sSL", - "--max-time", "25", - "--max-filesize", "16777216", - "-H", "User-Agent: hum/podcast", - "--", url, + "curl", "-sSL", + "--max-time", FETCH_TIMEOUT_S, + "--max-filesize", std.fmt.comptimePrint("{d}", .{MAX_BYTES}), + "-H", "User-Agent: hum/podcast", + "--", url, }); } @@ -351,7 +333,7 @@ test "rss: channel title, episodes, entities, CDATA, durations" { try testing.expect(f.episodes[0].published.len > 0); try testing.expectEqualStrings("Episode Two markup", f.episodes[1].title); - try testing.expectEqual(@as(u32, 95), f.episodes[1].duration_s); // bare seconds + try testing.expectEqual(@as(u32, 95), f.episodes[1].duration_s); } test "atom entries with an audio link" { @@ -397,7 +379,7 @@ test "hostile URLs never reach the player" { try testing.expect(!validUrl("javascript:alert(1)")); try testing.expect(!validUrl("file:///etc/passwd")); try testing.expect(!validUrl("/etc/passwd")); - try testing.expect(!validUrl("https://example.com/a b.mp3")); // space + try testing.expect(!validUrl("https://example.com/a b.mp3")); try testing.expect(!validUrl("https://example.com/\"quoted\".mp3")); try testing.expect(!validUrl("https://example.com/\x00.mp3")); try testing.expect(!validUrl("")); @@ -420,7 +402,6 @@ test "entity bombs expand to nothing because definitions are never read" { defer arena.deinit(); const a = arena.allocator(); - // billion-laughs shape plus an external-entity attempt const feed = \\ \\nope") == null); - // every truncation must return, not crash var n: usize = 0; while (n <= RSS.len) : (n += 1) _ = parse(a, RSS[0..n]); } @@ -504,23 +483,19 @@ test "subscription file round-trips and rejects hand-edited junk" { defer arena.deinit(); const a = arena.allocator(); - const p = try a.dupeZ(u8, "/tmp/hum_feeds_XXXXXX"); - const fd = fsutil.c.mkstemp(p.ptr); - try testing.expect(fd >= 0); - _ = fsutil.c.close(fd); + const p = try fsutil.makeTemp(a, "hum_feeds_", ""); defer _ = fsutil.c.unlink(p.ptr); try saveSubs(a, p, &[_]Sub{ .{ .url = "https://example.com/feed.xml", .title = "Some\tPodcast" }, - .{ .url = "javascript:alert(1)", .title = "nope" }, // never written + .{ .url = "javascript:alert(1)", .title = "nope" }, }); const back = loadSubs(a, p); try testing.expectEqual(@as(usize, 1), back.len); try testing.expectEqualStrings("https://example.com/feed.xml", back[0].url); - try testing.expectEqualStrings("Some Podcast", back[0].title); // tab neutralized + try testing.expectEqualStrings("Some Podcast", back[0].title); - // a hand-edited file can contain anything; bad rows are skipped try fsutil.writeFile(a, p, "file:///etc/passwd\tevil\n# comment\n\nhttps://ok.com/f.xml\tOK\n"); const back2 = loadSubs(a, p); try testing.expectEqual(@as(usize, 1), back2.len); diff --git a/src/fsutil.zig b/src/fsutil.zig index b139da0..9db7889 100644 --- a/src/fsutil.zig +++ b/src/fsutil.zig @@ -1,25 +1,40 @@ const std = @import("std"); -/// The one libc @cImport for the program; FreeBSD translate-c workarounds live here. -pub const c = @cImport({ - // FreeBSD's __ssp fortify wrappers break translate-c; disable them - @cDefine("_FORTIFY_SOURCE", "0"); - @cInclude("stdio.h"); - @cInclude("stdlib.h"); - @cInclude("errno.h"); - @cInclude("unistd.h"); -}); - -/// Our own files (history, playlists, config) are small by construction. +pub const c = struct { + pub const FILE = std.c.FILE; + pub const fopen = std.c.fopen; + pub const fclose = std.c.fclose; + pub const fread = std.c.fread; + pub const fwrite = std.c.fwrite; + pub const write = std.c.write; + pub const close = std.c.close; + pub const unlink = std.c.unlink; + pub const rmdir = std.c.rmdir; + pub const access = std.c.access; + pub const getenv = std.c.getenv; + pub const _exit = std.c._exit; + + pub extern "c" fn fseek(stream: *FILE, offset: c_long, whence: c_int) c_int; + pub extern "c" fn ftell(stream: *FILE) c_long; + pub extern "c" fn mkstemp(template: [*:0]u8) c_int; + pub extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; + pub extern "c" fn usleep(usec: c_uint) c_int; + + pub const SEEK_SET: c_int = 0; + pub const SEEK_END: c_int = 2; + pub const F_OK: c_uint = 0; + pub const EPIPE: c_int = @backingInt(std.c.E.PIPE); + pub const EEXIST: c_int = @backingInt(std.c.E.EXIST); +}; + const READ_ALL_CAP: usize = 8 * 1024 * 1024; pub fn readFileAlloc(arena: std.mem.Allocator, file_path: []const u8) ?[]u8 { return readCappedAlloc(arena, file_path, READ_ALL_CAP); } -/// Reads at most `max` bytes: a tag read must never be sized by the file. pub fn readCappedAlloc(arena: std.mem.Allocator, file_path: []const u8, max: usize) ?[]u8 { - const path_z = arena.dupeZ(u8, file_path) catch return null; + const path_z = arena.dupeSentinel(u8, file_path, 0) catch return null; const f = c.fopen(path_z.ptr, "rb") orelse return null; defer _ = c.fclose(f); @@ -35,7 +50,6 @@ pub fn readCappedAlloc(arena: std.mem.Allocator, file_path: []const u8, max: usi return buf[0..n]; } -/// LEGACY_DIR is the pre-v0.1.7 name; see `xdgPath`. const APP_DIR = "hum"; const LEGACY_DIR = "ytcli"; @@ -43,7 +57,6 @@ fn exists(path: [:0]const u8) bool { return c.access(path.ptr, c.F_OK) == 0; } -/// Falls back to the legacy dir when the new path does not exist, and copies nothing: an upgrade keeps working with no migration step. pub fn xdgPath( arena: std.mem.Allocator, env: *std.process.Environ.Map, @@ -67,10 +80,9 @@ pub fn xdgPath( return current; } -/// Create the parent directory, then write (mode "wb") or append (mode "ab") bytes. fn writeMode(arena: std.mem.Allocator, file_path: []const u8, bytes: []const u8, mode: [*:0]const u8) !void { if (std.fs.path.dirname(file_path)) |dir| try makePathZ(arena, dir); - const path_z = try arena.dupeZ(u8, file_path); + const path_z = try arena.dupeSentinel(u8, file_path, 0); const f = c.fopen(path_z.ptr, mode) orelse return error.OpenFailed; defer _ = c.fclose(f); if (bytes.len == 0) return; @@ -85,6 +97,20 @@ pub fn appendFile(arena: std.mem.Allocator, file_path: []const u8, bytes: []cons return writeMode(arena, file_path, bytes, "ab"); } +pub fn makeTemp(arena: std.mem.Allocator, prefix: []const u8, contents: []const u8) ![:0]u8 { + const path = try std.fmt.allocPrintSentinel(arena, "/tmp/{s}XXXXXX", .{prefix}, 0); + const fd = c.mkstemp(path.ptr); + if (fd < 0) return error.TempFileOpen; + defer _ = c.close(fd); + var off: usize = 0; + while (off < contents.len) { + const n = c.write(fd, contents[off..].ptr, contents.len - off); + if (n <= 0) return error.TempFileWrite; + off += @intCast(n); + } + return path; +} + pub fn makePathZ(arena: std.mem.Allocator, dir: []const u8) !void { var i: usize = 0; while (i < dir.len) { @@ -92,7 +118,7 @@ pub fn makePathZ(arena: std.mem.Allocator, dir: []const u8) !void { const start = i; while (i < dir.len and dir[i] != '/') : (i += 1) {} if (i == start) break; - const partial = try arena.dupeZ(u8, dir[0..i]); + const partial = try arena.dupeSentinel(u8, dir[0..i], 0); const r = std.c.mkdir(partial.ptr, 0o755); if (r != 0) { const e = std.c._errno().*; @@ -108,22 +134,14 @@ test "readFileAlloc round-trips contents; null on empty or missing" { defer arena.deinit(); const a = arena.allocator(); - const path = try a.dupeZ(u8, "/tmp/hum_rfa_XXXXXX"); - const fd = c.mkstemp(path.ptr); - try testing.expect(fd >= 0); + const path = try makeTemp(a, "hum_rfa_", "hello\nworld"); defer _ = c.unlink(path.ptr); - const data = "hello\nworld"; - try testing.expectEqual(@as(isize, data.len), c.write(fd, data.ptr, data.len)); - _ = c.close(fd); const got = readFileAlloc(a, path) orelse return error.TestUnexpectedNull; try testing.expectEqualStrings("hello\nworld", got); - const empty = try a.dupeZ(u8, "/tmp/hum_rfae_XXXXXX"); - const efd = c.mkstemp(empty.ptr); - try testing.expect(efd >= 0); + const empty = try makeTemp(a, "hum_rfae_", ""); defer _ = c.unlink(empty.ptr); - _ = c.close(efd); try testing.expect(readFileAlloc(a, empty) == null); try testing.expect(readFileAlloc(a, "/tmp/hum_definitely_missing_zzz") == null); @@ -134,7 +152,7 @@ test "makePathZ creates nested dirs and is idempotent" { defer arena.deinit(); const a = arena.allocator(); - const base = try a.dupeZ(u8, "/tmp/hum_mp_XXXXXX"); + const base = try a.dupeSentinel(u8, "/tmp/hum_mp_XXXXXX", 0); try testing.expect(c.mkdtemp(base.ptr) != null); const mid = try std.fmt.allocPrintSentinel(a, "{s}/a", .{base}, 0); @@ -159,7 +177,7 @@ test "xdgPath prefers the new dir, falls back to the pre-rename one" { defer arena.deinit(); const a = arena.allocator(); - const base = try a.dupeZ(u8, "/tmp/hum_xdg_XXXXXX"); + const base = try a.dupeSentinel(u8, "/tmp/hum_xdg_XXXXXX", 0); try testing.expect(c.mkdtemp(base.ptr) != null); const new_dir = try std.fmt.allocPrintSentinel(a, "{s}/hum", .{base}, 0); @@ -177,14 +195,11 @@ test "xdgPath prefers the new dir, falls back to the pre-rename one" { var env: std.process.Environ.Map = .init(a); try env.put("XDG_DATA_HOME", base); - // fresh install gets the new path try testing.expectEqualStrings(new_file, try xdgPath(a, &env, "XDG_DATA_HOME", ".local/share", "history")); - // only the legacy file exists: keep reading it try writeFile(a, old_file, "elephant gym\n"); try testing.expectEqualStrings(old_file, try xdgPath(a, &env, "XDG_DATA_HOME", ".local/share", "history")); - // the new path wins once it exists try writeFile(a, new_file, "autechre\n"); try testing.expectEqualStrings(new_file, try xdgPath(a, &env, "XDG_DATA_HOME", ".local/share", "history")); } diff --git a/src/history.zig b/src/history.zig index 43e08bf..f6207c9 100644 --- a/src/history.zig +++ b/src/history.zig @@ -31,7 +31,6 @@ pub fn load(arena: std.mem.Allocator, file_path: []const u8) ![][]const u8 { return out.toOwnedSlice(arena); } -/// Most recent wins: one line per search, rewritten whole to stay hand-editable. pub fn append(arena: std.mem.Allocator, file_path: []const u8, query: []const u8) !void { const clean = std.mem.trim(u8, try txt.sanitize(arena, query), " \t\r"); if (clean.len == 0) return; @@ -111,10 +110,7 @@ test "remove drops one entry and keeps the rest" { defer arena.deinit(); const a = arena.allocator(); - const p = try a.dupeZ(u8, "/tmp/hum_hist_XXXXXX"); - const fd = c.mkstemp(p.ptr); - try testing.expect(fd >= 0); - _ = c.close(fd); + const p = try fsutil.makeTemp(a, "hum_hist_", ""); defer _ = c.unlink(p.ptr); for ([_][]const u8{ "ado", "hong ting", "nina simone" }) |q| try append(a, p, q); @@ -139,10 +135,7 @@ test "append keeps one line per query and moves a repeat to the end" { defer arena.deinit(); const a = arena.allocator(); - const p = try a.dupeZ(u8, "/tmp/hum_hdup_XXXXXX"); - const fd = c.mkstemp(p.ptr); - try testing.expect(fd >= 0); - _ = c.close(fd); + const p = try fsutil.makeTemp(a, "hum_hdup_", ""); defer _ = c.unlink(p.ptr); for ([_][]const u8{ "tricot", "autechre", "tricot", "delta sleep", "tricot" }) |q| { @@ -152,7 +145,6 @@ test "append keeps one line per query and moves a repeat to the end" { const raw = fsutil.readFileAlloc(a, p) orelse return error.TestUnexpectedNull; try testing.expectEqualStrings("autechre\ndelta sleep\ntricot\n", raw); - // and the display order still puts the newest first const shown = try load(a, p); try testing.expectEqual(@as(usize, 3), shown.len); try testing.expectEqualStrings("tricot", shown[0]); diff --git a/src/library.zig b/src/library.zig index ef6ee93..04bfec7 100644 --- a/src/library.zig +++ b/src/library.zig @@ -5,7 +5,6 @@ const track_mod = @import("track.zig"); const txt = @import("text.zig"); const log = @import("log.zig"); -/// One indexed file. Strings live in the index arena and outlive the rows built from them. pub const Entry = struct { path: []const u8, title: []const u8 = "", @@ -13,19 +12,16 @@ pub const Entry = struct { album: []const u8 = "", track_no: u16 = 0, duration_s: u32 = 0, - /// mtime + size are the cache key: if neither changed, the tags did not. mtime: i64 = 0, size: u64 = 0, }; -/// Which files to open at all; the tag parser still dispatches on magic bytes. const EXTS = [_][]const u8{ ".mp3", ".flac", ".m4a", ".aac", ".alac", ".ogg", ".opus", ".wav", ".aif", ".aiff", ".wma", ".mp4", ".m4b", ".oga", ".ape", ".wv", }; -/// A symlinked directory can point at its own parent, so the walk needs these ends. const MAX_FILES: usize = 50_000; const MAX_DEPTH: usize = 12; @@ -41,12 +37,10 @@ pub fn isAudio(name: []const u8) bool { return false; } -/// A rebuildable index belongs in the cache, not beside the playlists. pub fn cachePath(arena: std.mem.Allocator, env: *std.process.Environ.Map) ![:0]const u8 { return fsutil.xdgPath(arena, env, "XDG_CACHE_HOME", ".cache", "library"); } -/// Several roots, $PATH-style. A leading `~/` is expanded so the config stays portable. pub fn roots(arena: std.mem.Allocator, env: *std.process.Environ.Map, configured: []const u8) ![][]const u8 { var out: std.ArrayList([]const u8) = .empty; var it = std.mem.splitScalar(u8, configured, ':'); @@ -63,19 +57,16 @@ pub fn roots(arena: std.mem.Allocator, env: *std.process.Environ.Map, configured return out.toOwnedSlice(arena); } -/// Returning false aborts: a scan of a network mount must be escapable. pub const Progress = struct { ctx: *anyopaque, on: *const fn (ctx: *anyopaque, scanned: usize) bool, }; -/// `complete` tells the caller not to cache a partial index as if it were whole. pub const Result = struct { entries: []Entry, complete: bool = true, }; -/// Reuses cached rows whose mtime and size are unchanged; tag-reads only what is new. pub fn scan( arena: std.mem.Allocator, io: std.Io, @@ -107,7 +98,6 @@ pub fn scan( if (entry.kind != .file) continue; if (entry.depth() > MAX_DEPTH) continue; if (!isAudio(entry.basename)) continue; - // skip dotted paths: .Trash, .git, resource forks if (std.mem.indexOf(u8, entry.path, "/.") != null or entry.path[0] == '.') continue; const full = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root, entry.path }); @@ -122,14 +112,14 @@ pub fn scan( } if (known.get(full)) |hit| { - if (hit.mtime == mtime and hit.size == st.size) { + const stale_mp3 = hit.duration_s == 0 and std.ascii.endsWithIgnoreCase(full, ".mp3"); + if (hit.mtime == mtime and hit.size == st.size and !stale_mp3) { try out.append(arena, hit); continue; } } - // path below the root, never the absolute path: a flat library would take its artist from wherever the root is nested - var meta = tags_mod.read(arena, full) orelse tags_mod.Tags{}; + var meta = tags_mod.read(arena, full, st.size) orelse tags_mod.Tags{}; if (meta.title.len == 0 or meta.artist.len == 0 or meta.album.len == 0) { const from_path = tags_mod.fromPath(arena, entry.path); if (meta.title.len == 0) meta.title = from_path.title; @@ -137,7 +127,6 @@ pub fn scan( if (meta.album.len == 0) meta.album = from_path.album; if (meta.track_no == 0) meta.track_no = from_path.track_no; } - // grouping drops empty names, so an untagged file needs a bucket to be reachable if (meta.artist.len == 0) meta.artist = "unknown"; if (meta.album.len == 0) meta.album = "unknown"; @@ -158,18 +147,15 @@ pub fn scan( // ------------------------------------------------------------------- cache -/// Rows plus the music_dir they were built from: without it, changing music_dir keeps serving the old library. pub const Cached = struct { roots: []const u8 = "", entries: []Entry = &.{}, - /// Usable only if it was built from the roots we are asking about now. pub fn matches(self: Cached, music_dir: []const u8) bool { return self.entries.len > 0 and std.mem.eql(u8, self.roots, music_dir); } }; -/// #roots header, then one sanitized tab-separated row per track. pub fn saveCache( arena: std.mem.Allocator, file_path: []const u8, @@ -206,7 +192,6 @@ pub fn loadCache(arena: std.mem.Allocator, file_path: []const u8) Cached { from_roots = line["#roots\t".len..]; continue; } - // a headerless cache predates the roots header and never matches if (line[0] == '#') continue; var f = std.mem.splitScalar(u8, line, '\t'); const path = f.next() orelse continue; @@ -273,7 +258,6 @@ pub fn albumsOf(arena: std.mem.Allocator, entries: []const Entry, artist: []cons fn beforeInAlbum(_: void, a: Entry, b: Entry) bool { if (a.track_no != b.track_no) { - // untracked files sort after numbered ones rather than jumping to the top if (a.track_no == 0) return false; if (b.track_no == 0) return true; return a.track_no < b.track_no; @@ -307,7 +291,6 @@ fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { return false; } -/// Substring match: title hits first, then artist and album. pub fn search(arena: std.mem.Allocator, entries: []const Entry, query: []const u8, max: usize) ![]Entry { var out: std.ArrayList(Entry) = .empty; if (query.len == 0) return out.items; @@ -326,7 +309,6 @@ pub fn search(arena: std.mem.Allocator, entries: []const Entry, query: []const u return out.items; } -/// Completions from the index, artists first. Prefix match: the ghost can only extend what is typed. pub fn namesMatching( arena: std.mem.Allocator, entries: []const Entry, @@ -348,10 +330,8 @@ pub fn namesMatching( return out.items; } -/// Album plus its artist, so a row can drill in without guessing. pub const AlbumRef = struct { album: []const u8, artist: []const u8 }; -/// Albums whose name, or whose artist, matches. An empty query means "all". pub fn albumsMatching( arena: std.mem.Allocator, entries: []const Entry, @@ -375,7 +355,6 @@ pub fn albumsMatching( return out.items; } -/// Artists whose name matches. An empty query means "all". pub fn artistsMatching( arena: std.mem.Allocator, entries: []const Entry, @@ -392,7 +371,6 @@ pub fn artistsMatching( return out.items; } -/// Index entry to queue row. pub fn toTrack(e: Entry) track_mod.Track { return .{ .source = .local, @@ -408,11 +386,7 @@ pub fn toTrack(e: Entry) track_mod.Track { const testing = std.testing; fn tmpPath(a: std.mem.Allocator) ![:0]const u8 { - const p = try a.dupeZ(u8, "/tmp/hum_lib_XXXXXX"); - const fd = fsutil.c.mkstemp(p.ptr); - if (fd < 0) return error.TempFailed; - _ = fsutil.c.close(fd); - return p; + return fsutil.makeTemp(a, "hum_lib_", ""); } test "isAudio matches by extension, case-insensitively, and rejects the rest" { @@ -480,7 +454,7 @@ test "cache writing neutralizes a tab or newline in a path or title" { try saveCache(a, p, "/m", &entries); const back = loadCache(a, p).entries; - try testing.expectEqual(@as(usize, 1), back.len); // one row, not two + try testing.expectEqual(@as(usize, 1), back.len); try testing.expectEqualStrings("/m/evil name.flac", back[0].path); try testing.expectEqualStrings("row forge /x.mp3 0 0 fake", back[0].title); } @@ -499,7 +473,7 @@ test "grouping lists artists and albums once, and orders tracks by number" { }; const artists = try artistsOf(a, &entries); - try testing.expectEqual(@as(usize, 2), artists.len); // "bohren" and "Bohren" are one + try testing.expectEqual(@as(usize, 2), artists.len); try testing.expectEqualStrings("Aphex Twin", artists[0]); const albums = try albumsOf(a, &entries, "BOHREN"); @@ -510,7 +484,7 @@ test "grouping lists artists and albums once, and orders tracks by number" { try testing.expectEqual(@as(usize, 3), tracks.len); try testing.expectEqualStrings("First", tracks[0].title); try testing.expectEqualStrings("Third", tracks[1].title); - try testing.expectEqualStrings("Untracked", tracks[2].title); // no number sorts last + try testing.expectEqualStrings("Untracked", tracks[2].title); } test "toTrack produces a playable local row with sane fallbacks" { @@ -545,7 +519,6 @@ test "search matches title first, then artist and album, and caps results" { const by_album = try search(a, &entries, "sunset", 10); try testing.expectEqualStrings("Prowler", by_album[0].title); - // a title hit outranks an artist hit for the same query const mixed = try search(a, &entries, "midnight", 10); try testing.expectEqualStrings("Midnight", mixed[0].title); @@ -566,13 +539,11 @@ test "namesMatching completes artists before albums before titles" { const got = try namesMatching(a, &entries, "bohr", 10); try testing.expectEqual(@as(usize, 3), got.len); - try testing.expectEqualStrings("Bohren & der Club of Gore", got[0]); // artist first - try testing.expectEqualStrings("Bohren Rarities", got[1]); // then album - try testing.expectEqualStrings("Bohren Theme", got[2]); // then title + try testing.expectEqualStrings("Bohren & der Club of Gore", got[0]); + try testing.expectEqualStrings("Bohren Rarities", got[1]); + try testing.expectEqualStrings("Bohren Theme", got[2]); - // prefix only — the ghost can only extend what is typed try testing.expectEqual(@as(usize, 0), (try namesMatching(a, &entries, "earth", 10)).len); - // an exact match adds nothing to complete try testing.expectEqual(@as(usize, 0), (try namesMatching(a, &entries, "Midnight", 10)).len); try testing.expectEqual(@as(usize, 0), (try namesMatching(a, &entries, "", 10)).len); try testing.expectEqual(@as(usize, 1), (try namesMatching(a, &entries, "bohr", 1)).len); @@ -583,13 +554,11 @@ test "a flat library takes no artist or album from the folder above the root" { defer arena.deinit(); const a = arena.allocator(); - // what scan() passes for a file sitting directly in the root const flat = tags_mod.fromPath(a, "song.mp3"); try testing.expectEqualStrings("song", flat.title); try testing.expectEqualStrings("", flat.artist); try testing.expectEqualStrings("", flat.album); - // a nested one still derives both const nested = tags_mod.fromPath(a, "Bohren/Black Earth/01 Midnight.flac"); try testing.expectEqualStrings("Midnight", nested.title); try testing.expectEqualStrings("Black Earth", nested.album); @@ -601,7 +570,6 @@ test "untagged files land in an 'unknown' bucket instead of vanishing" { defer arena.deinit(); const a = arena.allocator(); - // what scan() produces for a tagless file in a flat root const entries = [_]Entry{ .{ .path = "/m/x.flac", .title = "x", .artist = "unknown", .album = "unknown" }, .{ .path = "/m/y.flac", .title = "y", .artist = "Real", .album = "Album" }, @@ -628,10 +596,9 @@ test "a cache built from other roots is not reused" { const back = loadCache(a, p); try testing.expectEqualStrings("/old", back.roots); try testing.expect(back.matches("/old")); - try testing.expect(!back.matches("/new")); // music_dir changed → do not serve it + try testing.expect(!back.matches("/new")); try testing.expect(!back.matches("/old:/second")); - // several roots round-trip verbatim try saveCache(a, p, "/one:/two", &entries); try testing.expect(loadCache(a, p).matches("/one:/two")); } @@ -650,7 +617,7 @@ test "a headerless pre-v0.1.7 cache never matches" { try testing.expect(!back.matches("/m")); } -test "albumsMatching and artistsMatching dedupe and honour an empty query" { +test "albumsMatching and artistsMatching dedupe and honor an empty query" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); @@ -662,7 +629,7 @@ test "albumsMatching and artistsMatching dedupe and honour an empty query" { }; const all_albums = try albumsMatching(a, &entries, "", 10); - try testing.expectEqual(@as(usize, 2), all_albums.len); // deduped + try testing.expectEqual(@as(usize, 2), all_albums.len); const by_artist = try albumsMatching(a, &entries, "bohren", 10); try testing.expectEqual(@as(usize, 1), by_artist.len); diff --git a/src/log.zig b/src/log.zig index e1f3c6b..0311bd7 100644 --- a/src/log.zig +++ b/src/log.zig @@ -9,15 +9,12 @@ extern "c" fn time(?*time_t) time_t; extern "c" fn localtime(*const time_t) ?*Tm; extern "c" fn strftime(noalias [*]u8, usize, noalias [*:0]const u8, noalias *const Tm) usize; -// null leaves logging a no-op, so callers never check var path_z: ?[:0]const u8 = null; -/// $XDG_DATA_HOME/hum/log, beside the history file. pub fn path(arena: std.mem.Allocator, env: *std.process.Environ.Map) ![:0]const u8 { return fsutil.xdgPath(arena, env, "XDG_DATA_HOME", ".local/share", "log"); } -/// Pass a long-lived allocator; on any failure logging stays off. pub fn init(arena: std.mem.Allocator, env: *std.process.Environ.Map) void { const p = path(arena, env) catch return; if (std.fs.path.dirname(p)) |dir| { @@ -26,7 +23,6 @@ pub fn init(arena: std.mem.Allocator, env: *std.process.Environ.Map) void { path_z = p; } -/// Best-effort: errors are swallowed so logging never masks the original failure. pub fn write(comptime fmt: []const u8, args: anytype) void { const p = path_z orelse return; @@ -41,7 +37,6 @@ pub fn write(comptime fmt: []const u8, args: anytype) void { _ = c.fwrite(line.ptr, 1, line.len, f); } -// "YYYY-MM-DD HH:MM:SS" in local time via libc; returns the filled slice. fn stamp(out: *[20]u8) []const u8 { const t = time(null); const tm = localtime(&t) orelse return ""; diff --git a/src/main.zig b/src/main.zig index 182d302..35bbef1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -12,16 +12,26 @@ const tags_mod = @import("tags.zig"); const library = @import("library.zig"); const feed = @import("feed.zig"); const log = @import("log.zig"); +const mpv = @import("mpv.zig"); -// the one libc @cImport for the program lives in fsutil; don't add another const c = @import("fsutil.zig").c; +fn restoreDefaultSignals() void { + const default: std.posix.Sigaction = .{ .handler = .{ .handler = std.posix.SIG.DFL }, .mask = std.posix.sigemptyset(), .flags = 0 }; + std.posix.sigaction(std.posix.SIG.INT, &default, null); + std.posix.sigaction(std.posix.SIG.TERM, &default, null); +} + +fn printOut(comptime fmt: []const u8, args: anytype) !void { + var buf: [4096]u8 = undefined; + try putStdout(try std.fmt.bufPrint(&buf, fmt, args)); +} + fn putStdout(bytes: []const u8) !void { var i: usize = 0; while (i < bytes.len) { const n = c.write(1, bytes[i..].ptr, bytes.len - i); if (n < 0) { - // a closed pipe is the reader saying enough, not an error if (std.c._errno().* == c.EPIPE) std.process.exit(0); return error.WriteFailed; } @@ -31,6 +41,7 @@ fn putStdout(bytes: []const u8) !void { } pub const VERSION = @import("build_options").version; +pub const MPV_HINT = "nothing can play; install mpv (brew install mpv, apt install libmpv2)"; pub fn main(init: std.process.Init) !void { const gpa = init.gpa; @@ -93,7 +104,7 @@ pub fn main(init: std.process.Init) !void { return; } if (eq(first, "doctor")) { - try cmdDoctor(arena, io, init.environ_map); + try cmdDoctor(gpa, arena, io, init.environ_map); return; } if (eq(first, "library")) { @@ -119,11 +130,10 @@ pub fn main(init: std.process.Init) !void { fn resolveThemeName(arena: std.mem.Allocator, env: *std.process.Environ.Map) []const u8 { var name: []const u8 = "red"; if (config.path(arena, env)) |p| { - if (config.loadTheme(arena, p)) |saved| { + if (config.loadKey(arena, p, .theme)) |saved| { if (theme_mod.byName(saved) != null) name = saved; } } else |_| {} - // YTCLI_THEME still works so existing shell profiles keep going inline for (.{ "YTCLI_THEME", "HUM_THEME" }) |key| { if (env.get(key)) |e| { if (theme_mod.byName(e) != null) name = e; @@ -185,36 +195,35 @@ fn cmdPlay(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, words: const audio_url = try stream.resolve(gpa, io, t); defer gpa.free(audio_url); - var p = try player.Player.init(); + var p = player.Player.init() catch |err| { + if (err != error.MpvMissing) return err; + std.debug.print("libmpv not found: {s}\n", .{MPV_HINT}); + std.process.exit(1); + }; defer p.deinit(); - try p.loadUrl(arena, audio_url); + try p.loadUrl(arena, audio_url, try t.mediaTitle(arena)); while (true) { const ev = p.pollEvent(); switch (ev) { .end_file, .shutdown => return, + .audio_ready => restoreDefaultSignals(), else => _ = c.usleep(50_000), } } } -/// A lone URL or existing file is played rather than searched. fn directTarget(arena: std.mem.Allocator, io: std.Io, words: []const [:0]const u8) ?api.Track { if (words.len != 1) return null; const arg = words[0]; if (arg.len == 0) return null; - const source: track_mod.Source = if (track_mod.isUrl(arg)) - .url - else if (std.Io.Dir.cwd().statFile(io, arg, .{})) |st| - if (st.kind == .directory) return null else .local - else |_| - return null; - - if (source == .url) { + if (track_mod.isUrl(arg)) { return .{ .source = .url, .uri = arg, .title = arg, .artist = "stream" }; } + const st = std.Io.Dir.cwd().statFile(io, arg, .{}) catch return null; + if (st.kind == .directory) return null; - const meta = tags_mod.readOrPath(arena, arg); + const meta = tags_mod.readOrPath(arena, arg, st.size); return .{ .source = .local, .uri = arg, @@ -236,8 +245,6 @@ fn cmdSearch(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, words } } -/// Paths in use, settings, library state and required binaries — written for pasting into a bug report. -/// Podcast subscriptions: subscribe, list, unsubscribe, print episodes. fn cmdFeeds( gpa: std.mem.Allocator, arena: std.mem.Allocator, @@ -245,7 +252,6 @@ fn cmdFeeds( env: *std.process.Environ.Map, args: []const [:0]const u8, ) !void { - var buf: [4096]u8 = undefined; const fpath = feed.path(arena, env) catch { std.debug.print("no data path (HOME unset)\n", .{}); return; @@ -259,10 +265,10 @@ fn cmdFeeds( return; } for (subs.items) |sub| { - try putStdout(try std.fmt.bufPrint(&buf, "{s}\n {s}\n", .{ + try printOut("{s}\n {s}\n", .{ if (sub.title.len > 0) sub.title else "(untitled)", sub.url, - })); + }); } return; } @@ -294,10 +300,10 @@ fn cmdFeeds( }; try subs.append(arena, .{ .url = url, .title = parsed.title }); try feed.saveSubs(arena, fpath, subs.items); - try putStdout(try std.fmt.bufPrint(&buf, "subscribed: {s} ({d} episodes)\n", .{ + try printOut("subscribed: {s} ({d} episodes)\n", .{ if (parsed.title.len > 0) parsed.title else url, parsed.episodes.len, - })); + }); return; } @@ -324,7 +330,6 @@ fn cmdFeeds( return; } - // `hum feeds ` prints that feed's episodes for (subs.items) |sub| { const hay = if (sub.title.len > 0) sub.title else sub.url; if (!containsIgnoreCase(hay, args[0]) and !containsIgnoreCase(sub.url, args[0])) continue; @@ -343,7 +348,7 @@ fn cmdFeeds( std.fmt.bufPrint(&dur, "{d}:{d:0>2}", .{ ep.duration_s / 60, ep.duration_s % 60 }) catch "" else ""; - try putStdout(try std.fmt.bufPrint(&buf, "{d:2}. {s} {s}\n {s}\n", .{ i + 1, ep.title, when, ep.url })); + try printOut("{d:2}. {s} {s}\n {s}\n", .{ i + 1, ep.title, when, ep.url }); } return; } @@ -359,8 +364,7 @@ fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool { return false; } -fn cmdDoctor(arena: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) !void { - _ = io; +fn cmdDoctor(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) !void { var buf: [4096]u8 = undefined; try putStdout("hum " ++ VERSION ++ "\n\n"); @@ -375,17 +379,17 @@ fn cmdDoctor(arena: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map var legacy_seen = false; for (known) |k| { const path = k.path orelse { - try putStdout(try std.fmt.bufPrint(&buf, " {s: <14} (unavailable — HOME unset?)\n", .{k.label})); + try printOut(" {s: <14} (unavailable — HOME unset?)\n", .{k.label}); continue; }; const legacy = std.mem.indexOf(u8, path, "/ytcli/") != null; if (legacy) legacy_seen = true; - try putStdout(try std.fmt.bufPrint(&buf, " {s: <14} {s}{s}{s}\n", .{ + try printOut(" {s: <14} {s}{s}{s}\n", .{ k.label, path, if (exists(arena, path)) "" else " (not created yet)", if (legacy) " [pre-rename dir, still read]" else "", - })); + }); } if (legacy_seen) { try putStdout(" note: reading directories from before the ytcli→hum rename. Nothing was moved;\n" ++ @@ -394,29 +398,29 @@ fn cmdDoctor(arena: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map try putStdout("\nsettings\n"); const cfg = config.path(arena, env) catch ""; - inline for (.{ "theme", "volume", "scope", "splash", "music_dir" }) |key| { + for (std.enums.values(config.Key)) |key| { const val = if (cfg.len > 0) config.loadKey(arena, cfg, key) else null; - try putStdout(try std.fmt.bufPrint(&buf, " {s: <14} {s}\n", .{ key, val orelse "(unset)" })); + try printOut(" {s: <14} {s}\n", .{ @tagName(key), val orelse "(unset)" }); } try putStdout("\nlibrary\n"); - const music_dir = if (cfg.len > 0) config.loadKey(arena, cfg, "music_dir") else null; + const music_dir = if (cfg.len > 0) config.loadKey(arena, cfg, .music_dir) else null; if (music_dir) |dir| { const dirs = library.roots(arena, env, dir) catch &.{}; for (dirs) |root| { - try putStdout(try std.fmt.bufPrint(&buf, " root {s}{s}\n", .{ + try printOut(" root {s}{s}\n", .{ root, if (exists(arena, root)) "" else " MISSING", - })); + }); } const cache_path = library.cachePath(arena, env) catch ""; const cached: library.Cached = if (cache_path.len > 0) library.loadCache(arena, cache_path) else .{}; if (cached.entries.len == 0) { try putStdout(" index empty — run: hum library scan\n"); } else if (!cached.matches(dir)) { - try putStdout(try std.fmt.bufPrint(&buf, " index {d} tracks, but built from a different music_dir — rescan\n", .{cached.entries.len})); + try printOut(" index {d} tracks, but built from a different music_dir — rescan\n", .{cached.entries.len}); } else { - try putStdout(try std.fmt.bufPrint(&buf, " index {d} tracks\n", .{cached.entries.len})); + try printOut(" index {d} tracks\n", .{cached.entries.len}); } } else { try putStdout(" music_dir unset — run: hum library dir ~/Music\n"); @@ -425,28 +429,42 @@ fn cmdDoctor(arena: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map try putStdout("\ndependencies\n"); inline for (.{ "curl", "yt-dlp" }) |exe| { if (onPath(arena, env, exe)) |full| { - try putStdout(try std.fmt.bufPrint(&buf, " {s: <14} {s}\n", .{ exe, full })); + try printOut(" {s: <14} {s}\n", .{ exe, full }); + } else { + try printOut(" {s: <14} MISSING — youtube search and playback will fail\n", .{exe}); + } + } + if (stream.ytdlpVersion(gpa, io)) |ver| { + defer gpa.free(ver); + const v = std.mem.trim(u8, ver, " \r\n\t"); + const age = stream.ytdlpAgeDays(v, std.Io.Clock.real.now(io).toSeconds()); + if (age != null and age.? >= stream.STALE_DAYS) { + try printOut(" yt-dlp version {s} STALE ({d} days) — youtube rejects old builds; update it\n", .{ v, age.? }); } else { - try putStdout(" " ++ exe ++ (" " ** (14 - exe.len)) ++ " MISSING — youtube search and playback will fail\n"); + try printOut(" yt-dlp version {s}\n", .{v}); } } - try putStdout(" libmpv linked at build time (local files need nothing else)\n"); + if (mpv.load()) |_| { + try printOut(" libmpv {s}\n", .{mpv.describe(buf[2048..]) orelse "loaded"}); + } else |_| { + try putStdout(" libmpv MISSING — " ++ MPV_HINT ++ "\n"); + if (mpv.failure().len > 0) try printOut(" found one, but: {s}\n", .{mpv.failure()}); + } } fn exists(arena: std.mem.Allocator, path: []const u8) bool { - const z = arena.dupeZ(u8, path) catch return false; - return c.access(z.ptr, 0) == 0; // F_OK + const z = arena.dupeSentinel(u8, path, 0) catch return false; + return c.access(z.ptr, 0) == 0; } -/// First hit in $PATH — the same lookup the subprocess spawn will do. fn onPath(arena: std.mem.Allocator, env: *std.process.Environ.Map, name: []const u8) ?[]const u8 { const path_env = env.get("PATH") orelse return null; var it = std.mem.splitScalar(u8, path_env, ':'); while (it.next()) |dir| { if (dir.len == 0) continue; const full = std.fmt.allocPrint(arena, "{s}/{s}", .{ dir, name }) catch continue; - const z = arena.dupeZ(u8, full) catch continue; - if (c.access(z.ptr, 1) == 0) return full; // X_OK + const z = arena.dupeSentinel(u8, full, 0) catch continue; + if (c.access(z.ptr, 1) == 0) return full; } return null; } @@ -459,7 +477,6 @@ fn cmdLibrary( args: []const [:0]const u8, ) !void { _ = gpa; - var buf: [4096]u8 = undefined; const cfg = config.path(arena, env) catch { std.debug.print("no config path (HOME unset)\n", .{}); return; @@ -470,18 +487,17 @@ fn cmdLibrary( try putStdout("usage: hum library dir \n"); return; } - try config.saveKey(arena, cfg, "music_dir", args[1]); - try putStdout(try std.fmt.bufPrint(&buf, "music_dir = {s}\n", .{args[1]})); + try config.saveKey(arena, cfg, .music_dir, args[1]); + try printOut("music_dir = {s}\n", .{args[1]}); return; } - const music_dir = config.loadKey(arena, cfg, "music_dir") orelse { + const music_dir = config.loadKey(arena, cfg, .music_dir) orelse { try putStdout("no music_dir set — try: hum library dir ~/Music\n"); return; }; const cache = library.cachePath(arena, env) catch ""; const from_disk: library.Cached = if (cache.len > 0) library.loadCache(arena, cache) else .{}; - // a cache from other roots describes another library var entries: []library.Entry = if (from_disk.matches(music_dir)) from_disk.entries else &.{}; const rescan = args.len > 0 and eq(args[0], "scan"); @@ -493,13 +509,13 @@ fn cmdLibrary( } const artists = try library.artistsOf(arena, entries); - try putStdout(try std.fmt.bufPrint(&buf, "{d} tracks · {d} artists · {s}\n", .{ entries.len, artists.len, music_dir })); + try printOut("{d} tracks · {d} artists · {s}\n", .{ entries.len, artists.len, music_dir }); for (artists) |name| { var count: usize = 0; for (entries) |e| { if (std.ascii.eqlIgnoreCase(e.artist, name)) count += 1; } - try putStdout(try std.fmt.bufPrint(&buf, " {s} [{d}]\n", .{ name, count })); + try printOut(" {s} [{d}]\n", .{ name, count }); } } @@ -513,16 +529,15 @@ fn cmdPlaylists(arena: std.mem.Allocator, env: *std.process.Environ.Map, want: ? std.debug.print("(no playlists — press P on a result in the TUI)\n", .{}); return; } - var buf: [4096]u8 = undefined; for (lists) |e| { if (want) |name| { if (!std.mem.eql(u8, e.name, name)) continue; for (e.tracks, 0..) |t, i| { - try putStdout(try std.fmt.bufPrint(&buf, "{d:2}. {s} — {s} [{s}]\n", .{ i + 1, t.title, t.artist, t.video_id })); + try printOut("{d:2}. {s} — {s} [{s}]\n", .{ i + 1, t.title, t.artist, t.video_id }); } return; } - try putStdout(try std.fmt.bufPrint(&buf, "{s} [{d}]\n", .{ e.name, e.tracks.len })); + try printOut("{s} [{d}]\n", .{ e.name, e.tracks.len }); } if (want) |name| std.debug.print("no playlist named {s}\n", .{name}); } diff --git a/src/mpv.zig b/src/mpv.zig new file mode 100644 index 0000000..cb941f0 --- /dev/null +++ b/src/mpv.zig @@ -0,0 +1,144 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("build_options"); + +pub const Handle = opaque {}; + +pub const FORMAT_STRING: c_int = 1; +pub const FORMAT_FLAG: c_int = 3; +pub const FORMAT_DOUBLE: c_int = 5; + +pub const EVENT_NONE: c_int = 0; +pub const EVENT_SHUTDOWN: c_int = 1; +pub const EVENT_END_FILE: c_int = 7; +pub const EVENT_FILE_LOADED: c_int = 8; +pub const EVENT_IDLE: c_int = 11; +pub const EVENT_AUDIO_RECONFIG: c_int = 18; + +pub const END_FILE_REASON_EOF: c_int = 0; +pub const END_FILE_REASON_ERROR: c_int = 4; + +pub const Event = extern struct { + event_id: c_int, + @"error": c_int, + reply_userdata: u64, + data: ?*anyopaque, +}; + +pub const EndFile = extern struct { + reason: c_int, + @"error": c_int, +}; + +pub const Argv = [*:null]const ?[*:0]const u8; + +pub const Api = struct { + create: *const fn () callconv(.c) ?*Handle, + set_option_string: *const fn (*Handle, [*:0]const u8, [*:0]const u8) callconv(.c) c_int, + set_property_string: *const fn (*Handle, [*:0]const u8, [*:0]const u8) callconv(.c) c_int, + initialize: *const fn (*Handle) callconv(.c) c_int, + terminate_destroy: *const fn (*Handle) callconv(.c) void, + command: *const fn (*Handle, Argv) callconv(.c) c_int, + get_property: *const fn (*Handle, [*:0]const u8, c_int, *anyopaque) callconv(.c) c_int, + set_property: *const fn (*Handle, [*:0]const u8, c_int, *anyopaque) callconv(.c) c_int, + free: *const fn (?*anyopaque) callconv(.c) void, + wait_event: *const fn (*Handle, f64) callconv(.c) *Event, + error_string: *const fn (c_int) callconv(.c) [*:0]const u8, + client_api_version: *const fn () callconv(.c) c_ulong, +}; + +pub const Error = error{MpvMissing}; + +const system_paths = switch (builtin.os.tag) { + .macos => [_][:0]const u8{ + "/opt/homebrew/lib/libmpv.2.dylib", + "/usr/local/lib/libmpv.2.dylib", + "/opt/local/lib/libmpv.2.dylib", + }, + else => [_][:0]const u8{ "libmpv.so.2", "libmpv.so.1", "/home/linuxbrew/.linuxbrew/lib/libmpv.so.2" }, +}; + +const lib_name = if (builtin.os.tag == .macos) "libmpv.2.dylib" else "libmpv.so.2"; + +const prefix_path: [:0]const u8 = if (build_options.mpv_prefix.len > 0) + build_options.mpv_prefix ++ "/lib/" ++ lib_name +else + ""; + +var api: Api = undefined; +var loaded: ?[:0]const u8 = null; +var why_buf: [256]u8 = undefined; +var why: []const u8 = ""; + +pub fn load() Error!*const Api { + if (loaded != null) return &api; + const prefixed = [_][:0]const u8{prefix_path}; + for (prefixed ++ system_paths) |path| { + if (path.len == 0) continue; + if (tryOpen(path)) |a| { + api = a; + loaded = path; + return &api; + } + } + return error.MpvMissing; +} + +pub fn failure() []const u8 { + return why; +} + +pub fn describe(buf: []u8) ?[]const u8 { + const path = loaded orelse return null; + const v = api.client_api_version(); + return std.fmt.bufPrint(buf, "{s} (api {d}.{d})", .{ path, v >> 16, v & 0xffff }) catch null; +} + +fn tryOpen(path: [:0]const u8) ?Api { + const lib = std.c.dlopen(path, .{ .LAZY = true, .GLOBAL = true }) orelse { + const msg = std.mem.span(std.c.dlerror() orelse return null); + if (std.ascii.findIgnoreCase(msg, "no such file") == null) note("{s}", .{msg}); + return null; + }; + var out: Api = undefined; + const info = @typeInfo(Api).@"struct"; + inline for (info.field_names) |name| { + const sym = std.c.dlsym(lib, "mpv_" ++ name) orelse { + note("{s}: no mpv_{s}", .{ path, name }); + _ = std.c.dlclose(lib); + return null; + }; + @field(out, name) = @ptrCast(@alignCast(sym)); + } + const major = out.client_api_version() >> 16; + if (major < 1 or major > 2) { + note("{s}: client api {d} unsupported", .{ path, major }); + _ = std.c.dlclose(lib); + return null; + } + return out; +} + +pub fn mprisPlugin(buf: []u8) ?[:0]const u8 { + if (builtin.os.tag == .macos) return null; + const system = [_][:0]const u8{ + "/etc/mpv/scripts/mpris.so", + "/usr/lib/mpv-mpris/mpris.so", + "/usr/local/etc/mpv/scripts/mpris.so", + }; + for (system) |path| { + if (std.c.access(path, 0) == 0) return path; + } + const user = if (std.c.getenv("XDG_CONFIG_HOME")) |x| + std.fmt.bufPrintSentinel(buf, "{s}/mpv/scripts/mpris.so", .{std.mem.span(x)}, 0) + else if (std.c.getenv("HOME")) |h| + std.fmt.bufPrintSentinel(buf, "{s}/.config/mpv/scripts/mpris.so", .{std.mem.span(h)}, 0) + else + return null; + const path = user catch return null; + return if (std.c.access(path, 0) == 0) path else null; +} + +fn note(comptime fmt: []const u8, args: anytype) void { + why = std.fmt.bufPrint(&why_buf, fmt, args) catch why_buf[0..]; +} diff --git a/src/player.zig b/src/player.zig index 14d4055..6b3eae0 100644 --- a/src/player.zig +++ b/src/player.zig @@ -1,112 +1,125 @@ const std = @import("std"); -pub const c = @cImport({ - @cInclude("mpv/client.h"); -}); +const mpv = @import("mpv.zig"); pub const Error = error{ + MpvMissing, MpvCreate, MpvInit, MpvLoad, OutOfMemory, }; -pub const Event = enum { none, end_file, file_error, started, idle, shutdown }; +pub const Event = enum { none, end_file, file_error, started, audio_ready, idle, shutdown }; pub const Player = struct { - handle: *c.mpv_handle, + m: *const mpv.Api, + handle: *mpv.Handle, has_track: bool = false, - /// Set alongside a `.file_error` event. Points at a static libmpv string. last_error: []const u8 = "", pub fn init() Error!Player { - const h = c.mpv_create() orelse return error.MpvCreate; - _ = c.mpv_set_option_string(h, "video", "no"); - _ = c.mpv_set_option_string(h, "ytdl", "no"); - _ = c.mpv_set_option_string(h, "terminal", "no"); - _ = c.mpv_set_option_string(h, "idle", "yes"); - _ = c.mpv_set_option_string(h, "msg-level", "all=no"); - _ = c.mpv_set_option_string(h, "audio-display", "no"); - - _ = c.mpv_set_option_string(h, "input-media-keys", "yes"); - _ = c.mpv_set_option_string(h, "media-controls", "yes"); - - _ = c.mpv_set_option_string(h, "af", "@vis:lavfi=[astats=metadata=1:reset=1:length=0.05]"); - _ = c.mpv_set_option_string(h, "volume-max", "100"); - if (c.mpv_initialize(h) < 0) return error.MpvInit; - return .{ .handle = h }; + const m = try mpv.load(); + const h = m.create() orelse return error.MpvCreate; + _ = m.set_option_string(h, "video", "no"); + _ = m.set_option_string(h, "ytdl", "no"); + _ = m.set_option_string(h, "terminal", "no"); + _ = m.set_option_string(h, "idle", "yes"); + _ = m.set_option_string(h, "msg-level", "all=no"); + _ = m.set_option_string(h, "audio-display", "no"); + + _ = m.set_option_string(h, "input-media-keys", "yes"); + _ = m.set_option_string(h, "media-controls", "yes"); + var plugin_buf: [512]u8 = undefined; + if (mpv.mprisPlugin(&plugin_buf)) |plugin| _ = m.set_option_string(h, "scripts", plugin); + + _ = m.set_option_string(h, "af", "@vis:lavfi=[astats=metadata=1:reset=1:length=0.05]"); + _ = m.set_option_string(h, "volume-max", "100"); + if (m.initialize(h) < 0) { + m.terminate_destroy(h); + return error.MpvInit; + } + return .{ .m = m, .handle = h }; } pub fn deinit(self: *Player) void { - c.mpv_terminate_destroy(self.handle); + self.m.terminate_destroy(self.handle); } - pub fn loadUrl(self: *Player, alloc: std.mem.Allocator, url: []const u8) !void { - const url_z = try alloc.dupeZ(u8, url); + pub fn loadUrl(self: *Player, alloc: std.mem.Allocator, url: []const u8, title: []const u8) !void { + const url_z = try alloc.dupeSentinel(u8, url, 0); defer alloc.free(url_z); - var mode_z = [_:0]u8{ 'r', 'e', 'p', 'l', 'a', 'c', 'e' }; - var argv = [_:null][*c]const u8{ "loadfile", url_z.ptr, &mode_z }; - if (c.mpv_command(self.handle, &argv) < 0) return error.MpvLoad; + const title_z = try alloc.dupeSentinel(u8, title, 0); + defer alloc.free(title_z); + _ = self.m.set_property_string(self.handle, "force-media-title", title_z); + const argv = [_:null]?[*:0]const u8{ "loadfile", url_z.ptr, "replace" }; + if (self.m.command(self.handle, &argv) < 0) return error.MpvLoad; self.has_track = true; } pub fn stop(self: *Player) void { - var argv = [_:null][*c]const u8{"stop"}; - _ = c.mpv_command(self.handle, &argv); + const argv = [_:null]?[*:0]const u8{"stop"}; + _ = self.m.command(self.handle, &argv); self.has_track = false; } pub fn togglePause(self: *Player) bool { var pause: c_int = 0; - _ = c.mpv_get_property(self.handle, "pause", c.MPV_FORMAT_FLAG, &pause); + _ = self.m.get_property(self.handle, "pause", mpv.FORMAT_FLAG, &pause); var new: c_int = if (pause != 0) 0 else 1; - _ = c.mpv_set_property(self.handle, "pause", c.MPV_FORMAT_FLAG, &new); + _ = self.m.set_property(self.handle, "pause", mpv.FORMAT_FLAG, &new); return new != 0; } pub fn isPaused(self: *Player) bool { var pause: c_int = 0; - _ = c.mpv_get_property(self.handle, "pause", c.MPV_FORMAT_FLAG, &pause); + _ = self.m.get_property(self.handle, "pause", mpv.FORMAT_FLAG, &pause); return pause != 0; } pub fn toggleMute(self: *Player) bool { var m: c_int = 0; - _ = c.mpv_get_property(self.handle, "mute", c.MPV_FORMAT_FLAG, &m); + _ = self.m.get_property(self.handle, "mute", mpv.FORMAT_FLAG, &m); var new: c_int = if (m != 0) 0 else 1; - _ = c.mpv_set_property(self.handle, "mute", c.MPV_FORMAT_FLAG, &new); + _ = self.m.set_property(self.handle, "mute", mpv.FORMAT_FLAG, &new); return new != 0; } pub fn isMuted(self: *Player) bool { var m: c_int = 0; - _ = c.mpv_get_property(self.handle, "mute", c.MPV_FORMAT_FLAG, &m); + _ = self.m.get_property(self.handle, "mute", mpv.FORMAT_FLAG, &m); return m != 0; } pub fn seekRelative(self: *Player, seconds: f64) void { var buf: [32]u8 = undefined; - const s = std.fmt.bufPrintZ(&buf, "{d}", .{seconds}) catch return; - var mode_z = [_:0]u8{ 'r', 'e', 'l', 'a', 't', 'i', 'v', 'e' }; - var argv = [_:null][*c]const u8{ "seek", s.ptr, &mode_z }; - _ = c.mpv_command(self.handle, &argv); + const s = std.fmt.bufPrintSentinel(&buf, "{d}", .{seconds}, 0) catch return; + const argv = [_:null]?[*:0]const u8{ "seek", s.ptr, "relative" }; + _ = self.m.command(self.handle, &argv); + } + + pub fn seekPercent(self: *Player, pct: f64) void { + var buf: [32]u8 = undefined; + const s = std.fmt.bufPrintSentinel(&buf, "{d:.2}", .{std.math.clamp(pct, 0, 100)}, 0) catch return; + const argv = [_:null]?[*:0]const u8{ "seek", s.ptr, "absolute-percent" }; + _ = self.m.command(self.handle, &argv); } pub fn timePos(self: *Player) ?f64 { var v: f64 = 0; - if (c.mpv_get_property(self.handle, "time-pos", c.MPV_FORMAT_DOUBLE, &v) < 0) return null; + if (self.m.get_property(self.handle, "time-pos", mpv.FORMAT_DOUBLE, &v) < 0) return null; return v; } pub fn volume(self: *Player) f64 { var v: f64 = 100; - _ = c.mpv_get_property(self.handle, "volume", c.MPV_FORMAT_DOUBLE, &v); + _ = self.m.get_property(self.handle, "volume", mpv.FORMAT_DOUBLE, &v); return v; } pub fn setVolume(self: *Player, v: f64) void { var clamped = std.math.clamp(v, 0, 100); - _ = c.mpv_set_property(self.handle, "volume", c.MPV_FORMAT_DOUBLE, &clamped); + _ = self.m.set_property(self.handle, "volume", mpv.FORMAT_DOUBLE, &clamped); } pub fn nudgeVolume(self: *Player, delta: f64) f64 { @@ -116,46 +129,63 @@ pub const Player = struct { } pub fn rmsLevel(self: *Player) f64 { - var s: [*c]u8 = null; - const rc = c.mpv_get_property( - self.handle, - "af-metadata/vis/lavfi.astats.Overall.RMS_level", - c.MPV_FORMAT_STRING, - @ptrCast(&s), - ); - if (rc < 0) return 0; - if (s == null) return 0; - defer c.mpv_free(s); - const slice = std.mem.span(s); - const db = std.fmt.parseFloat(f64, slice) catch return 0; - const clamped = std.math.clamp(db, -60.0, 0.0); - return std.math.pow(f64, 10.0, clamped / 20.0); + const db = self.stat("Overall.RMS_level") orelse return 0; + return std.math.pow(f64, 10.0, std.math.clamp(db, -60.0, 0.0) / 20.0); + } + + pub const Levels = struct { level: f32, left: f32, right: f32, bright: f32 }; + + pub fn levels(self: *Player) Levels { + const overall = self.stat("Overall.RMS_level") orelse return .{ .level = 0, .left = 0, .right = 0, .bright = 0 }; + const left = self.stat("1.RMS_level") orelse overall; + const right = self.stat("2.RMS_level") orelse left; + const zcr = self.stat("1.Zero_crossings_rate") orelse 0; + return .{ + .level = dbNorm(overall), + .left = dbNorm(left), + .right = dbNorm(right), + .bright = @floatCast(std.math.clamp(@sqrt(@max(zcr, 0) / 0.15), 0, 1)), + }; + } + + fn dbNorm(db: f64) f32 { + return @floatCast(std.math.clamp((db + 50.0) / 40.0, 0, 1)); + } + + fn stat(self: *Player, comptime key: []const u8) ?f64 { + var s: ?[*:0]u8 = null; + const rc = self.m.get_property(self.handle, "af-metadata/vis/lavfi.astats." ++ key, mpv.FORMAT_STRING, @ptrCast(&s)); + if (rc < 0) return null; + const str = s orelse return null; + defer self.m.free(str); + const v = std.fmt.parseFloat(f64, std.mem.span(str)) catch return null; + return if (std.math.isFinite(v)) v else null; } pub fn duration(self: *Player) ?f64 { var v: f64 = 0; - if (c.mpv_get_property(self.handle, "duration", c.MPV_FORMAT_DOUBLE, &v) < 0) return null; + if (self.m.get_property(self.handle, "duration", mpv.FORMAT_DOUBLE, &v) < 0) return null; return v; } pub fn pollEvent(self: *Player) Event { - const ev = c.mpv_wait_event(self.handle, 0); - return switch (ev.*.event_id) { - c.MPV_EVENT_NONE => .none, - c.MPV_EVENT_END_FILE => blk: { - const ef: *c.mpv_event_end_file = @ptrCast(@alignCast(ev.*.data)); - if (ef.reason == c.MPV_END_FILE_REASON_EOF) break :blk .end_file; - // a stream that fails to open ends here; swallowing it reports playing at 00:00 - if (ef.reason == c.MPV_END_FILE_REASON_ERROR) { + const ev = self.m.wait_event(self.handle, 0); + return switch (ev.event_id) { + mpv.EVENT_NONE => .none, + mpv.EVENT_END_FILE => blk: { + const ef: *const mpv.EndFile = @ptrCast(@alignCast(ev.data orelse break :blk .none)); + if (ef.reason == mpv.END_FILE_REASON_EOF) break :blk .end_file; + if (ef.reason == mpv.END_FILE_REASON_ERROR) { self.has_track = false; - self.last_error = std.mem.span(c.mpv_error_string(ef.@"error")); + self.last_error = std.mem.span(self.m.error_string(ef.@"error")); break :blk .file_error; } break :blk .none; }, - c.MPV_EVENT_FILE_LOADED => .started, - c.MPV_EVENT_IDLE => .idle, - c.MPV_EVENT_SHUTDOWN => .shutdown, + mpv.EVENT_FILE_LOADED => .started, + mpv.EVENT_AUDIO_RECONFIG => .audio_ready, + mpv.EVENT_IDLE => .idle, + mpv.EVENT_SHUTDOWN => .shutdown, else => .none, }; } diff --git a/src/playlist.zig b/src/playlist.zig index 6f13926..e0a5588 100644 --- a/src/playlist.zig +++ b/src/playlist.zig @@ -48,7 +48,6 @@ pub fn load(arena: std.mem.Allocator, file_path: []const u8) ![]Entry { const title = f.next() orelse continue; const artist = f.next() orelse ""; const kind = f.next() orelse "Song"; - // a four-column row predates the source column and is a YouTube track const source = track_mod.sourceByName(f.next() orelse ""); const duration = track_mod.parseDuration(f.next() orelse ""); try tracks.append(arena, .{ @@ -65,13 +64,11 @@ pub fn load(arena: std.mem.Allocator, file_path: []const u8) ![]Entry { return out.toOwnedSlice(arena); } -/// One target column: a video id, or a path/URL. fn targetOf(t: api.Track) []const u8 { return if (t.source == .youtube) t.video_id else t.uri; } pub fn save(arena: std.mem.Allocator, file_path: []const u8, entries: []const Entry) !void { - // sanitize on write: a tab or newline in a title would forge rows here var buf: std.ArrayList(u8) = .empty; for (entries) |e| { try buf.print(arena, "[{s}]\n", .{try txt.sanitize(arena, e.name)}); @@ -145,11 +142,7 @@ pub fn removeList(arena: std.mem.Allocator, file_path: []const u8, name: []const const testing = std.testing; fn tmpPath(a: std.mem.Allocator) ![:0]const u8 { - const p = try a.dupeZ(u8, "/tmp/hum_pl_XXXXXX"); - const fd = c.mkstemp(p.ptr); - if (fd < 0) return error.TempFailed; - _ = c.close(fd); - return p; + return fsutil.makeTemp(a, "hum_pl_", ""); } test "playlists round-trip through the file" { @@ -199,7 +192,11 @@ test "cleanName trims and strips a leading bracket" { try testing.expectEqualStrings("chill]", cleanName("[chill]")); try testing.expectEqualStrings("", cleanName(" ")); - const long = "あ" ** 40; // 120 bytes, 3 per codepoint + const long = comptime blk: { + var s: []const u8 = ""; + for (0..40) |_| s = s ++ "あ"; + break :blk s; + }; const cut = cleanName(long); try testing.expectEqual(@as(usize, 63), cut.len); try testing.expect(std.unicode.utf8ValidateSlice(cut)); diff --git a/src/proc.zig b/src/proc.zig index d4f0fe3..9059e69 100644 --- a/src/proc.zig +++ b/src/proc.zig @@ -3,7 +3,6 @@ const log = @import("log.zig"); pub const Error = error{ProcFailed} || std.process.RunError; -/// A remote endpoint decides how much these print, so cap what we buffer. const STDOUT_LIMIT = 32 * 1024 * 1024; const STDERR_LIMIT = 64 * 1024; diff --git a/src/stream.zig b/src/stream.zig index 28133da..24ce409 100644 --- a/src/stream.zig +++ b/src/stream.zig @@ -1,10 +1,10 @@ const std = @import("std"); const proc = @import("proc.zig"); const track_mod = @import("track.zig"); +const RESOLVE_TIMEOUT_S = "15"; pub const Error = error{ NoVideoId, NoUri } || proc.Error || std.mem.Allocator.Error; -/// Only a YouTube row costs a subprocess; local files and URLs resolve offline. pub fn resolve(gpa: std.mem.Allocator, io: std.Io, t: track_mod.Track) Error![]u8 { return switch (t.source) { .youtube => resolveAudioUrl(gpa, io, t.video_id), @@ -20,7 +20,7 @@ pub fn resolveAudioUrl(gpa: std.mem.Allocator, io: std.Io, video_id: []const u8) const out = try proc.runCapture(gpa, io, &.{ "yt-dlp", "-f", "bestaudio", - "--no-warnings", "--socket-timeout", "15", + "--no-warnings", "--socket-timeout", RESOLVE_TIMEOUT_S, "-g", "--", video_id, }); defer gpa.free(out); @@ -31,8 +31,44 @@ pub fn resolveAudioUrl(gpa: std.mem.Allocator, io: std.Io, video_id: []const u8) return gpa.dupe(u8, url); } +pub const STALE_DAYS = 60; + +pub fn ytdlpAgeDays(version: []const u8, now_s: i64) ?u32 { + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, version, " \r\n\t"), '.'); + const y = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; + const m = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; + const d = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; + if (y < 2000 or y > 9999 or m < 1 or m > 12 or d < 1 or d > 31) return null; + const age = @divFloor(now_s, std.time.s_per_day) - daysFromCivil(y, m, d); + return if (age < 0) 0 else @intCast(age); +} + +fn daysFromCivil(year: i64, m: i64, d: i64) i64 { + const y = if (m <= 2) year - 1 else year; + const era = @divFloor(y, 400); + const yoe = y - era * 400; + const doy = @divFloor(153 * (if (m > 2) m - 3 else m + 9) + 2, 5) + d - 1; + const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; + return era * 146097 + doe - 719468; +} + +pub fn ytdlpVersion(gpa: std.mem.Allocator, io: std.Io) ?[]u8 { + return proc.runCapture(gpa, io, &.{ "yt-dlp", "--version" }) catch null; +} + const testing = std.testing; +test "yt-dlp age is counted in days from its date version" { + const oct_5_2026 = 1791158400; + try testing.expectEqual(@as(i64, 0), daysFromCivil(1970, 1, 1)); + try testing.expectEqual(@as(?u32, 0), ytdlpAgeDays("2026.10.05\n", oct_5_2026)); + try testing.expectEqual(@as(?u32, 48), ytdlpAgeDays("2026.08.18.122307", oct_5_2026)); + try testing.expectEqual(@as(?u32, 0), ytdlpAgeDays("2027.01.01", oct_5_2026)); + try testing.expectEqual(@as(?u32, null), ytdlpAgeDays("garbage", oct_5_2026)); + try testing.expectEqual(@as(?u32, null), ytdlpAgeDays("2026.13.01", oct_5_2026)); + try testing.expectEqual(@as(?u32, null), ytdlpAgeDays("2026.10", oct_5_2026)); +} + test "resolve hands back local paths and URLs without a subprocess" { const local: track_mod.Track = .{ .source = .local, .uri = "/music/a.flac", .title = "t", .artist = "a" }; const got = try resolve(testing.allocator, undefined, local); diff --git a/src/suggest.zig b/src/suggest.zig index f4cffc0..34ad288 100644 --- a/src/suggest.zig +++ b/src/suggest.zig @@ -1,5 +1,6 @@ const std = @import("std"); const proc = @import("proc.zig"); +const SUGGEST_TIMEOUT_S = "3"; const text = @import("text.zig"); pub const Error = error{ @@ -10,14 +11,13 @@ pub fn fetch(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, query if (query.len == 0) return &.{}; const escaped = try urlEscape(arena, query); - // ie/oe are not optional: without them the endpoint answers in latin-1 const url = try std.fmt.allocPrint( arena, "https://suggestqueries.google.com/complete/search?client=firefox&ds=yt&ie=utf-8&oe=utf-8&q={s}", .{escaped}, ); - const resp = try proc.runCapture(gpa, io, &.{ "curl", "-sS", "--max-time", "3", url }); + const resp = try proc.runCapture(gpa, io, &.{ "curl", "-sS", "--max-time", SUGGEST_TIMEOUT_S, url }); defer gpa.free(resp); const parsed = std.json.parseFromSlice(std.json.Value, arena, resp, .{}) catch return error.BadResponse; diff --git a/src/tags.zig b/src/tags.zig index 40da6c0..5513188 100644 --- a/src/tags.zig +++ b/src/tags.zig @@ -2,7 +2,6 @@ const std = @import("std"); const txt = @import("text.zig"); const fsutil = @import("fsutil.zig"); -/// Metadata read out of an audio file. Every field is optional. pub const Tags = struct { title: []const u8 = "", artist: []const u8 = "", @@ -11,29 +10,29 @@ pub const Tags = struct { duration_s: u32 = 0, }; -// Every length in a tag is chosen by the file; these are the ceilings it cannot exceed. -const HEAD_BYTES: usize = 1024 * 1024; // tags live in the first pages -const MAX_TEXT: usize = 512; // one field's worth of characters -const MAX_FRAMES: usize = 512; // ID3 frames / Vorbis comments per file -const MAX_ATOM_DEPTH: u8 = 8; // MP4 nesting +const HEAD_BYTES: usize = 1024 * 1024; +const MAX_TEXT: usize = 512; +const MAX_FRAMES: usize = 512; +const MAX_ATOM_DEPTH: u8 = 8; -/// Null means nothing usable. Never fails loudly: a corrupt file must not stop a scan. -pub fn read(arena: std.mem.Allocator, file_path: []const u8) ?Tags { +pub fn read(arena: std.mem.Allocator, file_path: []const u8, total_size: u64) ?Tags { const bytes = fsutil.readCappedAlloc(arena, file_path, HEAD_BYTES) orelse return null; - return parse(arena, bytes); + return parse(arena, bytes, total_size); } -/// Dispatch on magic bytes: the extension is a hint, the magic is what a decoder sees. -pub fn parse(arena: std.mem.Allocator, bytes: []const u8) ?Tags { +pub fn parse(arena: std.mem.Allocator, bytes: []const u8, total_size: u64) ?Tags { if (bytes.len < 12) return null; - if (std.mem.startsWith(u8, bytes, "ID3")) return parseId3(arena, bytes); + if (std.mem.startsWith(u8, bytes, "ID3")) return parseId3(arena, bytes, total_size); + if (bytes[0] == 0xFF and (bytes[1] & 0xE0) == 0xE0) { + const secs = mp3Duration(bytes, 0, total_size); + return if (secs > 0) Tags{ .duration_s = secs } else null; + } if (std.mem.startsWith(u8, bytes, "fLaC")) return parseFlac(arena, bytes); if (std.mem.startsWith(u8, bytes, "OggS")) return parseOgg(arena, bytes); if (std.mem.eql(u8, bytes[4..8], "ftyp")) return parseMp4(arena, bytes); return null; } -/// Bounds-checked reads: an out-of-range slice is an uncatchable panic, so check before slicing. const Cursor = struct { b: []const u8, i: usize = 0, @@ -86,7 +85,6 @@ const Cursor = struct { } }; -/// Sanitize, then cap. First writer wins, so a duplicate frame cannot overwrite a good value. fn store(arena: std.mem.Allocator, dst: *[]const u8, raw: []const u8) void { if (dst.len > 0) return; if (raw.len == 0 or raw.len > MAX_TEXT * 4) return; @@ -96,7 +94,6 @@ fn store(arena: std.mem.Allocator, dst: *[]const u8, raw: []const u8) void { dst.* = if (trimmed.len > MAX_TEXT) truncateUtf8(trimmed, MAX_TEXT) else trimmed; } -/// Cut on a codepoint boundary; a half character would reach the column math. fn truncateUtf8(s: []const u8, max: usize) []const u8 { var end = @min(s.len, max); while (end > 0 and (s[end] & 0xC0) == 0x80) end -= 1; @@ -105,17 +102,15 @@ fn truncateUtf8(s: []const u8, max: usize) []const u8 { // ---------------------------------------------------------------- ID3 (mp3) -fn parseId3(arena: std.mem.Allocator, bytes: []const u8) ?Tags { +fn parseId3(arena: std.mem.Allocator, bytes: []const u8, total_size: u64) ?Tags { var c: Cursor = .{ .b = bytes }; - _ = c.take(3) orelse return null; // "ID3" + _ = c.take(3) orelse return null; const major = c.byte() orelse return null; - _ = c.byte() orelse return null; // revision + _ = c.byte() orelse return null; const flags = c.byte() orelse return null; const tag_size = syncsafe(&c) orelse return null; - // 2.2 and 2.5 are left to the path-derived fallback if (major != 3 and major != 4) return null; - // unsynchronisation rewrites frame bytes; decline rather than expand in place if (flags & 0x80 != 0) return null; const body_len = @min(tag_size, c.remaining()); @@ -127,12 +122,12 @@ fn parseId3(arena: std.mem.Allocator, bytes: []const u8) ?Tags { var seen: usize = 0; while (seen < MAX_FRAMES) : (seen += 1) { const id = body.take(4) orelse break; - if (id[0] == 0) break; // padding + if (id[0] == 0) break; const size: usize = if (major == 4) syncsafe(&body) orelse break else body.u32be() orelse break; - _ = body.u16be() orelse break; // frame flags + _ = body.u16be() orelse break; if (size == 0 or size > body.remaining()) break; const frame = body.take(size) orelse break; @@ -151,15 +146,101 @@ fn parseId3(arena: std.mem.Allocator, bytes: []const u8) ?Tags { } } } + if (tags.duration_s == 0) { + tags.duration_s = mp3Duration(bytes, 10 + tag_size, total_size); + } return tags; } -/// ID3 sizes are seven bits per byte; the high bit is not part of the number. +// ------------------------------------------------------------ mp3 duration + +const MPEG1_L3_BITRATE = [_]u32{ 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0 }; +const MPEG2_L3_BITRATE = [_]u32{ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 }; +const SAMPLE_RATES = [_]u32{ 44100, 48000, 32000, 0 }; + +const Frame = struct { + mpeg1: bool, + mono: bool, + kbps: u32, + sample_rate: u32, + len: usize, +}; + +fn frameAt(bytes: []const u8, i: usize) ?Frame { + if (i + 4 > bytes.len) return null; + const h = bytes[i .. i + 4]; + if (h[0] != 0xFF or (h[1] & 0xE0) != 0xE0) return null; + + const version: u2 = @intCast((h[1] >> 3) & 0x03); + const layer: u2 = @intCast((h[1] >> 1) & 0x03); + if (layer != 1 or version == 1) return null; + + var sample_rate = SAMPLE_RATES[(h[2] >> 2) & 0x03]; + if (sample_rate == 0) return null; + if (version == 2) sample_rate /= 2; + if (version == 0) sample_rate /= 4; + + const mpeg1 = version == 3; + const bitrate_index = (h[2] >> 4) & 0x0F; + const kbps = if (mpeg1) MPEG1_L3_BITRATE[bitrate_index] else MPEG2_L3_BITRATE[bitrate_index]; + if (kbps == 0) return null; + + const padding: usize = (h[2] >> 1) & 0x01; + const coeff: usize = if (mpeg1) 144 else 72; + return .{ + .mpeg1 = mpeg1, + .mono = ((h[3] >> 6) & 0x03) == 3, + .kbps = kbps, + .sample_rate = sample_rate, + .len = coeff * kbps * 1000 / sample_rate + padding, + }; +} + +fn mp3Duration(bytes: []const u8, audio_start: usize, total_size: u64) u32 { + const at = findFrame(bytes, audio_start) orelse return 0; + const f = frameAt(bytes, at).?; + const samples_per_frame: u64 = if (f.mpeg1) 1152 else 576; + + const xing_at = at + 4 + @as(usize, if (f.mpeg1) + (if (f.mono) 17 else 32) + else + (if (f.mono) 9 else 17)); + if (xing_at + 12 <= bytes.len) { + const tag = bytes[xing_at .. xing_at + 4]; + if (std.mem.eql(u8, tag, "Xing") or std.mem.eql(u8, tag, "Info")) { + const flags = std.mem.readInt(u32, bytes[xing_at + 4 ..][0..4], .big); + if (flags & 1 != 0) { + const frames = std.mem.readInt(u32, bytes[xing_at + 8 ..][0..4], .big); + const secs = (@as(u64, frames) * samples_per_frame) / f.sample_rate; + return @intCast(@min(secs, std.math.maxInt(u32))); + } + } + } + + if (total_size <= at) return 0; + const secs = ((total_size - at) * 8) / (@as(u64, f.kbps) * 1000); + return @intCast(@min(secs, std.math.maxInt(u32))); +} + +fn findFrame(bytes: []const u8, from: usize) ?usize { + const start = @min(from, bytes.len); + const limit = @min(bytes.len, start + 64 * 1024); + var i = start; + while (i < limit) : (i += 1) { + const f = frameAt(bytes, i) orelse continue; + const next = i + f.len; + if (next + 4 > bytes.len) return i; + const g = frameAt(bytes, next) orelse continue; + if (g.mpeg1 == f.mpeg1 and g.sample_rate == f.sample_rate) return i; + } + return null; +} + fn syncsafe(c: *Cursor) ?usize { const s = c.take(4) orelse return null; var out: usize = 0; for (s) |b| { - if (b & 0x80 != 0) return null; // not sync-safe: refuse rather than guess + if (b & 0x80 != 0) return null; out = (out << 7) | (b & 0x7f); } return out; @@ -169,13 +250,12 @@ fn skipExtendedHeader(c: *Cursor, major: u8) bool { if (major == 4) { const size = syncsafe(c) orelse return false; if (size < 4) return false; - return c.skip(size - 4); // 2.4 counts its own four size bytes + return c.skip(size - 4); } const size = c.u32be() orelse return false; - return c.skip(size); // 2.3 excludes them + return c.skip(size); } -/// Unknown text encoding means skip the frame; guessing produces a bad decode. fn id3Text(arena: std.mem.Allocator, frame: []const u8) ?[]const u8 { if (frame.len < 2) return null; const body = frame[1..]; @@ -212,7 +292,6 @@ fn latin1ToUtf8(arena: std.mem.Allocator, s: []const u8) ?[]const u8 { return out.items; } -/// Decoded byte pair by byte pair: the frame body is at an arbitrary offset, so a []u16 cast would misalign. fn utf16ToUtf8(arena: std.mem.Allocator, s: []const u8, forced: ?std.builtin.Endian) ?[]const u8 { var body = s; var endian: std.builtin.Endian = forced orelse .little; @@ -224,7 +303,7 @@ fn utf16ToUtf8(arena: std.mem.Allocator, s: []const u8, forced: ?std.builtin.End } else if (body[0] == 0xFE and body[1] == 0xFF) { endian = .big; body = body[2..]; - } else return null; // encoding 1 promises a BOM; without one, decline + } else return null; } if (body.len < 2) return null; @@ -244,7 +323,7 @@ fn utf16ToUtf8(arena: std.mem.Allocator, s: []const u8, forced: ?std.builtin.End cp = 0x10000 + ((@as(u21, unit - 0xD800) << 10) | (low - 0xDC00)); i += 2; } else if (unit >= 0xDC00 and unit <= 0xDFFF) { - break; // unpaired low surrogate + break; } const n = std.unicode.utf8Encode(cp, &buf) catch break; out.appendSlice(arena, buf[0..n]) catch return null; @@ -263,7 +342,7 @@ fn readUnit(pair: []const u8, endian: std.builtin.Endian) u16 { fn parseFlac(arena: std.mem.Allocator, bytes: []const u8) ?Tags { var c: Cursor = .{ .b = bytes }; - _ = c.take(4) orelse return null; // "fLaC" + _ = c.take(4) orelse return null; var tags: Tags = .{}; var blocks: usize = 0; @@ -285,7 +364,6 @@ fn parseFlac(arena: std.mem.Allocator, bytes: []const u8) ?Tags { return tags; } -/// Sample rate and total samples share a bit field; rate 0 means unknown, never a divide. fn readStreamInfo(block: []const u8, tags: *Tags) void { if (block.len < 18) return; const packed_bits = std.mem.readInt(u64, block[10..18], .big); @@ -295,7 +373,6 @@ fn readStreamInfo(block: []const u8, tags: *Tags) void { tags.duration_s = @intCast(@min(total / rate, std.math.maxInt(u32))); } -/// vendor, count, count × (len, "KEY=value") — every length is the file's choice, so every one is checked. fn readVorbisComments(arena: std.mem.Allocator, block: []const u8, tags: *Tags) void { var c: Cursor = .{ .b = block }; const vendor_len = c.u32le() orelse return; @@ -334,7 +411,6 @@ fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { // -------------------------------------------------------------- Ogg / Opus -/// Find the comment magic in the head buffer rather than reassembling ogg packets. fn parseOgg(arena: std.mem.Allocator, bytes: []const u8) ?Tags { var tags: Tags = .{}; if (std.mem.indexOf(u8, bytes, "OpusTags")) |at| { @@ -356,7 +432,6 @@ fn parseMp4(arena: std.mem.Allocator, bytes: []const u8) ?Tags { return tags; } -/// Atoms are nested TLV: a size below its own header loops forever, so progress must be strictly forward, and depth is capped. fn walkAtoms(arena: std.mem.Allocator, bytes: []const u8, tags: *Tags, depth: u8) void { if (depth >= MAX_ATOM_DEPTH) return; var c: Cursor = .{ .b = bytes }; @@ -370,10 +445,10 @@ fn walkAtoms(arena: std.mem.Allocator, bytes: []const u8, tags: *Tags, depth: u8 size = c.u64be() orelse return; header = 16; } else if (size == 0) { - size = @as(u64, bytes.len - start); // "extends to end of file" + size = @as(u64, bytes.len - start); } - if (size < header) return; // would not advance - if (size > bytes.len - start) return; // claims more than the parent holds + if (size < header) return; + if (size > bytes.len - start) return; const body = bytes[start + header .. start + @as(usize, @intCast(size))]; @@ -384,7 +459,6 @@ fn walkAtoms(arena: std.mem.Allocator, bytes: []const u8, tags: *Tags, depth: u8 { walkAtoms(arena, body, tags, depth + 1); } else if (std.mem.eql(u8, kind, "meta")) { - // meta is a full atom: version/flags precede its children if (body.len > 4) walkAtoms(arena, body[4..], tags, depth + 1); } else if (std.mem.eql(u8, kind, "ilst")) { readIlst(arena, body, tags, depth + 1); @@ -415,7 +489,6 @@ fn readIlst(arena: std.mem.Allocator, bytes: []const u8, tags: *Tags, depth: u8) } else if (std.mem.eql(u8, kind, "\xA9alb")) { store(arena, &tags.album, payload); } else if (std.mem.eql(u8, kind, "trkn")) { - // 2 reserved bytes, then a big-endian u16 if (payload.len >= 4 and tags.track_no == 0) { tags.track_no = std.mem.readInt(u16, payload[2..4], .big); } @@ -425,7 +498,6 @@ fn readIlst(arena: std.mem.Allocator, bytes: []const u8, tags: *Tags, depth: u8) } } -/// Each item wraps its value in a `data` atom: size, "data", type, reserved, payload. fn dataPayload(item: []const u8) ?[]const u8 { var c: Cursor = .{ .b = item }; const size = c.u32be() orelse return null; @@ -438,13 +510,13 @@ fn dataPayload(item: []const u8) ?[]const u8 { fn readMvhd(body: []const u8, tags: *Tags) void { var c: Cursor = .{ .b = body }; const version = c.byte() orelse return; - _ = c.take(3) orelse return; // flags + _ = c.take(3) orelse return; var timescale: u64 = 0; var duration: u64 = 0; if (version == 1) { - _ = c.u64be() orelse return; // created - _ = c.u64be() orelse return; // modified + _ = c.u64be() orelse return; + _ = c.u64be() orelse return; timescale = c.u32be() orelse return; duration = c.u64be() orelse return; } else { @@ -459,7 +531,6 @@ fn readMvhd(body: []const u8, tags: *Tags) void { // ------------------------------------------------------------ path fallback -/// Path-derived fallback for files with no usable tags. pub fn fromPath(arena: std.mem.Allocator, file_path: []const u8) Tags { var tags: Tags = .{}; @@ -467,7 +538,6 @@ pub fn fromPath(arena: std.mem.Allocator, file_path: []const u8) Tags { const stem = if (std.mem.lastIndexOfScalar(u8, base, '.')) |dot| base[0..dot] else base; var title = stem; - // a leading number is a track number, not part of the title var digits: usize = 0; while (digits < title.len and title[digits] >= '0' and title[digits] <= '9') digits += 1; if (digits > 0 and digits < title.len) { @@ -487,10 +557,9 @@ pub fn fromPath(arena: std.mem.Allocator, file_path: []const u8) Tags { return tags; } -/// Tags where present, path where not. -pub fn readOrPath(arena: std.mem.Allocator, file_path: []const u8) Tags { +pub fn readOrPath(arena: std.mem.Allocator, file_path: []const u8, total_size: u64) Tags { const from_path = fromPath(arena, file_path); - var tags = read(arena, file_path) orelse return from_path; + var tags = read(arena, file_path, total_size) orelse return from_path; if (tags.title.len == 0) tags.title = from_path.title; if (tags.artist.len == 0) tags.artist = from_path.artist; if (tags.album.len == 0) tags.album = from_path.album; @@ -515,7 +584,6 @@ fn beBytes(n: u32) [4]u8 { return out; } -/// Declared size is explicit so a test can lie about it. fn id3Frame(a: std.mem.Allocator, id: []const u8, major: u8, body: []const u8, declared: ?u32) []u8 { var out: std.ArrayList(u8) = .empty; out.appendSlice(a, id) catch unreachable; @@ -539,16 +607,14 @@ fn id3(a: std.mem.Allocator, major: u8, frames: []const []const u8) []u8 { out.appendSlice(a, &.{ major, 0, 0 }) catch unreachable; out.appendSlice(a, &syncsafeBytes(body.items.len)) catch unreachable; out.appendSlice(a, body.items) catch unreachable; - // audio after the tag must be ignored out.appendSlice(a, &.{ 0xFF, 0xFB, 0x90, 0x00 }) catch unreachable; return out.items; } -/// No panic and no hang wherever the bytes stop; returning at all is the assertion. fn survivesEveryTruncation(a: std.mem.Allocator, bytes: []const u8) void { var n: usize = 0; while (n <= bytes.len) : (n += 1) { - _ = parse(a, bytes[0..n]); + _ = parse(a, bytes[0..n], 0); } } @@ -558,14 +624,14 @@ test "id3v2.3 reads latin-1, utf-8 and utf-16 text frames" { const a = arena.allocator(); const bytes = id3(a, 3, &.{ - id3Frame(a, "TIT2", 3, "\x00mot\xf6rhead", null), // latin-1 ö - id3Frame(a, "TPE1", 3, "\x03Bohren & der Club of Gore", null), // utf-8 - id3Frame(a, "TALB", 3, "\x01\xff\xfeB\x00l\x00a\x00c\x00k\x00", null), // utf-16 LE + BOM + id3Frame(a, "TIT2", 3, "\x00mot\xf6rhead", null), + id3Frame(a, "TPE1", 3, "\x03Bohren & der Club of Gore", null), + id3Frame(a, "TALB", 3, "\x01\xff\xfeB\x00l\x00a\x00c\x00k\x00", null), id3Frame(a, "TRCK", 3, "\x004/12", null), id3Frame(a, "TLEN", 3, "\x00222000", null), }); - const t = parse(a, bytes).?; + const t = parse(a, bytes, 0).?; try testing.expectEqualStrings("motörhead", t.title); try testing.expectEqualStrings("Bohren & der Club of Gore", t.artist); try testing.expectEqualStrings("Black", t.album); @@ -579,7 +645,7 @@ test "id3v2.4 sync-safe frame sizes" { const a = arena.allocator(); const bytes = id3(a, 4, &.{id3Frame(a, "TIT2", 4, "\x03Midnight", null)}); - try testing.expectEqualStrings("Midnight", parse(a, bytes).?.title); + try testing.expectEqualStrings("Midnight", parse(a, bytes, 0).?.title); } test "id3 frame declaring an absurd size is refused, not sliced" { @@ -587,14 +653,13 @@ test "id3 frame declaring an absurd size is refused, not sliced" { defer arena.deinit(); const a = arena.allocator(); - // the frame claims 4GB inside a tag of a few dozen bytes const bytes = id3(a, 3, &.{ id3Frame(a, "TIT2", 3, "\x03Good", null), id3Frame(a, "TALB", 3, "\x03Evil", 0xFFFF_FFFF), }); - const t = parse(a, bytes).?; - try testing.expectEqualStrings("Good", t.title); // frames before it still read - try testing.expectEqualStrings("", t.album); // the liar contributes nothing + const t = parse(a, bytes, 0).?; + try testing.expectEqualStrings("Good", t.title); + try testing.expectEqualStrings("", t.album); } test "id3 skips frames whose text encoding byte is unknown" { @@ -603,7 +668,7 @@ test "id3 skips frames whose text encoding byte is unknown" { const a = arena.allocator(); const bytes = id3(a, 3, &.{id3Frame(a, "TIT2", 3, "\x09garbage", null)}); - try testing.expectEqualStrings("", parse(a, bytes).?.title); + try testing.expectEqualStrings("", parse(a, bytes, 0).?.title); } test "id3 utf-16 without a BOM, and an unpaired surrogate, decode to nothing bad" { @@ -612,11 +677,10 @@ test "id3 utf-16 without a BOM, and an unpaired surrogate, decode to nothing bad const a = arena.allocator(); const no_bom = id3(a, 3, &.{id3Frame(a, "TIT2", 3, "\x01B\x00l\x00a\x00", null)}); - try testing.expectEqualStrings("", parse(a, no_bom).?.title); + try testing.expectEqualStrings("", parse(a, no_bom, 0).?.title); - // BOM, one good char, then an unpaired high surrogate const lone = id3(a, 3, &.{id3Frame(a, "TIT2", 3, "\x01\xff\xfeA\x00\x00\xd8", null)}); - const got = parse(a, lone).?.title; + const got = parse(a, lone, 0).?.title; try testing.expectEqualStrings("A", got); try testing.expect(std.unicode.utf8ValidateSlice(got)); } @@ -627,7 +691,7 @@ test "id3 tag text carrying escapes and tabs is neutralized" { const a = arena.allocator(); const bytes = id3(a, 3, &.{id3Frame(a, "TIT2", 3, "\x03evil\x1b[2Jtitle\there", null)}); - const t = parse(a, bytes).?; + const t = parse(a, bytes, 0).?; try testing.expectEqualStrings("evil [2Jtitle here", t.title); } @@ -647,11 +711,10 @@ fn flacFile(a: std.mem.Allocator, comments: []const []const u8, bad_count: ?u32) var out: std.ArrayList(u8) = .empty; out.appendSlice(a, "fLaC") catch unreachable; - // STREAMINFO: 44100 Hz, 9_797_400 samples → 222 s - var info = [_]u8{0} ** 34; + var info: [34]u8 = @splat(0); const packed_bits: u64 = (@as(u64, 44100) << 44) | 9_797_400; std.mem.writeInt(u64, info[10..18], packed_bits, .big); - out.append(a, 0) catch unreachable; // type 0, not last + out.append(a, 0) catch unreachable; out.appendSlice(a, &.{ 0, 0, 34 }) catch unreachable; out.appendSlice(a, &info) catch unreachable; @@ -670,7 +733,7 @@ fn flacFile(a: std.mem.Allocator, comments: []const []const u8, bad_count: ?u32) vc.appendSlice(a, cm) catch unreachable; } - out.append(a, 0x84) catch unreachable; // type 4, last block + out.append(a, 0x84) catch unreachable; out.appendSlice(a, &.{ @intCast((vc.items.len >> 16) & 0xff), @intCast((vc.items.len >> 8) & 0xff), @@ -687,13 +750,13 @@ test "flac reads vorbis comments and derives duration from STREAMINFO" { const bytes = flacFile(a, &.{ "TITLE=Midnight Black Earth", - "artist=Bohren & der Club of Gore", // key case is not significant + "artist=Bohren & der Club of Gore", "ALBUM=Black Earth", "TRACKNUMBER=1", "REPLAYGAIN_TRACK_GAIN=-7.4 dB", }, null); - const t = parse(a, bytes).?; + const t = parse(a, bytes, 0).?; try testing.expectEqualStrings("Midnight Black Earth", t.title); try testing.expectEqualStrings("Bohren & der Club of Gore", t.artist); try testing.expectEqualStrings("Black Earth", t.album); @@ -707,8 +770,8 @@ test "flac comment count of 4 billion allocates nothing" { const a = arena.allocator(); const bytes = flacFile(a, &.{"TITLE=Real"}, 0xFFFF_FFFF); - const t = parse(a, bytes).?; - try testing.expectEqualStrings("Real", t.title); // reads what is actually there + const t = parse(a, bytes, 0).?; + try testing.expectEqualStrings("Real", t.title); } test "flac survives truncation at every byte" { @@ -728,18 +791,18 @@ fn atom(a: std.mem.Allocator, kind: []const u8, body: []const u8) []u8 { fn dataAtom(a: std.mem.Allocator, payload: []const u8) []u8 { var body: std.ArrayList(u8) = .empty; - body.appendSlice(a, &.{ 0, 0, 0, 1, 0, 0, 0, 0 }) catch unreachable; // type + locale + body.appendSlice(a, &.{ 0, 0, 0, 1, 0, 0, 0, 0 }) catch unreachable; body.appendSlice(a, payload) catch unreachable; return atom(a, "data", body.items); } fn mp4File(a: std.mem.Allocator) []u8 { var mvhd: std.ArrayList(u8) = .empty; - mvhd.appendSlice(a, &.{ 0, 0, 0, 0 }) catch unreachable; // version 0 + flags - mvhd.appendSlice(a, &beBytes(0)) catch unreachable; // created - mvhd.appendSlice(a, &beBytes(0)) catch unreachable; // modified - mvhd.appendSlice(a, &beBytes(1000)) catch unreachable; // timescale - mvhd.appendSlice(a, &beBytes(222_000)) catch unreachable; // duration + mvhd.appendSlice(a, &.{ 0, 0, 0, 0 }) catch unreachable; + mvhd.appendSlice(a, &beBytes(0)) catch unreachable; + mvhd.appendSlice(a, &beBytes(0)) catch unreachable; + mvhd.appendSlice(a, &beBytes(1000)) catch unreachable; + mvhd.appendSlice(a, &beBytes(222_000)) catch unreachable; var trkn_body: std.ArrayList(u8) = .empty; trkn_body.appendSlice(a, &.{ 0, 0, 0, 7, 0, 12 }) catch unreachable; @@ -751,7 +814,7 @@ fn mp4File(a: std.mem.Allocator) []u8 { ilst.appendSlice(a, atom(a, "trkn", dataAtom(a, trkn_body.items))) catch unreachable; var meta_body: std.ArrayList(u8) = .empty; - meta_body.appendSlice(a, &.{ 0, 0, 0, 0 }) catch unreachable; // meta is a full atom + meta_body.appendSlice(a, &.{ 0, 0, 0, 0 }) catch unreachable; meta_body.appendSlice(a, atom(a, "ilst", ilst.items)) catch unreachable; var udta: std.ArrayList(u8) = .empty; @@ -772,7 +835,7 @@ test "mp4 reads ilst metadata and mvhd duration" { defer arena.deinit(); const a = arena.allocator(); - const t = parse(a, mp4File(a)).?; + const t = parse(a, mp4File(a), 0).?; try testing.expectEqualStrings("Midnight", t.title); try testing.expectEqualStrings("Bohren", t.artist); try testing.expectEqualStrings("Black Earth", t.album); @@ -785,14 +848,13 @@ test "mp4 atom smaller than its own header terminates the walk" { defer arena.deinit(); const a = arena.allocator(); - // size 4 cannot hold the 8-byte header: a trusting walker spins forever var bytes: std.ArrayList(u8) = .empty; bytes.appendSlice(a, atom(a, "ftyp", "M4A isom")) catch unreachable; bytes.appendSlice(a, &beBytes(4)) catch unreachable; bytes.appendSlice(a, "moov") catch unreachable; bytes.appendSlice(a, &.{ 0, 0, 0, 0 }) catch unreachable; - const t = parse(a, bytes.items).?; + const t = parse(a, bytes.items, 0).?; try testing.expectEqualStrings("", t.title); } @@ -807,7 +869,7 @@ test "mp4 atom claiming more than the file holds is refused" { bytes.appendSlice(a, "moov") catch unreachable; bytes.appendSlice(a, &.{ 0, 0, 0, 0 }) catch unreachable; - try testing.expectEqualStrings("", parse(a, bytes.items).?.title); + try testing.expectEqualStrings("", parse(a, bytes.items, 0).?.title); } test "mp4 nesting past the depth cap stops instead of exhausting the stack" { @@ -815,7 +877,6 @@ test "mp4 nesting past the depth cap stops instead of exhausting the stack" { defer arena.deinit(); const a = arena.allocator(); - // 200 nested moov atoms with the real metadata at the bottom var inner = atom(a, "moov", ""); var i: usize = 0; while (i < 200) : (i += 1) inner = atom(a, "moov", inner); @@ -824,7 +885,7 @@ test "mp4 nesting past the depth cap stops instead of exhausting the stack" { bytes.appendSlice(a, atom(a, "ftyp", "M4A isom")) catch unreachable; bytes.appendSlice(a, inner) catch unreachable; - _ = parse(a, bytes.items); // the assertion is that this returns at all + _ = parse(a, bytes.items, 0); } test "mp4 survives truncation at every byte" { @@ -856,11 +917,11 @@ test "ogg/opus comment header is found and parsed" { var bytes: std.ArrayList(u8) = .empty; bytes.appendSlice(a, "OggS") catch unreachable; - bytes.appendSlice(a, &([_]u8{0} ** 24)) catch unreachable; + bytes.appendSlice(a, &@as([24]u8, @splat(0))) catch unreachable; bytes.appendSlice(a, "OpusTags") catch unreachable; bytes.appendSlice(a, vc.items) catch unreachable; - const t = parse(a, bytes.items).?; + const t = parse(a, bytes.items, 0).?; try testing.expectEqualStrings("Opus Song", t.title); try testing.expectEqualStrings("Someone", t.artist); survivesEveryTruncation(a, bytes.items); @@ -871,11 +932,11 @@ test "files that are not audio yield nothing rather than garbage" { defer arena.deinit(); const a = arena.allocator(); - try testing.expect(parse(a, "") == null); - try testing.expect(parse(a, "not an audio file at all") == null); - try testing.expect(parse(a, &[_]u8{0xFF} ** 64) == null); - try testing.expect(parse(a, "ID3") == null); // truncated magic - try testing.expect(parse(a, "ID3\x02\x00\x00\x00\x00\x00\x00") == null); // v2.2 unsupported + try testing.expect(parse(a, "", 0) == null); + try testing.expect(parse(a, "not an audio file at all", 0) == null); + try testing.expect(parse(a, &@as([64]u8, @splat(0xFF)), 0) == null); + try testing.expect(parse(a, "ID3", 0) == null); + try testing.expect(parse(a, "ID3\x02\x00\x00\x00\x00\x00\x00", 0) == null); } test "fromPath derives artist, album, track and title from the layout" { @@ -893,7 +954,6 @@ test "fromPath derives artist, album, track and title from the layout" { try testing.expectEqualStrings("tone", bare.title); try testing.expectEqualStrings("", bare.artist); - // a title that is only digits keeps its name instead of becoming empty const numeric = fromPath(a, "/music/A/B/1979.mp3"); try testing.expectEqualStrings("1979", numeric.title); } @@ -910,7 +970,6 @@ test "random mutations of valid files never panic, hang or leak past bounds" { mp4File(a), }; - // deterministic so a failure reproduces from the seed var prng = std.Random.DefaultPrng.init(0x5EED_1234); const rand = prng.random(); @@ -918,7 +977,6 @@ test "random mutations of valid files never panic, hang or leak past bounds" { var round: usize = 0; while (round < 300) : (round += 1) { const copy = a.dupe(u8, seed) catch unreachable; - // favour the header region, where the length fields live const flips = 1 + rand.uintLessThan(usize, 8); var f: usize = 0; while (f < flips) : (f += 1) { @@ -928,7 +986,87 @@ test "random mutations of valid files never panic, hang or leak past bounds" { rand.uintLessThan(usize, copy.len); copy[at] = rand.int(u8); } - _ = parse(a, copy); + _ = parse(a, copy, 0); } } } + +test "mp3 duration from a CBR stream when TLEN is absent" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var bytes: std.ArrayList(u8) = .empty; + try bytes.appendSlice(a, id3(a, 3, &.{id3Frame(a, "TIT2", 3, "\x03No TLEN here", null)})); + try bytes.appendSlice(a, &cbrFrame); + try bytes.appendSlice(a, &cbrFrame); + + const t = parse(a, bytes.items, 1_600_000).?; + try testing.expectEqualStrings("No TLEN here", t.title); + try testing.expectApproxEqAbs(@as(f64, 100), @as(f64, @floatFromInt(t.duration_s)), 2); +} + +test "mp3 duration prefers an exact Xing frame count over the CBR estimate" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var frame: std.ArrayList(u8) = .empty; + try frame.appendSlice(a, &xingFrame(3830)); + try frame.appendSlice(a, &cbrFrame); + + const t = parse(a, frame.items, 99_999_999).?; + try testing.expectApproxEqAbs(@as(f64, 100), @as(f64, @floatFromInt(t.duration_s)), 1); +} + +test "mp3 duration skips a lone sync pattern that no frame follows" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var bytes: std.ArrayList(u8) = .empty; + try bytes.appendSlice(a, id3(a, 3, &.{id3Frame(a, "TIT2", 3, "\x03Sock", null)})); + try bytes.appendSlice(a, &.{ 0xFF, 0xFB, 0x90, 0x00 }); + try bytes.appendSlice(a, &@as([600]u8, @splat(0x55))); + try bytes.appendSlice(a, &xingFrame(3830)); + try bytes.appendSlice(a, &cbrFrame); + + const t = parse(a, bytes.items, 99_999_999).?; + try testing.expectApproxEqAbs(@as(f64, 100), @as(f64, @floatFromInt(t.duration_s)), 1); +} + +const cbrFrame = [_]u8{ 0xFF, 0xFB, 0x90, 0x00 } ++ @as([413]u8, @splat(0)); + +fn xingFrame(frames: u32) [417]u8 { + var f = cbrFrame; + @memcpy(f[36..40], "Xing"); + std.mem.writeInt(u32, f[40..44], 1, .big); + std.mem.writeInt(u32, f[44..48], frames, .big); + return f; +} + +test "mp3 duration declines rather than guessing on junk headers" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var bad: std.ArrayList(u8) = .empty; + try bad.appendSlice(a, &.{ 0xFF, 0xFB, 0xF0, 0x00 }); + try bad.appendSlice(a, &@as([64]u8, @splat(0))); + try testing.expect(parse(a, bad.items, 1_000_000) == null); + + var layer2: std.ArrayList(u8) = .empty; + try layer2.appendSlice(a, &.{ 0xFF, 0xFD, 0x90, 0x00 }); + try layer2.appendSlice(a, &@as([64]u8, @splat(0))); + try testing.expect(parse(a, layer2.items, 1_000_000) == null); + + var cbr: std.ArrayList(u8) = .empty; + try cbr.appendSlice(a, &.{ 0xFF, 0xFB, 0x90, 0x00 }); + try cbr.appendSlice(a, &@as([64]u8, @splat(0))); + try testing.expect(parse(a, cbr.items, 0) == null); + + const tagged = id3(a, 3, &.{id3Frame(a, "TIT2", 3, "\x03Still Titled", null)}); + const t = parse(a, tagged, 0).?; + try testing.expectEqualStrings("Still Titled", t.title); + try testing.expectEqual(@as(u32, 0), t.duration_s); +} diff --git a/src/tests.zig b/src/tests.zig index 03bf6ee..26e03d6 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -1,4 +1,3 @@ -// Test aggregator: importing a module here pulls in its colocated tests. test { _ = @import("theme.zig"); _ = @import("history.zig"); @@ -14,4 +13,5 @@ test { _ = @import("library.zig"); _ = @import("feed.zig"); _ = @import("stream.zig"); + _ = @import("vis.zig"); } diff --git a/src/text.zig b/src/text.zig index 4c81c6c..03c874a 100644 --- a/src/text.zig +++ b/src/text.zig @@ -1,6 +1,5 @@ const std = @import("std"); -/// Remote text reaches a raw-mode terminal and tab-delimited files: control bytes become spaces, malformed UTF-8 is dropped. pub fn sanitize(arena: std.mem.Allocator, s: []const u8) ![]const u8 { var out: std.ArrayList(u8) = .empty; try out.ensureTotalCapacity(arena, s.len); @@ -30,10 +29,9 @@ pub fn sanitize(arena: std.mem.Allocator, s: []const u8) ![]const u8 { i += 1; continue; }; - // C1 controls (U+0080–U+009F) drive a terminal much like the C0 set if (cp >= 0x80 and cp <= 0x9f) { out.appendAssumeCapacity(' '); - } else { + } else if (isBidiControl(cp)) {} else { out.appendSliceAssumeCapacity(s[i .. i + len]); } i += len; @@ -41,11 +39,42 @@ pub fn sanitize(arena: std.mem.Allocator, s: []const u8) ![]const u8 { return out.toOwnedSlice(arena); } -/// sanitize + dupe in one step for callers that own an arena and want a stored copy. +fn isBidiControl(cp: u21) bool { + return (cp >= 0x202a and cp <= 0x202e) or (cp >= 0x2066 and cp <= 0x2069); +} + pub fn sanitizeDupe(arena: std.mem.Allocator, s: []const u8) ?[]const u8 { return sanitize(arena, s) catch null; } +const Range = struct { u21, u21 }; + +const zero_ranges = [_]Range{ + .{ 0x300, 0x36f }, .{ 0x1160, 0x11ff }, .{ 0x1ab0, 0x1aff }, .{ 0x1dc0, 0x1dff }, + .{ 0x200b, 0x200f }, .{ 0x20d0, 0x20ff }, .{ 0xfe00, 0xfe0f }, .{ 0xfe20, 0xfe2f }, + .{ 0xfeff, 0xfeff }, +}; + +const wide_ranges = [_]Range{ + .{ 0x1100, 0x115f }, .{ 0x2e80, 0x303e }, .{ 0x3041, 0x33ff }, .{ 0x3400, 0x4dbf }, + .{ 0x4e00, 0x9fff }, .{ 0xa000, 0xa4cf }, .{ 0xa960, 0xa97f }, .{ 0xac00, 0xd7a3 }, + .{ 0xf900, 0xfaff }, .{ 0xfe10, 0xfe19 }, .{ 0xfe30, 0xfe6f }, .{ 0xff00, 0xff60 }, + .{ 0xffe0, 0xffe6 }, .{ 0x1f300, 0x1f64f }, .{ 0x1f680, 0x1f6ff }, .{ 0x1f900, 0x1f9ff }, + .{ 0x20000, 0x3fffd }, +}; + +fn inRanges(cp: u21, ranges: []const Range) bool { + for (ranges) |r| if (cp >= r[0] and cp <= r[1]) return true; + return false; +} + +pub fn cpWidth(cp: u21) usize { + if (cp < 0x20 or (cp >= 0x7f and cp < 0xa0)) return 0; + if (cp < 0x300) return 1; + if (inRanges(cp, &zero_ranges)) return 0; + return if (inRanges(cp, &wide_ranges)) 2 else 1; +} + const testing = std.testing; test "sanitize neutralizes terminal escapes and row separators" { @@ -65,8 +94,15 @@ test "sanitize drops malformed UTF-8 instead of passing it through" { defer arena.deinit(); const a = arena.allocator(); - try testing.expectEqualStrings("motrhead", try sanitize(a, "mot\xfcrhead")); // latin-1 ü - try testing.expectEqualStrings("ab", try sanitize(a, "a\x80b")); // stray continuation - try testing.expectEqualStrings("ab", try sanitize(a, "a\xf0\x9fb")); // truncated 4-byte - try testing.expectEqualStrings(" ", try sanitize(a, "\xc2\x9b")); // C1 CSI + try testing.expectEqualStrings("motrhead", try sanitize(a, "mot\xfcrhead")); + try testing.expectEqualStrings("ab", try sanitize(a, "a\x80b")); + try testing.expectEqualStrings("ab", try sanitize(a, "a\xf0\x9fb")); + try testing.expectEqualStrings(" ", try sanitize(a, "\xc2\x9b")); +} + +test "sanitize drops bidi overrides so a title cannot reorder its row" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const got = try sanitize(arena.allocator(), "song\u{202e}3pm.exe\u{2066}x\u{2069}"); + try std.testing.expectEqualStrings("song3pm.exex", got); } diff --git a/src/theme.zig b/src/theme.zig index 755e7f3..759f6de 100644 --- a/src/theme.zig +++ b/src/theme.zig @@ -152,7 +152,7 @@ test "indexOf matches all and only declared themes" { try testing.expectEqual(@as(?usize, 0), indexOf("red")); try testing.expectEqual(@as(?usize, 1), indexOf("cyan")); try testing.expectEqual(@as(?usize, all.len - 1), indexOf("solarized")); - try testing.expect(indexOf("yt") == null); // alias is not a declared name + try testing.expect(indexOf("yt") == null); } test "names is generated from the all table" { diff --git a/src/track.zig b/src/track.zig index acbfe19..5416d78 100644 --- a/src/track.zig +++ b/src/track.zig @@ -1,29 +1,29 @@ const std = @import("std"); -/// Where the bytes come from. pub const Source = enum { youtube, local, url }; pub const Track = struct { source: Source = .youtube, - /// youtube only — the id yt-dlp resolves to a stream video_id: []const u8 = "", - /// local: filesystem path · url: the URL itself. Empty for youtube. uri: []const u8 = "", browse_id: []const u8 = "", title: []const u8, artist: []const u8, album: []const u8 = "", - /// 0 = unknown; YouTube rows and untagged files both hit this. duration_s: u32 = 0, kind: []const u8 = "Song", - /// Artist and album rows have no playable target, and a local row with no path is a corrupt line. pub fn isPlayable(self: Track) bool { return switch (self.source) { .youtube => self.video_id.len > 0, .local, .url => self.uri.len > 0, }; } + + pub fn mediaTitle(self: Track, alloc: std.mem.Allocator) ![]const u8 { + if (self.source != .youtube or self.artist.len == 0) return self.title; + return std.fmt.allocPrint(alloc, "{s} — {s}", .{ self.title, self.artist }); + } }; pub fn sourceName(s: Source) []const u8 { @@ -34,14 +34,12 @@ pub fn sourceName(s: Source) []const u8 { }; } -/// Unknown or missing means youtube: pre-v0.1.7 playlists files have no source column. pub fn sourceByName(name: []const u8) Source { if (std.mem.eql(u8, name, "local")) return .local; if (std.mem.eql(u8, name, "url")) return .url; return .youtube; } -/// True for arguments meant to be handed to the player as-is rather than searched. pub fn isUrl(s: []const u8) bool { inline for (.{ "http://", "https://", "file://" }) |scheme| { if (std.mem.startsWith(u8, s, scheme)) return true; @@ -49,7 +47,6 @@ pub fn isUrl(s: []const u8) bool { return false; } -/// Display string to seconds; anything unparseable is 0, never an error. pub fn parseDuration(s: []const u8) u32 { var total: u32 = 0; var part: u32 = 0; @@ -88,7 +85,6 @@ test "isPlayable follows the source, not just video_id" { try testing.expect(local.isPlayable()); try testing.expect(!(Track{ .source = .local, .title = "t", .artist = "a" }).isPlayable()); - // a local row must not qualify just because some video_id lingers on it try testing.expect(!(Track{ .source = .local, .video_id = "abc", .title = "t", .artist = "a" }).isPlayable()); } diff --git a/src/ui.zig b/src/ui.zig index 9b187e2..e0c2a6d 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1,10 +1,8 @@ const std = @import("std"); const posix = std.posix; -const c = @cImport({ - @cDefine("_FORTIFY_SOURCE", "0"); - @cInclude("unistd.h"); -}); +const fsutil = @import("fsutil.zig"); +const c = fsutil.c; const api = @import("api.zig"); const stream = @import("stream.zig"); @@ -16,8 +14,10 @@ const track_mod = @import("track.zig"); const library = @import("library.zig"); const feed = @import("feed.zig"); const theme_mod = @import("theme.zig"); +const text_mod = @import("text.zig"); const config = @import("config.zig"); const log = @import("log.zig"); +const vis = @import("vis.zig"); const VERSION = @import("build_options").version; const STDIN: posix.fd_t = 0; @@ -31,8 +31,20 @@ const MAX_RESULTS: usize = 50; const VIS_BARS: usize = 28; const TICK_MS_PLAYING: i32 = 100; const TICK_MS_IDLE: i32 = 250; +const TICK_MS_VIS: i32 = vis.TICK_MS; +const MAX_FLIPBOOKS = 32; +const QUEUE_PANEL_COLS = 30; +const MOUSE_ON = "\x1b[?1000h\x1b[?1006h"; +const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l"; +const WHEEL_ROWS = 2; +const SEEK_SHORT_S: f64 = 10; +const SEEK_LONG_S: f64 = 60; +const VOLUME_STEP: f64 = 5; +const PASTE_ON = "\x1b[?2004h"; +const PASTE_OFF = "\x1b[?2004l"; +const MAX_PASTE = 4096; +const MAX_MODE_HITS = 12; var shuffle_salt: u64 = 0; -/// How long the splash lingers if nobody presses anything. const SPLASH_MS: i32 = 1600; const TYPING_STATUS = "type to search · ↑/↓ pick · ⏎ go · ^L library · ^Q queue · ^C quit"; @@ -40,7 +52,6 @@ const TYPING_STATUS = "type to search · ↑/↓ pick · ⏎ go · ^L library · const Phase = enum { typing, results, picker, queue }; const Repeat = enum { off, track, queue }; -/// What ⏎ searches. Independent of the ^T type filter. const Scope = enum { both, youtube, @@ -102,10 +113,8 @@ const State = struct { in_feeds: bool = false, feeds_path: []const u8 = "", feed_title: []const u8 = "", - /// set while a library scan is walking; drives the body panel scanning: bool = false, scan_count: usize = 0, - /// library matches shown above the suggestions while typing local_hits: []api.Track = &.{}, env: ?*std.process.Environ.Map = null, @@ -134,10 +143,32 @@ const State = struct { queue_from: Phase = .typing, now_track: ?api.Track = null, connecting: bool = false, + ytdlp_age: ?u32 = null, + ytdlp_checked: bool = false, volume: f64 = 100, repeat: Repeat = .off, tick: u64 = 0, + mouse: bool = true, + body_top: usize = 0, + body_rows: usize = 0, + np_top: usize = 0, + np_bar_cols: usize = 0, + mode_row: ?usize = null, + mode_hits: [MAX_MODE_HITS]ModeHit = undefined, + mode_hit_len: usize = 0, + vis_rect: Rect = .{}, + panel_rect: Rect = .{}, + panel_top: ?usize = null, + drag_from: ?usize = null, + vis_view: bool = false, + vis_idx: usize = 0, + vis_motion: vis.Motion = .{}, + vis_bufs: vis.Buffers = .{}, + vis_audio: vis.Audio = .{}, + vis_audio_tick: u64 = 0, + flipbooks: []const vis.Flipbook = &.{}, + vis_arena: std.heap.ArenaAllocator = undefined, search_arena: std.heap.ArenaAllocator = undefined, @@ -149,10 +180,8 @@ const State = struct { lib_arena: std.heap.ArenaAllocator = undefined, - /// the library index: outlives search_arena, which the row lists use idx_arena: std.heap.ArenaAllocator = undefined, - /// Not sug_arena: suggestion and history refreshes reset that one. hit_arena: std.heap.ArenaAllocator = undefined, draw_buf: std.ArrayList(u8) = .empty, @@ -182,7 +211,7 @@ pub fn run( var state: State = .{ .theme = theme, .theme_idx = theme_idx }; const splash_off = blk: { const p = config.path(arena, env) catch break :blk false; - const v = config.loadKey(arena, p, "splash") orelse break :blk false; + const v = config.loadKey(arena, p, .splash) orelse break :blk false; break :blk std.mem.eql(u8, v, "off"); }; if (!splash_off) showSplash(theme) catch {}; @@ -201,11 +230,23 @@ pub fn run( reloadPlaylists(&state); state.config_path = config.path(arena, env) catch ""; if (state.config_path.len > 0) { - if (config.loadKey(arena, state.config_path, "volume")) |v| { + if (config.loadKey(arena, state.config_path, .volume)) |v| { state.volume = std.math.clamp(std.fmt.parseFloat(f64, v) catch 100, 0, 100); } - state.music_dir = config.loadKey(arena, state.config_path, "music_dir") orelse ""; - if (config.loadKey(arena, state.config_path, "scope")) |sc| state.scope = Scope.byName(sc); + state.music_dir = config.loadKey(arena, state.config_path, .music_dir) orelse ""; + if (config.loadKey(arena, state.config_path, .scope)) |sc| state.scope = Scope.byName(sc); + } + if (state.config_path.len > 0) { + if (config.loadKey(arena, state.config_path, .mouse)) |m| state.mouse = !std.mem.eql(u8, m, "off"); + } + if (state.mouse) writeAll(MOUSE_ON) catch {}; + defer if (state.mouse) writeAll(MOUSE_OFF) catch {}; + writeAll(PASTE_ON) catch {}; + defer writeAll(PASTE_OFF) catch {}; + state.vis_arena = std.heap.ArenaAllocator.init(gpa); + state.flipbooks = loadFlipbooks(state.vis_arena.allocator(), io, state.config_path); + if (state.config_path.len > 0) { + if (config.loadKey(arena, state.config_path, .visualizer)) |v| state.vis_idx = vis.indexOf(v, state.flipbooks) orelse 0; } if (state.hist_path.len > 0) { state.hist = history.load(state.hist_arena.allocator(), state.hist_path) catch &.{}; @@ -223,6 +264,8 @@ pub fn run( state.sug_arena.deinit(); state.play_arena.deinit(); state.hist_arena.deinit(); + state.vis_bufs.deinit(gpa); + state.vis_arena.deinit(); if (state.pl) |p| { p.deinit(); gpa.destroy(p); @@ -233,7 +276,7 @@ pub fn run( draw(gpa, &state) catch |err| survive(&state, "draw", err); const playing = state.pl != null and (state.pl.?.has_track); - const timeout: i32 = if (playing) TICK_MS_PLAYING else TICK_MS_IDLE; + const timeout: i32 = if (playing and state.vis_view) TICK_MS_VIS else if (playing) TICK_MS_PLAYING else TICK_MS_IDLE; if (state.pl) |p| { while (true) { @@ -258,9 +301,6 @@ pub fn run( } } -/// Log a recoverable failure and show it; never unwind past the alt-screen teardown. - -/// Startup splash; `splash=off` in the config skips it. fn showSplash(th: theme_mod.Theme) !void { const art = [_][]const u8{ "██╗ ██╗██╗ ██╗███╗ ███╗", @@ -280,7 +320,6 @@ fn showSplash(th: theme_mod.Theme) !void { try buf.appendSlice(a, "\x1b[2J\x1b[H"); if (sz.cols < art_cols + 4 or sz.rows < 12) { - // no room to be fancy try buf.print(a, "\r\n {s}♫ hum{s} — terminal media player\r\n", .{ th.accent_strong, th.reset }); try buf.print(a, " {s}v{s}{s}\r\n", .{ th.dim, VERSION, th.reset }); } else { @@ -317,6 +356,7 @@ fn showSplash(th: theme_mod.Theme) !void { fn survive(state: *State, what: []const u8, err: anyerror) void { log.write("{s} failed: {s} phase={s}", .{ what, @errorName(err), @tagName(state.phase) }); + if (err == error.MpvMissing) return setStatus(state, "libmpv not found: install mpv", .{}); setStatus(state, "{s} failed: {s} (see log)", .{ what, @errorName(err) }); } @@ -342,9 +382,78 @@ const Key = union(enum) { end, ctrl_c, ctrl: u8, + mouse: Mouse, + paste: []const u8, unknown, }; +const Mouse = struct { button: u16, x: u16, y: u16, press: bool }; + +const Rect = struct { + x: usize = 0, + y: usize = 0, + w: usize = 0, + h: usize = 0, + + fn contains(r: Rect, col: usize, row: usize) bool { + return col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h; + } +}; + +const ModeHit = struct { + x: usize, + w: usize, + target: union(enum) { scope: Scope, filter: api.Filter }, +}; + +var paste_buf: [MAX_PASTE]u8 = undefined; + +fn readPaste() !Key { + const end = "\x1b[201~"; + var n: usize = 0; + var matched: usize = 0; + while (true) { + if (!(try waitInput(200))) break; + var one: [1]u8 = undefined; + if ((try posix.read(STDIN, &one)) == 0) break; + if (one[0] == end[matched]) { + matched += 1; + if (matched == end.len) break; + continue; + } + for (end[0..matched]) |b| { + if (n < paste_buf.len) paste_buf[n] = b; + n += 1; + } + matched = if (one[0] == end[0]) 1 else 0; + if (matched == 0) { + if (n < paste_buf.len) paste_buf[n] = one[0]; + n += 1; + } + } + return Key{ .paste = paste_buf[0..@min(n, paste_buf.len)] }; +} + +fn readMouse() !Key { + var buf: [24]u8 = undefined; + var n: usize = 0; + while (n < buf.len) { + if (!(try waitInput(20))) return .unknown; + var one: [1]u8 = undefined; + if ((try posix.read(STDIN, &one)) == 0) return .unknown; + if (one[0] == 'M' or one[0] == 'm') { + var it = std.mem.splitScalar(u8, buf[0..n], ';'); + const b = std.fmt.parseInt(u16, it.next() orelse return .unknown, 10) catch return .unknown; + const x = std.fmt.parseInt(u16, it.next() orelse return .unknown, 10) catch return .unknown; + const y = std.fmt.parseInt(u16, it.next() orelse return .unknown, 10) catch return .unknown; + return Key{ .mouse = .{ .button = b, .x = x, .y = y, .press = one[0] == 'M' } }; + } + buf[n] = one[0]; + n += 1; + } + return .unknown; +} + fn readKey(buf: *[8]u8) !Key { var one: [1]u8 = undefined; if ((try posix.read(STDIN, &one)) == 0) return .unknown; @@ -378,6 +487,16 @@ fn readEscape(_: *[8]u8) !Key { if (!(try waitInput(20))) return .escape; var c2: [1]u8 = undefined; if ((try posix.read(STDIN, &c2)) == 0) return .escape; + if (c2[0] == '<') return readMouse(); + if (c2[0] == '2') { + var tail: [3]u8 = undefined; + for (&tail, 0..) |*b, i| { + if (!(try waitInput(20))) return .unknown; + if ((try posix.read(STDIN, b[0..1])) == 0) return .unknown; + if (b.* == '~' and i < 2) return .unknown; + } + return if (std.mem.eql(u8, &tail, "00~")) readPaste() else .unknown; + } return switch (c2[0]) { 'A' => .up, 'B' => .down, @@ -410,26 +529,26 @@ fn handleKey( switch (key) { .ctrl => |b| switch (b) { - 0x10 => { // Ctrl+P + 0x10 => { if (state.pl) |p| _ = p.togglePause(); return true; }, - 0x0e => { // Ctrl+N + 0x0e => { try queueAdvance(gpa, arena, io, state, false); return true; }, - 0x13 => { // Ctrl+S + 0x13 => { if (state.pl) |p| { p.stop(); state.now_track = null; } return true; }, - 0x01 => { // Ctrl+A — save the playing track from any screen + 0x01 => { if (state.now_track) |t| openPicker(state, t) else state.status = "nothing playing to save"; return true; }, - 0x12 => { // Ctrl+R + 0x12 => { state.repeat = switch (state.repeat) { .off => .track, .track => .queue, @@ -442,28 +561,23 @@ fn handleKey( }; return true; }, - 0x05 => { // Ctrl+E — cycle what ⏎ searches - state.scope = state.scope.next(); - if (state.scope == .library and state.music_dir.len == 0) { - state.status = "search: library — but no music_dir set (hum library dir ~/Music)"; - } else { - setStatus(state, "search: {s}", .{state.scope.label()}); - } - // rebuild now so suggestions from the old scope do not linger - refreshSuggestionsLocal(state) catch {}; - state.last_fetched_len = 0; - if (state.config_path.len > 0) { - config.saveKey(arena, state.config_path, "scope", state.scope.label()) catch {}; - } + 0x05 => { + setScope(arena, state, state.scope.next()); return true; }, - 0x19 => { // Ctrl+Y + 0x16 => { + if (state.vis_view) { + state.vis_view = false; + } else if (state.now_track != null) { + state.vis_view = true; + } else state.status = "nothing playing to visualize"; + return true; + }, + 0x19 => { state.theme_idx = (state.theme_idx + 1) % theme_mod.all.len; state.theme = theme_mod.all[state.theme_idx].theme; state.status = theme_mod.all[state.theme_idx].name; - if (state.config_path.len > 0) { - config.saveTheme(arena, state.config_path, theme_mod.all[state.theme_idx].name); - } + saveSetting(arena, state, .theme, theme_mod.all[state.theme_idx].name); return true; }, else => {}, @@ -471,6 +585,17 @@ fn handleKey( else => {}, } + if (key == .mouse) return handleMouse(gpa, arena, io, state, key.mouse); + if (key == .paste) { + if (state.vis_view or (state.phase != .typing and state.phase != .picker)) return true; + var fba_buf: [MAX_PASTE * 2]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&fba_buf); + const clean = std.mem.trim(u8, text_mod.sanitize(fba.allocator(), key.paste) catch return true, " "); + if (clean.len == 0) return true; + return dispatchPhase(gpa, arena, io, state, .{ .text = clean }); + } + if (state.vis_view and state.now_track != null) return handleVis(arena, state, key); + return switch (state.phase) { .typing => try handleTyping(gpa, arena, io, state, key), .results => try handleResults(gpa, arena, io, state, key), @@ -494,7 +619,7 @@ fn handleTyping( switch (key) { .ctrl_c => return false, .ctrl => |b| switch (b) { - 0x18 => if (state.sel_row) |row| { // Ctrl+X — delete the selected playlist or past search + 0x18 => if (state.sel_row) |row| { if (on_playlist) { const idx = pls[row]; if (state.pl_del_pending) { @@ -514,21 +639,16 @@ fn handleTyping( } } }, - 0x0C => try openLibrary(gpa, io, state, false), // Ctrl+L - 0x06 => try openFeeds(gpa, io, state), // Ctrl+F - 0x11 => openQueue(state), // Ctrl+Q - 0x14 => { // Ctrl+T - // videos has no local meaning - state.filter = switch (state.filter) { - .all => .songs, - .songs => if (state.scope == .library) .albums else .videos, - .videos => .albums, - .albums => .artists, - .artists => .all, - }; - state.status = state.filter.label(); - if (state.scope == .library) refreshLocalHits(state); - }, + 0x0C => try openLibrary(gpa, io, state, false), + 0x06 => try openFeeds(gpa, io, state), + 0x11 => openQueue(state), + 0x14 => setFilter(state, switch (state.filter) { + .all => .songs, + .songs => if (state.scope == .library) .albums else .videos, + .videos => .albums, + .albums => .artists, + .artists => .all, + }), else => {}, }, .escape => { @@ -608,11 +728,11 @@ fn handleResults( .end => state.sel_track = max_idx, .enter => try activateSelected(gpa, arena, io, state), .ctrl => |b| switch (b) { - 0x06 => state.sel_track = @min(state.sel_track + 10, max_idx), // Ctrl+F - 0x02 => state.sel_track = if (state.sel_track > 10) state.sel_track - 10 else 0, // Ctrl+B - 0x0C => try openLibrary(gpa, io, state, state.in_library), // Ctrl+L - 0x11 => openQueue(state), // Ctrl+Q - 0x18 => if (state.view_pl != null) removeFromPlaylist(gpa, state), // Ctrl+X + 0x06 => state.sel_track = @min(state.sel_track + 10, max_idx), + 0x02 => state.sel_track = if (state.sel_track > 10) state.sel_track - 10 else 0, + 0x0C => try openLibrary(gpa, io, state, state.in_library), + 0x11 => openQueue(state), + 0x18 => if (state.view_pl != null) removeFromPlaylist(gpa, state), else => {}, }, .text => |t| { @@ -633,33 +753,7 @@ fn handleResults( 'l' => try activateSelected(gpa, arena, io, state), 'g' => state.sel_track = 0, 'G' => state.sel_track = max_idx, - ' ' => if (state.pl) |p| { - _ = p.togglePause(); - }, - '[' => if (state.pl) |p| { - p.seekRelative(-10); - }, - ']' => if (state.pl) |p| { - p.seekRelative(10); - }, - '{' => if (state.pl) |p| { - p.seekRelative(-60); - }, - '}' => if (state.pl) |p| { - p.seekRelative(60); - }, - '-', '_' => if (state.pl) |p| { - state.volume = p.nudgeVolume(-5); - persistVolume(arena, state); - }, - '=', '+' => if (state.pl) |p| { - state.volume = p.nudgeVolume(5); - persistVolume(arena, state); - }, - 'm' => if (state.pl) |p| { - _ = p.toggleMute(); - }, - else => {}, + else => _ = mediaKey(arena, state, t[0]), } } }, @@ -677,16 +771,14 @@ fn backToTyping(state: *State) void { state.lib_album = ""; if (state.view_pl != null) { state.view_pl = null; - state.tracks = &.{}; // tracks were borrowed from lib_arena + state.tracks = &.{}; } - // suggestions point into hist_arena, which a search since then may have recycled refreshSuggestionsLocal(state) catch { state.suggestions = &.{}; }; state.status = TYPING_STATUS; } -/// Reload the history file, then rebuild the suggestions that borrow from it. fn reloadHistory(state: *State) void { if (state.hist_path.len == 0) return; _ = state.hist_arena.reset(.retain_capacity); @@ -724,7 +816,6 @@ fn setStatus(state: *State, comptime fmt: []const u8, args: anytype) void { const MAX_PL_ROWS = 64; -/// Playlists whose name matches the current query, newest section of the typing view. fn matchedPlaylists(state: *State, buf: []usize) []usize { var n: usize = 0; for (state.playlists, 0..) |e, i| { @@ -748,7 +839,6 @@ fn selectedSuggestion(state: *State) ?[]const u8 { return if (i < state.suggestions.len) state.suggestions[i] else null; } -/// Which local file the cursor is on, if it is on one at all. fn selectedLocalHit(state: *State) ?usize { const row = state.sel_row orelse return null; var buf: [MAX_PL_ROWS]usize = undefined; @@ -758,7 +848,6 @@ fn selectedLocalHit(state: *State) ?usize { return if (i < state.local_hits.len) i else null; } -/// Play a local hit; the whole match list becomes the queue. fn playLocalHit(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, state: *State, idx: usize) !void { _ = state.play_arena.reset(.retain_capacity); const pa = state.play_arena.allocator(); @@ -913,7 +1002,6 @@ fn handlePicker(gpa: std.mem.Allocator, state: *State, key: Key) !bool { return true; } -/// Kept in State, not an arena: sug_arena is reset on every refresh. fn lastFetched(state: *const State) []const u8 { return state.last_fetched_buf[0..state.last_fetched_len]; } @@ -923,20 +1011,17 @@ fn setLastFetched(state: *State, q: []const u8) void { @memcpy(state.last_fetched_buf[0..state.last_fetched_len], q[0..state.last_fetched_len]); } -/// Cache only, never a scan: typing must not block on the filesystem. fn ensureIndexCached(state: *State) void { if (state.lib_ready or state.music_dir.len == 0 or state.lib_cache_path.len == 0) return; const cached = library.loadCache(state.idx_arena.allocator(), state.lib_cache_path); - // a cache built from other roots describes a different library if (!cached.matches(state.music_dir)) return; state.lib_entries = cached.entries; state.lib_ready = true; } -/// Re-read music_dir so a change lands without a restart. fn refreshMusicDir(state: *State, arena: std.mem.Allocator) void { if (state.config_path.len == 0) return; - const now = config.loadKey(arena, state.config_path, "music_dir") orelse ""; + const now = config.loadKey(arena, state.config_path, .music_dir) orelse ""; if (std.mem.eql(u8, now, state.music_dir)) return; state.music_dir = now; dropIndexViews(state); @@ -966,7 +1051,6 @@ fn refreshSuggestionsLocal(state: *State) !void { state.scroll_row = 0; } -/// One pool, closest first: history, library names, then remote suggestions. fn mergeSuggestions( a: std.mem.Allocator, state: *State, @@ -996,7 +1080,6 @@ fn refreshSuggestions(gpa: std.mem.Allocator, io: std.Io, state: *State) !void { const a = state.sug_arena.allocator(); setLastFetched(state, state.query.items); const local = try history.match(a, state.hist, state.query.items, MAX_SUGGESTIONS); - // library-only scope keeps your own history but asks YouTube nothing const remote: []const []const u8 = if (state.scope.wantsYoutube()) suggest.fetch(a, gpa, io, state.query.items, MAX_SUGGESTIONS) catch &.{} else @@ -1048,7 +1131,6 @@ fn runSearch(gpa: std.mem.Allocator, io: std.Io, state: *State) !void { state.status = "↑↓/jk pick · g/G ⌂⌃ · ⏎/l play · h back"; } -/// Library search fills the results list; `h` returns to typing, not up a level. fn runLibrarySearch(state: *State, query: []const u8) !void { ensureIndexCached(state); if (!state.lib_ready) { @@ -1062,7 +1144,6 @@ fn runLibrarySearch(state: *State, query: []const u8) !void { _ = state.search_arena.reset(.retain_capacity); const sa = state.search_arena.allocator(); - // ^T applies here too: songs are tracks, albums and artists drill in const rows: []api.Track = switch (state.filter) { .albums => blk: { const albums = library.albumsMatching(sa, state.lib_entries, query, MAX_RESULTS) catch &.{}; @@ -1120,16 +1201,236 @@ fn runLibrarySearch(state: *State, query: []const u8) !void { setStatus(state, "{d} local {s} · ⏎ open · h back", .{ rows.len, state.filter.label() }); } +fn mediaKey(arena: std.mem.Allocator, state: *State, ch: u8) bool { + const p = state.pl orelse return false; + switch (ch) { + ' ' => _ = p.togglePause(), + '[' => p.seekRelative(-SEEK_SHORT_S), + ']' => p.seekRelative(SEEK_SHORT_S), + '{' => p.seekRelative(-SEEK_LONG_S), + '}' => p.seekRelative(SEEK_LONG_S), + '-', '_' => { + state.volume = p.nudgeVolume(-VOLUME_STEP); + persistVolume(arena, state); + }, + '=', '+' => { + state.volume = p.nudgeVolume(VOLUME_STEP); + persistVolume(arena, state); + }, + 'm' => _ = p.toggleMute(), + else => return false, + } + return true; +} + +fn handleVis(arena: std.mem.Allocator, state: *State, key: Key) bool { + switch (key) { + .ctrl_c => return false, + .escape => state.vis_view = false, + .up => _ = mediaKey(arena, state, '='), + .down => _ = mediaKey(arena, state, '-'), + .left => _ = mediaKey(arena, state, '['), + .right => _ = mediaKey(arena, state, ']'), + .text => |t| if (t.len == 1) switch (t[0]) { + 'v' => cycleVis(arena, state, 1), + 'V' => cycleVis(arena, state, -1), + else => _ = mediaKey(arena, state, t[0]), + }, + else => {}, + } + return true; +} + +fn setScope(arena: std.mem.Allocator, state: *State, scope: Scope) void { + state.scope = scope; + if (scope == .library and state.music_dir.len == 0) { + state.status = "search: library — but no music_dir set (hum library dir ~/Music)"; + } else { + setStatus(state, "search: {s}", .{scope.label()}); + } + refreshSuggestionsLocal(state) catch {}; + state.last_fetched_len = 0; + saveSetting(arena, state, .scope, scope.label()); +} + +fn setFilter(state: *State, filter: api.Filter) void { + state.filter = if (state.scope == .library and filter == .videos) .all else filter; + state.status = state.filter.label(); + if (state.scope == .library) refreshLocalHits(state); +} + +fn dispatchPhase(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, state: *State, key: Key) !bool { + return switch (state.phase) { + .typing => try handleTyping(gpa, arena, io, state, key), + .results => try handleResults(gpa, arena, io, state, key), + .picker => try handlePicker(gpa, state, key), + .queue => try handleQueue(gpa, arena, io, state, key), + }; +} + +fn handleMouse(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, state: *State, m: Mouse) !bool { + const base = m.button & ~@as(u16, 4 | 8 | 16); + const row: usize = m.y -| 1; + const col: usize = m.x -| 1; + const vis_on = state.vis_view and state.now_track != null; + + if (base == 64 or base == 65) { + const up = base == 64; + if (vis_on and state.panel_rect.contains(col, row)) { + const first = panelFirst(state); + state.panel_top = if (up) first -| 1 else @min(first + 1, state.queue.len -| 1); + } else if (vis_on) { + _ = mediaKey(arena, state, if (up) '=' else '-'); + } else for (0..WHEEL_ROWS) |_| { + if (!try dispatchPhase(gpa, arena, io, state, if (up) .up else .down)) return false; + } + return true; + } + + if (!m.press) return mouseRelease(gpa, arena, io, state, col, row); + state.drag_from = null; + + if (state.now_track != null and state.np_bar_cols > 0 and row == state.np_top + 2 and col >= 2 and col < 2 + state.np_bar_cols) { + if (base == 0) if (state.pl) |p| p.seekPercent(@as(f64, @floatFromInt(col - 2)) * 100.0 / @as(f64, @floatFromInt(state.np_bar_cols))); + return true; + } + if (vis_on) { + if (state.vis_rect.contains(col, row)) { + if (base == 0) cycleVis(arena, state, 1) else if (base == 2) cycleVis(arena, state, -1); + } else if (base == 0) { + state.drag_from = panelIndexAt(state, col, row); + } + return true; + } + if (base != 0) return true; + + if (state.mode_row) |mr| if (row == mr) { + for (state.mode_hits[0..state.mode_hit_len]) |hit| { + if (col >= hit.x and col < hit.x + hit.w) switch (hit.target) { + .scope => |sc| setScope(arena, state, sc), + .filter => |f| setFilter(state, f), + }; + } + return true; + }; + if (row < state.body_top or row >= state.body_top + state.body_rows) return true; + const r = row - state.body_top; + + switch (state.phase) { + .typing => { + const idx = state.scroll_row + r; + if (idx >= typingRowCount(state)) return true; + if (state.sel_row == idx) return dispatchPhase(gpa, arena, io, state, .enter); + state.sel_row = idx; + }, + .results => { + const idx = state.scroll_track + r; + if (idx >= state.tracks.len) return true; + if (state.sel_track == idx) return dispatchPhase(gpa, arena, io, state, .enter); + state.sel_track = idx; + }, + .queue => { + const idx = state.queue_scroll + r; + if (idx < state.queue.len) state.drag_from = idx; + }, + .picker => { + const head: usize = if (state.picker_track != null) 2 else 0; + if (r < head) return true; + const idx = r - head; + if (idx > state.playlists.len) return true; + if (state.picker_sel == idx) return dispatchPhase(gpa, arena, io, state, .enter); + state.picker_sel = idx; + }, + } + return true; +} + +fn mouseRelease(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, state: *State, col: usize, row: usize) !bool { + const from = state.drag_from orelse return true; + state.drag_from = null; + if (state.vis_view and state.now_track != null) { + const to = panelIndexAt(state, col, row) orelse return true; + if (to != from) { + queueMove(state, from, to); + return true; + } + state.queue_idx = from; + try playQueueCurrent(gpa, arena, io, state); + return true; + } + if (state.phase != .queue or row < state.body_top or row >= state.body_top + state.body_rows) return true; + const to = state.queue_scroll + (row - state.body_top); + if (to >= state.queue.len) return true; + if (to != from) { + queueMove(state, from, to); + return true; + } + if (state.queue_sel == to) return dispatchPhase(gpa, arena, io, state, .enter); + state.queue_sel = to; + return true; +} + +fn panelFirst(state: *const State) usize { + return @min(state.panel_top orelse state.queue_idx, state.queue.len -| 1); +} + +fn panelIndexAt(state: *const State, col: usize, row: usize) ?usize { + if (!state.panel_rect.contains(col, row)) return null; + const r = row - state.panel_rect.y; + if (r == 0) return null; + const qi = panelFirst(state) + (r - 1) / 2; + return if (qi < state.queue.len) qi else null; +} + +fn typingRowCount(state: *State) usize { + var pl_buf: [MAX_PL_ROWS]usize = undefined; + return matchedPlaylists(state, &pl_buf).len + state.local_hits.len + state.suggestions.len; +} + +fn cycleVis(arena: std.mem.Allocator, state: *State, dir: i2) void { + const n = vis.count(state.flipbooks); + state.vis_idx = if (dir > 0) (state.vis_idx + 1) % n else (state.vis_idx + n - 1) % n; + saveSetting(arena, state, .visualizer, vis.name(state.vis_idx, state.flipbooks)); +} + +fn loadFlipbooks(arena: std.mem.Allocator, io: std.Io, config_path: []const u8) []const vis.Flipbook { + const base = std.fs.path.dirname(config_path) orelse return &.{}; + const dir_path = std.fmt.allocPrint(arena, "{s}/visualizers", .{base}) catch return &.{}; + var dir = std.Io.Dir.openDirAbsolute(io, dir_path, .{ .iterate = true }) catch return &.{}; + defer dir.close(io); + + var out: std.ArrayList(vis.Flipbook) = .empty; + var it = dir.iterate(); + while (it.next(io) catch null) |entry| { + if (out.items.len >= MAX_FLIPBOOKS) break; + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".vis")) continue; + const full = std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, entry.name }) catch continue; + const bytes = fsutil.readCappedAlloc(arena, full, vis.MAX_FILE_BYTES) orelse continue; + const stem = entry.name[0 .. entry.name.len - ".vis".len]; + const fb = vis.parseFlipbook(arena, bytes, stem) orelse { + log.write("visualizer {s}: no frames found, skipped", .{entry.name}); + continue; + }; + if (vis.indexOf(fb.name, out.items) != null) continue; + out.append(arena, fb) catch break; + } + return out.toOwnedSlice(arena) catch &.{}; +} + fn persistVolume(arena: std.mem.Allocator, state: *State) void { - if (state.config_path.len == 0) return; var buf: [16]u8 = undefined; - const s = std.fmt.bufPrint(&buf, "{d:.0}", .{state.volume}) catch return; - config.saveKey(arena, state.config_path, "volume", s) catch {}; + saveSetting(arena, state, .volume, std.fmt.bufPrint(&buf, "{d:.0}", .{state.volume}) catch return); +} + +fn saveSetting(arena: std.mem.Allocator, state: *State, key: config.Key, value: []const u8) void { + if (state.config_path.len == 0) return; + config.saveKey(arena, state.config_path, key, value) catch |err| log.write("config: saving {s} failed: {s}", .{ @tagName(key), @errorName(err) }); } fn ensurePlayer(gpa: std.mem.Allocator, state: *State) !*player.Player { if (state.pl) |p| return p; const p = try gpa.create(player.Player); + errdefer gpa.destroy(p); p.* = try player.Player.init(); p.setVolume(state.volume); state.pl = p; @@ -1160,7 +1461,6 @@ fn activateSelected(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io } if (std.mem.eql(u8, t.kind, "Album")) { state.lib_album = t.browse_id; - // a search result knows its artist; a browsed row already set it if (state.lib_artist.len == 0) state.lib_artist = t.artist; return showTracks(state); } @@ -1185,7 +1485,6 @@ fn activateSelected(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io // ------------------------------------------------------------------ library -/// Draws progress during a synchronous scan and lets any key abort it. const ScanCtx = struct { state: *State, gpa: std.mem.Allocator, @@ -1204,7 +1503,6 @@ const ScanCtx = struct { } }; -/// Drop everything borrowing from idx_arena (rows, local_hits, breadcrumb) before it is reset. The queue deep-copies into play_arena, so it survives. fn dropIndexViews(state: *State) void { if (state.in_library) { state.tracks = &.{}; @@ -1219,7 +1517,6 @@ fn dropIndexViews(state: *State) void { state.lib_ready = false; } -/// Cache first; scan only on request or when there is no cache. fn ensureIndex(gpa: std.mem.Allocator, io: std.Io, state: *State, force: bool) bool { if (state.lib_ready and !force) return true; @@ -1237,7 +1534,6 @@ fn ensureIndex(gpa: std.mem.Allocator, io: std.Io, state: *State, force: bool) b library.loadCache(ia, state.lib_cache_path) else .{}; - // rows are only worth reusing if they describe the roots we are scanning const cached: []library.Entry = if (from_disk.matches(state.music_dir)) from_disk.entries else &.{}; if (!force and cached.len > 0) { @@ -1262,7 +1558,6 @@ fn ensureIndex(gpa: std.mem.Allocator, io: std.Io, state: *State, force: bool) b return false; }; state.lib_entries = res.entries; - // never cache a partial scan, and leave it unready so the next ^L rescans state.lib_ready = res.complete; if (res.complete and state.lib_cache_path.len > 0) { library.saveCache(ia, state.lib_cache_path, state.music_dir, res.entries) catch {}; @@ -1286,7 +1581,6 @@ fn openLibrary(gpa: std.mem.Allocator, io: std.Io, state: *State, rescan: bool) } } -/// Library views reuse the results list; `kind` decides what ⏎ does. fn enterLibraryRows(state: *State, rows: []api.Track) void { state.phase = .results; state.in_library = true; @@ -1365,7 +1659,6 @@ fn libraryBack(state: *State) !void { backToTyping(state); } -/// Breadcrumb doubles as the section header. fn libraryCrumb(state: *State, buf: []u8) []const u8 { if (state.lib_album.len > 0) { return std.fmt.bufPrint(buf, "library · {s} · {s}", .{ state.lib_artist, state.lib_album }) catch "library"; @@ -1378,7 +1671,6 @@ fn libraryCrumb(state: *State, buf: []u8) []const u8 { // ------------------------------------------------------------------ podcasts -/// Lists subscriptions; fetching happens on open, never here. fn openFeeds(gpa: std.mem.Allocator, io: std.Io, state: *State) !void { _ = gpa; _ = io; @@ -1414,7 +1706,6 @@ fn openFeeds(gpa: std.mem.Allocator, io: std.Io, state: *State) !void { state.status = "podcasts · ⏎ open · h back"; } -/// Fetches a feed and lists its episodes. Blocking, like a search. fn openFeedEpisodes(gpa: std.mem.Allocator, io: std.Io, state: *State, url: []const u8, title: []const u8) !void { state.status = "fetching episodes…"; try draw(gpa, state); @@ -1475,7 +1766,6 @@ fn openQueue(state: *State) void { state.status = "queue · K/J move · d remove · ⏎ play · h back"; } -/// Queue grows as a fresh slice in play_arena, which owns it. fn queueAdd(state: *State, play_next: bool) !void { if (state.tracks.len == 0) return; const t = state.tracks[state.sel_track]; @@ -1497,28 +1787,32 @@ fn queueAdd(state: *State, play_next: bool) !void { setStatus(state, "queued {s}{s}", .{ if (play_next) "next: " else "", t.title }); } -/// queue_idx follows the track across a move, not the position. fn queueMove(state: *State, from: usize, to: usize) void { - if (from >= state.queue.len or to >= state.queue.len or from == to) return; - const tmp = state.queue[from]; - state.queue[from] = state.queue[to]; - state.queue[to] = tmp; - - if (state.queue_idx == from) { + const q = state.queue; + if (from >= q.len or to >= q.len or from == to) return; + const t = q[from]; + if (from < to) { + std.mem.copyForwards(api.Track, q[from..to], q[from + 1 .. to + 1]); + } else { + std.mem.copyBackwards(api.Track, q[to + 1 .. from + 1], q[to..from]); + } + q[to] = t; + const qi = state.queue_idx; + if (qi == from) { state.queue_idx = to; - } else if (state.queue_idx == to) { - state.queue_idx = from; + } else if (from < qi and qi <= to) { + state.queue_idx = qi - 1; + } else if (to <= qi and qi < from) { + state.queue_idx = qi + 1; } state.queue_sel = to; } -/// Shuffles the tail only; the playing track stays put. fn queueShuffle(state: *State) void { if (state.queue.len < 3) { state.status = "not enough queued to shuffle"; return; } - // std.crypto.random is absent in 0.16 and arc4random is not portable across our targets var stack_marker: u8 = 0; shuffle_salt +%= 1; var seed: u64 = @intFromPtr(&stack_marker); @@ -1582,7 +1876,7 @@ fn handleQueue( try playQueueCurrent(gpa, arena, io, state); }, .ctrl => |b| switch (b) { - 0x11 => state.phase = state.queue_from, // Ctrl+Q toggles back + 0x11 => state.phase = state.queue_from, else => {}, }, .text => |t| if (t.len == 1) switch (t[0]) { @@ -1599,21 +1893,7 @@ fn handleQueue( 'h' => state.phase = state.queue_from, 'g' => state.queue_sel = 0, 'G' => state.queue_sel = max_idx, - ' ' => if (state.pl) |p| { - _ = p.togglePause(); - }, - '-', '_' => if (state.pl) |p| { - state.volume = p.nudgeVolume(-5); - persistVolume(arena, state); - }, - '=', '+' => if (state.pl) |p| { - state.volume = p.nudgeVolume(5); - persistVolume(arena, state); - }, - 'm' => if (state.pl) |p| { - _ = p.toggleMute(); - }, - else => {}, + else => _ = mediaKey(arena, state, t[0]), }, else => {}, } @@ -1624,8 +1904,7 @@ fn drawQueue(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize, const n = state.queue.len; if (n == 0) { try drawRow(w, th, inner, " (queue is empty)", .{ .dim = true }); - var k: usize = 1; - while (k < rows) : (k += 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows -| 1); return; } clampScroll(&state.queue_scroll, state.queue_sel, n, rows); @@ -1641,8 +1920,7 @@ fn drawQueue(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize, const tag = if (t.source == .youtube) " [yt]" else " [local]"; try drawListRow(w, th, inner, label, tag, idx == state.queue_sel); } - var rem = rows - (end - start); - while (rem > 0) : (rem -= 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows - (end - start)); } fn cloneTrack(a: std.mem.Allocator, t: api.Track) !api.Track { @@ -1660,6 +1938,7 @@ fn cloneTrack(a: std.mem.Allocator, t: api.Track) !api.Track { } fn playQueueCurrent(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, state: *State) !void { + state.panel_top = null; if (state.queue.len == 0 or state.queue_idx >= state.queue.len) return; const t = state.queue[state.queue_idx]; if (!t.isPlayable()) { @@ -1670,7 +1949,6 @@ fn playQueueCurrent(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io const p = try ensurePlayer(gpa, state); p.stop(); state.now_track = t; - // only youtube needs resolving; a local file opens immediately state.connecting = t.source == .youtube; if (state.connecting) { state.status = "connecting to YouTube…"; @@ -1686,6 +1964,10 @@ fn playQueueCurrent(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io }); state.now_track = null; state.connecting = false; + if (t.source == .youtube) if (staleYtdlp(gpa, io, state)) |days| { + setStatus(state, "yt-dlp failed: it is {d} days old, update it", .{days}); + return; + }; state.status = switch (t.source) { .youtube => "yt-dlp failed (see log)", .local => "cannot open that file (see log)", @@ -1695,7 +1977,7 @@ fn playQueueCurrent(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io }; defer gpa.free(url); - try p.loadUrl(arena, url); + try p.loadUrl(arena, url, try t.mediaTitle(arena)); state.connecting = false; state.status = "playing"; } @@ -1706,7 +1988,6 @@ fn queueAdvance(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, st try playQueueCurrent(gpa, arena, io, state); return; } - // skip rows with no playable target var next = state.queue_idx + 1; while (next < state.queue.len and !state.queue[next].isPlayable()) next += 1; if (next >= state.queue.len) { @@ -1743,48 +2024,37 @@ fn handlePlayerEvent( const why = if (state.pl) |p| p.last_error else ""; const title = if (state.now_track) |t| t.title else ""; log.write("playback failed: {s} title=\"{s}\"", .{ why, title }); + const was_youtube = if (state.now_track) |t| t.source == .youtube else false; state.now_track = null; state.connecting = false; + if (was_youtube) if (staleYtdlp(gpa, io, state)) |days| { + setStatus(state, "playback failed: yt-dlp is {d} days old, update it", .{days}); + return; + }; setStatus(state, "playback failed: {s} (see log)", .{why}); }, + .audio_ready => installInterruptHandlers(), .shutdown => state.now_track = null, else => {}, } } -const Range = struct { u21, u21 }; - -// combining marks, hangul jungseong/jongseong, joiners, variation selectors -const zero_ranges = [_]Range{ - .{ 0x300, 0x36f }, .{ 0x1160, 0x11ff }, .{ 0x1ab0, 0x1aff }, .{ 0x1dc0, 0x1dff }, - .{ 0x200b, 0x200f }, .{ 0x20d0, 0x20ff }, .{ 0xfe00, 0xfe0f }, .{ 0xfe20, 0xfe2f }, - .{ 0xfeff, 0xfeff }, -}; - -// East Asian Wide + Fullwidth + emoji -const wide_ranges = [_]Range{ - .{ 0x1100, 0x115f }, .{ 0x2e80, 0x303e }, .{ 0x3041, 0x33ff }, .{ 0x3400, 0x4dbf }, - .{ 0x4e00, 0x9fff }, .{ 0xa000, 0xa4cf }, .{ 0xa960, 0xa97f }, .{ 0xac00, 0xd7a3 }, - .{ 0xf900, 0xfaff }, .{ 0xfe10, 0xfe19 }, .{ 0xfe30, 0xfe6f }, .{ 0xff00, 0xff60 }, - .{ 0xffe0, 0xffe6 }, .{ 0x1f300, 0x1f64f }, .{ 0x1f680, 0x1f6ff }, .{ 0x1f900, 0x1f9ff }, - .{ 0x20000, 0x3fffd }, -}; - -fn inRanges(cp: u21, ranges: []const Range) bool { - for (ranges) |r| if (cp >= r[0] and cp <= r[1]) return true; - return false; +fn staleYtdlp(gpa: std.mem.Allocator, io: std.Io, state: *State) ?u32 { + if (!state.ytdlp_checked) { + state.ytdlp_checked = true; + if (stream.ytdlpVersion(gpa, io)) |ver| { + defer gpa.free(ver); + state.ytdlp_age = stream.ytdlpAgeDays(ver, std.Io.Clock.real.now(io).toSeconds()); + } + } + const days = state.ytdlp_age orelse return null; + return if (days >= stream.STALE_DAYS) days else null; } -fn cpWidth(cp: u21) usize { - if (cp < 0x20 or (cp >= 0x7f and cp < 0xa0)) return 0; - if (cp < 0x300) return 1; - if (inRanges(cp, &zero_ranges)) return 0; - return if (inRanges(cp, &wide_ranges)) 2 else 1; -} +const cpWidth = text_mod.cpWidth; const Step = struct { cols: usize, len: usize }; -/// utf8Decode asserts on a bad lead byte, so lead bytes are validated here. fn stepCols(s: []const u8, i: usize) Step { const b = s[i]; if (b < 0x80) return .{ .cols = cpWidth(b), .len = 1 }; @@ -1875,6 +2145,9 @@ fn termSize() struct { cols: usize, rows: usize } { fn draw(gpa: std.mem.Allocator, state: *State) !void { const sz = termSize(); + state.np_bar_cols = 0; + if (state.vis_view and state.now_track == null) state.vis_view = false; + if (state.vis_view) return drawVisView(gpa, state, sz.cols, sz.rows); const need: usize = sz.cols * sz.rows * 32 + 4096; if (state.draw_buf.capacity < need) { try state.draw_buf.ensureTotalCapacity(gpa, need); @@ -1885,12 +2158,10 @@ fn draw(gpa: std.mem.Allocator, state: *State) !void { const height = sz.rows; const inner: usize = if (width > 4) width - 2 else 1; - // title + status + search + mode + section + bottom + hint const show_mode = state.phase == .typing or state.phase == .results; const chrome_main: usize = if (show_mode) 7 else 6; const min_body: usize = 3; - // the frame must never exceed `height`: the footer gives up rows before the body does const want_footer: usize = if (state.now_track != null) 6 else 0; var footer_rows = want_footer; if (want_footer > 0 and height < chrome_main + min_body + want_footer) { @@ -1904,13 +2175,7 @@ fn draw(gpa: std.mem.Allocator, state: *State) !void { const th = state.theme; try w.writeAll("\x1b[?2026h\x1b[H"); - const title = " ♫ hum "; - try w.print("{s}╭─{s}{s}{s}", .{ th.accent, th.accent_strong, title, th.reset }); - try w.writeAll(th.accent); - var i: usize = 0; - const used = 2 + visibleCols(title); - while (i + used < inner + 1) : (i += 1) try w.writeAll("─"); - try w.print("╮{s}\r\n", .{th.reset}); + try boxTop(&w, th, inner, " ♫ hum "); try drawRow(&w, th, inner, state.status, .{ .dim = true }); try drawSearchRow(&w, th, inner, state); @@ -1947,7 +2212,6 @@ fn draw(gpa: std.mem.Allocator, state: *State) !void { .queue => "queue", }; var sec_buf: [192]u8 = undefined; - // the header only tags these where the mode line is not drawn const show_filter = state.filter != .all and state.phase == .queue; const show_scope = state.scope != .both and state.phase == .queue; const sec_label = if (show_filter and show_scope) @@ -1961,11 +2225,13 @@ fn draw(gpa: std.mem.Allocator, state: *State) !void { try w.print("{s}├─ {s}{s}{s} ", .{ th.accent, th.bold, sec_label, th.reset }); try w.writeAll(th.accent); - const sec_used = 3 + visibleCols(sec_label) + 1; - var fill: usize = 0; - while (fill + sec_used < inner + 1) : (fill += 1) try w.writeAll("─"); + try w.splatBytesAll("─", (inner + 1) -| (3 + visibleCols(sec_label) + 1)); try w.print("┤{s}\r\n", .{th.reset}); + state.body_top = if (show_mode) 5 else 4; + state.mode_row = if (show_mode) 3 else null; + state.body_rows = body_rows; + state.np_top = state.body_top + body_rows + 1; if (state.scanning) { try drawScanning(&w, th, inner, body_rows, state); } else switch (state.phase) { @@ -1975,13 +2241,10 @@ fn draw(gpa: std.mem.Allocator, state: *State) !void { .queue => try drawQueue(&w, th, inner, body_rows, state), } - try w.print("{s}╰", .{th.accent}); - var j: usize = 0; - while (j < inner) : (j += 1) try w.writeAll("─"); - try w.print("╯{s}\r\n", .{th.reset}); + try boxBottom(&w, th, inner); if (state.now_track != null and footer_rows > 0) { - try drawNowPlaying(&w, th, inner, state, footer_rows); + try drawNowPlaying(&w, th, inner, state, footer_rows, true); } const tight = width < 76; @@ -1992,17 +2255,16 @@ fn draw(gpa: std.mem.Allocator, state: *State) !void { .queue => "KJ move · d remove · ⏎ play · h back", } else switch (state.phase) { .typing => if (pls.len > 0) - "↑↓ · ⏎ open/search · ^E scope · ^L library · ^F podcasts · ^Q queue · ^T filter · ^C quit" + "↑↓ · ⏎ open/search · ^E scope · ^L library · ^F podcasts · ^Q queue · ^T filter · ^Y theme · ^C quit" else - "tab accept · ⏎ search · ^E scope · ^L library · ^F podcasts · ^Q queue · ^T filter · ^C quit", + "tab accept · ⏎ search · ^E scope · ^L library · ^F podcasts · ^Q queue · ^T filter · ^Y theme · ^C quit", .results => if (state.view_pl != null) "jk/↑↓ · ⏎/l play · P add · d/^X remove · h back · [/] seek · -/= vol · m mute · ^R repeat · ␣/^P pause" else - "jk/↑↓ · ⏎/l play · a/A queue · ^Q queue view · P save · h back · [/] seek · -/= vol · ^R repeat · ␣ pause", + "jk/↑↓ · ⏎/l play · a/A queue · ^Q queue view · P save · h back · [/] seek · -/= vol · ^V visualizer · ␣ pause", .picker => "↑↓ pick · type to name a new playlist · ⏎ add · esc cancel", - .queue => "jk/↑↓ · KJ move · d remove · s shuffle · ⏎ play · h back · ␣ pause · -/= vol", + .queue => "jk/↑↓ · KJ move · d remove · s shuffle · ⏎ play · h back · ␣ pause · -/= vol · ^V visualizer", }; - // last row on screen: it must fit, or the frame wraps and scrolls the title away const hint_fit = truncateCols(hint, width -| 1); try w.print("{s} {s}{s}\x1b[K", .{ th.dim, hint_fit, th.reset }); try w.writeAll("\x1b[J\x1b[?2026l"); @@ -2010,7 +2272,135 @@ fn draw(gpa: std.mem.Allocator, state: *State) !void { try writeAll(w.buffered()); } -/// Body panel shown while a scan is running. +fn drawVisView(gpa: std.mem.Allocator, state: *State, width: usize, height: usize) !void { + const need: usize = width * height * 32 + 4096; + if (state.draw_buf.capacity < need) try state.draw_buf.ensureTotalCapacity(gpa, need); + state.draw_buf.clearRetainingCapacity(); + var w: std.Io.Writer = .fixed(state.draw_buf.allocatedSlice()); + const th = state.theme; + try w.writeAll("\x1b[?2026h\x1b[H"); + + if (width < 30 or height < 10) { + try w.writeAll("\x1b[2J"); + const msg = if (width >= 34) "make the terminal a little bigger" else "too small"; + const top = height / 2 -| 1; + try w.print("\x1b[{d};1H{s}{s}{s}", .{ top + 1, th.dim, truncateCols(msg, width), th.reset }); + try w.writeAll("\x1b[?2026l"); + return writeAll(w.buffered()); + } + + const inner = width - 2; + const np_rows: usize = if (height >= 16) 5 else 4; + const main_h = height - np_rows - 1; + const panel: usize = if (width >= 96) QUEUE_PANEL_COLS else 0; + const vis_w = width - 4 - (if (panel > 0) panel + 1 else 0); + const vis_h = main_h - 2; + + var lbl_buf: [96]u8 = undefined; + const n = vis.count(state.flipbooks); + const label = std.fmt.bufPrint(&lbl_buf, " ♫ hum · {s} {d}/{d} ", .{ vis.name(state.vis_idx, state.flipbooks), state.vis_idx + 1, n }) catch " ♫ hum "; + try boxTop(&w, th, inner, label); + + state.mode_row = null; + state.vis_rect = .{ .x = 2, .y = 1, .w = vis_w, .h = vis_h }; + state.panel_rect = if (panel > 0) .{ .x = vis_w + 4, .y = 1, .w = panel, .h = vis_h } else .{}; + var cv = try state.vis_bufs.canvas(gpa, vis_w, vis_h); + vis.render(&cv, state.vis_idx, state.flipbooks, &state.vis_motion, visAudio(state), state.tick); + + for (0..vis_h) |y| { + try w.print("{s}│{s} ", .{ th.accent, th.reset }); + try emitCanvasRow(&w, th, &cv, y); + try w.writeByte(' '); + if (panel > 0) { + try w.print("{s}│{s}", .{ th.dim, th.reset }); + try drawQueuePanelRow(&w, th, panel, y, state); + } + try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); + } + + try boxBottom(&w, th, inner); + + state.np_top = main_h; + try drawNowPlaying(&w, th, inner, state, np_rows, false); + + const hint = if (width < 76) + "v/V visualizer · ^V back · ␣ pause · -/= vol" + else + "v next · V prev · ^V/esc back · ␣ pause · -/= vol · [/] seek · m mute · ^Y theme"; + try w.print("{s} {s}{s}\x1b[K", .{ th.dim, truncateCols(hint, width -| 1), th.reset }); + try w.writeAll("\x1b[J\x1b[?2026l"); + try writeAll(w.buffered()); +} + +fn visAudio(state: *State) vis.Audio { + if (state.vis_audio_tick == state.tick) return state.vis_audio; + state.vis_audio_tick = state.tick; + const pl = state.pl orelse return .{}; + const idle = state.connecting or pl.isPaused() or pl.isMuted(); + const raw = if (idle) player.Player.Levels{ .level = 0, .left = 0, .right = 0, .bright = 0 } else pl.levels(); + const a = &state.vis_audio; + a.paused = idle; + inline for (.{ "level", "left", "right", "bright" }) |f| { + const cur = @field(a, f); + const tgt = @field(raw, f); + @field(a, f) = cur + (tgt - cur) * (if (tgt > cur) @as(f32, 0.6) else 0.18); + } + return a.*; +} + +fn emitCanvasRow(w: *std.Io.Writer, th: theme_mod.Theme, cv: *const vis.Canvas, y: usize) !void { + var cur: vis.Class = .none; + var enc: [4]u8 = undefined; + for (0..cv.w) |x| { + const cell = cv.at(x, y); + if (cell.cp == vis.WIDE_TAIL) continue; + if (cell.class != cur and cell.cp != ' ') { + try w.writeAll(th.reset); + try w.writeAll(switch (cell.class) { + .none => "", + .dim => th.dim, + .accent => th.accent, + .strong => th.accent_strong, + }); + cur = cell.class; + } + const len = std.unicode.utf8Encode(cell.cp, &enc) catch { + try w.writeByte(' '); + continue; + }; + try w.writeAll(enc[0..len]); + } + try w.writeAll(th.reset); +} + +fn drawQueuePanelRow(w: *std.Io.Writer, th: theme_mod.Theme, cols: usize, y: usize, state: *State) !void { + const room = cols -| 2; + var used: usize = 0; + try w.writeByte(' '); + if (y == 0) { + const t = truncateCols("up next", room); + try w.print("{s}{s}{s}", .{ th.dim, t, th.reset }); + used = visibleCols(t); + } else if (y >= 1) { + const qi = panelFirst(state) + (y - 1) / 2; + if (state.queue.len > 0 and qi < state.queue.len) { + const t = state.queue[qi]; + const playing = qi == state.queue_idx; + if ((y - 1) % 2 == 0) { + const mark = if (playing) "▶ " else " "; + const title = truncateCols(t.title, room -| 2); + try w.print("{s}{s}{s}{s}{s}{s}", .{ th.accent_strong, mark, th.reset, if (playing) th.bold else "", title, th.reset }); + used = 2 + visibleCols(title); + } else { + const artist = truncateCols(t.artist, room -| 2); + try w.print(" {s}{s}{s}", .{ th.dim, artist, th.reset }); + used = 2 + visibleCols(artist); + } + } + } + try w.splatByteAll(' ', (cols -| 1) -| used); +} + fn drawScanning(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize, state: *State) !void { const spin = [_][]const u8{ "▁▃▅▇", "▃▅▇▅", "▅▇▅▃", "▇▅▃▁" }; const bars = spin[(state.scan_count / 32) % spin.len]; @@ -2019,8 +2409,7 @@ fn drawScanning(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usiz const head = std.fmt.bufPrint(&buf, " {s} scanning your library…", .{bars}) catch " scanning…"; const top = rows / 3; - var k: usize = 0; - while (k < top) : (k += 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, top -| 0); try drawRow(w, th, inner, head, .{}); @@ -2029,11 +2418,9 @@ fn drawScanning(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usiz try drawRow(w, th, inner, line2, .{ .dim = true }); try drawRow(w, th, inner, " press any key to stop", .{ .dim = true }); - var rem = rows -| (top + 3); - while (rem > 0) : (rem -= 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows -| (top + 3)); } -/// Both toggles with every option visible. Width is computed, not guessed: this row builds its own content, and an over-long line wraps and scrolls the frame. fn modeGroupCols(key: []const u8, labels: []const []const u8, active: usize, only_active: bool) usize { var cols = visibleCols(key) + 3; for (labels, 0..) |label, i| { @@ -2046,21 +2433,36 @@ fn modeGroupCols(key: []const u8, labels: []const []const u8, active: usize, onl fn drawModeGroup( w: *std.Io.Writer, th: theme_mod.Theme, + state: *State, + x: usize, key: []const u8, labels: []const []const u8, active: usize, only_active: bool, -) !void { + is_scope: bool, +) !usize { try w.print("{s}{s}{s} ", .{ th.dim, key, th.reset }); + var at = x + visibleCols(key) + 3; for (labels, 0..) |label, i| { if (only_active and i != active) continue; + const width = visibleCols(label) + @as(usize, if (i == active) 2 else 0); if (i == active) { try w.print("{s} {s} {s}", .{ th.highlight, label, th.reset }); } else { try w.print("{s}{s}{s}", .{ th.dim, label, th.reset }); } try w.writeAll(" "); + if (state.mode_hit_len < MAX_MODE_HITS) { + const target: @FieldType(ModeHit, "target") = if (is_scope) + .{ .scope = Scope.byName(label) } + else + .{ .filter = std.meta.stringToEnum(api.Filter, label) orelse .all }; + state.mode_hits[state.mode_hit_len] = .{ .x = at, .w = width, .target = target }; + state.mode_hit_len += 1; + } + at += width + 2; } + return at; } fn drawModeLine(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: *State) !void { @@ -2071,7 +2473,6 @@ fn drawModeLine(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: *St .library => 2, }; - // videos has no local meaning; drop the option, not the menu const yt_filters = [_][]const u8{ "all", "songs", "videos", "albums", "artists" }; const lib_filters = [_][]const u8{ "all", "songs", "albums", "artists" }; const filters: []const []const u8 = if (state.scope == .library) &lib_filters else &yt_filters; @@ -2080,8 +2481,8 @@ fn drawModeLine(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: *St if (std.mem.eql(u8, label, state.filter.label())) filter_idx = i; } - const lead: usize = 2; // the two spaces after the left border - const gap: usize = 3; // between the two groups + const lead: usize = 2; + const gap: usize = 3; const full = lead + modeGroupCols("^E", &scopes, scope_idx, false) + gap + modeGroupCols("^T", filters, filter_idx, false); const only_active = full > inner; @@ -2092,17 +2493,34 @@ fn drawModeLine(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: *St full; try w.print("{s}│{s} ", .{ th.accent, th.reset }); - try drawModeGroup(w, th, "^E", &scopes, scope_idx, only_active); + state.mode_hit_len = 0; + const after = try drawModeGroup(w, th, state, 1 + lead, "^E", &scopes, scope_idx, only_active, true); try w.writeAll(" "); - try drawModeGroup(w, th, "^T", filters, filter_idx, only_active); + _ = try drawModeGroup(w, th, state, after + gap, "^T", filters, filter_idx, only_active, false); - var pad = inner -| used; - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', inner -| used); try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } const RowOpts = struct { dim: bool = false }; +fn boxTop(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, label: []const u8) !void { + const shown = truncateCols(label, inner -| 1); + try w.print("{s}╭─{s}{s}{s}{s}", .{ th.accent, th.accent_strong, shown, th.reset, th.accent }); + try w.splatBytesAll("─", (inner + 1) -| (2 + visibleCols(shown))); + try w.print("╮{s}\r\n", .{th.reset}); +} + +fn boxBottom(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize) !void { + try w.print("{s}╰", .{th.accent}); + try w.splatBytesAll("─", inner); + try w.print("╯{s}\r\n", .{th.reset}); +} + +fn blankRows(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, n: usize) !void { + for (0..n) |_| try drawRow(w, th, inner, "", .{}); +} + fn drawRow(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, text: []const u8, opts: RowOpts) !void { const t = truncateCols(text, inner -| 2); try w.print("{s}│{s} ", .{ th.accent, th.reset }); @@ -2110,8 +2528,7 @@ fn drawRow(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, text: []const u try w.writeAll(t); if (opts.dim) try w.writeAll(th.reset); const used = 1 + visibleCols(t); - var pad = inner -| used; - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', inner -| used); try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } @@ -2147,12 +2564,10 @@ fn drawSearchRow(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: *S used += visibleCols(g_show); } - var pad = room -| used; - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', room -| used); try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } -/// Backspace deletes a character, not a byte: walk back over UTF-8 continuation bytes. fn popCodepoint(buf: *std.ArrayList(u8)) void { if (buf.items.len == 0) return; var n: usize = 1; @@ -2178,8 +2593,7 @@ fn drawTyping(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize, const total = pls.len + state.local_hits.len + state.suggestions.len; if (total == 0) { try drawRow(w, th, inner, " (no suggestions yet — keep typing)", .{ .dim = true }); - var k: usize = 1; - while (k < rows) : (k += 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows -| 1); return; } clampScroll(&state.scroll_row, state.sel_row orelse 0, total, rows); @@ -2202,8 +2616,7 @@ fn drawTyping(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize, try drawListRow(w, th, inner, state.suggestions[idx - pls.len - state.local_hits.len], "", selected); } } - var rem = rows - (end - start); - while (rem > 0) : (rem -= 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows - (end - start)); } fn drawListRow(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, label: []const u8, tag: []const u8, selected: bool) !void { @@ -2217,8 +2630,7 @@ fn drawListRow(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, label: []co try w.writeAll(text); if (selected) try w.writeAll(th.reset); - var pad = room -| (2 + visibleCols(text) + tag_cols); - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', room -| (2 + visibleCols(text) + tag_cols)); try w.print("{s}{s}{s}", .{ th.dim, tag, th.reset }); try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } @@ -2245,13 +2657,12 @@ fn drawPicker(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize, try drawListRow(w, th, inner, line, "", state.picker_sel >= state.playlists.len); drawn += 1; } - while (drawn < rows) : (drawn += 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows -| drawn); } fn drawResults(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize, state: *State) !void { if (state.tracks.len == 0) { - var k: usize = 0; - while (k < rows) : (k += 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows); return; } clampScroll(&state.scroll_track, state.sel_track, state.tracks.len, rows); @@ -2274,13 +2685,12 @@ fn drawResults(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize const kind_tag = std.fmt.bufPrint(&kind_buf, " [{s}]", .{kindShort(t.kind)}) catch ""; const kind_cols = visibleCols(kind_tag); - // fixed-width duration column, blank when unknown, so the kind tag never jitters var dur_buf: [16]u8 = undefined; const dur_text: []const u8 = if (t.duration_s > 0) fmtTime(&dur_buf, @floatFromInt(t.duration_s)) else ""; - const dur_cols: usize = if (remaining > 40) 9 else 0; // " 1:02:03" + const dur_cols: usize = if (remaining > 40) 9 else 0; const text_room = remaining -| kind_cols -| dur_cols; const fit = fitTitleArtist(t.title, t.artist, text_room, sep_cols); @@ -2295,8 +2705,7 @@ fn drawResults(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize try w.writeAll(fit.artist); try w.writeAll(th.reset); - var pad = text_room -| fit.cols; - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', text_room -| fit.cols); if (dur_cols > 0) { var dpad = dur_cols -| visibleCols(dur_text); @@ -2308,13 +2717,11 @@ fn drawResults(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, rows: usize try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } - var rem = rows - (end - start); - while (rem > 0) : (rem -= 1) try drawRow(w, th, inner, "", .{}); + try blankRows(w, th, inner, rows - (end - start)); } const TitleArtist = struct { title: []const u8, artist: []const u8, cols: usize }; -/// Two thirds title, the rest artist, both cut on column boundaries. fn fitTitleArtist(title: []const u8, artist: []const u8, room: usize, sep_cols: usize) TitleArtist { const budget = if (room > sep_cols + 4) (room * 2) / 3 else room; const t = truncateCols(title, budget); @@ -2336,7 +2743,7 @@ fn kindShort(k: []const u8) []const u8 { const BAR_CHARS = [_][]const u8{ " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" }; -fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: *State, rows: usize) !void { +fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: *State, rows: usize, with_bars: bool) !void { const t = state.now_track.?; const pl = state.pl orelse return; const connecting = state.connecting; @@ -2350,12 +2757,7 @@ fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: * }; var lbl_buf: [64]u8 = undefined; const label = std.fmt.bufPrint(&lbl_buf, " {s}{s} ", .{ state_txt, rep }) catch " ▶ "; - try w.print("{s}╭─{s}{s}{s}", .{ th.accent, th.accent_strong, label, th.reset }); - try w.writeAll(th.accent); - var i: usize = 0; - const used = 2 + visibleCols(label); - while (i + used < inner + 1) : (i += 1) try w.writeAll("─"); - try w.print("╮{s}\r\n", .{th.reset}); + try boxTop(w, th, inner, label); { try w.print("{s}│{s} ", .{ th.accent, th.reset }); @@ -2364,8 +2766,7 @@ fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: * const fit = fitTitleArtist(t.title, t.artist, room, visibleCols(sep)); try w.print("{s}{s}{s}", .{ th.bold, fit.title, th.reset }); try w.print("{s}{s}{s}{s}", .{ th.dim, sep, fit.artist, th.reset }); - var pad = room -| fit.cols; - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', room -| fit.cols); try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } @@ -2395,6 +2796,7 @@ fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: * const time_cols = visibleCols(time_str); const bar_room = room -| time_cols; + state.np_bar_cols = bar_room; const filled: usize = @intFromFloat(@as(f64, @floatFromInt(bar_room)) * frac); var k: usize = 0; try w.writeAll(th.accent_strong); @@ -2406,10 +2808,10 @@ fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: * try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } - // compact footer: drop visualizer and next-up rather than overflow - const compact = rows < 6; + const show_bars = with_bars and rows >= 6; + const show_next = if (with_bars) rows >= 6 else rows >= 5; - if (!compact) { + if (show_bars) { try w.print("{s}│{s} ", .{ th.accent, th.reset }); const room = inner -| 1; const idle = paused or connecting or pl.isMuted(); @@ -2418,7 +2820,7 @@ fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: * try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } - if (!compact) { + if (show_next) { try w.print("{s}│{s} ", .{ th.accent, th.reset }); const room = inner -| 1; const prefix = "next ▸ "; @@ -2440,15 +2842,11 @@ fn drawNowPlaying(w: *std.Io.Writer, th: theme_mod.Theme, inner: usize, state: * try w.print("{s}{s}{s}", .{ th.dim, shown, th.reset }); used_n += visibleCols(shown); } - var pad = room -| used_n; - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', room -| used_n); try w.print("{s}│{s}\r\n", .{ th.accent, th.reset }); } - try w.print("{s}╰", .{th.accent}); - var b: usize = 0; - while (b < inner) : (b += 1) try w.writeAll("─"); - try w.print("╯{s}\r\n", .{th.reset}); + try boxBottom(w, th, inner); } fn drawBars(w: *std.Io.Writer, th: theme_mod.Theme, room: usize, tick: u64, paused: bool, rms: f64) !void { @@ -2474,8 +2872,7 @@ fn drawBars(w: *std.Io.Writer, th: theme_mod.Theme, room: usize, tick: u64, paus try w.writeAll(BAR_CHARS[lvl]); } try w.writeAll(th.reset); - var pad = room -| bars; - while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try w.splatByteAll(' ', room -| bars); } fn fmtTime(buf: []u8, secs: f64) []const u8 { @@ -2490,7 +2887,7 @@ fn fmtTime(buf: []u8, secs: f64) []const u8 { var saved_termios: ?posix.termios = null; fn onInterrupt(_: posix.SIG) callconv(.c) void { - const restore = "\x1b[?25h\x1b[?1049l"; + const restore = MOUSE_OFF ++ PASTE_OFF ++ "\x1b[?25h\x1b[?1049l"; _ = c.write(STDOUT, restore, restore.len); if (saved_termios) |t| posix.tcsetattr(STDIN, .NOW, t) catch {}; c._exit(130); @@ -2514,8 +2911,8 @@ fn enterRaw() !posix.termios { raw.lflag.IEXTEN = false; raw.iflag.IXON = false; raw.iflag.ICRNL = false; - raw.cc[@intFromEnum(posix.V.MIN)] = 1; - raw.cc[@intFromEnum(posix.V.TIME)] = 0; + raw.cc[@backingInt(posix.V.MIN)] = 1; + raw.cc[@backingInt(posix.V.TIME)] = 0; try posix.tcsetattr(STDIN, .NOW, raw); return orig; } @@ -2560,7 +2957,6 @@ test "visibleCols counts CJK and fullwidth as two columns" { } test "column math survives invalid UTF-8 (latin-1 from suggest endpoint)" { - // 0xFC: latin-1 ü, an invalid UTF-8 lead byte const bad = "mot\xfcrhead"; try testing.expectEqual(@as(usize, 9), visibleCols(bad)); try testing.expectEqualStrings("mot", truncateCols(bad, 3)); @@ -2571,7 +2967,7 @@ test "column math survives invalid UTF-8 (latin-1 from suggest endpoint)" { try testing.expectEqual(@as(usize, 3), visibleCols(lone_lead)); const stray_cont = "\x80\xbfok"; try testing.expectEqual(@as(usize, 4), visibleCols(stray_cont)); - const truncated_wide = "\xe6\xbc"; // first two bytes of 漢 + const truncated_wide = "\xe6\xbc"; try testing.expectEqual(@as(usize, 2), visibleCols(truncated_wide)); } @@ -2622,9 +3018,9 @@ test "typing rows put playlists before suggestions and filter by query" { var buf: [MAX_PL_ROWS]usize = undefined; try testing.expectEqual(@as(usize, 2), matchedPlaylists(&st, &buf).len); - try testing.expect(selectedSuggestion(&st) == null); // no selection + try testing.expect(selectedSuggestion(&st) == null); st.sel_row = 1; - try testing.expect(selectedSuggestion(&st) == null); // still a playlist row + try testing.expect(selectedSuggestion(&st) == null); st.sel_row = 2; try testing.expectEqualStrings("nina simone", selectedSuggestion(&st).?); diff --git a/src/vis.zig b/src/vis.zig new file mode 100644 index 0000000..0ac4b48 --- /dev/null +++ b/src/vis.zig @@ -0,0 +1,606 @@ +const std = @import("std"); +const text = @import("text.zig"); + +pub const Class = enum(u2) { none, dim, accent, strong }; + +pub const Cell = struct { + cp: u21 = ' ', + class: Class = .none, +}; + +pub const WIDE_TAIL: u21 = 0; + +pub const Audio = struct { + level: f32 = 0, + left: f32 = 0, + right: f32 = 0, + bright: f32 = 0, + paused: bool = true, +}; + +pub const builtin_names = [_][]const u8{ "bars", "cube", "orbit", "rings", "plasma" }; + +const MAX_BARS = 64; + +pub const Motion = struct { + last_tick: u64 = 0, + ax: f32 = 0.6, + ay: f32 = 0.2, + az: f32 = 0, + orbit: f32 = 0, + ring: f32 = 0, + plasma: f32 = 0, + flip: f32 = 0, + bar: [MAX_BARS]f32 = @splat(0), + peak: [MAX_BARS]f32 = @splat(0), +}; + +pub const Buffers = struct { + cells: []Cell = &.{}, + dots: []u8 = &.{}, + dot_class: []Class = &.{}, + + pub fn canvas(self: *Buffers, gpa: std.mem.Allocator, w: usize, h: usize) !Canvas { + const n = w * h; + if (self.cells.len < n) { + self.deinit(gpa); + self.cells = try gpa.alloc(Cell, n); + self.dots = try gpa.alloc(u8, n); + self.dot_class = try gpa.alloc(Class, n); + } + var cv: Canvas = .{ .w = w, .h = h, .cells = self.cells[0..n], .dots = self.dots[0..n], .dot_class = self.dot_class[0..n] }; + cv.clear(); + return cv; + } + + pub fn deinit(self: *Buffers, gpa: std.mem.Allocator) void { + gpa.free(self.cells); + gpa.free(self.dots); + gpa.free(self.dot_class); + self.* = .{}; + } +}; + +pub const Canvas = struct { + w: usize, + h: usize, + cells: []Cell, + dots: []u8, + dot_class: []Class, + + fn clear(self: *Canvas) void { + @memset(self.cells, .{}); + @memset(self.dots, 0); + @memset(self.dot_class, .none); + } + + pub fn at(self: *const Canvas, x: usize, y: usize) Cell { + return self.cells[y * self.w + x]; + } + + fn put(self: *Canvas, x: isize, y: isize, cp: u21, class: Class) void { + if (x < 0 or y < 0) return; + const ux: usize = @intCast(x); + const uy: usize = @intCast(y); + if (ux >= self.w or uy >= self.h) return; + self.cells[uy * self.w + ux] = .{ .cp = cp, .class = class }; + } + + const DOT = [4][2]u8{ .{ 0x01, 0x08 }, .{ 0x02, 0x10 }, .{ 0x04, 0x20 }, .{ 0x40, 0x80 } }; + + fn dot(self: *Canvas, px: f32, py: f32, class: Class) void { + if (!(px >= 0 and py >= 0)) return; + const x: usize = @intFromFloat(px + 0.5); + const y: usize = @intFromFloat(py + 0.5); + if (x >= self.w * 2 or y >= self.h * 4) return; + const i = (y / 4) * self.w + x / 2; + self.dots[i] |= DOT[y % 4][x % 2]; + if (@backingInt(class) > @backingInt(self.dot_class[i])) self.dot_class[i] = class; + } + + fn line(self: *Canvas, x0: f32, y0: f32, x1: f32, y1: f32, class: Class) void { + const span = @max(@abs(x1 - x0), @abs(y1 - y0)); + if (!std.math.isFinite(span)) return; + const n: usize = @intFromFloat(@min(@max(span, 1), 4096)); + const nf: f32 = @floatFromInt(n); + var i: usize = 0; + while (i <= n) : (i += 1) { + const f: f32 = @as(f32, @floatFromInt(i)) / nf; + self.dot(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f, class); + } + } + + fn flushDots(self: *Canvas) void { + for (self.dots, 0..) |b, i| { + if (b != 0) self.cells[i] = .{ .cp = 0x2800 + @as(u21, b), .class = self.dot_class[i] }; + } + } +}; + +pub fn count(flipbooks: []const Flipbook) usize { + return builtin_names.len + flipbooks.len; +} + +pub fn name(which: usize, flipbooks: []const Flipbook) []const u8 { + if (which < builtin_names.len) return builtin_names[which]; + const i = which - builtin_names.len; + return if (i < flipbooks.len) flipbooks[i].name else builtin_names[0]; +} + +pub fn indexOf(n: []const u8, flipbooks: []const Flipbook) ?usize { + for (builtin_names, 0..) |b, i| if (std.mem.eql(u8, b, n)) return i; + for (flipbooks, 0..) |f, i| if (std.mem.eql(u8, f.name, n)) return builtin_names.len + i; + return null; +} + +pub fn render(cv: *Canvas, which: usize, flipbooks: []const Flipbook, m: *Motion, a: Audio, tick: u64) void { + const steps: usize = @intCast(@min(tick -% m.last_tick, 4)); + m.last_tick = tick; + if (cv.w == 0 or cv.h == 0) return; + switch (which) { + 0 => bars(cv, m, a, tick, steps), + 1 => cube(cv, m, a, steps), + 2 => orbit(cv, m, a, steps), + 3 => rings(cv, m, a, steps), + 4 => plasma(cv, m, a, steps), + else => { + const i = which - builtin_names.len; + if (i < flipbooks.len) flipbook(cv, &flipbooks[i], m, a, steps) else bars(cv, m, a, tick, steps); + }, + } +} + +fn byLevel(v: f32) Class { + return if (v > 0.66) .strong else if (v > 0.33) .accent else .dim; +} + +fn ff(x: usize) f32 { + return @floatFromInt(x); +} + +const PART = [_]u21{ 0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587 }; +const FULL: u21 = 0x2588; +const CAP: u21 = 0x2580; + +fn bars(cv: *Canvas, m: *Motion, a: Audio, tick: u64, steps: usize) void { + const bw: usize = if (cv.w >= 72) 2 else 1; + const n = @min(MAX_BARS, (cv.w + 1) / (bw + 1)); + if (n == 0) return; + const used = n * (bw + 1) - 1; + const x0 = (cv.w - used) / 2; + var env = std.math.clamp(a.level * 1.15, 0, 1); + if (a.paused) env = 0 else if (env < 0.06) env = 0.06; + const tf: f32 = @floatFromInt(tick % 100_000); + const hf = ff(cv.h); + + for (0..n) |i| { + const fi = ff(i); + const mod = (@sin(tf * 0.22 + fi * 0.42) + @sin(tf * 0.11 + fi * 0.19 + 1.9)) * 0.25 + 0.75; + const target = std.math.clamp(env * mod, 0, 1); + for (0..steps) |_| { + m.bar[i] += (target - m.bar[i]) * 0.45; + m.peak[i] = @max(m.peak[i] - 0.015, m.bar[i]); + } + const height = m.bar[i] * hf; + const full: usize = @intFromFloat(height); + const frac = height - ff(full); + const x = x0 + i * (bw + 1); + for (0..cv.h) |row| { + const class: Class = byLevel(ff(row) / hf * 1.15); + const cp: ?u21 = if (row < full) FULL else if (row == full and frac > 0.12) PART[@min(6, @as(usize, @intFromFloat(frac * 7)))] else null; + if (cp) |c| for (0..bw) |b| cv.put(@intCast(x + b), @intCast(cv.h - 1 - row), c, class); + } + const prow: usize = @min(cv.h - 1, @as(usize, @intFromFloat(m.peak[i] * hf))); + if (m.peak[i] > 0.04 and prow > full) { + for (0..bw) |b| cv.put(@intCast(x + b), @intCast(cv.h - 1 - prow), CAP, .strong); + } + } +} + +const V3 = [3]f32; + +fn rot(p: V3, ax: f32, ay: f32, az: f32) V3 { + var x = p[0]; + var y = p[1]; + var z = p[2]; + var c = @cos(ax); + var s = @sin(ax); + const y1 = y * c - z * s; + z = y * s + z * c; + y = y1; + c = @cos(ay); + s = @sin(ay); + const x1 = x * c + z * s; + z = -x * s + z * c; + x = x1; + c = @cos(az); + s = @sin(az); + const x2 = x * c - y * s; + y = x * s + y * c; + return .{ x2, y, z }; +} + +const CUBE_V = [_]V3{ .{ -1, -1, -1 }, .{ 1, -1, -1 }, .{ 1, 1, -1 }, .{ -1, 1, -1 }, .{ -1, -1, 1 }, .{ 1, -1, 1 }, .{ 1, 1, 1 }, .{ -1, 1, 1 } }; +const CUBE_E = [_][2]u8{ .{ 0, 1 }, .{ 1, 2 }, .{ 2, 3 }, .{ 3, 0 }, .{ 4, 5 }, .{ 5, 6 }, .{ 6, 7 }, .{ 7, 4 }, .{ 0, 4 }, .{ 1, 5 }, .{ 2, 6 }, .{ 3, 7 } }; +const OCTA_V = [_]V3{ .{ 1, 0, 0 }, .{ -1, 0, 0 }, .{ 0, 1, 0 }, .{ 0, -1, 0 }, .{ 0, 0, 1 }, .{ 0, 0, -1 } }; +const OCTA_E = [_][2]u8{ .{ 0, 2 }, .{ 0, 3 }, .{ 0, 4 }, .{ 0, 5 }, .{ 1, 2 }, .{ 1, 3 }, .{ 1, 4 }, .{ 1, 5 }, .{ 2, 4 }, .{ 2, 5 }, .{ 3, 4 }, .{ 3, 5 } }; + +fn wire(cv: *Canvas, verts: []const V3, edges: []const [2]u8, scale: f32, ax: f32, ay: f32, az: f32, front: Class, back: Class) void { + const w = ff(cv.w * 2); + const h = ff(cv.h * 4); + const size = @min(w, h) * 0.5 * scale; + var p: [8]V3 = undefined; + for (verts, 0..) |v, i| { + const q = rot(v, ax, ay, az); + const d = 3.4 / (q[2] + 3.4); + p[i] = .{ w / 2 + q[0] * size * d, h / 2 + q[1] * size * d, q[2] }; + } + for (edges) |e| { + const a = p[e[0]]; + const b = p[e[1]]; + cv.line(a[0], a[1], b[0], b[1], if (a[2] + b[2] < 0) front else back); + } +} + +fn wrap(x: f32) f32 { + return @mod(x, std.math.tau * 64); +} + +fn cube(cv: *Canvas, m: *Motion, a: Audio, steps: usize) void { + const spin: f32 = if (a.paused) 0.002 else 0.012 + a.bright * 0.03; + for (0..steps) |_| { + m.ax = wrap(m.ax + spin * 0.7); + m.ay = wrap(m.ay + spin); + m.az = wrap(m.az + spin * 0.25); + } + wire(cv, &CUBE_V, &CUBE_E, 0.5 * (0.82 + 0.3 * a.level), m.ax, m.ay, m.az, .strong, .dim); + wire(cv, &OCTA_V, &OCTA_E, 0.3 + 0.25 * a.level, -m.ax * 1.4, -m.ay * 0.8, m.az, .accent, .dim); + cv.flushDots(); +} + +fn orbit(cv: *Canvas, m: *Motion, a: Audio, steps: usize) void { + const speed: f32 = if (a.paused) 0.002 else 0.01 + a.bright * 0.035; + for (0..steps) |_| m.orbit = wrap(m.orbit + speed); + const w = ff(cv.w * 2); + const h = ff(cv.h * 4); + const ampx = w * 0.46 * (0.35 + 0.65 * a.left); + const ampy = h * 0.44 * (0.35 + 0.65 * a.right); + const N = 420; + for ([_]f32{ 0.5, 0 }, [_]Class{ .dim, .strong }) |lag, class| { + var px: f32 = 0; + var py: f32 = 0; + for (0..N + 1) |i| { + const u = ff(i) / N * std.math.tau; + const x = w / 2 + @sin(3 * u + m.orbit - lag) * ampx; + const y = h / 2 + @sin(2 * u) * ampy; + if (i > 0) cv.line(px, py, x, y, class); + px = x; + py = y; + } + } + cv.flushDots(); +} + +const RAMP = " .:-=+*#%@"; + +fn ramp(cv: *Canvas, x: usize, y: usize, v: f32) void { + const idx: usize = @intFromFloat(std.math.clamp(v * 10, 0, 9)); + if (idx == 0) return; + const class: Class = if (idx >= 7) .strong else if (idx >= 4) .accent else .dim; + cv.put(@intCast(x), @intCast(y), RAMP[idx], class); +} + +fn rings(cv: *Canvas, m: *Motion, a: Audio, steps: usize) void { + const speed: f32 = if (a.paused) 0.01 else 0.12 + a.level * 0.45; + for (0..steps) |_| m.ring = wrap(m.ring + speed); + const cx = ff(cv.w) / 2; + const cy = ff(cv.h) / 2; + const maxd = @max(1, @sqrt(cx * cx + cy * cy * 4)); + for (0..cv.h) |y| for (0..cv.w) |x| { + const dx = ff(x) - cx; + const dy = (ff(y) - cy) * 2; + const d = @sqrt(dx * dx + dy * dy); + const v = (0.5 + 0.5 * @sin(d * 0.6 - m.ring)) * (0.25 + 0.75 * a.level) * (1 - 0.55 * d / maxd); + ramp(cv, x, y, v); + }; +} + +fn plasma(cv: *Canvas, m: *Motion, a: Audio, steps: usize) void { + const speed: f32 = if (a.paused) 0.003 else 0.025 + a.level * 0.09; + for (0..steps) |_| m.plasma = wrap(m.plasma + speed); + const t = m.plasma; + const cx = ff(cv.w) / 2; + const cy = ff(cv.h) / 2; + for (0..cv.h) |y| for (0..cv.w) |x| { + const fx = ff(x); + const fy = ff(y) * 2; + const dx = fx - cx; + const dy = (ff(y) - cy) * 2; + var v = @sin(fx * 0.11 + t) + @sin(fy * 0.09 + t * 0.7) + @sin((fx + fy) * 0.06 + t * 0.5) + + @sin(@sqrt(dx * dx + dy * dy) * 0.13 - t * 1.3); + v = (v + 4) / 8 * (0.35 + 0.65 * a.level); + ramp(cv, x, y, v); + }; +} + +// ------------------------------------------------------------- flipbooks + +pub const Flipbook = struct { + name: []const u8, + fps: f32 = 8, + speed_level: bool = false, + color: ColorMode = .level, + frames: []const []const []const u8, + width: usize, + height: usize, +}; + +pub const ColorMode = enum { level, dim, accent, strong }; + +pub const MAX_FILE_BYTES = 64 * 1024; +const MAX_FRAMES = 120; +const MAX_LINES = 60; +const MAX_COLS = 200; +const MAX_NAME_COLS = 24; + +pub fn parseFlipbook(arena: std.mem.Allocator, bytes: []const u8, fallback_name: []const u8) ?Flipbook { + var fb: Flipbook = .{ .name = "", .frames = &.{}, .width = 0, .height = 0 }; + var frames: std.ArrayList([]const []const u8) = .empty; + var cur: std.ArrayList([]const u8) = .empty; + var in_header = true; + + var it = std.mem.splitScalar(u8, bytes[0..@min(bytes.len, MAX_FILE_BYTES)], '\n'); + while (it.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + if (std.mem.eql(u8, std.mem.trim(u8, line, " \t"), "---")) { + if (!in_header and cur.items.len > 0) { + if (frames.items.len >= MAX_FRAMES) break; + frames.append(arena, cur.toOwnedSlice(arena) catch return null) catch return null; + } + cur = .empty; + in_header = false; + continue; + } + if (in_header) { + headerLine(arena, &fb, line); + continue; + } + if (cur.items.len >= MAX_LINES) continue; + const clean = text.sanitize(arena, line) catch return null; + const fit = clipCols(clean, MAX_COLS); + cur.append(arena, fit) catch return null; + fb.width = @max(fb.width, colsOf(fit)); + } + if (!in_header and cur.items.len > 0 and frames.items.len < MAX_FRAMES) { + frames.append(arena, cur.toOwnedSlice(arena) catch return null) catch return null; + } + + for (frames.items) |f| { + var h = f.len; + while (h > 0 and std.mem.trim(u8, f[h - 1], " ").len == 0) h -= 1; + fb.height = @max(fb.height, h); + } + if (frames.items.len == 0 or fb.width == 0 or fb.height == 0) return null; + fb.frames = frames.toOwnedSlice(arena) catch return null; + if (fb.name.len == 0) fb.name = clipCols(text.sanitize(arena, fallback_name) catch return null, MAX_NAME_COLS); + if (fb.name.len == 0) return null; + return fb; +} + +fn headerLine(arena: std.mem.Allocator, fb: *Flipbook, line: []const u8) void { + const t = std.mem.trim(u8, line, " \t"); + if (t.len == 0 or t[0] == '#') return; + const eq = std.mem.indexOfScalar(u8, t, '=') orelse return; + const key = std.mem.trim(u8, t[0..eq], " \t"); + var val = std.mem.trim(u8, t[eq + 1 ..], " \t"); + if (std.mem.indexOfScalar(u8, val, '#')) |hash| val = std.mem.trim(u8, val[0..hash], " \t"); + if (std.mem.eql(u8, key, "name")) { + const clean = text.sanitize(arena, val) catch return; + fb.name = clipCols(std.mem.trim(u8, clean, " "), MAX_NAME_COLS); + } else if (std.mem.eql(u8, key, "fps")) { + const f = std.fmt.parseFloat(f32, val) catch return; + if (std.math.isFinite(f)) fb.fps = std.math.clamp(f, 1, 30); + } else if (std.mem.eql(u8, key, "speed")) { + fb.speed_level = std.mem.eql(u8, val, "level"); + } else if (std.mem.eql(u8, key, "color")) { + fb.color = std.meta.stringToEnum(ColorMode, val) orelse fb.color; + } +} + +fn colsOf(s: []const u8) usize { + var n: usize = 0; + var it = std.unicode.Utf8View.initUnchecked(s).iterator(); + while (it.nextCodepoint()) |cp| n += text.cpWidth(cp); + return n; +} + +fn clipCols(s: []const u8, max: usize) []const u8 { + var n: usize = 0; + var it = std.unicode.Utf8View.initUnchecked(s).iterator(); + var end: usize = 0; + while (it.nextCodepoint()) |cp| { + const w = text.cpWidth(cp); + if (n + w > max) break; + n += w; + end = it.i; + } + return s[0..end]; +} + +pub const TICK_MS = 40; +const TICK_SECONDS: f32 = @as(f32, TICK_MS) / 1000; + +fn flipbook(cv: *Canvas, fb: *const Flipbook, m: *Motion, a: Audio, steps: usize) void { + const rate: f32 = if (a.paused) 0 else fb.fps * TICK_SECONDS * (if (fb.speed_level) 0.3 + 1.7 * a.level else 1); + for (0..steps) |_| m.flip = @mod(m.flip + rate, @as(f32, @floatFromInt(fb.frames.len))); + const idx = @min(fb.frames.len - 1, @as(usize, @intFromFloat(m.flip))); + const frame = fb.frames[idx]; + const class: Class = switch (fb.color) { + .level => if (a.paused) .dim else byLevel(a.level * 1.2), + .dim => .dim, + .accent => .accent, + .strong => .strong, + }; + const ox: isize = @divFloor(@as(isize, @intCast(cv.w)) - @as(isize, @intCast(fb.width)), 2); + const oy: isize = @divFloor(@as(isize, @intCast(cv.h)) - @as(isize, @intCast(fb.height)), 2); + for (frame, 0..) |row, ry| { + if (ry >= fb.height) break; + var x = ox; + var it = std.unicode.Utf8View.initUnchecked(row).iterator(); + while (it.nextCodepoint()) |cp| { + const w = text.cpWidth(cp); + if (w == 0) continue; + const y = oy + @as(isize, @intCast(ry)); + const fits = x >= 0 and x + @as(isize, @intCast(w)) <= @as(isize, @intCast(cv.w)); + if (cp != ' ' and fits) { + cv.put(x, y, cp, class); + if (w == 2) cv.put(x + 1, y, WIDE_TAIL, class); + } + x += @intCast(w); + } + } +} + +// ----------------------------------------------------------------- tests + +const testing = std.testing; + +test "every visualizer renders at every size without leaving the canvas" { + var bufs: Buffers = .{}; + defer bufs.deinit(testing.allocator); + var m: Motion = .{}; + const loud: Audio = .{ .level = 1, .left = 1, .right = 1, .bright = 1, .paused = false }; + var tick: u64 = 0; + for ([_]usize{ 1, 2, 3, 7, 12, 40, 97, 160 }) |w| { + for ([_]usize{ 1, 2, 3, 5, 18, 50 }) |h| { + var cv = try bufs.canvas(testing.allocator, w, h); + for (0..builtin_names.len) |which| { + tick += 1; + render(&cv, which, &.{}, &m, loud, tick); + render(&cv, which, &.{}, &m, .{}, tick + 1); + } + } + } +} + +test "motion survives a resize: the next frame continues from the same angle" { + var bufs: Buffers = .{}; + defer bufs.deinit(testing.allocator); + var m: Motion = .{}; + const a: Audio = .{ .level = 0.5, .bright = 0.5, .paused = false }; + var cv = try bufs.canvas(testing.allocator, 80, 24); + render(&cv, 1, &.{}, &m, a, 1); + const before = m.ay; + cv = try bufs.canvas(testing.allocator, 33, 9); + render(&cv, 1, &.{}, &m, a, 2); + try testing.expect(m.ay > before); + try testing.expect(m.ay - before < 0.1); +} + +test "a paused visualizer barely moves" { + var bufs: Buffers = .{}; + defer bufs.deinit(testing.allocator); + var m: Motion = .{}; + var cv = try bufs.canvas(testing.allocator, 40, 12); + render(&cv, 3, &.{}, &m, .{ .paused = true }, 1); + const r0 = m.ring; + render(&cv, 3, &.{}, &m, .{ .paused = true }, 2); + try testing.expect(m.ring - r0 < 0.02); +} + +test "flipbook parses header and frames" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const src = + \\# a comment + \\name = heartbeat + \\fps = 12 + \\speed = level + \\color = accent # trailing comment + \\--- + \\ .-. + \\ ( ) + \\--- + \\ .---. + \\( ) + \\ + ; + const fb = parseFlipbook(arena.allocator(), src, "file").?; + try testing.expectEqualStrings("heartbeat", fb.name); + try testing.expectEqual(@as(f32, 12), fb.fps); + try testing.expect(fb.speed_level); + try testing.expectEqual(ColorMode.accent, fb.color); + try testing.expectEqual(@as(usize, 2), fb.frames.len); + try testing.expectEqual(@as(usize, 7), fb.width); + try testing.expectEqual(@as(usize, 2), fb.height); +} + +test "flipbook strips escapes, caps sizes, and falls back to the file name" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const evil = parseFlipbook(a, "fps = 9999\n---\n\x1b[2Jboom\x07\n", "evil").?; + try testing.expectEqualStrings("evil", evil.name); + try testing.expectEqual(@as(f32, 30), evil.fps); + try testing.expect(std.mem.indexOfScalar(u8, evil.frames[0][0], 0x1b) == null); + + var big: std.ArrayList(u8) = .empty; + try big.appendSlice(a, "---\n"); + for (0..500) |_| try big.appendSlice(a, "xxx\n---\n"); + const many = parseFlipbook(a, big.items, "many").?; + try testing.expect(many.frames.len <= MAX_FRAMES); + + const wide = parseFlipbook(a, "---\n" ++ @as([1000]u8, @splat('w')) ++ "\n", "wide").?; + try testing.expectEqual(@as(usize, MAX_COLS), wide.width); + + try testing.expect(parseFlipbook(a, "name = empty\n", "x") == null); + try testing.expect(parseFlipbook(a, "---\n \n\n---\n", "blank") == null); +} + +test "flipbook frames and names cannot carry terminal control sequences" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const payloads = [_][]const u8{ + "\x1b]52;c;cm0gLXJmIH4=\x07", + "\x1b]0;owned\x07", + "\x1b[6n", + "\x1b[?1049l\x1b[2J", + "\x9b6n", + "\x1bP+q544e\x1b\\", + "a\u{202e}gnirts\u{2066}x\u{2069}", + "\xc0\xaf\xed\xa0\x80\xff", + }; + for (payloads) |p| { + const src = try std.fmt.allocPrint(a, "name = {s}\n---\n{s}\n", .{ p, p }); + const fb = parseFlipbook(a, src, "x") orelse continue; + for ([_][]const u8{ fb.name, fb.frames[0][0] }) |out| { + try testing.expect(std.unicode.utf8ValidateSlice(out)); + var it = std.unicode.Utf8View.initUnchecked(out).iterator(); + while (it.nextCodepoint()) |cp| { + try testing.expect(cp >= 0x20 and cp != 0x7f); + try testing.expect(cp < 0x80 or cp > 0x9f); + try testing.expect(!(cp >= 0x202a and cp <= 0x202e) and !(cp >= 0x2066 and cp <= 0x2069)); + } + } + } +} + +test "flipbook with wide characters stays inside a narrow canvas" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const fb = parseFlipbook(arena.allocator(), "---\n音楽音楽音楽\n♫ hum ♫\n", "cjk").?; + try testing.expectEqual(@as(usize, 12), fb.width); + var bufs: Buffers = .{}; + defer bufs.deinit(testing.allocator); + var m: Motion = .{}; + for ([_]usize{ 1, 2, 3, 5, 11, 12, 13 }) |w| { + var cv = try bufs.canvas(testing.allocator, w, 3); + render(&cv, builtin_names.len, &.{fb}, &m, .{ .level = 0.5, .paused = false }, 1); + for (0..cv.h) |y| for (0..cv.w) |x| { + const cell = cv.at(x, y); + if (cell.cp == WIDE_TAIL) try testing.expect(x > 0 and text.cpWidth(cv.at(x - 1, y).cp) == 2); + }; + } +}