Commit Graph
361 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 4.8 b7015b1ede file browser: type-ahead jump-to-item, flips to filter on longer bursts
Replaces the old "any key focuses the search box" with classic file-manager
type-ahead in FileGrid: keys within 1s accumulate into a burst. 1–2 chars select
and scroll to the first item (folder or file, in sort order) whose name starts
with the burst; the 3rd char flips it into the search filter (dumps the typed
text into the box and hands off focus, cursor at end). Idle >1s resets the burst.
Removed the single-key focus-search branch from useFileBrowserApp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:06:20 +00:00
pastilhasandClaude Opus 4.8 184adc0e8e soulseek: queue downloads straight out of the cached tree
a browsed folder is a path, not a file list, and what clicking one means is
"everything under here" — so the expansion happens in the sidecar, off the
cache, rather than making the browser walk the tree a level at a time and
rebuild paths it only half knows.

browse reports file names as basenames, unlike search, so the peer's real
path is rejoined from the folder row. sizes come from the cache too: slskd
matches a queued download on filename AND size, so a number supplied by the
client would be a transfer that silently never starts.

a subtree can be the peer's whole share (284k files on one measured peer), so
an over-limit request is refused with its count rather than truncated into a
partial download nobody asked for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:57:01 +00:00
pastilhasandClaude Opus 4.8 b94dccd17c soulseek: open a filtered row only when the filter hit something below it
every row in the filtered skeleton was rendered open, so searching a band
name unfolded each of its albums into a wall of tracks that matched nothing.
a row now starts open only when the filter matched a descendant; one that
matched on its own name stays shut, and opening it leaves the filter behind
and browses its real contents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:37:25 +00:00
pastilhasandClaude Opus 4.8 85b7337b68 soulseek: let a filtered row open into its real contents
the tree search asked for no limit and clampInt read a missing param as 0
(Number(null) is 0, and 0 is finite), so it clamped to the minimum and ran
with LIMIT 1: one match came back, its siblings looked like non-matches, and
a folder with 25 matching albums showed none of them.

on top of that, a filtered row was a dead end — it only ever showed the
matches, with a note counting what it was hiding. now the note is the same
"show all N folders" button browse mode already had: clicking it drops that
row out of the filtered set, so from there down it is ordinary lazy
browsing, which is usually why you searched for the folder in the first
place. dropped the "don't match the filter" wording with it, since a
truncated result makes that sentence false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:27:10 +00:00
pastilhasandClaude Opus 4.8 1159c0787d soulseek: render cached shares as a tree, not a path list
slskd's browse response is flat: every folder is a full backslash-delimited
path. Rendered as-is, a filter for "pogues" gave 26 rows that all began with
the same 31 characters, and the real hierarchy — which is the only way to tell
an artist folder from an album folder — was invisible.

The shape is now derived once, at ingest, in the sidecar: buildTree() links
each path to its parent, synthesizes any ancestor slskd omitted (measured:
exactly one missing across ~30k folders on two real peers, but a single gap
would strand a whole subtree), and rolls subtree file counts and sizes up
bottom-up. A parent's own files are usually just cover art, so the number
worth showing on a collapsed row is the subtree's.

Storing the shape rather than recomputing it is what lets the UI open one
level at a time. Levels are still paged, because fan-out is brutal — the
widest folder measured has 1,181 children.

Filtering keeps the tree instead of falling back to a list: the search route
returns matches plus every ancestor, and the UI renders that skeleton
pre-expanded, so you see where a hit lives. Matching runs against the whole
path, so a matched folder implies its descendants match too and a matched
subtree arrives complete. The match cap is reported in the payload and shown
in the UI rather than passed off as the whole answer.

The two existing snapshots were backfilled by scripts/rebuild-soulseek-tree.ts,
which runs the same buildTree + finishSoulseekBrowse the ingest path runs — no
second implementation to drift, and no peer contact needed. Kept for the next
time the tree shape changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:05:22 +00:00
pastilhasandClaude Opus 4.8 1ac5bffb6c soulseek: cache peer share trees server-side
Browsing a peer inline could never work. slskd answers GET /users/{u}/browse with
the entire tree in one blocking response — measured at 59 MB / 18k folders / 284k
files for a single real peer — and it takes minutes because it round-trips to that
peer. The browser was made to wait for that, so navigating away threw the whole
thing out and the panel showed an error more often than a tree.

