Commit Graph

17 Commits

Author SHA1 Message Date
bjarneo bd8d5d07c0 Fix review findings: concurrency, plugin sandbox, provider bugs, and dedup (#254)
* fix(playlist): synchronize Playlist for concurrent plugin/UI access

Lua plugin callbacks run on goroutines (hooks, timers, keybinds) and read
playlist state through StateProvider closures, while the Bubbletea loop
mutates the same *Playlist. The struct had no synchronization, so reads of
p.tracks/p.order/p.pos raced with Next/Prev/Add/Remove/shuffle, and
Current()'s unguarded p.tracks[idx] could panic on a stale index.

Add a sync.Mutex and guard every exported method. Reads and writes are now
serialized; no exported method calls another, so defer-unlock cannot deadlock.

Fixes the high-severity track/player getter race and the playlist-mutation
data race.

* fix(luaplugin): block SSRF in cliamp.http

doHTTP passed plugin-supplied URLs straight to the HTTP client with no
validation, letting a plugin reach loopback, RFC1918, and link-local
169.254.169.254 (cloud metadata), and follow redirects into those targets.

Add an ssrfGuard on the dialer Control hook so every connection attempt
(including redirects and DNS-rebound hosts) is checked against the resolved
IP and rejected if non-public, and reject non-http(s) schemes up front.

A per-plugin network permission gate remains a possible follow-up; it is
omitted here to avoid breaking existing plugins that legitimately use http.

* fix(pluginmgr): sandbox plugin VM during metadata extraction

extractMetadata ran DoFile on the entire plugin file with full stdlib, so
any top-level os.execute/io call ran unsandboxed the moment a user listed
installed plugins. Apply the same luaplugin.Sandbox used by the runtime
before executing the file. Exports luaplugin.Sandbox for reuse.

* fix(luaplugin): error on oversized fs.read instead of silent truncation

fs.read used os.ReadFile (whole file into memory) then sliced to 1MB,
returning a silently truncated value as success and not bounding memory for
huge files. Stream through io.LimitReader(maxSize+1) and return an explicit
error when the file exceeds the limit.

* fix(luaplugin): make write allowlist symlink-safe

isWriteAllowed validated paths lexically: the strings.Contains(abs, "..")
check was dead (filepath.Abs already cleans "..", and it false-positived on
filenames like "..bar"), and a symlink planted inside an allowed dir could
redirect a write outside it. Resolve symlinks on the deepest existing
ancestor of the target and on the allow dirs themselves before the prefix
check. Also fixes the same gap in the exec cwd check, which reuses this
function.

* fix(luaplugin): bound timer callbacks with hookTimeout

timer.after/every called CallByParam directly with no context timeout, so a
runaway callback held the plugin mutex forever, blocking every other hook,
keybind, and visualizer for that plugin. Add a shared callBounded helper that
applies hookTimeout (mirroring invokeHook) and use it from both timer paths.

* fix(luaplugin): bound visualizer render/init/destroy with hookTimeout

RenderVis runs on the UI render loop and called CallByParam with no timeout
while holding the plugin mutex; a hanging render() froze the UI with no
escape. InitVis/DestroyVis had the same gap. Route all three through
callBounded so a misbehaving visualizer times out and render falls back to
the previous frame.

* fix(luaplugin): wait for async hook goroutines before closing LStates

Emit spawned untracked goroutines that call CallByParam on a plugin LState.
Close stopped timers/execs then closed every LState without waiting, so a UI
event dispatched just before shutdown could run against an already-closed
LState (p.L.Close does not take the plugin mutex). Track Emit goroutines in a
WaitGroup, set a closing flag under mu to reject late dispatch, and Wait
before closing the LStates.

* fix(ui): guard spectrum visualizers against zero-width panel

renderFirework/renderBubbles/renderSakura compute dotCols = PanelWidth*2 and
then mod by dotCols/dotRows/len(bands); on a narrow terminal PanelWidth can be
0, so seed % uint64(dotCols) panicked and crashed the TUI. Add the same
dotRows<4 || dotCols<4 early-out the braille-grid renderers already use.

* fix(history): do not clobber history on a transient load error

Record discarded loadLocked's error and proceeded as if history were empty,
so a one-off read failure (permission/I-O glitch) made saveLocked atomically
rewrite the file with only the new entry, destroying all prior rows.
Propagate the error instead, leaving the on-disk file untouched.

* fix(history): detect write errors before committing history file

saveLocked ignored errors from writeEntry's formatted writes, so a failure
mid-write (e.g. ENOSPC) could still rename a truncated temp file over the
real history. Thread writes through an errWriter and abort the rename when a
write fails.

* fix(lyrics): stop cleanQuery from erasing titles with label words

The noise regex matched official/lyric/audio/video as bare substrings
followed by .*, so 'Videotape', 'Audioslave', and 'Video Games' were cleaned
to empty or truncated queries, breaking lyric lookups. Restrict bare-label
stripping to genuine trailing labels (dash suffix, or 'official video/audio'
and 'lyric(s) video' phrases). Add regression test cases.

* fix(jellyfin,emby): synchronize client token/userID/album cache

Client.token, userID, and albumCache were lazily read and written from
concurrent tea.Cmd goroutines (Playlists, album browse, SearchTracks all run
in their own goroutines) with no synchronization, a data race on the lazy
auth writes and the cached slice. Add a mutex guarding those fields, taken
only around field access and never across network I/O, with double-checked
auth so at most a redundant (not racy) auth can occur.

* fix(ipc): close in-flight connections on shutdown

handleConn's read loop only observed the 60s per-request read deadline, never
s.done, and Close closed only the listener. A still-connected client (e.g. a
vis-bands polling client) kept its handler goroutine alive, so Close blocked
on wg.Wait for up to 60s. Track live connections and close them in Close so
their scanner.Scan unblocks immediately; addConn shares the conn mutex with
the done check to close the accept-during-shutdown race.

* refactor(ipc): route all reply-waits through waitReply

The reply/timeout/shutdown select was open-coded in load/theme/vis/bands/
status and waitReply was used elsewhere with a generic 'timeout' message.
Parameterize waitReply with a label and timeout and use it everywhere, which
also gives the previously-generic commands specific timeout messages.

* fix(ipc): handle accept errors instead of spinning silently

acceptLoop swallowed every non-shutdown Accept error and retried at 10/s,
treating a permanently closed listener as transient. Return on net.ErrClosed
and log other errors before backing off.

* fix(ui): notify Lua plugins on jump-to-time seek

The jump enter handler called notifyPlayback (MPRIS only) plus notifier.Seeked,
skipping plugin notification. Use finishSeek, which calls notifyAll, so a jump
seek emits the playback-state event to plugins like every other completed
seek. Seek stays synchronous (immediate seek), matching the existing test.

* fix(resolve): bound startup feed sniff with a short timeout

Args runs on the startup path and called sniffFeedURL, which did a HEAD on
the 30s feed/M3U client, so one slow CDN could stall cliamp startup for up to
30s during pure URL classification. Give the sniff a dedicated 5s client.

* fix(pluginmgr): reject empty download body

An empty 200 response left download returning a non-nil empty slice, so
Install wrote a 0-byte plugin file. Treat an empty body as a download error
so the next candidate URL is tried (or the install fails cleanly).

* fix(pluginmgr): reject raw URLs with no usable filename

A raw URL ending in '/' made path.Base yield '.' or '/', producing a
degenerate plugin filename. Return a clear error instead.

* fix(config): let --low-power=false override config-enabled low power

The override only set LowPower when the flag was true, so --low-power=false
could not disable a config.toml low_power=true. Assign the flag value
directly, matching the other boolean overrides.

* fix(main): log MPRIS/NowPlaying init failure instead of dropping it

wireMediaCtl's error was silently discarded in TUI mode, so a dbus/MPRIS
setup failure left media-control silently disabled with no trace. Log it to
the app log (not stderr, which would corrupt the TUI).

* docs(plugins): remove unimplemented player getters

cliamp.player.eq_preset(), visualizer(), and theme() were documented but
never registered in the player API (calling them errors). Implementing them
would require exposing model state to plugin goroutines, which would add a
data race; remove them from the docs so the reference matches the API.

* fix(luaplugin): reject partial bands table in set_eq_preset

A bands table missing entries silently zeroed the unset bands. Require all
10 values and raise an arg error otherwise so partial input can't corrupt
the EQ curve.

* fix(luaplugin): track EmitKey goroutines and gate on shutdown

EmitKey spawned fire-and-forget goroutines per keypress that were untracked
and not gated on shutdown, so a keypress during Close could call into a
closed LState. Track them in the manager WaitGroup and skip dispatch once
closing, matching Emit.

* fix(mediactl): apply MPRIS volume changes in order

Each volume Change spawned its own goroutine to send SetVolumeMsg, so rapid
changes could be delivered out of order and an older volume win. send
(prog.Send) is goroutine-safe and non-blocking, so call it directly.

* fix(mediactl): reject stale-track MPRIS SetPosition

trackid was a fixed constant for every track, so SetPosition ignored its
trackID argument and would seek whatever track became current after the
client read its position. Assign a unique track object path per track change
and ignore SetPosition when its trackID does not match the current track.

* fix(radio): write favorites atomically

save() truncated the real favorites file with os.Create and ignored every
write error, so a partial write could lose all favorites. Build the content
in memory, write a temp file, and rename so a failed write can't corrupt the
existing file and the error surfaces to the caller.

* fix(radio): propagate save errors from ToggleFavorite

ToggleFavorite discarded the error from Add/Remove (which persist favorites),
so a failed save was silently swallowed. Return it to the caller.

* refactor(spotify): drop pointless lock around local slice append

The mutex around appending the Your Music entry only touched the
function-local 'all' slice, guarding nothing shared (the other locks in the
loop legitimately guard trackCache).

* fix(spotify): return cloned playlist list, not the cache slice

Playlists returned the cached slice directly (both on cache hit and after
building it), so a caller mutating the result corrupted the cache. Return a
clone on both paths.

* fix(spotify): return cloned cached tracks

Tracks handed out the cached track slice directly, so a caller mutating it
corrupted the per-playlist cache. Clone on both the cache-hit and freshly
built return paths. (The snapshot-based invalidation within the playlist-list
TTL is an intentional caching tradeoff and is left as-is.)

* fix(spotify): don't sleep before giving up on rate limit

On the final retry the loop slept the full backoff (up to 128s) and then
returned the rate-limited error without making another request. Break out
immediately on the last attempt.

* fix(jellyfin): guard AlbumList against negative offset

A negative offset passed the offset>=len check and then panicked at
out[offset:end]. Clamp offset to 0 first, matching the Emby client.

* docs(playlist): correct MoveQueue comment

MoveQueue swaps any two positions, not only adjacent ones.

* refactor(ui): simplify dead math.Min in flame tier mapping

math.Min(0.65, 0.55) is a constant 0.55; replace with the literal and drop
the now-unused math import.

* refactor(netease): compare app code against its own constant

resp.Code is NetEase's application-level status, not an HTTP status; it was
compared against http.StatusOK which only coincidentally shares the value
200. Introduce neteaseCodeOK and use it at all four comparison sites.

* fix(resolve): strip audio extension from derived title

humanizeBasename left a trailing extension (e.g. .mp3) in the title derived
from a URL basename. Drop a recognized audio extension first, leaving
non-media dotted suffixes untouched.

* fix(resolve): bound RSS feed read size

resolveFeed decoded the response body with no size limit. Wrap it in an
io.LimitReader (32 MB) so an oversized or malicious feed can't exhaust
memory.

* fix(navidrome): return cloned cache slices

Playlists and Tracks handed out the internal cached slices, so a caller
mutating the result corrupted the cache. Return slices.Clone on the cache-hit
and freshly built return paths.

* fix(player): include ffmpeg stderr in decode error

decodeFFmpeg wrapped the exec error but dropped ffmpeg's stderr, where the
actual failure reason is written. Surface ExitError.Stderr in the message.

* refactor(player): forward speedStreamer.Err directly

beep.Streamer already declares Err() error, so the local errorer type
assertion was redundant. Call ss.s.Err() directly, matching the eq and
volume streamers.

* fix(ui): don't drop queued seek/lyric cmds on reconnect

When the scheduled stream reconnect fired, the tick handler returned early
with only playTrack + tick, discarding the seekCmd/lyricCmd computed earlier
in the same tick. Fold them into the reconnect batch.

* fix(ui): nav list footer counts reflect committed filter

The footer only showed the filtered count while the search input bar was
open (searching); once a filter was committed with Enter it fell back to the
full count. Add navFilteredTotal and use the filtered count whenever a query
is active, across the artist, album, and track footers.

* refactor: remove dead internal/control package

internal/control duplicated the message types in internal/playback and had no
importers (only its own test); the live code uses internal/playback. Remove
it.

* fix(providers): implement Refresh for self-hosted providers

Navidrome, Plex, Jellyfin, and Emby cache playlist/track (and album) data but
none implemented playlist.Refresher, so the UI refresh key was a silent
no-op for them. Add Refresh to clear the caches (including the jellyfin/emby
client album cache) so the next fetch hits the server.

* refactor: share minimal-TOML section parser

The [[section]] parse loop was duplicated three times (radio loadStations,
radio loadFavoriteStations, local loadTOML). Extract tomlutil.ParseSections
and rewrite all three against it. Behavior is preserved, including the
single-section-type assumption of the original parsers.

* refactor: extract shared Emby/Jellyfin client into internal/embyapi

The emby and jellyfin clients were ~95% identical (~700 duplicated lines) and
had already drifted (jellyfin lacked the negative-offset guard and error
wrapping). Extract one shared Client into internal/embyapi, isolating the real
differences behind a dialect: auth header scheme (Emby Authorization vs
Jellyfin X-Emby-Authorization), ping endpoint, user-id discovery, error
prefix, and metadata key.

emby and jellyfin become thin wrappers (NewClient, IsStreamURL, type aliases)
plus their providers. Client tests are consolidated in embyapi: shared
behavior once, dialect specifics per dialect. The client now uses a per-
instance http.Client (SetHTTPClient) instead of a package global, which is
what makes it testable from the provider packages.
2026-05-28 22:42:07 +02:00
Nicholas Zambetti a9fe9bd556 Full-width separators and separator label clean-up (#252)
* Avoid duplicated "Playlists" in local provider header

* Make section separators/headers full-width

* Title-case provider subsection separator headers

* Use ANSI-aware width truncation for separator lines
2026-05-28 19:56:12 +02:00
Nicholas Zambetti d6cd99697c Improvements to album header visibility & control (#234)
Re-runs the cohesion heuristic as tracks are added or queued so headers adapt to playlist growth, while preserving the user's ctrl+h preference via a manual override flag. The Add path uses running counters so the cost stays O(k) per call instead of O(N) per Add.
2026-05-23 09:18:27 +02:00
Nicholas Zambetti a1ba57cbc2 Standardize scrolling and expanded height for all overlays (#233)
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
* Implement stateful windowed scrolling for all overlays

* Add Ctrl+X support to toggle expanded height in all overlays

* Remove ctrl+k helpkey from Search Overlay so it is consistent with others

* Remove now-unused stateless scrollStart helper

* Re-clamp queue scroll after movement or deletion

* Fix unreachable ctrl+x handler in provider search

* Fix device picker list scroll wrap for empty lists
2026-05-16 19:33:54 +02:00
Nicholas Zambetti 9eb29e7acb Unify Overlay and List Rendering Logic (#230)
* Deduplicate overlay headers and footers using 'chrome' pattern

* Adopt help-line naming convention for playlist manager

* Abstract overlay height measurement into 'measureChrome' helper

* Deduplicate list rendering code with common 'renderSimpleList' helper

* De-duplicate Visible() calls in list rendering

* Use helper to format list index/count, handle empty list condition

* Avoid mutating 'after' slice in renderSimpleList

* Use centralized helpers for overlay list viewable range and filter matches

* Count only rendered track rows in playlist manager footer

* Exclude non-content rows from playlist footer count

* Remove redundant range footer count when playlist filter is active

* Clamp range counter start index to prevent invalid list footer ranges
2026-05-15 21:06:51 +02:00
Nicholas Zambetti 72c5829b6c Support responsive height in Playlist Manager and Playlist views (#226)
* Update playlist manager and playlist views to respect responsive height

* Correct and harmonize playlist help hints

* Standardize viewport-aware footers

* Restore footer messages

* Use spacing in playlist manager list to improve readability

* Consolidate to remove duplication of playlist header and footer

* Refactor playlist manager list spacing and rendering

* Improve 'New Playlist' with help hints that display current track

* Correct navigation help hint in Playlist Manager
2026-05-14 09:53:34 +02:00
Bjarne Øverli 83a0098a50 Consolidate overlay scroll/visibility helpers in ui/model
Extract clampScroll and measureOverlayVisible to remove three near-duplicate
*MaybeAdjustScroll bodies and four near-duplicate overlay-height probes
(file browser, keymap, theme picker, lyrics). Add fitLines for the
truncate-then-pad-to-budget pattern used in view.go. Net -66 lines.
2026-05-07 17:46:43 +02:00
Bjarne Øverli 2b0a904633 Stop recomputing album-header default on every track Add
The heuristic ran on every Add path (including each YTDL batch of 20
tracks), which scaled O(N^2) on incremental loads and silently
overrode the user's Ctrl+H toggle whenever a track was queued. Recompute
only on Replace and on first population; preserve the user's choice
otherwise.

Also dedupe the " · Album" suffix logic into a small helper, and pull
the cohesion ratio into a named constant.
2026-05-06 19:46:20 +02:00
fad647834d Sticky album headers with auto-toggle heuristic and manual toggle keybind (#208)
* Implement sticky album headers and hide redundant album suffixes

* Add blank album separator when album ends and next track lacks album info

* Add keybind 'H' to toggle album headers

* Centralize album grouping and header logic

* Change 'toggle headers' keybind from H to ctrl+h

* Use modern Go iterators for playlist rendering and album grouping

* Support ctrl+h to toggle album headers in playlist manager and provider browser

* Add cohesion heuristic to auto-toggle album headers
2026-05-06 19:38:56 +02:00
Bjarne Øverli 8da79b2ef0 Simplify post-review
Move TotalDurationSecs into the playlist package and reuse it from the
local provider; collapse plMgrPlaylist/TrackRealIndex into one helper;
share filter-header rendering between the playlist manager and nav
browser; share the 'N tracks · 47:22' subtitle helper between them too;
drop the navSearchBar wrapper that became a one-liner.

Performance: stop materializing filtered slices on every render of the
playlist manager (iterate by index instead); replace the linear-scan
providerListHasSections with slices.ContainsFunc; hoist duplicate
RuneCountInString calls in formatTrackRow.

Collapse providerEmptyStateHints' switch into a package-level map.
2026-05-04 18:40:47 +02:00
Bjarne Øverli 3b2f57aac4 Improve provider playlists/song views
Provider playlists pane:
- Mark the currently loaded playlist with a ▶ prefix and active style
- Group rows under section headers when providers populate Section;
  Spotify now buckets playlists into Library / Your playlists / Followed
- Refresh with Ctrl+R; status message confirms
- Empty state names the provider and offers a remediation hint
- Show 'Name · 12 tracks · 1h 23m' on rows when data is available

Provider browser overlay (N):
- Right-aligned per-track durations and a 'N tracks · 47:22' subtitle
- Move the / filter input under the title (matches keymap overlay)
- Cursor wraps top↔bottom on every screen
- Clearer help labels: Play from here / Queue this / Replace queue / Append all

Cross-cutting:
- Replace 'Loading X...' text with a time-driven braille-dot spinner
  used everywhere a list is loading
- Quick-switch (S/N/P/J/Y/L/R) now works from inside the nav browser
  and the playlist manager, not just the main pane
2026-05-04 18:40:47 +02:00
Bjarne Øverli 977600d4bf Improve playlist manager UX with filter, smart Enter, and durations
Add an incremental `/` filter to both the playlist list and track list
screens. Make Enter on the track screen play the highlighted track and
queue the rest from there; introduce capital P for play-all-from-top.
Footer now shows the resolved now-playing track for `a`, and empty
states teach next steps.

Surface track durations: PlaylistInfo gains an optional DurationSecs
populated by the local provider, the manager track list shows
right-aligned per-track durations and a 'N tracks · 47:22' subtitle,
and playlist rows render 'Name · 12 tracks · 1h 23m' when known.
2026-05-04 18:40:46 +02:00
Tom di Mino 63b1e69e92 Album separators in playlist views (#190)
* Album separators in playlist views

Tracks with album metadata now display grouped under dimmed
separator headers in both the main playlist and the playlist
manager. Scroll logic accounts for separator rows consuming
budget—cursor stays visible across album boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Suppress album separators for Spotify playlist tracks

Spotify playlists typically have every track from a different album,
causing a separator on every row. Gate separator rendering behind
an isStreamingPlaylistTrack check so library-style providers (local,
Navidrome, Jellyfin, Plex) keep separators while Spotify skips them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Dedupe album-separator row counting

Extract albumSeparatorRows as the single source of truth for both
playlistScroll (scroll.go) and renderPlMgrTracks (view_overlays.go).

The playlist manager's inline scroll-row counter was missing the
isStreamingPlaylistTrack exclusion that its renderer applied, so
mixed Spotify+local playlists could mis-scroll. Routing both paths
through one helper fixes the inconsistency.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Bjarne Øverli <bjarne.oeverli@gmail.com>
2026-04-25 18:43:05 +02:00
Gjermund Garaba 1b53660a63 refactor: go 1.26 modernize (#178)
* refactor: go 1.26 modernize

* coderabbit pr fixes
2026-04-13 18:00:58 +02:00
Bjarne Øverli 820a41541b Style help keys as pills and replace play/pause icon
Render keybinding hints with a background-highlighted pill style
instead of dim brackets for a cleaner, modern look. Replace the ⏯
icon with ▶❚❚ for better visual clarity.
2026-04-03 14:21:40 +02:00
bjarneo 32cb03c687 Pr 158 review (#160)
* feat: CLI playlist management, Unix socket IPC, and SSH streaming

Three features that make cliamp agent-native and scriptable:

1. `cliamp playlist {list,create,add,show,remove,delete}` — manage TOML
   playlists from the CLI with --json output and --ssh HOST for remote
   directory walking

2. Unix socket IPC at ~/.config/cliamp/cliamp.sock — play/pause/next/prev/
   stop/status/volume/seek/load/queue commands over JSON-RPC, following
   the MPRIS prog.Send() pattern

3. SSH streaming via ssh:// URL scheme — pipe audio from remote machines
   through the existing decoder pipeline, zero filesystem mounts needed

Zero new Go dependencies. All existing tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): SSH track glyph + duration metadata fallback

- Show ↗ prefix on SSH-streamed tracks in the playlist view
- Store/read duration_secs in local TOML playlists
- Fall back to track metadata duration when player reports 0
  (piped SSH streams can't determine total length from audio frames)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: album separators, favorites, playlist resume, enrich command

- Album group separators in playlist view (triggers on album field change)
- Favorites system: * key toggles ★, [★ N] count in header, CLI `playlist favorite`
- [67/123] position counter in playlist header
- Playlist resume: saves and restores playlist + track + position across sessions
- `cliamp playlist enrich` probes duration via afinfo and derives album from path
- `ConnectTimeout=5` on all SSH commands to prevent stale hangs
- `SavePlaylist()` exported on local provider
- `*` added to keymap overlay and bottom help bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: persist TUI favorites to disk, fix truncation, add cross-playlist favorites view

- TUI * key now persists favorites to TOML via FavoriteSetter interface
- ★ prefix deducted from truncation budget (prevents line overflow)
- JSON output includes duration_secs and favorite fields
- `cliamp playlist favorites` lists all ★ tracks across all playlists
- FavoriteSetter interface in provider/interfaces.go
- Bottom help bar: [*]Fav (no extra ★ symbol)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: accent-color favorites, atomic TOML writes, strict --index parsing

Kotharat design review + silent failure audit fixes:
- ★ renders in accent color (yellow) independently from line style — scannable in long playlists
- savePlaylist uses atomic write (tmp + rename) — no data loss on crash
- --index parsing uses strconv.Atoi — rejects fractional/malformed input
- Bounds check on PlaylistFavorite re-read — no panic on concurrent modification
- Help bar priority 80 → 75 for [*]Fav — yields to navigation hints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ipc): remote theme switching via cliamp theme <name>

- `cliamp theme list` — list all available themes (built-in + custom)
- `cliamp theme <name>` — change theme in running TUI via IPC
- ThemeMsg with reply channel for error reporting ("theme not found")
- TUI handler reloads themes from disk before applying (picks up new custom themes)
- Added Name field to IPC Request for theme name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(theme): clean up theme CLI UX from review findings

- `cliamp theme` (no args) prints usage cleanly instead of printing playback state
- `theme list` matching is case-insensitive (prevents routing confusion)
- Documented LoadAll() precedent in ThemeMsg handler (matches openThemePicker pattern)
- Updated CLAUDE.md and agent docs with theme commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Blade Runner visualizers, theme-conditional UI chrome, IPC vis command

Two new visualizer modes inspired by the film's on-screen electronics:
- Esper: phosphor grid scan with CRT afterglow decay (4-level persistence)
- VoightKampff: concentric iris rings with bass-driven bellows indicator

Theme-conditional UI chrome (active when neon-blade-runner theme is set):
- Seek bar: ▰▱ blocks with ◆ thumb (Voight-Kampff analog meter)
- Album separators: ── ◈ ALBUM ◈ ── (LAPD terminal data headers)
- SSH track glyph: ◉ (replicant eye) instead of ↗
- Time format: T+MM:SS (mission elapsed time)
- Position counter: [SUBJ 003/123] (interrogation notation)
- Loading spinner: cycles ENHANCE / SCANNING / PROCESSING
- Title flicker: 2% chance per frame of 2-3 char CRT glitch cluster
- Track transition: 4-frame ░▒▓█ static burst

IPC visualizer command:
- cliamp vis <name>  — switch visualizer by name
- cliamp vis next    — cycle to next mode (same as v key)
- cliamp vis list    — list all modes with * active marker

Also adds neon-blade-runner-amber theme (1982 LAPD terminal amber phosphor).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor PR: urfave/cli v3, consolidate duplications, fix SSH+ffmpeg bug

CLI framework:
- Replace hand-rolled flag parser with urfave/cli v3 command tree
- Move command definitions to commands.go, keep main.go focused on TUI startup
- Rename --volume/--theme flags to --vol/--start-theme to avoid subcommand collisions
- Hide unhelpful (default: 0) on int flags where 0 means auto/config

Code consolidation (cmd/playlist.go):
- Use player.SupportedExts instead of duplicate audioExtensions (adds 4 missing formats)
- Use resolve.CollectAudioFiles instead of duplicate walkDir
- Use playlist.TrackFromFilename instead of local copy (includes sanitizeTag)
- Add bulk AddTracks() to avoid N+1 file open/close per track
- Add provider.Exists() using os.Stat instead of parsing all TOML files
- Extract newProvider() and collectLocalAudio() helpers

Architecture fixes:
- Unify MPRIS/IPC message types via internal/control package with type aliases
- Remove ~30 lines of duplicated handler cases in update.go
- Add SSH+ffmpeg guard in pipeline.go (was silently passing ssh:// to ffmpeg)
- Create internal/sshurl for proper URL parsing with port support
- Replace macOS-only afinfo with cross-platform ffprobe for remote duration probing
- Add consistent StrictHostKeyChecking=yes to all SSH call sites
- Fix StrictHostKeyChecking docs to match code (yes, not accept-new)

IPC fixes:
- Remove TOCTOU os.Stat before DialTimeout in ipc/client.go
- Remove unnecessary 64KB scanner buffer in ipc/server.go
- Add nil-guards on msg.Reply for ThemeMsg and StatusRequestMsg
- Change ipcSend/overridesFromFlags to return errors instead of os.Exit

Removals:
- Remove CLAUDE.md
- Remove Esper and Voight-Kampff visualizers
- Remove neon-blade-runner theme-conditional chrome
- Delete hand-rolled ParseFlags and helpers from config/flags.go

* Remove review file

* Add Local provider pill, L keybinding, default radio startup

- Register Local as a provider pill tab (after Radio)
- Add L keybinding to switch to Local provider for browsing TOML playlists
- Load 3 cliamp radio streams directly on default startup (no async M3U)
- Skip playlist resume on default startup so radio streams aren't replaced
- Only allow position resume (SetResume) when CLI args given, never ResumePlaylist
- Update keybinding docs, keymap overlay, and site

---------

Co-authored-by: Tom di Mino <contact@tomdimino.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:46:17 +02:00
Bjarne Øverli 77cac44689 Split the model into smaller files 2026-03-30 16:52:25 +02:00