From 6bb6626ada9789e1d997378d8ab10783ab5ca158 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 17 Jul 2026 17:00:45 -0700 Subject: [PATCH] juke-wizard: surface live work status --- .../Sources/JukeWizard/JukeController.swift | 111 ++++++++++++++---- juke-wizard/Sources/JukeWizard/Model.swift | 1 + .../Sources/JukeWizard/TrackRowView.swift | 29 ++--- .../Sources/JukeWizard/WorkStatus.swift | 91 ++++++++++++++ juke-wizard/Sources/JukeWizard/main.swift | 11 ++ juke-wizard/bin/jukewizard | 16 +++ 6 files changed, 222 insertions(+), 37 deletions(-) create mode 100644 juke-wizard/Sources/JukeWizard/WorkStatus.swift diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift index 59a5bc9c1..e45efcb33 100644 --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -34,6 +34,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, var current: Int = -1 var menuBar: MenuBarCD? var watchTimer: Timer? + var activityTimer: Timer? var watchMtimes: [String: Date] = [:] var keyMonitor: Any? @@ -91,6 +92,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, var titleLabel: NSTextField! var artistLabel: NSTextField! var laneLabel: NSTextField! + var activityLabel: NSTextField! var linkButtons: [NSButton] = [] var wave: WaveformView! var playButton: NSButton! @@ -114,13 +116,21 @@ final class JukeController: NSWindowController, NSWindowDelegate, self.watchDirs = watch self.selectPath = selectArg let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1120, height: 720), + contentRect: NSRect(x: 0, y: 0, width: 820, height: 540), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) window.title = "JukeWizard — \(library.tracks.count) tracks" + // JukeWizard is a compact listening utility: keep it visible above + // normal document windows and available across Spaces. Previously it + // could fall behind a full-screen development stack while its process + // remained healthy, which looked exactly like a crash. + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.hidesOnDeactivate = false + window.isMovableByWindowBackground = true window.isRestorable = false // don't let AppKit re-select a stale row over our pick window.isReleasedWhenClosed = false // keep it around so the menu-bar CD can reopen it - window.minSize = NSSize(width: 720, height: 460) + window.minSize = NSSize(width: 640, height: 420) window.center() super.init(window: window) window.delegate = self @@ -137,11 +147,19 @@ final class JukeController: NSWindowController, NSWindowDelegate, } else if !library.tracks.isEmpty { select(0, autoplay: false) } } else if !library.tracks.isEmpty { select(0, autoplay: false) } armWatch() + armActivityStatus() installKeyMonitor() } required init?(coder: NSCoder) { fatalError() } deinit { if let m = keyMonitor { NSEvent.removeMonitor(m) } } + // Keep the single player window alive: the menu-bar CD and Dock icon can + // restore it instantly, and playback/queue state cannot be lost on close. + func windowShouldClose(_ sender: NSWindow) -> Bool { + sender.orderOut(nil) + return false + } + // ── keyboard control (yields to text editing) ──────────────────────── private func installKeyMonitor() { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] e in @@ -180,13 +198,16 @@ final class JukeController: NSWindowController, NSWindowDelegate, nowPlaying = NowPlayingMedia(frame: .zero) content.addSubview(nowPlaying) - titleLabel = label("", size: 24, bold: true) + titleLabel = label("", size: 19, bold: true) titleLabel.lineBreakMode = .byTruncatingTail titleLabel.textColor = Palette.gold artistLabel = label("", size: 13, color: NSColor(white: 0.85, alpha: 1)) laneLabel = label("", size: 11, color: .secondaryLabelColor) laneLabel.lineBreakMode = .byTruncatingTail content.addSubview(titleLabel); content.addSubview(artistLabel); content.addSubview(laneLabel) + activityLabel = label("● watching agents + renders", size: 10, color: Palette.teal) + activityLabel.lineBreakMode = .byTruncatingTail + content.addSubview(activityLabel) for svc in LinkService.allCases { let b = NSButton(title: svc.title, target: self, action: #selector(linkClicked(_:))) @@ -244,6 +265,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, listTable.selectionHighlightStyle = .none listTable.dataSource = self listTable.delegate = self + listTable.setDraggingSourceOperationMask([.copy], forLocal: false) listTable.target = self listTable.action = #selector(listClicked) listScroll = NSScrollView() @@ -359,22 +381,24 @@ final class JukeController: NSWindowController, NSWindowDelegate, private func relayout() { guard let content = window?.contentView else { return } let W = content.bounds.width, H = content.bounds.height - let pad: CGFloat = 12 + let pad: CGFloat = 8 // ── header (now-playing) across the top ─────────────────────────────── - let headerH = max(240, min(380, H * 0.46)) + let headerH = max(178, min(245, H * 0.39)) let headerBottom = H - headerH - let mediaSide = min(headerH - pad * 2, W * 0.44) + let mediaSide = min(headerH - pad * 2, W * 0.34) nowPlaying.frame = NSRect(x: pad, y: headerBottom + pad, width: mediaSide, height: headerH - pad * 2) - let rx = pad + mediaSide + 14 + let rx = pad + mediaSide + 10 let rw = max(120, W - rx - pad) - var y = H - pad - 32 - titleLabel.frame = NSRect(x: rx, y: y, width: rw, height: 32) - y -= 24 - artistLabel.frame = NSRect(x: rx, y: y, width: rw, height: 20) - y -= 18 + var y = H - pad - 25 + titleLabel.frame = NSRect(x: rx, y: y, width: rw, height: 25) + y -= 19 + artistLabel.frame = NSRect(x: rx, y: y, width: rw, height: 17) + y -= 15 laneLabel.frame = NSRect(x: rx, y: y, width: rw, height: 16) - y -= 26 + y -= 16 + activityLabel.frame = NSRect(x: rx, y: y, width: rw, height: 14) + y -= 21 var lx = rx // per-service link buttons row for b in linkButtons where !b.isHidden { let bw = b.attributedTitle.size().width + 16 @@ -385,21 +409,21 @@ final class JukeController: NSWindowController, NSWindowDelegate, // transport row pinned to the header's bottom edge let transY = headerBottom + pad - transportExtra[0].frame = NSRect(x: rx, y: transY, width: 40, height: 30) // prev - playButton.frame = NSRect(x: rx + 44, y: transY, width: 54, height: 30) - transportExtra[1].frame = NSRect(x: rx + 102, y: transY, width: 40, height: 30) // next - notesToggle.frame = NSRect(x: rx + 150, y: transY, width: 92, height: 30) - ledLabel.frame = NSRect(x: rx + rw - 160, y: transY + 5, width: 160, height: 22) + transportExtra[0].frame = NSRect(x: rx, y: transY, width: 34, height: 25) + playButton.frame = NSRect(x: rx + 37, y: transY, width: 44, height: 25) + transportExtra[1].frame = NSRect(x: rx + 84, y: transY, width: 34, height: 25) + notesToggle.frame = NSRect(x: rx + 124, y: transY, width: 75, height: 25) + ledLabel.frame = NSRect(x: rx + rw - 135, y: transY + 3, width: 135, height: 20) // waveform fills the space between the links row and the transport let waveTop = linksBottom - 6 - let waveBottom = transY + 38 - wave.frame = NSRect(x: rx, y: waveBottom, width: rw, height: max(44, waveTop - waveBottom)) + let waveBottom = transY + 31 + wave.frame = NSRect(x: rx, y: waveBottom, width: rw, height: max(32, waveTop - waveBottom)) // ── track list underneath ───────────────────────────────────────────── - let sortY = headerBottom - 4 - 22 - sortPopup.frame = NSRect(x: pad, y: sortY, width: 230, height: 22) - listScroll.frame = NSRect(x: pad, y: pad, width: W - pad * 2, height: sortY - pad - 6) + let sortY = headerBottom - 2 - 20 + sortPopup.frame = NSRect(x: pad, y: sortY, width: 205, height: 20) + listScroll.frame = NSRect(x: pad, y: pad, width: W - pad * 2, height: sortY - pad - 3) // ── drawer overlays the list when open ──────────────────────────────── if drawerOpen { @@ -622,6 +646,40 @@ final class JukeController: NSWindowController, NSWindowDelegate, ledLabel.stringValue = "\(JukeController.mmss(wave.currentTime)) / \(JukeController.mmss(wave.duration))" } + // ── live work awareness ──────────────────────────────────────────────── + // Slab's ledger tells us which agents are active; local process inspection + // catches the narrower render/bake window. Polling is read-only and cheap. + private func armActivityStatus() { + pollActivityStatus() + activityTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + self?.pollActivityStatus() + } + } + private func pollActivityStatus() { + let activities = WorkStatus.snapshot(tracks: library.tracks) + for t in library.tracks { + let matches = activities.filter { a in + (a.track != nil && a.track == t.title) || (a.track == nil && a.lane == t.lane) + } + t.liveStatus = matches.first.map { $0.track == nil ? "\($0.state) in \(t.lane)" : $0.state } + } + if activities.isEmpty { + activityLabel.stringValue = "● agents + renders idle" + activityLabel.textColor = Palette.inkDim + } else { + activityLabel.stringValue = activities.prefix(3).map { a in + let target = a.track ?? a.lane ?? "pop" + return "● \(target): \(a.state)" + }.joined(separator: " ") + activityLabel.textColor = activities.contains(where: { $0.state == "baking" }) ? Palette.gold : Palette.teal + } + if let t = track { + laneLabel.stringValue = Self.metaLine(t) + laneLabel.textColor = t.liveStatus == nil ? .secondaryLabelColor : Palette.gold + } + listTable.reloadData() + } + // ── tables ─────────────────────────────────────────────────────────────── func numberOfRows(in tableView: NSTableView) -> Int { if tableView == listTable { return library.tracks.count } @@ -641,6 +699,13 @@ final class JukeController: NSWindowController, NSWindowDelegate, let c = t.data.comments[row] return "\(JukeController.mmss(c.t)) \(c.text)" } + // Export the actual audio file to Finder, Messages, Mail, etc. AppKit's + // file-URL pasteboard type lets each destination decide whether to copy or + // attach it; JukeWizard never moves or mutates the source track. + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { + guard tableView == listTable, row >= 0, row < library.tracks.count else { return nil } + return library.tracks[row].url as NSURL + } // ── auto-pop watcher ───────────────────────────────────────────────────── private func armWatch() { diff --git a/juke-wizard/Sources/JukeWizard/Model.swift b/juke-wizard/Sources/JukeWizard/Model.swift index 6dac32a28..227e9afd7 100644 --- a/juke-wizard/Sources/JukeWizard/Model.swift +++ b/juke-wizard/Sources/JukeWizard/Model.swift @@ -46,6 +46,7 @@ final class Track { let lane: String var data: JukeData var meta: TrackMeta? + var liveStatus: String? init(url: URL, lane: String, title: String? = nil) { self.url = url diff --git a/juke-wizard/Sources/JukeWizard/TrackRowView.swift b/juke-wizard/Sources/JukeWizard/TrackRowView.swift index de9264f7c..99a85654a 100644 --- a/juke-wizard/Sources/JukeWizard/TrackRowView.swift +++ b/juke-wizard/Sources/JukeWizard/TrackRowView.swift @@ -6,7 +6,7 @@ import AppKit final class TrackRowView: NSView { static let id = NSUserInterfaceItemIdentifier("TrackRow") - static let height: CGFloat = 46 + static let height: CGFloat = 36 private let thumb = NSImageView() private let titleField = NSTextField(labelWithString: "") @@ -20,16 +20,16 @@ final class TrackRowView: NSView { wantsLayer = true thumb.imageScaling = .scaleProportionallyUpOrDown thumb.wantsLayer = true - thumb.layer?.cornerRadius = 5 + thumb.layer?.cornerRadius = 4 thumb.layer?.masksToBounds = true thumb.layer?.borderWidth = 1 thumb.layer?.borderColor = NSColor.white.withAlphaComponent(0.15).cgColor - titleField.font = .systemFont(ofSize: 13, weight: .semibold) + titleField.font = .systemFont(ofSize: 12, weight: .semibold) titleField.lineBreakMode = .byTruncatingTail - artistField.font = .systemFont(ofSize: 10.5) + artistField.font = .systemFont(ofSize: 9.5) artistField.textColor = .secondaryLabelColor artistField.lineBreakMode = .byTruncatingTail - badges.font = .systemFont(ofSize: 12, weight: .bold) + badges.font = .systemFont(ofSize: 10, weight: .bold) badges.alignment = .right for v in [thumb, titleField, artistField, badges] { addSubview(v) } } @@ -46,8 +46,9 @@ final class TrackRowView: NSView { let cc = t.data.comments.count titleField.stringValue = t.title titleField.textColor = TrackRowView.titleColor(t) - artistField.stringValue = (t.meta?.artist ?? "Aesthetic Dot Computer") + artistField.stringValue = (t.liveStatus.map { "● \($0) · " } ?? "") + (t.meta?.artist ?? "Aesthetic Dot Computer") + (cc > 0 ? " 💬\(cc)" : "") + artistField.textColor = t.liveStatus == nil ? .secondaryLabelColor : Palette.gold badges.attributedStringValue = TrackRowView.badgeString(t) tint = TrackRowView.laneTint(t.lane) needsDisplay = true @@ -56,15 +57,15 @@ final class TrackRowView: NSView { override func layout() { super.layout() - let h = bounds.height, pad: CGFloat = 8 - let side = h - 10 - thumb.frame = NSRect(x: pad, y: 5, width: side, height: side) - let tx = pad + side + 9 - let bw: CGFloat = 96 + let h = bounds.height, pad: CGFloat = 6 + let side = h - 6 + thumb.frame = NSRect(x: pad, y: 3, width: side, height: side) + let tx = pad + side + 7 + let bw: CGFloat = 72 let textW = bounds.width - tx - bw - pad - titleField.frame = NSRect(x: tx, y: 6, width: textW, height: 17) - artistField.frame = NSRect(x: tx, y: 24, width: textW, height: 14) - badges.frame = NSRect(x: bounds.width - bw - pad, y: 14, width: bw, height: 18) + titleField.frame = NSRect(x: tx, y: 3, width: textW, height: 15) + artistField.frame = NSRect(x: tx, y: 18, width: textW, height: 12) + badges.frame = NSRect(x: bounds.width - bw - pad, y: 9, width: bw, height: 16) } override func draw(_ dirtyRect: NSRect) { diff --git a/juke-wizard/Sources/JukeWizard/WorkStatus.swift b/juke-wizard/Sources/JukeWizard/WorkStatus.swift new file mode 100644 index 000000000..5b12dce7c --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/WorkStatus.swift @@ -0,0 +1,91 @@ +// WorkStatus.swift — read-only awareness of music work happening around JukeWizard. +// Combines Slab's fleet agent ledger with local renderer/build processes, then +// maps each activity to a /pop lane or track. Nothing here controls an agent. +import Foundation + +struct WorkActivity { + var lane: String? + var track: String? + var state: String + var detail: String + var priority: Int +} + +enum WorkStatus { + private struct LedgerFile: Decodable { var entries: [LedgerEntry] } + private struct LedgerEntry: Decodable { + var name: String + var subject: String + var status: String + var cwd: String + var updated: Double + var agentType: String? + } + + static func snapshot(tracks: [Track]) -> [WorkActivity] { + var result = renderProcesses(tracks: tracks) + let fm = FileManager.default + let root = (NSHomeDirectory() as NSString).appendingPathComponent(".config/slab/ledger") + var files = [(root as NSString).appendingPathComponent("local.json")] + let peers = (root as NSString).appendingPathComponent("peers") + if let names = try? fm.contentsOfDirectory(atPath: peers) { + files += names.filter { $0.hasSuffix(".json") }.map { (peers as NSString).appendingPathComponent($0) } + } + let nowMS = Date().timeIntervalSince1970 * 1000 + for file in files { + guard let data = fm.contents(atPath: file), + let ledger = try? JSONDecoder().decode(LedgerFile.self, from: data) else { continue } + for entry in ledger.entries { + guard nowMS - entry.updated < 10 * 60 * 1000, + !["complete", "blank", "interrupted"].contains(entry.status) else { continue } + let haystack = "\(entry.cwd) \(entry.subject)".lowercased() + let lane = popLane(in: entry.cwd) ?? tracks.first(where: { + haystack.contains($0.title.lowercased()) || haystack.contains("/\($0.lane.lowercased())") + })?.lane + guard lane != nil else { continue } + let track = tracks.first(where: { $0.lane == lane && haystack.contains($0.title.lowercased()) })?.title + let who = "\(entry.agentType ?? "agent") \(entry.name)" + result.append(WorkActivity(lane: lane, track: track, state: "agent \(entry.status)", + detail: "\(who): \(entry.subject)", priority: 1)) + } + } + return dedupe(result) + } + + private static func renderProcesses(tracks: [Track]) -> [WorkActivity] { + let p = Process() + let pipe = Pipe() + p.executableURL = URL(fileURLWithPath: "/bin/ps") + p.arguments = ["-axo", "command="] + p.standardOutput = pipe + p.standardError = FileHandle.nullDevice + guard (try? p.run()) != nil else { return [] } + p.waitUntilExit() + guard let text = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) else { return [] } + let markers = ["render-", "render-c.mjs", "bake.mjs", "ffmpeg", "swift build", "gen-score"] + return text.split(separator: "\n").compactMap { raw in + let command = String(raw) + guard markers.contains(where: command.contains), !command.contains("JukeWizard") else { return nil } + let lane = popLane(in: command) ?? tracks.first(where: { command.lowercased().contains($0.title.lowercased()) })?.lane + guard let lane else { return nil } + let track = tracks.first(where: { $0.lane == lane && command.lowercased().contains($0.title.lowercased()) })?.title + return WorkActivity(lane: lane, track: track, state: "baking", + detail: track.map { "rendering \($0)" } ?? "rendering \(lane)", priority: 0) + } + } + + private static func popLane(in text: String) -> String? { + guard let r = text.range(of: "/pop/") else { return nil } + let tail = text[r.upperBound...] + let lane = tail.prefix { $0 != "/" && !$0.isWhitespace } + return lane.isEmpty ? nil : String(lane) + } + + private static func dedupe(_ xs: [WorkActivity]) -> [WorkActivity] { + var seen = Set() + return xs.sorted { $0.priority < $1.priority }.filter { + let key = "\($0.lane ?? "")|\($0.track ?? "")|\($0.state)" + return seen.insert(key).inserted + } + } +} diff --git a/juke-wizard/Sources/JukeWizard/main.swift b/juke-wizard/Sources/JukeWizard/main.swift index a918ce662..576b1585d 100644 --- a/juke-wizard/Sources/JukeWizard/main.swift +++ b/juke-wizard/Sources/JukeWizard/main.swift @@ -6,6 +6,7 @@ // // Usage: // jukewizard [ ...] [--watch ] +// bin/jukewizard --queue focused ordered queue // (no args → opens ~/Desktop/MASTER-playlist.m3u8 if present) // // --watch auto-pop: when a fresh audio file lands here, add it @@ -54,6 +55,16 @@ final class JukeAppDelegate: NSObject, NSApplicationDelegate { // Stay resident when the window closes — the spinning-CD menu-bar item is // JukeWizard's persistent face; click it to bring the window back. func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + // A hidden resident window should always come back from a Dock click. + // Without this, AppKit can activate the process while leaving its only + // window closed, which looks indistinguishable from a crash. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { controller?.showWindow(nil) } + controller?.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return true + } } let app = NSApplication.shared diff --git a/juke-wizard/bin/jukewizard b/juke-wizard/bin/jukewizard index 8054de9d0..837fa65a4 100755 --- a/juke-wizard/bin/jukewizard +++ b/juke-wizard/bin/jukewizard @@ -11,6 +11,7 @@ # usage: # juke-wizard/bin/jukewizard # browse the whole /pop library # juke-wizard/bin/jukewizard # load library + select/play that track +# juke-wizard/bin/jukewizard --queue # focused ordered playlist; play a # juke-wizard/bin/jukewizard # load library + add these too # juke-wizard/bin/jukewizard ... --watch # auto-pop new renders # @@ -25,6 +26,21 @@ BIN="$WIZ/.build/release/JukeWizard" LIB="$REPO/pop/out/pop-library.json" node "$REPO/pop/bin/pop-library.mjs" >/dev/null 2>&1 || true # refresh the index +# Focused queue mode: use only the following loose files/folders/playlists, +# preserving their command-line order. This makes `--queue a.mp3 b.mp3` a +# real two-track listening session instead of burying those tracks inside the +# master /pop library. Select + autoplay the first supplied audio file. +if [ "${1:-}" = "--queue" ]; then + shift + if [ "$#" -eq 0 ]; then + echo "usage: jukewizard --queue [...]" >&2 + exit 2 + fi + FIRST="$1" + case "$FIRST" in /*) SEL="$FIRST";; *) SEL="$(pwd)/$FIRST";; esac + exec "$BIN" "$@" --select "$SEL" +fi + # No args → just the master library. if [ "$#" -eq 0 ]; then exec "$BIN" "$LIB" -- 2.51.2