So the fetch moves into the sidecar and the result into Postgres. Clicking "fetch
shares" returns 202 and the job keeps running without the tab; the UI polls the
snapshot row and reads back pages. Folders are rows and files ride along as jsonb
on their folder, because folders are what you filter and page through while files
are only ever read for the one folder you opened — a row per file would be 284k
rows per peer for no gain.

A failed or in-flight refresh deliberately leaves the previous folders in place: a
peer going offline shouldn't cost you a good cache, so the panel drives off rows
existing rather than off status. Interrupted 'pending' snapshots are failed at
sidecar boot, since the job died with the process and would otherwise spin forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:59:45 +00:00
pastilhasandClaude Opus 4.8 df3643612c soulseek: fix browse crashing on the wrapped share-tree response
GET /users/{u}/browse returns { directories, directoryCount, lockedDirectories,
lockedDirectoryCount }, not the bare Directory[] that 0.26.0's source suggests,
so spreading it threw "E is not iterable" and every browse failed. Read
.directories, tolerating both shapes since the two disagree.

Also note in the type that browsed files carry only a basename, unlike search
results which carry the full remote path slskd needs to enqueue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:47:50 +00:00
pastilhasandClaude Opus 4.8 ef541bbc1b soulseek: always-visible search delete, clear all, brighter header
The per-search remove button was hover-gated with opacity-0, so it read as
absent until you happened to hover the row. Make it always visible and red on
hover, and add a Clear all next to refresh. slskd has no bulk delete (only
DELETE /searches/{id}), so clearing fans out one request per search, drops each
cached result set, and reconciles with a reload — partial failures surface in a
toast. The destructive click arms once and disarms itself after a few seconds
instead of opening a modal, matching the other panels, which confirm nothing.

That header row sits outside any card, where the muted zinc greys wash out
against dark mode's green background — switch the whole level to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:36:42 +00:00
pastilhasandClaude Opus 4.8 b00608f3b3 soulseek: peer menu with browse and favorites
slskd has no favorites or buddy-list concept — 0.26.0's UsersController exposes
only endpoint/browse/directory/info/status — so Officer owns that data itself:
a soulseek_favorites table served by the slskd sidecar under a /_officer/*
namespace, which can never collide with slskd's /api/v0/*. The main server
gains exactly one line, injecting X-Officer-User on the proxy hop, so it stays
a thin auth proxy and grows no Soulseek logic. The route is handled before the
upstream check, so favorites keep working with slskd down.

Usernames in search results and downloads become a dropdown (browse shares,
toggle favorite). Browsing publishes to a nonce-stamped, consumed-once channel
so the Users section looks the peer up without re-running the expensive browse
on every remount, and favorites get their own section at the top of that panel,
which doubles as its landing content. CardHeader had to split its toggle row to
host the dropdown, since a trigger can't live inside the collapse button.

The schema file is deliberately self-contained so it can move wholesale into
the sidecar directory when sidecars start owning their own schema. Its DDL was
applied by hand, matching drizzle's constraint naming, rather than running a
whole-schema push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:36:37 +00:00
pastilhasandClaude Opus 4.8 f777ed4197 soulseek: revalidate cached search results instead of freezing them
The results cache was write-once, read-forever: opening a search before its
first responses landed cached the empty grouping, and every later visit
short-circuited on it — the history row showed results while the detail view
stayed empty, permanently.

Serve the cache as a first paint only and always re-fetch behind it, poll
while the search is still running so responses fill in live, and add a manual
refresh. Collapse state now survives a revalidation (only newly arrived users
auto-expand), the empty state distinguishes "still searching" from "no
results", and deleting a search drops its cache entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 22:37:57 +00:00
pastilhasandClaude Opus 4.8 fd203138bf client: tolerate empty response bodies on 2xx
Every verb ended in an unconditional res.json(), which throws
"Unexpected end of JSON input" on 201/204 responses that carry no body —
so a request that actually succeeded still surfaced as an error (e.g. a
sent chat message toasting a failure). Read text first and only parse
when non-empty, otherwise resolve undefined. Non-empty JSON is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:35:40 +00:00
pastilhasandClaude Opus 4.8 f4a47a035a soulseek: wire sections into the view, default to dashboard, persist zoom
- render dashboard/uploads/rooms/chat/users/system from the section channel
- nav + view both default to the dashboard section
- register the view panel's zoom header; read the persisted zoom via useDashboardState

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:30 +00:00
pastilhasandClaude Opus 4.8 5ea06e2d8c soulseek: dashboard, uploads, rooms, chat, users, and system panels
- dashboard: connection health, live download/upload tallies, recent searches
- uploads: mirror of downloads (what peers pull from us), per-row cancel/clear
- rooms: join chat rooms, transcript + composer, available-room datalist
- chat: private 1:1 conversations with unread badges and self-aligned bubbles
- users: look up a peer's presence/profile and browse their shared folders
- system: server connection state with connect/disconnect and version info
- view header: +/- zoom controls persisted per panel
- search view: restyle history to the shared dark (zinc) theme

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:25 +00:00
pastilhasandClaude Opus 4.8 e25143cc9c soulseek: download bulk actions, queue position, per-folder download
- downloads: split into active/completed with a bulk toolbar (retry errored,
  cancel all, remove completed) mirroring slskd's downloads page
- downloads: queued rows hover to show place-in-queue and click to force a refresh
- search results: add a per-folder "Download folder (N)" button alongside the
  per-user download-all

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:16 +00:00
pastilhasandClaude Opus 4.8 520944c6ca soulseek: shared slskd types, helpers, and card primitives
- add types for transfers, private conversations, users, rooms, and server state
- add formatClock / folderLabel / formatDuration helpers and the groupResponses shaper
- move the per-panel zoom key into the persisted screens/ namespace
- extract shared dark-theme Card / SubCard / RowList primitives used by the panels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:07 +00:00
pastilhasandClaude Opus 4.8 e5950b6493 music: dismiss the dock when playback ends
The in-flow music dock reserves its height whenever a track is loaded (current),
even paused — so after a queue finished (onEndOfQueue set playing=false but kept
the queue) the dock lingered, shifting the layout up with a faint idle bar. On
natural end-of-queue, close() the queue so the dock releases its space. The saved
now-playing snapshot is untouched, so reload still resumes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:34:44 +00:00
pastilhasandClaude Opus 4.8 c1594594f5 soulseek: nav sections, search results subpanel, stable client deps
- split into nav (vertical section menu) + view panels via soulseek:section channel
- search view: history list + input, opens results subpanel for a past search
- search results load stored responses (no re-run), session-cached, ranked
- remove client from effect/callback deps (useClient is a fresh object per render)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:19:39 +00:00
pastilhasandClaude Opus 4.8 2a509646e7 slskd: /soulseek search + downloads on the Workspace/Panel framework
Two panels (search + transfers) via WorkspaceView, coordinating over the
soulseek:refresh channel. Adds the page-title rule and documents the
route conventions in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:21:29 +00:00
pastilhasandClaude Opus 4.8 362b806c8a file browser: restore the inline video-download modal, drop the side panel
Reverts the video-download UI from the ephemeral side panel back to the modal
(VideoDownloadDialog) that predated it — the "download video" button opens the
modal again and downloads inline via /download-video, exactly as before. The
six wiring files had no non-download changes since the modal→panel conversion,
so they're restored verbatim from that commit's parent; the panel file is
removed. The inline ReClip routes it uses are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:42:27 +00:00
pastilhasandClaude Opus 4.8 f3dc4415bb music: per-device now-playing (browser vs phone)
Key music_now_playing on (user_id, device) instead of user_id alone so the
browser ('web') and phone ('' default) each keep their own resume snapshot
instead of sharing one row. Sidecar /now-playing threads device (?device=,
default '') through get/set/clearNowPlaying; the web client tags its calls
?device=web. Phone unchanged → '' bucket, inherits the existing row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 03:13:16 +00:00
pastilhasandClaude Opus 4.8 b2fb6f148c replace hardcoded download job with download-media script capability
move video/audio downloads off the dedicated ReClip download lane onto the
generic script-job path:

- delete execute-download.ts, reclip-client.ts and the POST /jobs/download
  endpoint; drop the 'download' mode from the pipeline_jobs enum (legacy rows
  tolerated)
- execute-script.ts: strip the @@officer:progress@@ sentinel from the log,
  emit progress events, and isolate viewer/log writes (safeEmit/safeLog) so a
  broadcast or log throw can't wedge the stdout pump
- pipeline-job-manager.ts: persist latest progress; guard sendToViewer sends
- ScriptJobDetail: render the two progress bars; DownloadJobDetail kept for
  legacy history rows
- TaskRunnerModal: ScriptRunner descends into the triggered directory
- VideoDownloadPanel: rewire startJob to the script-job path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 01:10:32 +00:00
pastilhasandClaude Opus 4.8 459b8ab730 download job: restore the metadata phase — ReClip needs the title for filenames
Verified against live ReClip: POST /api/download with title:"" → a hash filename
(b5d04adc86.mp3); with title:"Me at the zoo" → "Me at the zoo.mp3". So ReClip
names the file from the title WE send (falling back to a hash) — it does not
self-name. The title is mandatory, which means a metadata pass is required.

Back to two phases:
1. metadata — fetch each item's /api/info (title + validity), keep survivors, skip
   errors.
2. download — download each survivor passing its title, so files land with real
   names; skip download errors.

Keeps the exact-urls[] input (Mix playlists can't drift) and the one-request-per-
item download. Progress is two counters again (Titles + Download); UI shows two
bars. ~2 requests/item is inherent to needing the title (per the user's call:
correctness over speed).

Verified two-phase filtering + title passthrough + skip-on-error with a mock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 19:17:27 +00:00
pastilhasandClaude Opus 4.8 7d149bd4d1 download job: use the panel's exact url list (no server re-expansion)
A YouTube Mix/radio playlist (list=RD…) returns a different set of items on every
/api/playlist call (observed 779 / 1485 / 529 for the same URL). The job used to
re-expand the playlist server-side, so it would download a different list than the
count shown on the decision screen.

The panel now passes the already-expanded `urls[]` into the job, and the executor
uses them verbatim (falling back to expanding `url` only when no list is given).
The job downloads exactly what you decided on. Endpoint takes `urls[]` (stored as
inputs.urls) or `url`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:44:45 +00:00
pastilhasandClaude Opus 4.8 c55ec5884b download job: collapse to one phase (no metadata prefetch)
The job's two phases were a misread — the "count" phase is the client-side
playlist expansion (for the inline-vs-job decision, already done in the panel).
The job itself is just one download request per item.

Dropped the in-job metadata pass entirely:
- reclip-client: reclipDownloadOne no longer prefetches /api/info for a title —
  ReClip names the file from the video title itself, so it's a single request
  per item.
- execute-download: one phase — expand the playlist, then /api/download each url,
  skip failures. Progress is a single { done, failed, total, current } counter
  (no meta/dl split); ~2× faster and downloads start right after expansion.
- UI (DownloadJobDetail + panel JobView): one "Downloaded" bar instead of two.

Verified: every item is attempted directly (no /api/info gate), skip-on-error
counts correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:35:10 +00:00
pastilhasandClaude Opus 4.8 bc1b799a27 jobs: download-job UI — panel decision screen + live progress + /jobs renderer
Front half of the download-job feature.

Panel (VideoDownloadPanel): after Fetch expands a playlist and the count is known,
a decision screen — "Found N items" → pick Audio/Video + subfolder → "Download all
as a job" (POST /jobs/download), or "fetch inline to pick individually" (the
existing card grid). A single video still goes straight to the inline card. The
job phase shows live two-phase progress (polled from the job) + a "View in Jobs"
link; it notes the job runs server-side so closing the panel is fine, and it
refreshes the browser as each file lands.

/jobs (DownloadJobDetail + JobsPage dispatch): a `download` job renders a compact
two-phase readout — Metadata and Download bars (processed/total, found/skipped and
saved/failed) + the current item — polled from the job's progress, with a Stop.

Executor tweak: phase-1 meta.done now counts kept (not processed) so both phases
read the same `(done+failed)/total`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:20:07 +00:00
pastilhasandClaude Opus 4.8 612fd18c41 video download panel: per-format actions (video/audio) + select mode
Reworks the download model around a format choice (video = ReClip video, audio =
audioOnly) instead of a global "extract audio" toggle:

- Input phase is just URL + Fetch (dropped the audio checkbox).
- Each card tracks video + audio download state independently and shows a button
  per format (Video only when ReClip reports video formats — audio-only sources
  get just Audio).
- Playlist top actions: "All video" (when any item has video), "All audio", and
  "Select".
- Select mode: each card shows Video/Audio checkboxes (pick one, both, or none per
  item); a "Start download (N)" button runs the chosen set and shows a fake
  progress bar (N/M count, no real byte progress). Bulk "All video/audio" reuse
  the same progress bar.
- Single item = large card with the two format buttons, no bulk row.

VideoInfo gains `formats` so the client can tell video-capable from audio-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:32:06 +00:00
pastilhasandClaude Opus 4.8 46b1d62138 video download panel: grid layout — numbered playlist cells + large single card
Uses the resizable panel + container queries instead of a cramped vertical list:
- Single item → one large card (big 16:9 thumbnail, title, uploader·duration,
  full-width download button).
- Playlist → a responsive grid (2 cols, 3 at @520px, 4 at @760px of panel width)
  of compact cells, each with a position number badge and an overlay download/
  status chip.
Loading skeletons, error, and per-cell download states (download → downloading →
saving → saved / retry) carry over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:15:21 +00:00
pastilhasandClaude Opus 4.8 0a8ec845ef web: music dock joins the layout flow — removes the nav-dock overlap hacks
The music dock was position:fixed, overlaying the bottom of the content, so the
nav dock needed a pile of hacks to dodge it: a hand-measured MUSIC_DOCK_HEIGHT
(72) constant, a presence flag, a translateY lift, and a matching hover-threshold
lift (via a ref) so it wouldn't slide away under the cursor. Fragile the moment
the music dock's height changed.

Now the dock is an in-flow bottom bar that reserves its own height:
- DashboardLayout is a flex column: the content region (flex-1, min-h-0) shrinks
  when the music dock takes its space; MusicPlayerHost renders an in-flow bar
  (shrink-0) instead of a fixed overlay.
- The nav Dock is absolute within the content region and measures its reveal/hide
  boundary from that region's bottom edge (a boundaryRef) — so it always sits just
  above whatever's at the bottom, music dock or not, with zero knowledge of it.
- Deleted MUSIC_DOCK_HEIGHT, musicDockPresent, the lift, and the transform hack.

Bonus: content at the very bottom is no longer hidden under the fixed dock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:05:36 +00:00
pastilhasandClaude Opus 4.8 cc141ae91a web music: closing the dock clears the saved now-playing (so it stays closed)
The dock's X only reset local player state; the server's now-playing snapshot
survived, so a reload restored the dock. Closing now also DELETEs /music/now-playing
before clearing the queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:56:40 +00:00
pastilhasandClaude Opus 4.8 0ab82964b9 file browser: video downloader as an ephemeral side panel (was a modal)
Moves the video-download UI from a modal into the same ephemeral side-panel slot
the file viewer uses (double-click a video), per request. It's driven by the
`download` search param (the target folder) + `downloadRoot`, exactly like the
existing view/play/chat ephemeral panels.

- layouts: singleDownloadLayout (files-download panel).
- VideoDownloadPanel (new, apps/FileBrowser): self-contained — reads the target
  folder/root from the params, does its own useFilesAPI, prefetch + per-entry /
  download-all flow (unchanged from the dialog), and bumps the shared
  `files:refresh-signal` so the browser re-lists when a file lands. Header shows
  a "Saving to <folder>" hint.
- useFileViewerPanels: register files-download (param → layout → panel + close),
  add download/downloadRoot to the on-refresh cleanup keys.
- useFileBrowserApp: replace the showVideoDownload modal state with
  openVideoDownload(), which sets the download param for the current folder+root.
- Toolbar + FileViewContainer trigger openVideoDownload(); drop the modal mount
  and delete VideoDownloadDialog.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:33:51 +00:00
pastilhasandClaude Opus 4.8 f18d19e7d2 file browser: video download — metadata prefetch + playlist handling
Reworks the download-video dialog into a prefetch-then-download flow, mirroring
ReClip's own web UI. Still a pure proxy to ReClip (its yt-dlp); no downloader
logic moves to the platform.

Server (thin ReClip proxies alongside /download-video):
- POST /file-browser/video-info { url }     → ReClip /api/info → { title, thumbnail, duration, uploader }
- POST /file-browser/video-playlist { url } → ReClip /api/playlist → { urls }
  Both return { error } inline (200) so the client can render failures per-card.

UI (VideoDownloadDialog, now self-contained; useFileBrowserApp exposes `files`
and drops the old single-shot state/handler):
- Paste a URL → Fetch. A playlist URL (list=) expands via /video-playlist, then
  each entry's /video-info is prefetched sequentially (ReClip does yt-dlp per
  video), rendering a card (thumbnail, title, uploader, duration) that fills in
  progressively.
- Per-entry Download, plus Download All when there's more than one; per-card
  status (downloading → saving → saved / retry-on-error) via the existing
  background job + poll.
- Playlists get an optional "subfolder you name" field (ReClip's /api/playlist
  carries no playlist title); blank = current folder.
- Quality is always best (matches the mobile Share flow — no picker); the
  audio-only toggle applies to the whole batch.

Verified ReClip's contract live: /api/info returns the metadata fields, and
/api/playlist returns { urls } (17 entries in ~1.2s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:19:07 +00:00
pastilhasandClaude Opus 4.8 43a4372ba3 web /music: reindex now refreshes BOTH panels in place
The MusicBrowser and MusicDetail panels each kept their OWN local `manifest`
(and folder-listing) state, fetched in their own effects. The reindex (↻) button
lives in MusicBrowser and only refreshed its own copy — MusicDetail (the right
panel showing the tracklist/grid) never heard about it, so newly-indexed content
only appeared after navigating (which re-ran its effects).

Add a shared `music:resync` panel channel: when a reindex completes, MusicBrowser
bumps it to a fresh nonce, and both panels re-run their manifest/libraries/folder
fetches. In MusicDetail the fresh manifest object identity also re-triggers the
[cwd, manifest] listing effect, so the open album's meta/tracklist and any folder
grid refresh in place — no navigation required. Drops the browser's now-redundant
hand-refresh of its own state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:16:11 +00:00
pastilhasandClaude Opus 4.8 597675d6a0 fix: paused track resumed on any page click (unlock listener overrode pause)
The AudioContext autoplay-unlock listener (pointerdown → ctx.resume()) was
persistent, so every click anywhere resumed the context — including after a
deliberate pause (pause = ctx.suspend()). Result: pause, click back on the page,
and the track resumed from its position while React's `playing` stayed false, so
the button showed "paused" and it took two clicks to actually stop it.

The gesture only needs to unlock the context ONCE; sticky activation lets
engine.play() resume it thereafter. Make the listener { once: true } so it can't
fight an intentional pause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:44:25 +00:00
pastilhasandClaude Opus 4.8 f0d475ea2e fix: gapless player wiped the queue on natural track end (stale closure)
The engine is created once (mount effect), so its onIndex/onEndOfQueue callbacks
captured the first-render syncIndex/setPlaying. useGlobal's setData reads the
`data` from the render that created the setter when given a functional updater —
that's the INITIAL empty state. So when a track ended and the engine called
syncIndex(i) → setState(s => ({...s, index:i})), `s` was {queue:[], index:0}:
the queue got wiped, `current` went undefined, playback stopped and the dock
vanished. Manual track selection was unaffected because playQueue passes a plain
object (no stale `data` read) — which is why it seemed to work.

Route the two engine-invoked setters through refs kept current each render, so a
natural advance mirrors into the live state instead of the stale initial one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:33:31 +00:00
pastilhasandClaude Opus 4.8 0ef8e9fbd2 web /music: sample-accurate gapless playback (Web Audio)
The player used a single <audio> element and advanced by swapping .src, which
re-fetches + re-buffers the next file — an audible gap between tracks. For a
continuous DJ mix (silence already trimmed at the edges) that's the whole
problem. Replace the <audio> element with a Web Audio engine that decodes each
track to an AudioBuffer and schedules the NEXT track's source to start() at the
exact AudioContext time the current track ends → sample-accurate, zero gap on
auto-advance.

- gapless-engine.ts (new): AudioContext + gain, LRU-capped decoded-buffer cache,
  fetch-whole-file → decodeAudioData, boundary scheduling, seek/skip/play-pause
  (pause = ctx.suspend so the clock + scheduled next freeze together), a
  generation counter to invalidate stale onended/async, and a gesture unlock for
  autoplay policy. Callbacks: onIndex/onTime/onEndOfQueue/onLoadingChange.
- MusicPlayerHost.tsx: drives the engine instead of an <audio> element. React
  keeps the queue/index (useMusicPlayer); user actions (new album, jump, prev/
  next) command the engine, and the engine's own natural advance mirrors back
  via syncIndex WITHOUT restarting playback (that's what keeps the seam gapless).
  Preserves restore/persist/heartbeat/album-nav/volume/heart; adds a decode
  spinner on the play button (startup/skip has fetch+decode latency by nature).
- useMusicPlayer.ts: syncIndex() — set index without touching `playing`.

Trade-off (chosen deliberately over near-gapless preloading): true gapless
needs the whole next file decoded to PCM ahead of time (~200MB per 10-min
track), so the buffer cache is capped at 3. Verified the scheduler state
machine with a mocked AudioContext: next track scheduled at the current's exact
end sample, advance/promote/seek/skip/end-of-queue all correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:18:09 +00:00
pastilhasandClaude Opus 4.8 11263ce120 task modal: audio info panel for Get Lyrics (title/artist/length/lyrics)
Display-only panel at the top of the Get Lyrics run-task form (single file only):

- Backend: GET /file-browser/audio-meta?path= — ffprobe format tags + duration,
  plus a second probe for embedded lyrics (USLT/SYLT/lyrics* keys, case-insensitive).
  Returns { title, artist, duration, hasLyrics }; tolerant of missing tags/probe
  failures.
- Client: files.audioMeta(path) + AudioMeta type in useFilesAPI.
- TaskRunnerModal: prefetch audioMeta for get-lyrics single-file runs (bypasses
  the hasTrackPickers early-return, error-tolerant), and render AudioMetaPanel
  above TaskInputForm — title/artist + a muted length + Lyrics: Yes/No chip,
  filename fallback. Directories + other tasks unaffected (no panel, no probing).

Verified ffprobe logic on a real embedded-lyrics file. tsgo clean; formatted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:07:18 +00:00
pastilhasandClaude Opus 4.8 48d24544f3 task modal: add multi-line 'text' input type (textarea)
`type: string` renders a single-line <input>; the new `type: text` renders a
resizable multi-line <textarea> (6 rows) — for pasted multi-line values like
lyrics. Checked before options/default so `text` always means free-form text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:54:27 +00:00
pastilhasandClaude Opus 4.8 ee6c7b0fdd music (web): Favorites view (inspired by the app's FavoritesScreen)
A heart button in the left panel header opens a Favorites view in the right
panel (coordinated via a new music:favorites panel channel). Grouped
Artists / Albums / Tracks, each row: cover thumb (indexed cover, icon fallback)
+ title/subtitle + a heart to un-favorite. Click an album/artist to navigate
the library there; click a track to play it in album context. Navigating
anywhere closes the view (cwd-change effect). Empty state prompts to heart
something.

useMusicFavorites now also returns the grouped `favorites`; shared gains the
channel + parseAlbumName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:40:33 +00:00
pastilhasandClaude Opus 4.8 f887fb4679 music (web): cursor-pointer on dock controls (Tailwind v4 no longer defaults it)
Add cursor-pointer to the player dock's buttons — track-info, prev/next (with
disabled:cursor-default), play/pause, mute, and close — and to MusicHeart (so
every heart across the player/tracklists gets it). The volume slider and SeekBar
already had it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:58:59 +00:00
pastilhasandClaude Opus 4.8 3f2efbf852 music (web): click the dock track info to open its album in /music
The bottom player dock's cover + title/artist is now a button — clicking it sets
the music:cwd channel to the playing album and navigates to /music, landing on
that album's tracklist (current track highlighted). Uses the global panel
channel + react-router navigate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:55:53 +00:00
pastilhasandClaude Opus 4.8 5b17121de8 music (web): browser filter + reindex; richer album tracklist
Left panel (MusicBrowser):
- Fuzzy filter input (case-insensitive subsequence match) over the current
  library/folder list; resets on navigation, with a clear button + "No matches".
- Reindex button (spins while running) → POST /music/reindex, then refreshes the
  manifest and current listing.

Right panel album view (MusicDetail):
- Current track clearly highlighted: primary tint background + a Volume2 marker
  replacing the track number + medium weight.
- Every track shows its artist (falling back to album artist) under the title,
  and its duration on the right. Web Track type gains albumArtist + durationSec;
  fmtDuration/fuzzyMatch added to shared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:52:34 +00:00
pastilhasandClaude Opus 4.8 3924155bfb music (web): larger, spaced browser rows with cover thumbs; hide dotfiles
Left panel (MusicBrowser) polish + a shared fix:
- Larger row text (text-base; "Music" heading text-lg).
- More space between rows (gap-1.5 + py-2).
- Leading thumbnail is the folder's indexed cover (its folder.jpg/cover.jpg,
  server-compressed) with a Folder/Library icon fallback when there's none or
  the image fails.
- Hide hidden files/folders (dotfiles like .claude) in the listing — applied to
  MusicBrowser and the MusicDetail libraries/grid so neither panel shows them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:38:09 +00:00
pastilhasandClaude Opus 4.8 0995aaa8af music (web): resume currently-playing on reload/return
The web player kept no durable state, so a reload (and a return to /music,
whose cwd resets) lost your place. Wire it to the platform's per-user
/api/music/now-playing (same endpoints the app uses):

- MusicPlayerHost persists a snapshot (track + position) on play/pause + track
  change + a 10s heartbeat (position read via ref so the heartbeat stays live).
- On first load with an empty queue it restores that snapshot: rebuilds the
  album queue (sortTracks), loads it PAUSED (browsers block autoplay on reload),
  and seeks to the saved position once metadata is in. A restore guard stops the
  load from clobbering the saved position with 0.
- MusicDetail auto-opens the currently-playing album once on mount (when it has
  no location yet), so /music lands on the track — without yanking you back
  after you navigate away.

Adds loadQueue() (paused) to useMusicPlayer + a NowPlaying type. tsgo clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:24:47 +00:00
pastilhasandClaude Opus 4.8 759dcb8b5b music (web): artist heart on non-discography artist pages too
The artist heart only rendered in the discography view (disco && !album), which
needs a _discography.md — so artists without one (plain grid of album folders)
had no heart. Add an artist header + heart to the grid view when on an artist
folder (crumbs>=2), matching the app's inArtist rule. favKey = artist music-rel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:17:17 +00:00
pastilhasandClaude Opus 4.8 5af3119096 music (web): Favorites hearts in the /music player
Wires the web player to the platform's per-user /api/music/favorites (same
endpoints the app uses). New useMusicFavorites hook (react-query, shared
optimistic cache) + a reusable MusicHeart toggle, placed at:
- album header (album) + each track row (track, reveals on row hover, filled
  favorites stay shown)
- album cards in the artist/grid views (album)
- artist discography header (artist)
- the now-playing player bar (current track)

Track key = homePath "Music/<rel>/<file>"; album/artist keys = music-rel.
No dedicated Favorites browsing view yet (hearts only), matching the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:58:02 +00:00
pastilhasandClaude Opus 4.8 6fc34363c8 music (web): order album tracks by track number, not readdir order
meta.json is written in ffprobe/readdir order (arbitrary), and the web player
rendered/queued it as-is — so albums like Andrew Bird's "The Mysterious
Production of Eggs" showed scrambled (11, 7, 5, 14, 1, …). The web Track type
didn't even carry the `track` tag.

Add `track` to the Track type + a shared sortTracks(): by track NUMBER (parsed
from the "n/total" tag), falling back to tag title only for tracks without a
number. Applied to the rendered tracklist (setAlbum) and both play queues.
Verified it reorders that album to 1→14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:50:34 +00:00
pastilhasandClaude Opus 4.8 54fa21dd46 workspace: panel-level responsiveness via CSS container queries
Panels are now container contexts (@container → container-type: inline-size)
on both the normal and transparent PanelSlot render paths, so panel content
can respond to the *panel's* width instead of only the viewport's. Non-
breaking: existing screens keep their viewport (lg:/md:) variants untouched.

First adopter: the bTop system-monitor grid reflows on panel drag —
grid-cols-1 → 2 at @min-[600px] → 3 at @min-[960px] (panel width), keeping
cards ≥300px so they no longer squeeze on a narrow panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:16:17 +00:00
pastilhasandClaude Opus 4.8 de8f70562b docs: SYSTEM_MONITOR_API.md contract for the app + drop dead Pm2Logs.tsx
Full /api/system-monitor/* contract (stats snapshot incl. cpu/mem/disks/temp/
gpu/net/power, pm2, docker, and the two SSE log streams) with response shapes,
auth (Bearer or ?token=), owner-only note, and the net/power rate caveats — so
the app can implement the same views. Also removes the orphaned Pm2Logs.tsx
(superseded by LogStream) that a prior commit left tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:03:10 +00:00
pastilhasandClaude Opus 4.8 f9752d2868 system-monitor: GPU, Network, and Power cards in bTop
Three more same-level grid cards (bTop now 7 cards; Temperature stays under
Memory via natural 3-col flow):
- GPU: gpu_busy_percent + VRAM used/total from /sys/class/drm (instant).
- Network: ↓/↑ throughput (bytes/sec) from /proc/net/dev deltas between /stats
  calls, aggregate + top interfaces.
- Power: CPU package watts via RAPL energy delta + GPU watts (amdgpu hwmon).

Note: RAPL energy_uj is root-only by default (Spectre-era lockdown), so CPU
package power shows "—" unless made readable (a udev rule); GPU watts work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:59:27 +00:00
pastilhasandClaude Opus 4.8 75a978d2e2 system-monitor: CPU temperature in bTop (Temperature card under Memory)
- Backend: readTemps() scans /sys/class/hwmon for all temp sensors and picks the
  CPU one (k10temp/coretemp/zenpower Tctl/Tdie/Package); added to /stats.temp.
- BtopView: a Temperature card at lg:col-start-2 (under Memory, second row) —
  big CPU °C (color-graded), its sensor label, and the other sensors (GPU, NVMe,
  wifi…) listed beneath.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:51:54 +00:00