diff --git a/juke-wizard/Package.swift b/juke-wizard/Package.swift --- a/juke-wizard/Package.swift +++ b/juke-wizard/Package.swift @@ -2,8 +2,11 @@ // swift-tools-version:5.9 import PackageDescription let package = Package( - name: "JukeWizard", + name: "MenuBandJuke", platforms: [.macOS(.v12)], + products: [ + .library(name: "MenuBandJuke", targets: ["MenuBandJuke"]), + ], dependencies: [ .package(path: "../slab/macos-audio"), ], @@ -13,16 +16,14 @@ name: "JukeDSP", path: "Sources/JukeDSP", publicHeadersPath: "include" ), - .executableTarget( - name: "JukeWizard", + .target( + name: "MenuBandJuke", dependencies: [ .product(name: "ACMacAudio", package: "macos-audio"), "JukeDSP", ], path: "Sources/JukeWizard", - resources: [ - .copy("Assets"), - ] + exclude: ["Assets"] ), .testTarget( name: "JukeDSPTests", @@ -31,7 +32,7 @@ path: "Tests/JukeDSPTests" ), .testTarget( name: "JukeWizardTests", - dependencies: ["JukeWizard"], + dependencies: ["MenuBandJuke"], path: "Tests/JukeWizardTests" ), ] diff --git a/juke-wizard/Sources/JukeWizard/BackdropView.swift b/juke-wizard/Sources/JukeWizard/BackdropView.swift --- a/juke-wizard/Sources/JukeWizard/BackdropView.swift +++ b/juke-wizard/Sources/JukeWizard/BackdropView.swift @@ -15,9 +15,7 @@ override init(frame: NSRect) { // static fallback (same illy the dock icon comes from) fallbackImage = { - let b = Bundle.module - if let u = b.url(forResource: "jukewizard-mascot", withExtension: "png", subdirectory: "Assets") - ?? b.url(forResource: "jukewizard-mascot", withExtension: "png"), + if let u = JukeResources.url(forResource: "jukewizard-mascot", withExtension: "png"), let img = NSImage(contentsOf: u) { return img } return nil }() @@ -30,9 +28,7 @@ override func hitTest(_ point: NSPoint) -> NSView? { nil } private func setupVideo() { - let b = Bundle.module - guard let url = b.url(forResource: "jukewizard-backdrop", withExtension: "mp4", subdirectory: "Assets") - ?? b.url(forResource: "jukewizard-backdrop", withExtension: "mp4") else { return } + guard let url = JukeResources.url(forResource: "jukewizard-backdrop", withExtension: "mp4") else { return } let item = AVPlayerItem(url: url) let queue = AVQueuePlayer() queue.isMuted = true diff --git a/juke-wizard/Sources/JukeWizard/ControlServer.swift b/juke-wizard/Sources/JukeWizard/ControlServer.swift --- a/juke-wizard/Sources/JukeWizard/ControlServer.swift +++ b/juke-wizard/Sources/JukeWizard/ControlServer.swift @@ -1,7 +1,7 @@ import Foundation import Darwin -/// Local, bounded JSON-line control for the running JukeWizard. The Unix +/// Local, bounded JSON-line control for Menu Band's Juke window. The Unix /// socket is user-only; every command is still validated by JukeController. final class JukeControlServer { private let controller: JukeController @@ -55,7 +55,7 @@ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { Self.reply(fd, ["ok": false, "error": "invalid or oversized JSON request"]); return } DispatchQueue.main.async { - guard let self else { Self.reply(fd, ["ok": false, "error": "JukeWizard unavailable"]); return } + guard let self else { Self.reply(fd, ["ok": false, "error": "Menu Band Juke unavailable"]); return } Self.reply(fd, self.controller.control(object)) } } diff --git a/juke-wizard/Sources/JukeWizard/DJMixerView.swift b/juke-wizard/Sources/JukeWizard/DJMixerView.swift --- a/juke-wizard/Sources/JukeWizard/DJMixerView.swift +++ b/juke-wizard/Sources/JukeWizard/DJMixerView.swift @@ -422,7 +422,7 @@ ] return specs.compactMap { name, variant, filename, key in guard let url = render(name: filename, variant: variant) else { return nil } let track = Track(url: url, lane: "practice", title: name) - track.meta = TrackMeta(artist: "JukeWizard", backend: "C synthesis", status: "PRACTICE", + track.meta = TrackMeta(artist: "Menu Band Juke", backend: "C synthesis", status: "PRACTICE", updated: nil, revisions: nil, bytes: nil, durationSec: duration, bpm: bpm, key: key, releaseDate: nil, art: nil, media: nil, links: nil) @@ -470,11 +470,16 @@ final class DJPlatterView: NSView { weak var deck: DJDeckPlayer? var accent: NSColor = Palette.teal var deckName = "A" + var canDetachRecord = true + var onDetachRequested: ((NSPoint) -> Void)? private var lastAngle: CGFloat? private var lastTimestamp: TimeInterval? private var scratchOrigin: Double = 0 private var scratchOffset: Double = 0 private var scratchIdleTimer: Timer? + private var dragStartScreen: NSPoint? + private var detachGestureResolved = false + private var detachDeniedUntil: TimeInterval = 0 override var acceptsFirstResponder: Bool { true } override var mouseDownCanMoveWindow: Bool { false } @@ -503,6 +508,27 @@ override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } let c = center, r = radius + guard deck?.track != nil else { + NSColor.black.withAlphaComponent(0.08).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - r, y: c.y - r, + width: r * 2, height: r * 2)).fill() + let bed = NSBezierPath(ovalIn: NSRect(x: c.x - r + 2, y: c.y - r + 2, + width: r * 2 - 4, height: r * 2 - 4)) + bed.setLineDash([7, 7], count: 2, phase: 0) + bed.lineWidth = 2 + accent.withAlphaComponent(0.42).setStroke() + bed.stroke() + let empty = "EMPTY" as NSString + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: max(13, r * 0.12), weight: .bold), + .foregroundColor: accent.withAlphaComponent(0.62) + ] + let size = empty.size(withAttributes: attrs) + empty.draw(at: NSPoint(x: c.x - size.width / 2, y: c.y - size.height / 2), + withAttributes: attrs) + return + } + context.saveGState() let shadow = NSShadow() shadow.shadowColor = NSColor.black.withAlphaComponent(0.55) @@ -550,6 +576,14 @@ ring.lineWidth = 3 ring.stroke() } + if ProcessInfo.processInfo.systemUptime < detachDeniedUntil { + Palette.coral.withAlphaComponent(0.9).setStroke() + let denied = NSBezierPath(ovalIn: NSRect(x: c.x - r + 2, y: c.y - r + 2, + width: r * 2 - 4, height: r * 2 - 4)) + denied.lineWidth = 5 + denied.stroke() + } + let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: max(15, r * 0.20), weight: .black), .foregroundColor: NSColor.white @@ -565,6 +599,7 @@ return atan2(point.y - center.y, point.x - center.x) } override func mouseDown(with event: NSEvent) { + guard deck?.track != nil else { return } guard hypot(convert(event.locationInWindow, from: nil).x - center.x, convert(event.locationInWindow, from: nil).y - center.y) <= radius else { return } window?.makeFirstResponder(self) @@ -573,6 +608,8 @@ lastAngle = angle(for: event) lastTimestamp = event.timestamp scratchOrigin = deck?.currentTime ?? 0 scratchOffset = 0 + dragStartScreen = NSEvent.mouseLocation + detachGestureResolved = false deck?.beginScratch() scratchIdleTimer?.invalidate() scratchIdleTimer = DJRunLoopTimer.scheduled(every: 0.02) { [weak self] _ in @@ -582,6 +619,32 @@ } } override func mouseDragged(with event: NSEvent) { + if !detachGestureResolved, let start = dragStartScreen { + let screen = NSEvent.mouseLocation + let travel = hypot(screen.x - start.x, screen.y - start.y) + let local = convert(event.locationInWindow, from: nil) + let outsideBed = hypot(local.x - center.x, local.y - center.y) > radius * 1.08 + if travel > max(52, radius * 0.46), outsideBed { + detachGestureResolved = true + scratchIdleTimer?.invalidate() + scratchIdleTimer = nil + deck?.endScratch() + lastAngle = nil + lastTimestamp = nil + if canDetachRecord { + onDetachRequested?(screen) + } else { + detachDeniedUntil = ProcessInfo.processInfo.systemUptime + 0.42 + NSSound.beep() + needsDisplay = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + self?.needsDisplay = true + } + } + return + } + } + guard !detachGestureResolved else { return } guard let prior = lastAngle else { return } let next = angle(for: event) var delta = next - prior @@ -601,7 +664,9 @@ lastAngle = nil lastTimestamp = nil scratchIdleTimer?.invalidate() scratchIdleTimer = nil - deck?.endScratch() + if !detachGestureResolved { deck?.endScratch() } + dragStartScreen = nil + detachGestureResolved = false NSCursor.openHand.set() } } @@ -702,6 +767,13 @@ self.peakDuration = duration self.needsDisplay = true } } + } + + func clear() { + loadToken += 1 + peaks = [] + peakDuration = 0 + needsDisplay = true } override func draw(_ dirtyRect: NSRect) { @@ -1053,7 +1125,7 @@ let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: solo ? 150 : 300, height: 400), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) - window.title = "JukeWizard · Alignment" + window.title = "Menu Band Juke · Alignment" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = true @@ -1112,7 +1184,7 @@ } } final class DJDeckView: NSView { - let deck = DJDeckPlayer() + private(set) var deck = DJDeckPlayer() let platter = DJPlatterView(frame: .zero) private let deckLabel: NSTextField private let trackPopup = NSPopUpButton(frame: .zero, pullsDown: false) @@ -1127,7 +1199,16 @@ private var tracks: [Track] = [] var onStateChange: (() -> Void)? var onTrackLoaded: ((Track) -> Void)? var onSync: (() -> Void)? - var onPopout: (() -> Void)? + var onDetach: ((DJDeckPlayer, Track, NSPoint) -> Void)? + var canDetachRecord = true { + didSet { + platter.canDetachRecord = canDetachRecord + popoutButton.isHidden = !canDetachRecord || deck.track == nil + platter.setAccessibilityHelp(canDetachRecord + ? "Turn the record to scratch, or pull it off the bed to float it" + : "Turn the record to scratch. This source must stay on the main deck") + } + } init(name: String, accent: NSColor) { deckLabel = NSTextField(labelWithString: name) @@ -1171,10 +1252,15 @@ popoutButton.target = self popoutButton.action = #selector(popout) popoutButton.bezelStyle = .rounded popoutButton.contentTintColor = accent - popoutButton.toolTip = "Float this record as its own scratch deck" + popoutButton.toolTip = "Pull this record off the bed into a floating deck" + platter.onDetachRequested = { [weak self] point in _ = self?.detachRecord(at: point) } [deckLabel, trackPopup, platter, playButton, bpmSlider, bpmLabel, timeLabel, syncButton, resetButton, popoutButton].forEach(addSubview) + installDeckCallback() + } + + private func installDeckCallback() { deck.onStateChange = { [weak self] in self?.refresh() self?.onStateChange?() @@ -1204,13 +1290,35 @@ guard let index = tracks.firstIndex(where: { $0.url == track.url }) else { return } trackPopup.selectItem(at: index) load(index) if autoplay { deck.play() } + } + + func load(_ track: Track, autoplay: Bool) { + if let index = tracks.firstIndex(where: { $0.url == track.url }) { + trackPopup.selectItem(at: index) + } else { + tracks.append(track) + trackPopup.addItem(withTitle: "\(track.title) — \(track.lane)") + trackPopup.selectItem(at: tracks.count - 1) + } + deck.load(track) + onTrackLoaded?(track) + bpmSlider.minValue = deck.sourceBPM * 0.5 + bpmSlider.maxValue = deck.sourceBPM * 1.5 + if autoplay { deck.play() } + refresh() } func refresh() { + let hasTrack = deck.track != nil playButton.title = deck.isPlaying ? "❚❚" : "▶" - bpmLabel.stringValue = String(format: "%.1f BPM", deck.targetBPM) + bpmLabel.stringValue = hasTrack ? String(format: "%.1f BPM", deck.targetBPM) : "— BPM" bpmSlider.doubleValue = deck.targetBPM timeLabel.stringValue = "\(JukeController.mmss(deck.currentTime)) / \(JukeController.mmss(deck.duration))" + playButton.isEnabled = hasTrack + bpmSlider.isEnabled = hasTrack + syncButton.isEnabled = hasTrack + resetButton.isEnabled = hasTrack + popoutButton.isHidden = !canDetachRecord || !hasTrack platter.needsDisplay = true } @@ -1252,7 +1360,70 @@ @objc private func togglePlay() { deck.toggle() } @objc private func bpmChanged() { deck.setBPM(bpmSlider.doubleValue); refresh() } @objc private func sync() { onSync?() } @objc private func resetBPM() { deck.resetBPM(); refresh() } - @objc private func popout() { onPopout?() } + @objc private func popout() { + let point = window.map { NSPoint(x: $0.frame.midX, y: $0.frame.midY) } ?? NSEvent.mouseLocation + _ = detachRecord(at: point) + } + + @discardableResult + func detachRecord(at point: NSPoint) -> Bool { + guard canDetachRecord, let track = deck.track else { return false } + let floatingDeck = deck + floatingDeck.endScratch() + floatingDeck.onStateChange = nil + deck = DJDeckPlayer() + platter.deck = deck + installDeckCallback() + trackPopup.selectItem(at: -1) + refresh() + onDetach?(floatingDeck, track, point) + return true + } +} + +/// One compact channel in the main-window mixer for a record that has been +/// pulled into its own window. Floating records remain playable directly; +/// this strip keeps their transport and level reachable from the selector. +final class DJDetachedChannelView: NSView { + weak var controller: DJPopoutDeckController? + private let playButton = NSButton(title: "❚❚", target: nil, action: nil) + private let titleLabel = NSTextField(labelWithString: "") + private let levelSlider = NSSlider(value: 1, minValue: 0, maxValue: 1, + target: nil, action: nil) + + init(controller: DJPopoutDeckController) { + self.controller = controller + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 7 + layer?.backgroundColor = NSColor.black.withAlphaComponent(0.14).cgColor + titleLabel.font = .systemFont(ofSize: 10, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.stringValue = controller.trackTitle + playButton.bezelStyle = .inline + playButton.target = self + playButton.action = #selector(toggle) + playButton.toolTip = "Play or pause this floating record" + levelSlider.controlSize = .mini + levelSlider.isContinuous = true + levelSlider.target = self + levelSlider.action = #selector(levelChanged) + levelSlider.toolTip = "Floating record level" + [titleLabel, playButton, levelSlider].forEach(addSubview) + refresh() + } + required init?(coder: NSCoder) { fatalError() } + + override func layout() { + playButton.frame = NSRect(x: 4, y: 4, width: 30, height: bounds.height - 8) + titleLabel.frame = NSRect(x: 38, y: bounds.height - 17, + width: max(30, bounds.width - 44), height: 14) + levelSlider.frame = NSRect(x: 38, y: 2, width: max(30, bounds.width - 44), height: 14) + } + + func refresh() { playButton.title = controller?.isPlaying == true ? "❚❚" : "▶" } + @objc private func toggle() { controller?.toggle(); refresh() } + @objc private func levelChanged() { controller?.setChannelGain(levelSlider.floatValue) } } final class DJMixerView: NSView { @@ -1265,6 +1436,8 @@ private let waveformB = DJWaveformOutputView(frame: .zero) private let crossfader = NSSlider(value: 0, minValue: -1, maxValue: 1, target: nil, action: nil) private let crossLabel = NSTextField(labelWithString: "A 50 · 50 B") private let practiceButton = NSButton(title: "PRIMPATS", target: nil, action: nil) + private let detachedScroll = NSScrollView(frame: .zero) + private let detachedRack = NSStackView(frame: .zero) private var displayTimer: Timer? private var availableTracks: [Track] = [] private var primpatCount = 0 @@ -1278,12 +1451,18 @@ private var popoutD: DJPopoutDeckController? private var alignmentPopout: DJAlignmentWindowController? private var rateSyncTimer: Timer? private var peakAlignTimer: Timer? + private var detachedDecks: [DJPopoutDeckController] = [] + private var detachedChannels: [DJDetachedChannelView] = [] + private var detachedSerial = 0 private(set) var masterVolume: Float = 0.8 var onStateChange: (() -> Void)? var onDetach: (() -> Void)? private var deckAppearance: NSAppearance? - var isPlaying: Bool { deckA.deck.isPlaying || deckB.deck.isPlaying || deckC.isPlaying || deckD.isPlaying } + var isPlaying: Bool { + deckA.deck.isPlaying || deckB.deck.isPlaying || deckC.isPlaying || deckD.isPlaying + || detachedDecks.contains(where: { $0.isPlaying }) + } var dominantDeck: DJDeckView { crossfader.doubleValue <= 0 ? deckA : deckB } var dominantTitle: String { dominantDeck.deck.track?.title ?? "DJ Mix" } var dominantBPM: Double { dominantDeck.deck.targetBPM } @@ -1309,7 +1488,19 @@ waveformB.deck = deckB.deck waveformB.accent = Palette.coral waveformB.deckName = "B" waveformB.setAccessibilityLabel("Deck B output waveform") - [deckA, deckB, waveformA, waveformB, crossfader, crossLabel, practiceButton].forEach(addSubview) + detachedRack.orientation = .horizontal + detachedRack.alignment = .centerY + detachedRack.spacing = 6 + detachedScroll.documentView = detachedRack + detachedScroll.hasHorizontalScroller = true + detachedScroll.hasVerticalScroller = false + detachedScroll.autohidesScrollers = true + detachedScroll.scrollerStyle = .overlay + detachedScroll.drawsBackground = false + detachedScroll.borderType = .noBorder + detachedScroll.isHidden = true + [deckA, deckB, waveformA, waveformB, crossfader, crossLabel, + practiceButton, detachedScroll].forEach(addSubview) deckA.onStateChange = { [weak self] in self?.onStateChange?() } deckB.onStateChange = { [weak self] in self?.onStateChange?() } deckA.onTrackLoaded = { [weak self] track in @@ -1324,8 +1515,16 @@ self?.alignmentPopout?.trackChanged(track, at: 1) } deckA.onSync = { [weak self] in self?.sync(self?.deckA, to: self?.deckB) } deckB.onSync = { [weak self] in self?.sync(self?.deckB, to: self?.deckA) } - deckA.onPopout = { [weak self] in self?.showPopoutA() } - deckB.onPopout = { [weak self] in self?.showPopoutB() } + deckA.onDetach = { [weak self] deck, track, point in + self?.waveformA.deck = self?.deckA.deck + self?.waveformA.clear() + self?.float(deck: deck, track: track, near: point) + } + deckB.onDetach = { [weak self] deck, track, point in + self?.waveformB.deck = self?.deckB.deck + self?.waveformB.clear() + self?.float(deck: deck, track: track, near: point) + } applyCrossfade() } required init?(coder: NSCoder) { fatalError() } @@ -1337,6 +1536,7 @@ } func configure(tracks: [Track], primaryIndex: Int) { soloMode = false + updateSoloVisibility() let primpats = DJPrimpats.makeTracks() let practice = DJPracticeTracks.make() primpatCount = primpats.count @@ -1354,6 +1554,7 @@ } func configureSolo(tracks: [Track], primaryIndex: Int) { soloMode = true + updateSoloVisibility() let primpats = DJPrimpats.makeTracks() let practice = DJPracticeTracks.make() primpatCount = primpats.count @@ -1377,17 +1578,32 @@ if !self.soloMode { self.deckB.refresh() self.waveformB.needsDisplay = true } + self.detachedChannels.forEach { $0.refresh() } } } func stopDisplay() { displayTimer?.invalidate(); displayTimer = nil } - func pauseAll() { deckA.deck.pause(); deckB.deck.pause(); deckC.pause(); deckD.pause() } + func pauseAll() { + deckA.deck.pause(); deckB.deck.pause(); deckC.pause(); deckD.pause() + detachedDecks.forEach { $0.pause() } + } func toggleDominant() { dominantDeck.deck.toggle() } func stepDominant(by offset: Int) { dominantDeck.step(by: offset) } + func loadPrimary(_ track: Track, autoplay: Bool = true) { deckA.load(track, autoplay: autoplay) } + @discardableResult + func detachPrimary() -> Bool { + let point = window.map { NSPoint(x: $0.frame.midX, y: $0.frame.midY) } ?? NSEvent.mouseLocation + return deckA.detachRecord(at: point) + } + func setRecordDetachmentAllowed(_ allowed: Bool) { + deckA.canDetachRecord = allowed + deckB.canDetachRecord = allowed + } func setMasterVolume(_ value: Float) { masterVolume = max(0, min(1, value)) applyCrossfade() + detachedDecks.forEach { $0.setMasterGain(masterVolume) } } func setAppearance(_ appearance: NSAppearance?) { @@ -1397,11 +1613,55 @@ popoutB?.window?.appearance = appearance popoutC?.window?.appearance = appearance popoutD?.window?.appearance = appearance alignmentPopout?.window?.appearance = appearance + detachedDecks.forEach { $0.window?.appearance = appearance } popoutA?.window?.contentView?.needsDisplay = true popoutB?.window?.contentView?.needsDisplay = true popoutC?.window?.contentView?.needsDisplay = true popoutD?.window?.contentView?.needsDisplay = true alignmentPopout?.window?.contentView?.needsDisplay = true + } + + private func updateSoloVisibility() { + deckB.isHidden = soloMode + waveformB.isHidden = soloMode + crossfader.isHidden = soloMode + crossLabel.isHidden = soloMode + } + + private func float(deck: DJDeckPlayer, track: Track, near point: NSPoint) { + detachedSerial += 1 + let controller = DJPopoutDeckController( + deck: deck, name: String(detachedSerial), accent: Palette.teal) + controller.setMasterGain(masterVolume) + controller.window?.appearance = deckAppearance + controller.trackChanged(track) + let channel = DJDetachedChannelView(controller: controller) + channel.translatesAutoresizingMaskIntoConstraints = false + channel.widthAnchor.constraint(equalToConstant: 148).isActive = true + channel.heightAnchor.constraint(equalToConstant: 38).isActive = true + controller.onStateChange = { [weak self, weak channel] in + channel?.refresh() + self?.onStateChange?() + } + controller.onClose = { [weak self, weak controller] in + guard let self, let controller else { return } + self.detachedDecks.removeAll { $0 === controller } + if let index = self.detachedChannels.firstIndex(where: { $0.controller === controller }) { + let channel = self.detachedChannels.remove(at: index) + self.detachedRack.removeArrangedSubview(channel) + channel.removeFromSuperview() + } + self.detachedScroll.isHidden = self.detachedChannels.isEmpty + self.needsLayout = true + self.onStateChange?() + } + detachedDecks.append(controller) + detachedChannels.append(channel) + detachedRack.addArrangedSubview(channel) + detachedScroll.isHidden = false + controller.show(track: track, near: point) + needsLayout = true + onStateChange?() } @objc func loadPractice() { loadPrimpats() } @@ -1635,6 +1895,21 @@ let crossHeight: CGFloat = 52 let waveHeight: CGFloat = min(104, max(76, bounds.height * 0.22)) let waveRow = (waveHeight - 4) / 2 let waveBottom = crossHeight + gap + if soloMode { + waveformA.frame = NSRect(x: pad, y: crossHeight, width: bounds.width - pad * 2, + height: waveHeight) + deckA.frame = NSRect(x: pad, y: crossHeight + waveHeight + gap, + width: bounds.width - pad * 2, + height: max(160, bounds.height - crossHeight - waveHeight - gap)) + practiceButton.frame = NSRect(x: pad, y: 8, width: 86, height: 26) + detachedScroll.frame = NSRect(x: 98, y: 3, width: max(0, bounds.width - 102), height: 44) + detachedRack.frame = NSRect(x: 0, y: 0, + width: max(detachedScroll.bounds.width, + CGFloat(detachedChannels.count) * 154), + height: 38) + return + } + detachedScroll.frame = .zero waveformB.frame = NSRect(x: pad, y: waveBottom, width: bounds.width - pad * 2, height: waveRow) waveformA.frame = NSRect(x: pad, y: waveBottom + waveRow + 4, width: bounds.width - pad * 2, height: waveRow) diff --git a/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift b/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift --- a/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift +++ b/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift @@ -8,11 +8,19 @@ /// control: press to stop it under the hand, drag around the groove to scratch, /// and release to resume the state it had before the touch. final class DJPopoutDeckController: NSWindowController, NSWindowDelegate { private let recordView: DJRadialRecordView + private let player: DJDeckPlayer private let deckName: String private var displayTimer: Timer? private var hasPositioned = false + private var masterGain: Float = 0.8 + private var channelGain: Float = 1 + private(set) var trackTitle = "record" + var onClose: (() -> Void)? + var onStateChange: (() -> Void)? + var isPlaying: Bool { player.isPlaying } init(deck: DJDeckPlayer, name: String, accent: NSColor) { + player = deck deckName = name recordView = DJRadialRecordView(frame: NSRect(x: 0, y: 0, width: 350, height: 350)) recordView.deck = deck @@ -25,7 +33,7 @@ styleMask: [.borderless], backing: .buffered, defer: false ) - window.title = "JukeWizard · Deck \(name)" + window.title = "Menu Band Juke · Deck \(name)" window.isMovableByWindowBackground = true window.level = .floating window.collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] @@ -38,6 +46,7 @@ super.init(window: window) window.delegate = self recordView.onClose = { [weak window] in window?.performClose(nil) } + player.onStateChange = { [weak self] in self?.onStateChange?() } } required init?(coder: NSCoder) { fatalError() } @@ -45,16 +54,46 @@ deinit { displayTimer?.invalidate() } func show(track: Track?) { if let track { recordView.load(track) } - window?.title = "JukeWizard · \(deckName) · \(track?.title ?? "record")" + trackTitle = track?.title ?? "record" + window?.title = "Menu Band Juke · \(deckName) · \(track?.title ?? "record")" positionOnce() showWindow(nil) window?.orderFrontRegardless() startDisplay() } + func show(track: Track?, near point: NSPoint) { + if let track { recordView.load(track) } + trackTitle = track?.title ?? "record" + window?.title = "Menu Band Juke · \(deckName) · \(track?.title ?? "record")" + showWindow(nil) + if let window { + let origin = NSPoint(x: point.x - window.frame.width / 2, + y: point.y - window.frame.height / 2) + window.setFrameOrigin(origin) + } + window?.orderFrontRegardless() + hasPositioned = true + startDisplay() + } + + func setMasterGain(_ gain: Float) { + masterGain = max(0, min(1, gain)) + applyGain() + } + func setChannelGain(_ gain: Float) { + channelGain = max(0, min(1, gain)) + applyGain() + } + func toggle() { player.toggle() } + func pause() { player.pause() } + + private func applyGain() { player.setGain(masterGain * channelGain) } + func trackChanged(_ track: Track) { + trackTitle = track.title recordView.load(track) - window?.title = "JukeWizard · \(deckName) · \(track.title)" + window?.title = "Menu Band Juke · \(deckName) · \(track.title)" } private func startDisplay() { @@ -83,9 +122,11 @@ window.setFrameOrigin(origin) } func windowWillClose(_ notification: Notification) { + player.pause() recordView.cancelTrackpadLock() displayTimer?.invalidate() displayTimer = nil + onClose?() } func windowDidResignKey(_ notification: Notification) { diff --git a/juke-wizard/Sources/JukeWizard/DJPrimpats.swift b/juke-wizard/Sources/JukeWizard/DJPrimpats.swift --- a/juke-wizard/Sources/JukeWizard/DJPrimpats.swift +++ b/juke-wizard/Sources/JukeWizard/DJPrimpats.swift @@ -56,7 +56,7 @@ catalog.compactMap { metadata in guard let url = render(metadata) else { return nil } let track = Track(url: url, lane: "primpats", title: metadata.title) track.meta = TrackMeta( - artist: "JukeWizard", + artist: "Menu Band Juke", backend: "Primpats local \(metadata.waveform.rawValue) synthesis · \(frequencyLabel(metadata.frequency)) Hz", status: "PRIMPAT", updated: nil, diff --git a/juke-wizard/Sources/JukeWizard/DockIcon.swift b/juke-wizard/Sources/JukeWizard/DockIcon.swift deleted file mode 100644 --- a/juke-wizard/Sources/JukeWizard/DockIcon.swift +++ /dev/null @@ -1,95 +0,0 @@ -// DockIcon.swift — artwork-backed JukeWizard Dock record. Cover art is -// composed into a classic CD once per track; Core Animation rotates that -// cached image in the Dock compositor, with no per-frame AppKit redraw. -import AppKit -import QuartzCore - -private final class DockRecordView: NSView { - private let recordLayer = CALayer() - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - recordLayer.frame = bounds - recordLayer.contentsGravity = .resizeAspect - recordLayer.magnificationFilter = .trilinear - recordLayer.minificationFilter = .trilinear - layer?.addSublayer(recordLayer) - } - required init?(coder: NSCoder) { fatalError() } - - override func layout() { recordLayer.frame = bounds } - - func set(image: NSImage, playing: Bool, bpm: Double) { - recordLayer.contents = image.cgImage(forProposedRect: nil, context: nil, hints: nil) - recordLayer.removeAnimation(forKey: "record-spin") - recordLayer.transform = CATransform3DIdentity - guard playing else { return } - let spin = CABasicAnimation(keyPath: "transform.rotation.z") - spin.fromValue = 0 - spin.toValue = -Double.pi * 2 - spin.duration = (60.0 / bpm) * 8.0 - spin.repeatCount = .infinity - spin.timingFunction = CAMediaTimingFunction(name: .linear) - spin.isRemovedOnCompletion = false - recordLayer.add(spin, forKey: "record-spin") - } -} - -enum DockIcon { - private static var fallbackImage: NSImage? - private static var artwork: NSImage? - private static var recordImage: NSImage? - private static var bpm: Double = 120 - private static var isPlaying = false - private static let recordView = DockRecordView(frame: NSRect(x: 0, y: 0, width: 256, height: 256)) - - static func install(prefix: String) { - apply(prefix: prefix) - DistributedNotificationCenter.default().addObserver( - forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"), - object: nil, queue: .main - ) { _ in if artwork == nil { apply(prefix: prefix) } } - } - - static func setNowPlaying(art: NSImage?, playing: Bool, bpm newBPM: Double? = nil) { - let artChanged = artwork !== art - let playingChanged = isPlaying != playing - let nextBPM = min(200, max(40, newBPM ?? 120)) - let tempoChanged = abs(nextBPM - bpm) > 0.01 - artwork = art - isPlaying = playing - bpm = nextBPM - if artChanged { - recordImage = art.map { CDArtworkRenderer.disc(from: $0, side: 256, shadow: true) } - } - guard artChanged || playingChanged || tempoChanged else { return } - guard let recordImage else { - NSApp.dockTile.contentView = nil - if let fallbackImage { NSApp.applicationIconImage = fallbackImage } - NSApp.dockTile.display() - return - } - recordView.set(image: recordImage, playing: playing, bpm: bpm) - NSApp.dockTile.contentView = recordView - NSApp.dockTile.display() - } - - private static func apply(prefix: String) { - let dark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - let variant = dark ? "dark" : "light" - let bundle = Bundle.module - let image = bundle.url(forResource: "\(prefix)-icon-\(variant)", - withExtension: "png", subdirectory: "Assets") - .flatMap { NSImage(contentsOf: $0) } - ?? bundle.url(forResource: "\(prefix)-icon-\(variant)", withExtension: "png") - .flatMap { NSImage(contentsOf: $0) } - ?? bundle.url(forResource: "\(prefix)-mascot", withExtension: "png", subdirectory: "Assets") - .flatMap { NSImage(contentsOf: $0) } - if let image { - fallbackImage = image - if artwork == nil { NSApp.applicationIconImage = image } - } - } -} diff --git a/juke-wizard/Sources/JukeWizard/JukeAppleMusic.swift b/juke-wizard/Sources/JukeWizard/JukeAppleMusic.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeAppleMusic.swift @@ -0,0 +1,145 @@ +import AppKit +import Foundation +import MusicKit + +struct AppleMusicTrackResult { + let title: String + let artist: String + let album: String + let duration: Double + let artworkURL: URL? + fileprivate let song: Song +} + +@available(macOS 12.0, *) +final class JukeAppleMusic { + enum AppleMusicError: LocalizedError { + case denied + + var errorDescription: String? { + switch self { + case .denied: return "Apple Music access was not granted." + } + } + } + + var authorizationStatus: MusicAuthorization.Status { MusicAuthorization.currentStatus } + + func authorize() async throws { + guard await MusicAuthorization.request() == .authorized else { + throw AppleMusicError.denied + } + } + + func search(_ query: String) async throws -> [AppleMusicTrackResult] { + if MusicAuthorization.currentStatus != .authorized { try await authorize() } + var request = MusicCatalogSearchRequest(term: query, types: [Song.self]) + request.limit = 30 + let response = try await request.response() + return response.songs.map { song in + AppleMusicTrackResult( + title: song.title, + artist: song.artistName, + album: song.albumTitle ?? "", + duration: song.duration ?? 0, + artworkURL: song.artwork?.url(width: 512, height: 512), + song: song) + } + } + + @available(macOS 14.0, *) + func play(_ result: AppleMusicTrackResult) async throws { + let player = ApplicationMusicPlayer.shared + player.queue = ApplicationMusicPlayer.Queue(for: [result.song]) + try await player.play() + } + + @available(macOS 14.0, *) + func pause() { ApplicationMusicPlayer.shared.pause() } + + @available(macOS 14.0, *) + func toggle() async throws { + let player = ApplicationMusicPlayer.shared + if player.state.playbackStatus == .playing { player.pause() } + else { try await player.play() } + } +} + +final class AppleMusicTrackRowView: NSTableCellView { + static let id = NSUserInterfaceItemIdentifier("apple-music-track-row") + private let titleField = NSTextField(labelWithString: "") + private let detailField = NSTextField(labelWithString: "") + private let durationField = NSTextField(labelWithString: "") + var selected = false { didSet { needsDisplay = true } } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + titleField.font = .systemFont(ofSize: 13, weight: .semibold) + titleField.lineBreakMode = .byTruncatingTail + detailField.font = .systemFont(ofSize: 11) + detailField.textColor = .secondaryLabelColor + detailField.lineBreakMode = .byTruncatingTail + durationField.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + durationField.textColor = .secondaryLabelColor + durationField.alignment = .right + [titleField, detailField, durationField].forEach(addSubview) + } + required init?(coder: NSCoder) { fatalError() } + + func configure(_ track: AppleMusicTrackResult) { + titleField.stringValue = track.title + detailField.stringValue = [track.artist, track.album].filter { !$0.isEmpty }.joined(separator: " · ") + durationField.stringValue = JukeController.mmss(track.duration) + } + + override func layout() { + titleField.frame = NSRect(x: 12, y: bounds.height - 23, width: bounds.width - 80, height: 18) + detailField.frame = NSRect(x: 12, y: 5, width: bounds.width - 80, height: 15) + durationField.frame = NSRect(x: bounds.width - 63, y: 13, width: 51, height: 16) + } + + override func draw(_ dirtyRect: NSRect) { + if selected { + NSColor.white.withAlphaComponent(0.16).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 4, dy: 2), xRadius: 6, yRadius: 6).fill() + } + super.draw(dirtyRect) + } +} + +final class AestheticCloudRowView: NSTableCellView { + static let id = NSUserInterfaceItemIdentifier("aesthetic-cloud-track-row") + private let titleField = NSTextField(labelWithString: "") + private let detailField = NSTextField(labelWithString: "") + var selected = false { didSet { needsDisplay = true } } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + titleField.font = .systemFont(ofSize: 13, weight: .semibold) + titleField.lineBreakMode = .byTruncatingTail + detailField.font = .systemFont(ofSize: 11) + detailField.textColor = .secondaryLabelColor + addSubview(titleField); addSubview(detailField) + } + required init?(coder: NSCoder) { fatalError() } + + func configure(_ track: JukeCloudTrack) { + titleField.stringValue = track.name + detailField.stringValue = ByteCountFormatter.string(fromByteCount: track.bytes, countStyle: .file) + } + + override func layout() { + titleField.frame = NSRect(x: 12, y: bounds.height - 23, width: bounds.width - 24, height: 18) + detailField.frame = NSRect(x: 12, y: 5, width: bounds.width - 24, height: 15) + } + + override func draw(_ dirtyRect: NSRect) { + if selected { + Palette.coral.withAlphaComponent(0.20).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 4, dy: 2), xRadius: 6, yRadius: 6).fill() + } + super.draw(dirtyRect) + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeCloud.swift b/juke-wizard/Sources/JukeWizard/JukeCloud.swift --- a/juke-wizard/Sources/JukeWizard/JukeCloud.swift +++ b/juke-wizard/Sources/JukeWizard/JukeCloud.swift @@ -194,7 +194,7 @@ scroll.frame = NSRect(x: 12, y: 54, width: 556, height: 286) scroll.autoresizingMask = [.width, .height] content.addSubview(scroll) - configure(loadButton, "Load in JukeWizard", #selector(loadAction)) + configure(loadButton, "Load in Menu Band Juke", #selector(loadAction)) configure(copyButton, "Copy play command", #selector(copyAction)) loadButton.frame = NSRect(x: 12, y: 12, width: 144, height: 30) copyButton.frame = NSRect(x: 160, y: 12, width: 138, height: 30) diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -12,8 +12,8 @@ // watched folder it's added to the queue and starts playing, window to // front — so a new render announces itself. import AppKit -// the JukeWizard palette — pulled from the mascot illy: deep teal robe, -// buttery gold star, coral hat-band, warm cream ground. +// Juke keeps its teal / gold / coral identity, now as translucent washes over +// the same liquid material used by Menu Band. enum Palette { static let teal = NSColor(srgbRed: 0.10, green: 0.52, blue: 0.55, alpha: 1) static let gold = NSColor(srgbRed: 0.95, green: 0.74, blue: 0.20, alpha: 1) @@ -21,7 +21,9 @@ static let coral = NSColor(srgbRed: 0.95, green: 0.45, blue: 0.38, alpha: 1) static let cream = NSColor(srgbRed: 0.99, green: 0.97, blue: 0.91, alpha: 1) static let inkDim = NSColor(srgbRed: 0.42, green: 0.40, blue: 0.36, alpha: 1) static func bg(_ dark: Bool) -> NSColor { - dark ? NSColor(srgbRed: 0.10, green: 0.12, blue: 0.13, alpha: 1) : cream + dark + ? NSColor(srgbRed: 0.04, green: 0.12, blue: 0.14, alpha: 0.10) + : NSColor(srgbRed: 0.92, green: 0.98, blue: 1.00, alpha: 0.04) } static func deckSurface(_ accent: NSColor, dark: Bool, alpha: CGFloat = 1) -> NSColor { let base = dark ? NSColor(white: 0.025, alpha: 1) : cream @@ -44,7 +46,6 @@ let selectPath: String? var playlistName: String? let fullLibraryPath: String var current: Int = -1 - var menuBar: MenuBarCD? var watchTimer: Timer? var activityTimer: Timer? var activityPollInFlight = false @@ -119,11 +120,12 @@ var speedLabel: NSTextField! var speedResetButton: NSButton! var ledLabel: NSTextField! var notesToggle: NSButton! - var djButton: NSButton! - var cloudButton: NSButton! + var sourceActionButton: NSButton! var cloudWindow: JukeCloudWindowController? var djMixer: DJMixerView! + var providerDeck: JukeProviderDeckView! var djMode = false + var djConfigured = false var roomButton: NSButton! var roomPopover: NSPopover? var roomMixer: RoomMixerView? @@ -131,12 +133,23 @@ var miniPopover: NSPopover? var miniPlayer: JukeMiniPlayerView? let roomAudio = JukeRoomAudio() let spotify = JukeSpotify() + let cloud = JukeCloudClient() + private lazy var appleMusic = JukeAppleMusic() + var activeSource: JukeSource = .local var spotifyMode = false + var appleMusicMode = false var spotifyResults: [SpotifyTrackResult] = [] var selectedSpotifyRow = -1 var spotifyState: SpotifyPlaybackState? var spotifyArtworkURL: URL? var spotifyArt: NSImage? + var cloudTracks: [JukeCloudTrack] = [] + var selectedCloudRow = -1 + var appleMusicResults: [AppleMusicTrackResult] = [] + var selectedAppleMusicRow = -1 + var appleMusicPlaying = false + var appleMusicArtworkURL: URL? + var appleMusicArt: NSImage? var drawerPanel: NSView! var drawerOpen = false var currentArt: NSImage? @@ -167,11 +180,13 @@ self.selectPath = selectArg self.playlistName = playlistName self.fullLibraryPath = fullLibraryPath let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 820, height: 540), - styleMask: [.titled, .closable, .miniaturizable, .resizable], + contentRect: NSRect(x: 0, y: 0, width: 820, height: 568), + styleMask: [.titled, .closable, .miniaturizable, .resizable, + .fullSizeContentView], backing: .buffered, defer: false) - window.title = "JukeWizard — \(library.tracks.count) tracks" - // JukeWizard is a compact listening utility: keep it visible above + window.title = "" + window.setAccessibilityLabel("Menu Band Juke") + // Juke is a compact Menu Band listening surface: 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. @@ -179,9 +194,18 @@ window.level = .floating window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.hidesOnDeactivate = false window.isMovableByWindowBackground = true + window.isOpaque = false + window.backgroundColor = .clear + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.titlebarSeparatorStyle = .none + window.hasShadow = true + if let frame = window.contentView?.bounds { + window.contentView = JukeLiquidSurface(frame: frame) + } 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: 640, height: 420) + window.minSize = NSSize(width: 720, height: 420) window.center() super.init(window: window) window.delegate = self @@ -206,8 +230,6 @@ UserDefaults.standard.double(forKey: "listPlaybackRate"))) } speedLabel.stringValue = String(format: "%.2f×", speedSlider.doubleValue) relayout() - menuBar = MenuBarCD() - menuBar?.onClick = { [weak self] in self?.quickToggleFull() } roomAudio.onState = { [weak self] state in DispatchQueue.main.async { self?.renderRoomState(state) } } @@ -226,7 +248,7 @@ } else if !library.tracks.isEmpty { select(0, autoplay: false) } } else if !library.tracks.isEmpty { select(0, autoplay: false) } let requestedSpotify = !(spotifySearch?.trimmingCharacters( in: .whitespacesAndNewlines).isEmpty ?? true) - requestedSpotify ? activateSpotifyMode() : activateLibraryMode() + requestedSpotify ? activateSource(.spotify) : activateSource(.local) if startBeats { setDJMode(true, singleDeck: true) djMixer.loadBeats(solo: true) @@ -273,23 +295,25 @@ return false // esc leaves playlist scope case 49: togglePlay(); return true // space case 123: if djMode { djMixer.dominantDeck.deck.seek(to: djMixer.dominantDeck.deck.currentTime - 5) } - else if spotifyMode { spotify.seek(offsetMS: -5000) } else { wave.seek(to: wave.currentTime - 5) } + else if spotifyMode { spotify.seek(offsetMS: -5000) } + else if !appleMusicMode { wave.seek(to: wave.currentTime - 5) } return true // ← back 5s case 124: if djMode { djMixer.dominantDeck.deck.seek(to: djMixer.dominantDeck.deck.currentTime + 5) } - else if spotifyMode { spotify.seek(offsetMS: 5000) } else { wave.seek(to: wave.currentTime + 5) } + else if spotifyMode { spotify.seek(offsetMS: 5000) } + else if !appleMusicMode { wave.seek(to: wave.currentTime + 5) } return true // → fwd 5s case 126: prevTrack(); return true // ↑ prev track case 125: nextTrack(); return true // ↓ next track case 18, 19, 20, 21, 23: // 1–5 stars let map: [UInt16: Int] = [18: 1, 19: 2, 20: 3, 21: 4, 23: 5] - if !spotifyMode, let n = map[e.keyCode], let t = track { + if activeSource == .local, let n = map[e.keyCode], let t = track { t.data.stars = n; renderStars(n); t.save() listTable.reloadData(forRowIndexes: IndexSet(integer: current), columnIndexes: IndexSet(integer: 0)) } return true - case 8: if !spotifyMode { addCommentNow() }; return true // c comment @ now - case 29: if !spotifyMode { clearStarsClicked() }; return true // 0 clear stars + case 8: if activeSource == .local { addCommentNow() }; return true // c comment @ now + case 29: if activeSource == .local { clearStarsClicked() }; return true // 0 clear stars default: return false } } @@ -403,10 +427,10 @@ scopeButton.toolTip = "Leave this playlist and show all local tracks (Esc)" content.addSubview(scopeButton) refreshPlaylistScopeButton() - sourceTabs = NSSegmentedControl(labels: ["Spotify", "Aesthetic"], + sourceTabs = NSSegmentedControl(labels: JukeSource.allCases.map { $0.label() }, trackingMode: .selectOne, target: self, action: #selector(sourceTabChanged)) - sourceTabs.selectedSegment = 0 + sourceTabs.selectedSegment = JukeSource.local.rawValue sourceTabs.controlSize = .small content.addSubview(sourceTabs) @@ -415,28 +439,21 @@ trackingMode: .selectOne, target: self, action: #selector(appearanceChanged)) appearanceTabs.selectedSegment = 0 appearanceTabs.controlSize = .small - appearanceTabs.toolTip = "Follow macOS, or pin JukeWizard to light or dark" + appearanceTabs.toolTip = "Follow macOS, or pin Menu Band Juke to light or dark" content.addSubview(appearanceTabs) - djButton = NSButton(title: "DJ", target: self, action: #selector(toggleDJMode)) - djButton.bezelStyle = .rounded - djButton.setButtonType(.pushOnPushOff) - djButton.contentTintColor = Palette.teal - djButton.toolTip = "Mix two local tracks" - content.addSubview(djButton) - - cloudButton = NSButton(title: "☁︎", target: self, action: #selector(showCloud)) - cloudButton.bezelStyle = .rounded - cloudButton.contentTintColor = Palette.teal - cloudButton.toolTip = "Sign in and sync tracks with Juke Cloud" - content.addSubview(cloudButton) + sourceActionButton = NSButton(title: "Add files…", target: self, + action: #selector(sourceActionClicked)) + sourceActionButton.bezelStyle = .rounded + sourceActionButton.contentTintColor = Palette.teal + content.addSubview(sourceActionButton) spotifySearchField = NSSearchField(frame: .zero) - spotifySearchField.placeholderString = "Search Spotify" + spotifySearchField.placeholderString = "Search" spotifySearchField.sendsSearchStringImmediately = false spotifySearchField.sendsWholeSearchString = true spotifySearchField.target = self - spotifySearchField.action = #selector(searchSpotifyFromField) + spotifySearchField.action = #selector(searchActiveSourceFromField) content.addSubview(spotifySearchField) listTable = NSTableView() @@ -463,9 +480,18 @@ djMixer = DJMixerView(frame: .zero) djMixer.isHidden = true djMixer.onStateChange = { [weak self] in self?.refreshPlaybackPresence() } - djMixer.onDetach = { [weak self] in self?.window?.orderOut(nil) } + djMixer.onDetach = { [weak self] in self?.refreshPlaybackPresence() } content.addSubview(djMixer) + providerDeck = JukeProviderDeckView(frame: .zero) + providerDeck.isHidden = true + providerDeck.onToggle = { [weak self] in self?.togglePlay() } + providerDeck.onSeek = { [weak self] target in + guard let self, self.spotifyMode, let state = self.spotifyState else { return } + self.spotify.seek(offsetMS: Int((target - state.position) * 1000)) + } + content.addSubview(providerDeck) + playButton.contentTintColor = Palette.teal transportExtra.forEach { $0.contentTintColor = Palette.teal } } @@ -474,7 +500,7 @@ // The collapsible notes + comments + rating drawer (hidden by default). private func buildDrawer(in content: NSView) { drawerPanel = NSView() drawerPanel.wantsLayer = true - drawerPanel.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.82).cgColor + drawerPanel.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.52).cgColor drawerPanel.layer?.cornerRadius = 10 drawerPanel.layer?.borderWidth = 1 drawerPanel.layer?.borderColor = Palette.teal.withAlphaComponent(0.5).cgColor @@ -544,12 +570,9 @@ notesToggle.state = drawerOpen ? .on : .off relayout() } - @objc private func toggleDJMode() { setDJMode(!djMode) } - private func setDJMode(_ enabled: Bool, singleDeck: Bool = false) { guard enabled != djMode else { return } if enabled { - if spotifyMode { activateLibraryMode() } wave.pause() playButton.title = "▶" nowPlaying.setPaused(true) @@ -557,31 +580,37 @@ if drawerOpen { drawerOpen = false notesToggle.state = .off } - if singleDeck { - djMixer.configureSolo(tracks: library.tracks, primaryIndex: max(0, current)) - } else { - djMixer.configure(tracks: library.tracks, primaryIndex: max(0, current)) + if !djConfigured { + if singleDeck { + djMixer.configureSolo(tracks: library.tracks, primaryIndex: max(0, current)) + } else { + djMixer.configure(tracks: library.tracks, primaryIndex: max(0, current)) + } + djConfigured = true } djMixer.setMasterVolume(quickVolume) djMode = true - djButton.state = .on - djButton.contentTintColor = Palette.coral djMixer.isHidden = false + providerDeck.isHidden = true setPlayerChromeHidden(true) + listScroll.isHidden = false + activityLabel.isHidden = false djMixer.startDisplay() roomAudio.useSource(.aesthetic) window?.isMovableByWindowBackground = false - window?.title = "JukeWizard · DJ" } else { - djMixer.pauseAll() djMixer.stopDisplay() djMixer.isHidden = true djMode = false - djButton.state = .off - djButton.contentTintColor = Palette.teal window?.isMovableByWindowBackground = true - setPlayerChromeHidden(false) - window?.title = spotifyMode ? "JukeWizard · Spotify" : "JukeWizard — \(library.tracks.count) tracks" + if spotifyMode || appleMusicMode { + setPlayerChromeHidden(true) + listScroll.isHidden = false + activityLabel.isHidden = false + spotifySearchField.isHidden = false + } else { + setPlayerChromeHidden(false) + } } relayout() refreshPlaybackPresence() @@ -604,15 +633,16 @@ activityLabel.isHidden = false playButton.isHidden = false ledLabel.isHidden = false roomButton.isHidden = false - speedSlider.isHidden = spotifyMode - speedLabel.isHidden = spotifyMode - speedResetButton.isHidden = spotifyMode + let externalSource = spotifyMode || appleMusicMode + speedSlider.isHidden = externalSource + speedLabel.isHidden = externalSource + speedResetButton.isHidden = externalSource listScroll.isHidden = false transportExtra.forEach { $0.isHidden = false } - wave.isHidden = spotifyMode - spotifyProgress.isHidden = !spotifyMode - sortPopup.isHidden = spotifyMode - spotifySearchField.isHidden = !spotifyMode + wave.isHidden = externalSource + spotifyProgress.isHidden = !externalSource + sortPopup.isHidden = externalSource + spotifySearchField.isHidden = !externalSource notesToggle.isHidden = false drawerPanel.isHidden = !drawerOpen if !spotifyMode, let t = track { loadLinks(t) } @@ -621,8 +651,8 @@ private func applyThemeBackground() { let dark = window?.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua let chassis = Palette.bg(dark) - window?.backgroundColor = chassis - window?.contentView?.layer?.backgroundColor = chassis.cgColor + window?.backgroundColor = .clear + (window?.contentView as? JukeLiquidSurface)?.setTint(chassis) window?.contentView?.needsDisplay = true listTable?.reloadData() } @@ -634,6 +664,7 @@ case .light: window?.appearance = NSAppearance(named: .aqua) case .dark: window?.appearance = NSAppearance(named: .darkAqua) } djMixer?.setAppearance(window?.appearance) + providerDeck?.needsDisplay = true applyThemeBackground() } @@ -674,21 +705,44 @@ guard let content = window?.contentView else { return } let W = content.bounds.width, H = content.bounds.height let pad: CGFloat = 8 let topBarH: CGFloat = 34 - let contentTop = H - topBarH - sourceTabs.frame = NSRect(x: pad, y: H - 27, width: 170, height: 22) - appearanceTabs.frame = NSRect(x: W - pad - 172, y: H - 27, width: 172, height: 22) - djButton.frame = NSRect(x: 184, y: H - 28, width: 52, height: 24) - cloudButton.frame = NSRect(x: 240, y: H - 28, width: 46, height: 24) - scopeButton.frame = NSRect(x: 292, y: H - 27, - width: max(0, W - 292 - 188), height: 22) + let titlebarInset = max(content.safeAreaInsets.top, 28) + let usableTop = H - titlebarInset + let contentTop = usableTop - topBarH + let tabsWidth = min(470, max(360, W - 290)) + sourceTabs.frame = NSRect(x: pad, y: usableTop - 27, width: tabsWidth, height: 22) + appearanceTabs.frame = NSRect(x: W - pad - 172, y: usableTop - 27, width: 172, height: 22) + sourceActionButton.frame = NSRect(x: max(pad, W - pad - 272), y: usableTop - 28, + width: 92, height: 24) + scopeButton.frame = .zero + scopeButton.isHidden = true - if djMode { - djMixer.frame = NSRect(x: pad, y: pad, width: W - pad * 2, - height: H - topBarH - pad) + let externalSource = spotifyMode || appleMusicMode + if djMode || externalSource { + let browserWidth = max(260, min(370, W * 0.42)) + let controlsY = contentTop - 24 + if activeSource == .local { + sortPopup.isHidden = false + spotifySearchField.isHidden = true + sortPopup.frame = NSRect(x: pad, y: controlsY, width: browserWidth, height: 22) + } else { + sortPopup.isHidden = true + spotifySearchField.isHidden = false + spotifySearchField.frame = NSRect(x: pad, y: controlsY, + width: browserWidth, height: 22) + } + activityLabel.frame = NSRect(x: pad, y: controlsY - 19, + width: browserWidth, height: 15) + listScroll.frame = NSRect(x: pad, y: pad, width: browserWidth, + height: max(80, controlsY - pad - 22)) + let deckX = pad + browserWidth + 10 + let deckFrame = NSRect(x: deckX, y: pad, width: W - deckX - pad, + height: contentTop - pad) + djMixer.frame = deckFrame + providerDeck.frame = deckFrame return } // ── header (now-playing) across the top ─────────────────────────────── - let headerH = max(178, min(245, (H - topBarH) * 0.39)) + let headerH = max(178, min(245, (usableTop - topBarH) * 0.39)) let headerBottom = contentTop - headerH let mediaSide = min(headerH - pad * 2, W * 0.34) nowPlaying.frame = NSRect(x: pad, y: headerBottom + pad, width: mediaSide, height: headerH - pad * 2) @@ -735,7 +789,7 @@ // ── track list underneath ───────────────────────────────────────────── let sortY = headerBottom - 2 - 20 sortPopup.frame = NSRect(x: pad, y: sortY, width: 180, height: 20) - let searchX = spotifyMode ? pad : pad + 184 + let searchX = externalSource ? pad : pad + 184 spotifySearchField.frame = NSRect(x: searchX, y: sortY, width: max(120, W - searchX - pad), height: 20) listScroll.frame = NSRect(x: pad, y: pad, width: W - pad * 2, height: sortY - pad - 3) @@ -766,43 +820,10 @@ notesScroll.frame = NSRect(x: dp, y: notesBottom, width: dw - dp * 2, height: notesH) notesPlaceholder.frame = NSRect(x: dp + 6, y: notesBottom + notesH - 20, width: 100, height: 16) } - // Keep the compact menu-bar CD, Dock artwork, and mini player in sync. + // Keep Menu Band Juke's in-window playback surfaces in sync. Application + // identity and status items remain exclusively owned by Menu Band. private func refreshPlaybackPresence() { - let bpm = djMode ? djMixer.dominantBPM - : (spotifyMode ? nil : track?.meta?.bpm.map(Double.init)) - let playing = djMode ? djMixer.isPlaying - : (spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying) - let title = djMode ? djMixer.dominantTitle - : (spotifyMode ? (spotifyState?.title ?? "") : (track?.title ?? "")) - let artist = spotifyMode - ? spotifyState?.artists - : track.map { $0.meta?.artist ?? "Aesthetic Dot Computer" } - if djMode { - menuBar?.setDecks([ - .init(id: "A", artist: djMixer.deckA.deck.track.map { - $0.meta?.artist ?? "Aesthetic Dot Computer" - }, - title: djMixer.deckA.deck.track?.title ?? "Deck A", - art: Self.artwork(for: djMixer.deckA.deck.track), accent: Palette.teal, - bpm: djMixer.deckA.deck.targetBPM, playing: djMixer.deckA.deck.isPlaying), - .init(id: "B", artist: djMixer.deckB.deck.track.map { - $0.meta?.artist ?? "Aesthetic Dot Computer" - }, - title: djMixer.deckB.deck.track?.title ?? "Deck B", - art: Self.artwork(for: djMixer.deckB.deck.track), accent: Palette.coral, - bpm: djMixer.deckB.deck.targetBPM, playing: djMixer.deckB.deck.isPlaying), - ]) - } else { - menuBar?.setSingleDeck(artist: artist, title: title, art: currentArt, bpm: bpm, playing: playing) - } - DockIcon.setNowPlaying(art: currentArt, playing: playing, bpm: bpm) miniPlayer?.refresh() - } - - private static func artwork(for track: Track?) -> NSImage? { - track?.meta?.art.flatMap { - NSImage(contentsOf: URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath)) - } } private func refreshPlaylistScopeButton() { @@ -824,7 +845,6 @@ }) { current = index } listTable.reloadData() - window?.title = "JukeWizard — \(library.tracks.count) tracks" activityLabel.stringValue = "● full Aesthetic library · \(library.tracks.count) tracks" activityLabel.textColor = Palette.teal relayout() @@ -895,71 +915,372 @@ roomMixer?.show(state, layout: roomAudio.layout, pan: roomAudio.pan) miniPlayer?.refresh() } - // ── headless Spotify source ─────────────────────────────────────────── - private func activateSpotifyMode() { - if djMode { setDJMode(false) } - if wave?.isPlaying == true { wave.pause() } + // ── music sources ───────────────────────────────────────────────────── + private func activateSource(_ source: JukeSource) { + let departingSource = activeSource + if departingSource == .spotify, source != .spotify { spotify.pause() } + if departingSource == .appleMusic, source != .appleMusic { + appleMusicPlaying = false + if #available(macOS 14.0, *) { appleMusic.pause() } + } + if source == .spotify || source == .appleMusic { + djMixer.pauseAll() + } + activeSource = source + spotifyMode = source == .spotify + appleMusicMode = source == .appleMusic + sourceTabs?.selectedSegment = source.rawValue + djMixer?.setRecordDetachmentAllowed(source.canDetachRecords) + if drawerOpen { drawerOpen = false drawerPanel?.isHidden = true notesToggle?.state = .off } - spotifyMode = true - roomAudio.useSource(.spotify) - window?.title = "JukeWizard · Spotify" - wave?.isHidden = true - speedSlider?.isHidden = true - speedLabel?.isHidden = true - speedResetButton?.isHidden = true - spotifyProgress?.isHidden = false + + switch source { + case .local: + providerDeck.isHidden = true + sourceActionButton.title = "Add files…" + sourceActionButton.isEnabled = true + spotifySearchField.placeholderString = "Search \(source.label())" + setDJMode(true, singleDeck: true) + activityLabel.stringValue = "\(library.tracks.count) tracks on \(source.label())" + activityLabel.textColor = Palette.gold + listTable.reloadData() + pollActivityStatus() + case .aesthetic: + providerDeck.isHidden = true + sourceActionButton.title = ACSession.shared.token() == nil ? "Sign in" : "Publish…" + sourceActionButton.isEnabled = true + spotifySearchField.placeholderString = "Search Aesthetic releases" + setDJMode(true, singleDeck: true) + activityLabel.stringValue = "Loading Aesthetic releases…" + activityLabel.textColor = Palette.coral + loadAestheticCloud() + case .spotify: + sourceActionButton.title = "Connected" + sourceActionButton.isEnabled = false + spotifySearchField.placeholderString = "Search Spotify" + if djMode { setDJMode(false) } + configureExternalSource() + providerDeck.configure(source: .spotify) + providerDeck.update(title: spotifyState?.title ?? "", + artist: spotifyState?.artists ?? "", + album: spotifyState?.album ?? "", + art: spotifyArt, + duration: spotifyState?.duration ?? 0, + position: spotifyState?.position ?? 0, + playing: spotifyState?.isPlaying ?? false, + canSeek: true) + roomAudio.useSource(.spotify) + currentArt = spotifyArt + activityLabel.stringValue = "● juked headless · connecting" + activityLabel.textColor = Palette.teal + if let state = spotifyState { renderSpotifyState(state) } + case .appleMusic: + sourceActionButton.title = "Connect" + sourceActionButton.isEnabled = true + spotifySearchField.placeholderString = "Search Apple Music" + if djMode { setDJMode(false) } + configureExternalSource() + providerDeck.configure(source: .appleMusic) + let selected = appleMusicResults.indices.contains(selectedAppleMusicRow) + ? appleMusicResults[selectedAppleMusicRow] : nil + providerDeck.update(title: selected?.title ?? "", + artist: selected?.artist ?? "", + album: selected?.album ?? "", + art: appleMusicArt, + duration: selected?.duration ?? 0, + position: 0, + playing: appleMusicPlaying, + canSeek: false) + activityLabel.stringValue = if #available(macOS 14.0, *) { + "● Apple Music · ready to connect" + } else { + "⚠ Apple Music requires macOS 14 or newer" + } + activityLabel.textColor = if #available(macOS 14.0, *) { .labelColor } else { .systemRed } + } + relayout() + refreshPlaybackPresence() + } + + private func configureExternalSource() { + setPlayerChromeHidden(true) + providerDeck?.isHidden = false + listScroll?.isHidden = false + activityLabel?.isHidden = false sortPopup?.isHidden = true spotifySearchField?.isHidden = false notesToggle?.isEnabled = false - sourceTabs?.selectedSegment = 0 - currentArt = spotifyArt - activityLabel?.stringValue = "● juked headless · connecting" - activityLabel?.textColor = Palette.teal listTable?.reloadData() - relayout() - if let state = spotifyState { renderSpotifyState(state) } - else { refreshPlaybackPresence() } } - private func activateLibraryMode() { - if djMode { setDJMode(false) } - if spotifyMode, spotifyState?.isPlaying == true { spotify.pause() } - spotifyMode = false - roomAudio.useSource(.aesthetic) - window?.title = "JukeWizard — \(library.tracks.count) tracks" - wave.isHidden = false - speedSlider.isHidden = false - speedLabel.isHidden = false - speedResetButton.isHidden = false - spotifyProgress.isHidden = true - sortPopup.isHidden = false - spotifySearchField.isHidden = true - notesToggle.isEnabled = true - sourceTabs.selectedSegment = 1 - listTable.reloadData() - if let t = track { - titleLabel.stringValue = t.title - artistLabel.stringValue = t.meta?.artist ?? "Aesthetic Dot Computer" - laneLabel.stringValue = Self.metaLine(t) - updateNowPlaying(t); loadLinks(t); updateTime() + private func refreshProviderDeck() { + guard !providerDeck.isHidden else { return } + if spotifyMode { + providerDeck.update(title: spotifyState?.title ?? "", + artist: spotifyState?.artists ?? "", + album: spotifyState?.album ?? "", + art: spotifyArt, + duration: spotifyState?.duration ?? 0, + position: spotifyState?.position ?? 0, + playing: spotifyState?.isPlaying ?? false, + canSeek: true) + } else if appleMusicMode { + let selected = appleMusicResults.indices.contains(selectedAppleMusicRow) + ? appleMusicResults[selectedAppleMusicRow] : nil + providerDeck.update(title: selected?.title ?? "", + artist: selected?.artist ?? "", + album: selected?.album ?? "", + art: appleMusicArt, + duration: selected?.duration ?? 0, + position: 0, + playing: appleMusicPlaying, + canSeek: false) } - playButton.title = wave.isPlaying ? "❚❚" : "▶" - nowPlaying.setPaused(!wave.isPlaying) - relayout() - refreshPlaybackPresence() - pollActivityStatus() } + private func activateSpotifyMode() { activateSource(.spotify) } + private func activateLibraryMode() { activateSource(.local) } + @objc private func sourceTabChanged() { - sourceTabs.selectedSegment == 0 ? activateSpotifyMode() : activateLibraryMode() + guard let source = JukeSource(rawValue: sourceTabs.selectedSegment) else { return } + activateSource(source) + } + + @objc private func searchActiveSourceFromField() { + switch activeSource { + case .local: break + case .aesthetic: + selectedCloudRow = filteredCloudTracks.isEmpty ? -1 : 0 + activityLabel.stringValue = "\(filteredCloudTracks.count) Aesthetic release\(filteredCloudTracks.count == 1 ? "" : "s")" + listTable.reloadData() + case .spotify: searchSpotify(spotifySearchField.stringValue) + case .appleMusic: searchAppleMusic(spotifySearchField.stringValue) + } + } + + private var filteredCloudTracks: [JukeCloudTrack] { + let query = spotifySearchField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !query.isEmpty else { return cloudTracks } + return cloudTracks.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + @objc private func sourceActionClicked() { + switch activeSource { + case .local: + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.beginSheetModal(for: window!) { [weak self] response in + guard response == .OK, let self else { return } + panel.urls.forEach { self.library.add(path: $0.path) } + self.listTable.reloadData() + self.activityLabel.stringValue = "\(self.library.tracks.count) tracks on \(JukeSource.local.label())" + } + case .aesthetic: + if ACSession.shared.token() == nil { + ACLogin.shared.signIn { [weak self] result in + guard case .success = result else { return } + self?.sourceActionButton.title = "Publish…" + self?.loadAestheticCloud() + } + } else { + publishToAesthetic() + } + case .spotify: + break + case .appleMusic: + connectAppleMusic() + } + } + + private func loadAestheticCloud() { + guard ACSession.shared.token() != nil else { + cloudTracks = [] + selectedCloudRow = -1 + activityLabel.stringValue = "Sign in to browse Aesthetic releases" + listTable.reloadData() + return + } + Task { [weak self] in + guard let self else { return } + do { + let tracks = try await cloud.list() + await MainActor.run { + guard self.activeSource == .aesthetic else { return } + self.cloudTracks = tracks + self.selectedCloudRow = tracks.isEmpty ? -1 : 0 + self.activityLabel.stringValue = tracks.isEmpty + ? "No Aesthetic releases" + : "\(tracks.count) Aesthetic release\(tracks.count == 1 ? "" : "s")" + self.listTable.reloadData() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + + private func publishToAesthetic() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.beginSheetModal(for: window!) { [weak self] response in + guard response == .OK, let self else { return } + self.activityLabel.stringValue = "Publishing…" + Task { + do { + for url in panel.urls { _ = try await self.cloud.upload(file: url) } + await MainActor.run { self.loadAestheticCloud() } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + } + + private func playAestheticCloudResult(at row: Int) { + let visibleTracks = filteredCloudTracks + guard visibleTracks.indices.contains(row) else { return } + selectedCloudRow = row + listTable.reloadData() + let cloudTrack = visibleTracks[row] + activityLabel.stringValue = "Loading \(cloudTrack.name)…" + Task { [weak self] in + guard let self else { return } + do { + let url = try await cloud.download(cloudTrack) + await MainActor.run { + self.library.addFile(url, lane: "Aesthetic") + guard let track = self.library.tracks.first(where: { + $0.url.standardizedFileURL == url.standardizedFileURL + }) else { return } + self.djMixer.loadPrimary(track) + self.activityLabel.stringValue = "Pull the record off the bed to float it" + self.refreshPlaybackPresence() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + + private func connectAppleMusic() { + guard #available(macOS 14.0, *) else { return } + activityLabel.stringValue = "Connecting Apple Music…" + Task { [weak self] in + guard let self else { return } + do { + try await appleMusic.authorize() + await MainActor.run { + self.sourceActionButton.title = "Connected" + self.sourceActionButton.isEnabled = false + self.activityLabel.stringValue = "● Apple Music · connected" + self.spotifySearchField.becomeFirstResponder() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } } - @objc private func searchSpotifyFromField() { - searchSpotify(spotifySearchField.stringValue) + private func searchAppleMusic(_ rawQuery: String) { + guard #available(macOS 14.0, *) else { return } + let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return } + activityLabel.stringValue = "Searching Apple Music for “\(query)”…" + Task { [weak self] in + guard let self else { return } + do { + let results = try await appleMusic.search(query) + await MainActor.run { + guard self.activeSource == .appleMusic else { return } + self.appleMusicResults = results + self.selectedAppleMusicRow = results.isEmpty ? -1 : 0 + self.sourceActionButton.title = "Connected" + self.sourceActionButton.isEnabled = false + self.activityLabel.stringValue = results.isEmpty + ? "No Apple Music tracks for “\(query)”" + : "\(results.count) Apple Music tracks" + self.listTable.reloadData() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + + private func playAppleMusicResult(at row: Int) { + guard #available(macOS 14.0, *), appleMusicResults.indices.contains(row) else { return } + selectedAppleMusicRow = row + let result = appleMusicResults[row] + titleLabel.stringValue = result.title + titleLabel.textColor = .labelColor + artistLabel.stringValue = result.artist + laneLabel.stringValue = [result.album, "Apple Music · stays on this deck"] + .filter { !$0.isEmpty }.joined(separator: " · ") + spotifyProgress.duration = result.duration + spotifyProgress.position = 0 + playButton.title = "❚❚" + appleMusicPlaying = true + djMixer.pauseAll() + spotify.pause() + listTable.reloadData() + refreshProviderDeck() + presentAppleMusicArtwork(result.artworkURL) + Task { [weak self] in + do { + try await self?.appleMusic.play(result) + await MainActor.run { self?.refreshPlaybackPresence() } + } + catch { + await MainActor.run { + self?.appleMusicPlaying = false + self?.activityLabel.stringValue = error.localizedDescription + self?.activityLabel.textColor = .systemRed + self?.refreshProviderDeck() + self?.refreshPlaybackPresence() + } + } + } + } + + private func presentAppleMusicArtwork(_ url: URL?) { + appleMusicArtworkURL = url + appleMusicArt = nil + currentArt = nil + refreshProviderDeck() + guard let url else { nowPlaying.present(art: nil, videoURL: nil); return } + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let data, let art = NSImage(data: data) else { return } + DispatchQueue.main.async { + guard self?.appleMusicArtworkURL == url else { return } + self?.appleMusicArt = art + self?.currentArt = art + self?.nowPlaying.present(art: art, videoURL: nil) + self?.nowPlaying.setPaused(false) + self?.refreshProviderDeck() + self?.refreshPlaybackPresence() + } + }.resume() } private func searchSpotify(_ rawQuery: String, autoplayFirst: Bool = false) { @@ -995,10 +1316,17 @@ let result = spotifyResults[row] titleLabel.stringValue = result.title titleLabel.textColor = NSColor(srgbRed: 0.11, green: 0.73, blue: 0.33, alpha: 1) artistLabel.stringValue = result.artists - laneLabel.stringValue = [result.album, "Spotify · juked"].filter { !$0.isEmpty }.joined(separator: " · ") + laneLabel.stringValue = [result.album, "Spotify · stays on this deck"].filter { !$0.isEmpty }.joined(separator: " · ") spotifyProgress.duration = result.duration spotifyProgress.position = 0 playButton.title = "❚❚" + djMixer.pauseAll() + if #available(macOS 14.0, *) { appleMusic.pause() } + appleMusicPlaying = false + providerDeck.update(title: result.title, artist: result.artists, + album: result.album, art: nil, + duration: result.duration, position: 0, + playing: true, canSeek: true) spotify.play(result) var rows = IndexSet(integer: row) if old >= 0, old < spotifyResults.count { rows.insert(old) } @@ -1007,19 +1335,11 @@ } private func renderSpotifyState(_ state: SpotifyPlaybackState?) { spotifyState = state - // `juked` can already be playing when the resident JukeWizard starts - // (for example after Spotify Connect hands playback to its device). - // Follow that live source so the menu-bar CD does not remain parked on - // an idle Aesthetic-library selection. - if state?.isPlaying == true, !spotifyMode, !djMode, !wave.isPlaying { - activateSpotifyMode() - return - } guard spotifyMode, let state else { return } titleLabel.stringValue = state.title titleLabel.textColor = NSColor(srgbRed: 0.11, green: 0.73, blue: 0.33, alpha: 1) artistLabel.stringValue = state.artists - laneLabel.stringValue = [state.album, "Spotify · headless juked"].filter { !$0.isEmpty }.joined(separator: " · ") + laneLabel.stringValue = [state.album, "Spotify · stays on this deck"].filter { !$0.isEmpty }.joined(separator: " · ") laneLabel.textColor = NSColor(white: 0.68, alpha: 1) spotifyProgress.duration = state.duration spotifyProgress.position = state.position @@ -1030,10 +1350,12 @@ if let spotifyArt { currentArt = spotifyArt nowPlaying.present(art: spotifyArt, videoURL: nil) } + refreshProviderDeck() if state.artworkURL != spotifyArtworkURL { spotifyArtworkURL = state.artworkURL spotifyArt = nil currentArt = nil + refreshProviderDeck() guard let url = state.artworkURL else { nowPlaying.present(art: nil, videoURL: nil); return } URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in guard let data, let art = NSImage(data: data) else { return } @@ -1043,6 +1365,7 @@ self?.spotifyArt = art self?.currentArt = art self?.nowPlaying.present(art: art, videoURL: nil) self?.nowPlaying.setPaused(!state.isPlaying) + self?.refreshProviderDeck() self?.refreshPlaybackPresence() } }.resume() @@ -1050,16 +1373,26 @@ } refreshPlaybackPresence() } var quickTitle: String { - djMode ? djMixer.dominantTitle - : (spotifyMode ? (spotifyState?.title ?? "Spotify") : (track?.title ?? "Aesthetic")) + if djMode { return djMixer.dominantTitle } + if spotifyMode { return spotifyState?.title ?? "Spotify" } + if appleMusicMode, appleMusicResults.indices.contains(selectedAppleMusicRow) { + return appleMusicResults[selectedAppleMusicRow].title + } + return track?.title ?? activeSource.label() } var quickSubtitle: String { if djMode { return String(format: "DJ mix · %.1f BPM", djMixer.dominantBPM) } if spotifyMode { return [spotifyState?.artists ?? "", "Spotify"].filter { !$0.isEmpty }.joined(separator: " · ") } + if appleMusicMode, appleMusicResults.indices.contains(selectedAppleMusicRow) { + return [appleMusicResults[selectedAppleMusicRow].artist, "Apple Music"] + .filter { !$0.isEmpty }.joined(separator: " · ") + } return [track?.meta?.artist ?? "Aesthetic Dot Computer", "Aesthetic"].joined(separator: " · ") } var quickIsPlaying: Bool { - djMode ? djMixer.isPlaying : (spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying) + djMode ? djMixer.isPlaying + : (spotifyMode ? (spotifyState?.isPlaying ?? false) + : (appleMusicMode ? appleMusicPlaying : wave.isPlaying)) } var quickRoomSummary: String { switch roomAudio.state { @@ -1112,7 +1445,7 @@ miniPlayer?.refresh() } func makeDockMenu() -> NSMenu { - let menu = NSMenu(title: "JukeWizard") + let menu = NSMenu(title: "Menu Band Juke") let heading = NSMenuItem(title: "\(quickTitle) — \(quickSubtitle)", action: nil, keyEquivalent: "") heading.isEnabled = false menu.addItem(heading) @@ -1138,7 +1471,7 @@ menu.addItem(.separator()) let room = NSMenuItem(title: quickRoomSummary, action: nil, keyEquivalent: "") room.isEnabled = false menu.addItem(room) - let open = NSMenuItem(title: "Open JukeWizard", action: #selector(quickOpenFull), keyEquivalent: "") + let open = NSMenuItem(title: "Open Menu Band Juke", action: #selector(quickOpenFull), keyEquivalent: "") open.target = self menu.addItem(open) return menu @@ -1177,7 +1510,6 @@ private var track: Track? { (current >= 0 && current < library.tracks.count) ? library.tracks[current] : nil } func select(_ i: Int, autoplay: Bool) { guard i >= 0, i < library.tracks.count else { return } - if djMode { setDJMode(false) } if spotifyMode { spotify.pause(); activateLibraryMode() } commitNotes() let old = current @@ -1191,6 +1523,17 @@ laneLabel.textColor = .secondaryLabelColor updateNowPlaying(t) loadLinks(t) relayout() // link count changes the header row width + if djMode { + djMixer.loadPrimary(t, autoplay: autoplay) + var rows = IndexSet(integer: i) + if old >= 0, old < library.tracks.count { rows.insert(old) } + if activeSource == .local { + listTable.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + listTable.scrollRowToVisible(i) + } + refreshPlaybackPresence() + return + } wave.load(track: t) wave.playbackRate = speedSlider.doubleValue wave.comments = t.data.comments @@ -1263,8 +1606,12 @@ } @objc private func listClicked() { let r = listTable.clickedRow - if spotifyMode { playSpotifyResult(at: r) } - else if r >= 0 { select(r, autoplay: true) } + switch activeSource { + case .local: if r >= 0 { select(r, autoplay: true) } + case .aesthetic: playAestheticCloudResult(at: r) + case .spotify: playSpotifyResult(at: r) + case .appleMusic: playAppleMusicResult(at: r) + } } @objc private func togglePlay() { if djMode { @@ -1274,11 +1621,26 @@ spotify.toggle() let playing = !(spotifyState?.isPlaying ?? false) playButton.title = playing ? "❚❚" : "▶" nowPlaying.setPaused(!playing) + } else if appleMusicMode { + guard #available(macOS 14.0, *) else { return } + appleMusicPlaying.toggle() + playButton.title = appleMusicPlaying ? "❚❚" : "▶" + nowPlaying.setPaused(!appleMusicPlaying) + Task { [weak self] in + do { try await self?.appleMusic.toggle() } + catch { + await MainActor.run { + self?.appleMusicPlaying.toggle() + self?.refreshPlaybackPresence() + } + } + } } else { wave.togglePlay() playButton.title = wave.isPlaying ? "❚❚" : "▶" nowPlaying.setPaused(!wave.isPlaying) } + refreshProviderDeck() refreshPlaybackPresence() } @objc private func speedChanged(_ sender: NSSlider) { @@ -1294,11 +1656,13 @@ } @objc private func prevTrack() { if djMode { djMixer.stepDominant(by: -1) } else if spotifyMode { spotify.previous() } + else if appleMusicMode { return } else if current > 0 { select(current - 1, autoplay: true) } } @objc private func nextTrack() { if djMode { djMixer.stepDominant(by: 1) } else if spotifyMode { spotify.next() } + else if appleMusicMode { return } else if current < library.tracks.count - 1 { select(current + 1, autoplay: true) } } @@ -1311,12 +1675,14 @@ return ["ok": false, "error": "missing command"] } func state() -> [String: Any] { var out: [String: Any] = [ - "ok": true, "mode": djMode ? "dj" : (spotifyMode ? "spotify" : "library"), + "ok": true, + "mode": djMode ? "dj" : (spotifyMode ? "spotify" : (appleMusicMode ? "appleMusic" : "library")), + "source": String(describing: activeSource), "playing": quickIsPlaying, "index": current, "queueCount": library.tracks.count ] if let t = track { out["title"] = t.title; out["path"] = t.url.path; out["lane"] = t.lane } - if !spotifyMode && !djMode { + if !spotifyMode && !appleMusicMode && !djMode { out["position"] = wave.currentTime; out["duration"] = wave.duration out["speed"] = wave.playbackRate } @@ -1324,6 +1690,31 @@ return out } switch command { case "status": return state() + case "source": + guard let rawSource = request["source"] as? String else { + return ["ok": false, "error": "source requires local, aesthetic, spotify, or appleMusic"] + } + let source: JukeSource? + switch rawSource.lowercased() { + case "local": source = .local + case "aesthetic": source = .aesthetic + case "spotify": source = .spotify + case "apple", "applemusic", "apple-music": source = .appleMusic + default: source = nil + } + guard let source else { + return ["ok": false, "error": "unknown source"] + } + activateSource(source) + return state() + case "detach": + guard djMode, activeSource.canDetachRecords else { + return ["ok": false, "error": "this source must stay on the main deck"] + } + guard djMixer.detachPrimary() else { + return ["ok": false, "error": "the main bed has no record"] + } + return state() case "list": let limit = max(1, min(1000, request["limit"] as? Int ?? 500)) let rows: [[String: Any]] = library.tracks.prefix(limit).enumerated().map { i, t in @@ -1472,7 +1863,7 @@ self?.pollActivityStatus() } } private func pollActivityStatus() { - guard !spotifyMode, !activityPollInFlight else { return } + guard activeSource == .local, !activityPollInFlight else { return } activityPollInFlight = true let tracks = library.tracks DispatchQueue.global(qos: .utility).async { [weak self] in @@ -1480,7 +1871,7 @@ let activities = WorkStatus.snapshot(tracks: tracks) DispatchQueue.main.async { guard let self else { return } self.activityPollInFlight = false - guard !self.spotifyMode else { return } + guard self.activeSource == .local else { return } self.renderActivityStatus(activities) } } @@ -1512,18 +1903,40 @@ } // ── tables ─────────────────────────────────────────────────────────────── func numberOfRows(in tableView: NSTableView) -> Int { - if tableView == listTable { return spotifyMode ? spotifyResults.count : library.tracks.count } + if tableView == listTable { + switch activeSource { + case .local: return library.tracks.count + case .aesthetic: return filteredCloudTracks.count + case .spotify: return spotifyResults.count + case .appleMusic: return appleMusicResults.count + } + } return track?.data.comments.count ?? 0 } // list = dressed-up view rows; comments = plain cell strings. func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - if tableView == listTable, spotifyMode, row < spotifyResults.count { + if tableView == listTable, activeSource == .spotify, row < spotifyResults.count { let cell = (listTable.makeView(withIdentifier: SpotifyTrackRowView.id, owner: self) as? SpotifyTrackRowView) ?? { let view = SpotifyTrackRowView(); view.identifier = SpotifyTrackRowView.id; return view }() cell.configure(spotifyResults[row]) cell.selected = row == selectedSpotifyRow return cell } + if tableView == listTable, activeSource == .aesthetic, row < filteredCloudTracks.count { + let cell = (listTable.makeView(withIdentifier: AestheticCloudRowView.id, owner: self) as? AestheticCloudRowView) + ?? { let view = AestheticCloudRowView(); view.identifier = AestheticCloudRowView.id; return view }() + cell.configure(filteredCloudTracks[row]) + cell.selected = row == selectedCloudRow + return cell + } + if tableView == listTable, activeSource == .appleMusic, row < appleMusicResults.count { + let cell = (listTable.makeView(withIdentifier: AppleMusicTrackRowView.id, owner: self) as? AppleMusicTrackRowView) + ?? { let view = AppleMusicTrackRowView(); view.identifier = AppleMusicTrackRowView.id; return view }() + cell.configure(appleMusicResults[row]) + cell.selected = row == selectedAppleMusicRow + return cell + } + guard activeSource == .local else { return nil } guard tableView == listTable, row < library.tracks.count else { return nil } let cell = (listTable.makeView(withIdentifier: TrackRowView.id, owner: self) as? TrackRowView) ?? { let v = TrackRowView(); v.identifier = TrackRowView.id; return v }() @@ -1540,7 +1953,7 @@ // 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, !spotifyMode, + guard tableView == listTable, activeSource == .local, row >= 0, row < library.tracks.count else { return nil } return library.tracks[row].url as NSURL } @@ -1586,7 +1999,6 @@ let sel = listTable.selectedRow if library.tracks.contains(where: { $0.url.standardizedFileURL.path == here }) { listTable.reloadData() // refresh its sidecar/stars } else if library.addFile(url, lane: lane) != nil { - window?.title = "JukeWizard — \(library.tracks.count) tracks" listTable.reloadData() } if sel >= 0 { listTable.selectRowIndexes(IndexSet(integer: sel), byExtendingSelection: false) } diff --git a/juke-wizard/Sources/JukeWizard/JukeLiquidSurface.swift b/juke-wizard/Sources/JukeWizard/JukeLiquidSurface.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeLiquidSurface.swift @@ -0,0 +1,52 @@ +import AppKit + +/// The shared glass chassis behind Juke's controls. It mirrors Menu Band's +/// popover material while keeping Juke independently buildable as a package. +final class JukeLiquidSurface: NSView { + private final class PassthroughTintView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } + + private let backdrop: NSView + private let tint = PassthroughTintView() + + override init(frame frameRect: NSRect) { + if #available(macOS 26.0, *) { + let glass = NSGlassEffectView() + glass.style = .clear + glass.tintColor = .clear + backdrop = glass + } else { + let vibrancy = NSVisualEffectView() + vibrancy.material = .popover + vibrancy.blendingMode = .behindWindow + vibrancy.state = .active + backdrop = vibrancy + } + + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + + // Keep the glass effect at full strength so copy behind the window is + // refracted instead of competing with Juke. The explicit clear tint + // above is what lets the desktop's actual color remain present. + backdrop.alphaValue = 1 + backdrop.frame = bounds + backdrop.autoresizingMask = [.width, .height] + addSubview(backdrop) + + tint.wantsLayer = true + tint.frame = bounds + tint.autoresizingMask = [.width, .height] + addSubview(tint) + } + + required init?(coder: NSCoder) { fatalError() } + + override var isOpaque: Bool { false } + + func setTint(_ color: NSColor) { + tint.layer?.backgroundColor = color.cgColor + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift b/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift --- a/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift +++ b/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift @@ -3,7 +3,7 @@ final class JukeMiniPlayerView: NSView { weak var controller: JukeController? private let art = NSImageView() - private let title = NSTextField(labelWithString: "JukeWizard") + private let title = NSTextField(labelWithString: "Menu Band Juke") private let subtitle = NSTextField(labelWithString: "") private let room = NSTextField(labelWithString: "") private let previous = NSButton(title: "⏮", target: nil, action: nil) diff --git a/juke-wizard/Sources/JukeWizard/JukeProviderDeckView.swift b/juke-wizard/Sources/JukeWizard/JukeProviderDeckView.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeProviderDeckView.swift @@ -0,0 +1,180 @@ +import AppKit + +/// The main-window deck used by streaming providers whose media cannot leave +/// the service player. It deliberately shares the launch bed's record grammar, +/// but exposes no drag or pop-out affordance. +final class JukeProviderDeckView: NSView { + private let recordView = JukeProviderRecordView(frame: .zero) + private let titleLabel = NSTextField(labelWithString: "Choose a track") + private let artistLabel = NSTextField(labelWithString: "") + private let albumLabel = NSTextField(labelWithString: "") + private let fixedLabel = NSTextField(labelWithString: "FIXED TO DECK") + private let playButton = NSButton(title: "▶", target: nil, action: nil) + private let timeLabel = NSTextField(labelWithString: "0:00 / 0:00") + private let progress = SpotifyProgressView(frame: .zero) + private var accent = Palette.teal + + var onToggle: (() -> Void)? + var onSeek: ((Double) -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 12 + layer?.borderWidth = 1 + titleLabel.font = .systemFont(ofSize: 16, weight: .bold) + titleLabel.lineBreakMode = .byTruncatingTail + artistLabel.font = .systemFont(ofSize: 12, weight: .medium) + artistLabel.textColor = .secondaryLabelColor + artistLabel.lineBreakMode = .byTruncatingTail + albumLabel.font = .systemFont(ofSize: 11) + albumLabel.textColor = .tertiaryLabelColor + albumLabel.lineBreakMode = .byTruncatingTail + fixedLabel.font = .monospacedSystemFont(ofSize: 10, weight: .bold) + fixedLabel.alignment = .center + fixedLabel.toolTip = "This service keeps playback on the main deck" + playButton.bezelStyle = .rounded + playButton.target = self + playButton.action = #selector(toggle) + timeLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium) + timeLabel.textColor = .secondaryLabelColor + progress.onSeek = { [weak self] target in self?.onSeek?(target) } + [recordView, titleLabel, artistLabel, albumLabel, fixedLabel, + playButton, timeLabel, progress].forEach(addSubview) + setAccessibilityRole(.group) + setAccessibilityLabel("Fixed streaming deck") + } + required init?(coder: NSCoder) { fatalError() } + + func configure(source: JukeSource) { + accent = source == .spotify + ? NSColor(srgbRed: 0.11, green: 0.73, blue: 0.33, alpha: 1) + : NSColor(srgbRed: 0.98, green: 0.22, blue: 0.35, alpha: 1) + recordView.accent = accent + recordView.providerMark = source == .spotify ? "S" : "♪" + fixedLabel.textColor = accent + playButton.contentTintColor = accent + layer?.borderColor = accent.withAlphaComponent(0.55).cgColor + if titleLabel.stringValue.isEmpty { titleLabel.stringValue = "Choose a track" } + needsDisplay = true + } + + func update(title: String, artist: String, album: String, art: NSImage?, + duration: Double, position: Double, playing: Bool, canSeek: Bool) { + titleLabel.stringValue = title.isEmpty ? "Choose a track" : title + artistLabel.stringValue = artist + albumLabel.stringValue = album + recordView.art = art + recordView.isPlaying = playing + playButton.title = playing ? "❚❚" : "▶" + playButton.isEnabled = !title.isEmpty + progress.duration = duration + progress.position = position + progress.allowsSeeking = canSeek + progress.alphaValue = canSeek ? 1 : 0.45 + timeLabel.stringValue = "\(JukeController.mmss(position)) / \(JukeController.mmss(duration))" + } + + override func layout() { + let pad: CGFloat = 14 + let top = bounds.height - pad + titleLabel.frame = NSRect(x: pad, y: top - 24, width: bounds.width - pad * 2, height: 22) + artistLabel.frame = NSRect(x: pad, y: top - 43, width: bounds.width - pad * 2, height: 17) + albumLabel.frame = NSRect(x: pad, y: top - 60, width: bounds.width - pad * 2, height: 15) + + let controlsH: CGFloat = 88 + let recordTop = top - 67 + let recordBottom = controlsH + 10 + let diameter = max(90, min(bounds.width - 38, recordTop - recordBottom)) + recordView.frame = NSRect(x: (bounds.width - diameter) / 2, + y: recordBottom + (recordTop - recordBottom - diameter) / 2, + width: diameter, height: diameter) + + fixedLabel.frame = NSRect(x: bounds.midX - 70, y: 66, width: 140, height: 16) + playButton.frame = NSRect(x: pad, y: 32, width: 45, height: 27) + progress.frame = NSRect(x: pad + 53, y: 32, width: max(80, bounds.width - pad * 2 - 53), height: 27) + timeLabel.frame = NSRect(x: pad, y: 9, width: bounds.width - pad * 2, height: 18) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + recordView.needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + Palette.deckSurface(accent, dark: dark).withAlphaComponent(0.34).setFill() + NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12).fill() + } + + @objc private func toggle() { onToggle?() } +} + +final class JukeProviderRecordView: NSView { + var accent = Palette.teal { didSet { needsDisplay = true } } + var providerMark = "S" { didSet { needsDisplay = true } } + var art: NSImage? { didSet { needsDisplay = true } } + var isPlaying = false { didSet { needsDisplay = true } } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setAccessibilityRole(.image) + setAccessibilityLabel("Streaming record fixed to the main deck") + } + required init?(coder: NSCoder) { fatalError() } + + override func draw(_ dirtyRect: NSRect) { + let c = NSPoint(x: bounds.midX, y: bounds.midY) + let r = max(1, min(bounds.width, bounds.height) / 2 - 6) + let disc = NSRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2) + + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = .black.withAlphaComponent(0.58) + shadow.shadowBlurRadius = 11 + shadow.shadowOffset = NSSize(width: 0, height: -4) + shadow.set() + NSColor(white: 0.025, alpha: 1).setFill() + NSBezierPath(ovalIn: disc).fill() + NSGraphicsContext.restoreGraphicsState() + + for groove in stride(from: r * 0.38, through: r * 0.93, by: max(3, r * 0.04)) { + NSColor(white: 0.22, alpha: 0.58).setStroke() + let path = NSBezierPath(ovalIn: NSRect(x: c.x - groove, y: c.y - groove, + width: groove * 2, height: groove * 2)) + path.lineWidth = 0.7 + path.stroke() + } + + let labelR = r * 0.31 + let labelRect = NSRect(x: c.x - labelR, y: c.y - labelR, + width: labelR * 2, height: labelR * 2) + if let art { + NSGraphicsContext.saveGraphicsState() + NSBezierPath(ovalIn: labelRect).addClip() + art.draw(in: labelRect, from: .zero, operation: .sourceOver, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + } else { + accent.setFill() + NSBezierPath(ovalIn: labelRect).fill() + let mark = providerMark as NSString + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: max(20, r * 0.22), weight: .black), + .foregroundColor: NSColor.white + ] + let size = mark.size(withAttributes: attrs) + mark.draw(at: NSPoint(x: c.x - size.width / 2, y: c.y - size.height / 2), + withAttributes: attrs) + } + + accent.withAlphaComponent(isPlaying ? 1 : 0.46).setStroke() + let marker = NSBezierPath() + marker.move(to: NSPoint(x: c.x, y: c.y + r * 0.42)) + marker.line(to: NSPoint(x: c.x, y: c.y + r * 0.87)) + marker.lineWidth = max(2, r * 0.025) + marker.lineCapStyle = .round + marker.stroke() + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeResources.swift b/juke-wizard/Sources/JukeWizard/JukeResources.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeResources.swift @@ -0,0 +1,26 @@ +import Foundation + +enum JukeResources { + /// Resolve without SwiftPM's generated `Bundle.module` accessor, which + /// embeds architecture-specific build directories in the executable. + static func url(forResource name: String, withExtension ext: String) -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + let executable = URL(fileURLWithPath: CommandLine.arguments[0]) + .standardizedFileURL.deletingLastPathComponent() + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let roots = [ + Bundle.main.resourceURL?.appendingPathComponent("Assets"), + executable.appendingPathComponent("Assets"), + home.appendingPathComponent(".local/lib/jukewizard/Assets"), + home.appendingPathComponent("aesthetic-computer/juke-wizard/Sources/JukeWizard/Assets"), + home.appendingPathComponent("Developer/aesthetic-computer/juke-wizard/Sources/JukeWizard/Assets"), + cwd.appendingPathComponent("Sources/JukeWizard/Assets"), + cwd.appendingPathComponent("juke-wizard/Sources/JukeWizard/Assets"), + ].compactMap { $0 } + for root in roots { + let candidate = root.appendingPathComponent(name).appendingPathExtension(ext) + if FileManager.default.fileExists(atPath: candidate.path) { return candidate } + } + return nil + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift --- a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift +++ b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift @@ -87,7 +87,7 @@ } let mix = channels(for: layout) let sender = ACAudioRoomSender() - sender.onLog = { NSLog("JukeWizard room sender: \($0)") } + sender.onLog = { NSLog("Menu Band Juke room sender: \($0)") } try sender.start() var local: ACAudioRoomReceiver? @@ -96,7 +96,7 @@ do { if let localMix = mix.local { let receiver = ACAudioRoomReceiver(configuration: .init( host: "127.0.0.1", name: "Neo", channel: localMix.channel, gain: localMix.gain)) - receiver.onLog = { NSLog("JukeWizard room Neo: \($0)") } + receiver.onLog = { NSLog("Menu Band Juke room Neo: \($0)") } try receiver.start() local = receiver } @@ -124,8 +124,8 @@ try process.run() remoteProcess = process } - let tap = ACProcessAudioTap(processID: pid, name: "JukeWizard \(source.rawValue)", muteOriginal: true) - tap.onLog = { NSLog("JukeWizard room tap: \($0)") } + let tap = ACProcessAudioTap(processID: pid, name: "Menu Band Juke \(source.rawValue)", muteOriginal: true) + tap.onLog = { NSLog("Menu Band Juke room tap: \($0)") } try tap.start { sender.send($0) } self.sender = sender diff --git a/juke-wizard/Sources/JukeWizard/JukeSource.swift b/juke-wizard/Sources/JukeWizard/JukeSource.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeSource.swift @@ -0,0 +1,46 @@ +import Foundation + +enum JukeSource: Int, CaseIterable { + case local, aesthetic, spotify, appleMusic + + var fixedLabel: String? { + switch self { + case .local: return nil + case .aesthetic: return "Aesthetic" + case .spotify: return "Spotify" + case .appleMusic: return "Apple Music" + } + } + + var canDetachRecords: Bool { + switch self { + case .local, .aesthetic: return true + case .spotify, .appleMusic: return false + } + } + + func label(machineName: String = JukeSource.machineName) -> String { + fixedLabel ?? Self.shortMachineName(machineName) + } + + static var machineName: String { + Host.current().localizedName ?? ProcessInfo.processInfo.hostName + } + + static func shortMachineName(_ raw: String) -> String { + var name = raw.trimmingCharacters(in: .whitespacesAndNewlines) + for suffix in [".localdomain", ".local"] where name.lowercased().hasSuffix(suffix) { + name.removeLast(suffix.count) + break + } + guard !name.isEmpty else { return "This Mac" } + if let macRange = name.range(of: "MacBook ", options: [.caseInsensitive, .backwards]) { + let suffix = name[macRange.upperBound...].trimmingCharacters(in: .whitespaces) + if !suffix.isEmpty { return suffix } + } + if let last = name.split(separator: " ").last, name.count > 18 { + return String(last) + } + return name + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeSpotify.swift b/juke-wizard/Sources/JukeWizard/JukeSpotify.swift --- a/juke-wizard/Sources/JukeWizard/JukeSpotify.swift +++ b/juke-wizard/Sources/JukeWizard/JukeSpotify.swift @@ -122,10 +122,14 @@ private static func executableURL() -> URL? { let installed = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".local/bin/juked") if FileManager.default.isExecutableFile(atPath: installed.path) { return installed } - var root = URL(fileURLWithPath: #filePath) - for _ in 0..<4 { root.deleteLastPathComponent() } - let source = root.appendingPathComponent("slab/juked/bin/juked") - return FileManager.default.isExecutableFile(atPath: source.path) ? source : nil + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [ + home.appendingPathComponent("aesthetic-computer/slab/juked/bin/juked"), + home.appendingPathComponent("Developer/aesthetic-computer/slab/juked/bin/juked"), + URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("slab/juked/bin/juked"), + ] + return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) } private static func run(_ arguments: [String]) throws -> Data { diff --git a/juke-wizard/Sources/JukeWizard/MascotView.swift b/juke-wizard/Sources/JukeWizard/MascotView.swift --- a/juke-wizard/Sources/JukeWizard/MascotView.swift +++ b/juke-wizard/Sources/JukeWizard/MascotView.swift @@ -7,13 +7,9 @@ import AppKit final class MascotView: NSView { private let image: NSImage? = { - let bundle = Bundle.module - if let url = bundle.url(forResource: "jukewizard-mascot", withExtension: "png", subdirectory: "Assets"), - let img = NSImage(contentsOf: url) { return img } - if let url = bundle.url(forResource: "jukewizard-mascot", withExtension: "png"), + if let url = JukeResources.url(forResource: "jukewizard-mascot", withExtension: "png"), let img = NSImage(contentsOf: url) { return img } - let here = FileManager.default.currentDirectoryPath - return NSImage(contentsOfFile: "\(here)/juke-wizard/Sources/JukeWizard/Assets/jukewizard-mascot.png") + return nil }() override func hitTest(_ point: NSPoint) -> NSView? { nil } diff --git a/juke-wizard/Sources/JukeWizard/MenuBandJuke.swift b/juke-wizard/Sources/JukeWizard/MenuBandJuke.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/MenuBandJuke.swift @@ -0,0 +1,161 @@ +// MenuBandJuke.swift — Menu Band's complete Juke feature. Menu Band owns the +// process, application menu, status item, and Dock identity; this module owns +// only the Juke window, playback, library, DJ, room, cloud, annotations, and +// local control socket. +import AppKit + +public final class MenuBandJuke { + private var controller: JukeController? + private var controlServer: JukeControlServer? + + public init() {} + + public var isVisible: Bool { + controller?.window?.isVisible == true && controller?.window?.isMiniaturized == false + } + + /// Start the Juke once. Later calls reuse the same controller and simply + /// raise its window, so Menu Band never creates competing players. + public func start( + arguments: [String] = [], + showsWindow: Bool = true + ) { + if controller != nil { + if showsWindow { open() } + return + } + + var watch: [String] = [] + var paths: [String] = [] + var selectPath: String? + var spotifySearch: String? + var startPrimpats = false + var startBeats = false + var launchInBackground = !showsWindow + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--watch", index + 1 < arguments.count { + watch.append(arguments[index + 1]); index += 2; continue + } + if argument == "--select", index + 1 < arguments.count { + selectPath = arguments[index + 1]; index += 2; continue + } + if argument == "--spotify-search", index + 1 < arguments.count { + spotifySearch = arguments[index + 1]; index += 2; continue + } + if argument == "--primpats" { startPrimpats = true; index += 1; continue } + if argument == "--beats" { startBeats = true; index += 1; continue } + if argument == "--background" { launchInBackground = true; index += 1; continue } + paths.append(argument); index += 1 + } + + let aesthetic = Self.defaultLibraryPath() + if paths.isEmpty { + let master = NSHomeDirectory() + "/Desktop/MASTER-playlist.m3u8" + if FileManager.default.fileExists(atPath: aesthetic) { paths = [aesthetic] } + else if FileManager.default.fileExists(atPath: master) { paths = [master] } + } + + let masterPath = URL(fileURLWithPath: aesthetic).standardizedFileURL.path + let includesMasterLibrary = paths.contains { + URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath) + .standardizedFileURL.path == masterPath + } + let scopedInputs = includesMasterLibrary ? [] : paths + let playlistName: String? = scopedInputs.isEmpty ? nil : { + if scopedInputs.count > 1 { return "\(scopedInputs.count)-track playlist" } + let url = URL(fileURLWithPath: (scopedInputs[0] as NSString).expandingTildeInPath) + if ["m3u", "m3u8"].contains(url.pathExtension.lowercased()) { + return url.deletingPathExtension().lastPathComponent + } + return url.hasDirectoryPath ? url.lastPathComponent : "Playlist" + }() + + let library = Library(inputs: paths) + if let selectPath { + let url = URL(fileURLWithPath: (selectPath as NSString).expandingTildeInPath) + if !library.tracks.contains(where: { + $0.url.standardizedFileURL.path == url.standardizedFileURL.path + }) { + let lane = url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + library.addFile(url, lane: lane) + } + } + + let controller = JukeController( + library: library, + watch: watch, + select: selectPath, + spotifySearch: spotifySearch, + startPrimpats: startPrimpats, + startBeats: startBeats, + playlistName: playlistName, + fullLibraryPath: aesthetic + ) + self.controller = controller + let server = JukeControlServer(controller: controller) + self.controlServer = server + server.start() + + if launchInBackground { + controller.window?.orderOut(nil) + } else { + show(controller) + } + + guard !launchInBackground else { return } + if startBeats { + DispatchQueue.main.async { [weak controller] in controller?.showDetachedBeats() } + } else if startPrimpats { + DispatchQueue.main.async { [weak controller] in controller?.showDetachedPrimpats() } + } else { + DispatchQueue.main.async { [weak controller] in controller?.quickOpenFull() } + } + } + + public func open() { + guard let controller else { + start(showsWindow: true) + return + } + show(controller) + controller.quickOpenFull() + } + + public func toggle() { + guard let controller else { + start(showsWindow: true) + return + } + controller.quickToggleFull() + } + + public func stop() { + controlServer?.stop() + controlServer = nil + } + + private func show(_ controller: JukeController) { + controller.showWindow(nil) + if controller.window?.isMiniaturized == true { controller.window?.deminiaturize(nil) } + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private static func defaultLibraryPath() -> String { + if let configured = ProcessInfo.processInfo.environment["MENU_BAND_JUKE_LIBRARY"], + !configured.isEmpty { + return (configured as NSString).expandingTildeInPath + } + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [ + home.appendingPathComponent("aesthetic-computer/pop/out/pop-library.json"), + home.appendingPathComponent("Developer/aesthetic-computer/pop/out/pop-library.json"), + URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("pop/out/pop-library.json"), + ] + return candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })?.path + ?? candidates[0].path + } +} diff --git a/juke-wizard/Sources/JukeWizard/MenuBarCD.swift b/juke-wizard/Sources/JukeWizard/MenuBarCD.swift deleted file mode 100644 --- a/juke-wizard/Sources/JukeWizard/MenuBarCD.swift +++ /dev/null @@ -1,219 +0,0 @@ -import AppKit - -/// JukeWizard's compact menu-bar presence: current artist and track beside an -/// artwork CD that spins only while this JukeWizard instance is playing. -final class MenuBarCD { - struct DeckState { - let id: String - let artist: String? - let title: String - let art: NSImage? - let accent: NSColor - let bpm: Double - let playing: Bool - } - - private struct VisibleDeck { - let id: String - let title: String - let image: NSImage - let bpm: Double - var angle: CGFloat - } - - private let statusItem: NSStatusItem - private let fallbackImage: NSImage - private var baseImage: NSImage - private var timer: Timer? - private var decks: [VisibleDeck] = [] - private let side: CGFloat = 20 - private let maximumCreditWidth: CGFloat = 260 - private let beatsPerRevolution: Double = 8 - - var onClick: (() -> Void)? - - init() { - statusItem = NSStatusBar.system.statusItem(withLength: 24) - statusItem.autosaveName = "jukewizard" - fallbackImage = Self.marked(Self.loadCD(side: side)) - baseImage = fallbackImage - if let button = statusItem.button { - button.image = baseImage - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyDown - button.font = .systemFont(ofSize: 12, weight: .medium) - button.lineBreakMode = .byTruncatingTail - button.toolTip = "JukeWizard" - button.target = self - button.action = #selector(clicked) - button.sendAction(on: [.leftMouseUp]) - } - } - - deinit { timer?.invalidate() } - - private static func loadCD(side: CGFloat) -> NSImage { - let url = Bundle.module.url(forResource: "jukewizard-cd", withExtension: "png", - subdirectory: "Assets") - ?? Bundle.module.url(forResource: "jukewizard-cd", withExtension: "png") - let image = url.flatMap(NSImage.init(contentsOf:)) - ?? NSImage(size: NSSize(width: side, height: side)) - image.size = NSSize(width: side, height: side) - image.isTemplate = false - return image - } - - private static func marked(_ image: NSImage) -> NSImage { - let output = NSImage(size: image.size) - output.lockFocus() - image.draw(at: .zero, from: NSRect(origin: .zero, size: image.size), - operation: .sourceOver, fraction: 1) - let diameter: CGFloat = 2.2 - let mark = NSRect(x: image.size.width * 0.72 - diameter / 2, - y: image.size.height * 0.72 - diameter / 2, - width: diameter, height: diameter) - NSColor.black.withAlphaComponent(0.72).setFill() - NSBezierPath(ovalIn: mark.insetBy(dx: -0.45, dy: -0.45)).fill() - NSColor.white.withAlphaComponent(0.96).setFill() - NSBezierPath(ovalIn: mark).fill() - output.unlockFocus() - output.isTemplate = false - return output - } - - @objc private func clicked() { onClick?() } - - func setSingleDeck(artist: String?, title: String, art: NSImage?, bpm: Double?, playing: Bool) { - baseImage = art.map { Self.marked(CDArtworkRenderer.disc(from: $0, side: side)) } - ?? fallbackImage - setDecks([DeckState(id: "single", artist: artist, title: title, art: art, accent: Palette.gold, - bpm: bpm ?? 120, playing: playing)]) - updateCredit(Self.credit(artist: artist, title: title)) - } - - func setDecks(_ states: [DeckState]) { - let previousAngles = Dictionary(uniqueKeysWithValues: decks.map { ($0.id, $0.angle) }) - decks = states.filter(\.playing).prefix(2).map { state in - let candidate = state.bpm.isFinite && state.bpm > 0 ? state.bpm : 120 - let bpm = min(240, max(30, candidate)) - let image = state.art.map { Self.marked(CDArtworkRenderer.disc(from: $0, side: side)) } - ?? Self.record(side: side, accent: state.accent) - return VisibleDeck(id: state.id, title: Self.credit(artist: state.artist, title: state.title), image: image, - bpm: bpm, angle: previousAngles[state.id] ?? 0) - } - updateCredit(decks.map(\.title).joined(separator: " + ")) - if decks.isEmpty { - stopSpin() - } else { - renderDecks() - startSpin() - } - } - - static func credit(artist: String?, title: String) -> String { - let artist = artist?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let title = title.trimmingCharacters(in: .whitespacesAndNewlines) - return [artist, title].filter { !$0.isEmpty }.joined(separator: " — ") - } - - private func updateCredit(_ credit: String) { - guard let button = statusItem.button else { return } - button.title = credit - button.imagePosition = credit.isEmpty ? .imageOnly : .imageLeading - button.toolTip = credit.isEmpty ? "JukeWizard" : credit - let iconWidth: CGFloat = decks.count > 1 ? 40 : 24 - let measured = (credit as NSString).size(withAttributes: [.font: button.font!]).width - let creditWidth = credit.isEmpty ? 0 : min(maximumCreditWidth, ceil(measured) + 10) - statusItem.length = iconWidth + creditWidth - } - - private func startSpin() { - guard timer == nil else { return } - timer?.invalidate() - let timer = Timer(timeInterval: 1.0 / 24.0, repeats: true) { [weak self] _ in - self?.tick() - } - timer.tolerance = 1.0 / 240.0 - RunLoop.main.add(timer, forMode: .common) - self.timer = timer - } - - private func stopSpin() { - timer?.invalidate() - timer = nil - statusItem.button?.image = baseImage - } - - private func tick() { - for index in decks.indices { - decks[index].angle -= CGFloat(360 * (decks[index].bpm / 60) / beatsPerRevolution / 24) - if decks[index].angle <= -360 { decks[index].angle += 360 } - } - renderDecks() - } - - private func renderDecks() { - guard !decks.isEmpty else { statusItem.button?.image = baseImage; return } - if decks.count == 1 { - statusItem.button?.image = rotated(decks[0].image, by: decks[0].angle) - return - } - let output = NSImage(size: NSSize(width: 36, height: side)) - output.lockFocus() - NSGraphicsContext.current?.imageInterpolation = .high - for (index, deck) in decks.enumerated() { - let disc = rotated(deck.image, by: deck.angle) - disc.draw(in: NSRect(x: CGFloat(index) * 17, y: 1, width: 19, height: 19), - from: NSRect(origin: .zero, size: disc.size), operation: .sourceOver, fraction: 1) - } - output.unlockFocus() - output.isTemplate = false - statusItem.button?.image = output - } - - private static func record(side: CGFloat, accent: NSColor) -> NSImage { - let image = NSImage(size: NSSize(width: side, height: side)) - image.lockFocus() - let outer = NSRect(x: 0.7, y: 0.7, width: side - 1.4, height: side - 1.4) - NSColor(white: 0.04, alpha: 1).setFill() - NSBezierPath(ovalIn: outer).fill() - for inset in stride(from: side * 0.13, through: side * 0.34, by: side * 0.07) { - NSColor.white.withAlphaComponent(0.20).setStroke() - let groove = NSBezierPath(ovalIn: outer.insetBy(dx: inset, dy: inset)) - groove.lineWidth = 0.45 - groove.stroke() - } - let label = outer.insetBy(dx: side * 0.31, dy: side * 0.31) - accent.setFill() - NSBezierPath(ovalIn: label).fill() - NSColor.white.setFill() - NSBezierPath(ovalIn: NSRect(x: side / 2 - 1, y: side / 2 - 1, - width: 2, height: 2)).fill() - accent.setStroke() - let marker = NSBezierPath() - marker.move(to: NSPoint(x: side / 2, y: side * 0.70)) - marker.line(to: NSPoint(x: side / 2, y: side * 0.91)) - marker.lineWidth = 1.6 - marker.stroke() - image.unlockFocus() - image.isTemplate = false - return image - } - - private func rotated(_ image: NSImage, by degrees: CGFloat) -> NSImage { - let size = image.size - let output = NSImage(size: size) - output.lockFocus() - NSGraphicsContext.current?.imageInterpolation = .high - let transform = NSAffineTransform() - transform.translateX(by: size.width / 2, yBy: size.height / 2) - transform.rotate(byDegrees: degrees) - transform.translateX(by: -size.width / 2, yBy: -size.height / 2) - transform.concat() - image.draw(at: .zero, from: NSRect(origin: .zero, size: size), - operation: .sourceOver, fraction: 1) - output.unlockFocus() - output.isTemplate = false - return output - } -} diff --git a/juke-wizard/Sources/JukeWizard/SpotifyViews.swift b/juke-wizard/Sources/JukeWizard/SpotifyViews.swift --- a/juke-wizard/Sources/JukeWizard/SpotifyViews.swift +++ b/juke-wizard/Sources/JukeWizard/SpotifyViews.swift @@ -47,6 +47,7 @@ final class SpotifyProgressView: NSView { var duration: Double = 0 { didSet { needsDisplay = true } } var position: Double = 0 { didSet { needsDisplay = true } } + var allowsSeeking = true var onSeek: ((Double) -> Void)? override func draw(_ dirtyRect: NSRect) { @@ -65,7 +66,7 @@ override func mouseDown(with event: NSEvent) { seek(event) } override func mouseDragged(with event: NSEvent) { seek(event) } private func seek(_ event: NSEvent) { - guard duration > 0 else { return } + guard allowsSeeking, duration > 0 else { return } let x = convert(event.locationInWindow, from: nil).x onSeek?(duration * Double(min(1, max(0, x / max(1, bounds.width))))) } diff --git a/juke-wizard/Sources/JukeWizard/TrackRowView.swift b/juke-wizard/Sources/JukeWizard/TrackRowView.swift --- a/juke-wizard/Sources/JukeWizard/TrackRowView.swift +++ b/juke-wizard/Sources/JukeWizard/TrackRowView.swift @@ -73,7 +73,7 @@ // colored chip tinted by lane; brighter when selected let r = bounds.insetBy(dx: 3, dy: 2) let path = NSBezierPath(roundedRect: r, xRadius: 7, yRadius: 7) (selected ? tint.blended(withFraction: 0.45, of: .white) ?? tint - : tint).withAlphaComponent(selected ? 0.55 : 0.22).setFill() + : tint).withAlphaComponent(selected ? 0.46 : 0.16).setFill() path.fill() if selected { tint.withAlphaComponent(0.9).setStroke() diff --git a/juke-wizard/Sources/JukeWizard/WaveformView.swift b/juke-wizard/Sources/JukeWizard/WaveformView.swift --- a/juke-wizard/Sources/JukeWizard/WaveformView.swift +++ b/juke-wizard/Sources/JukeWizard/WaveformView.swift @@ -46,7 +46,9 @@ override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true - layer?.backgroundColor = NSColor(white: 0.07, alpha: 1).cgColor + layer?.backgroundColor = NSColor(white: 0.03, alpha: 0.62).cgColor + layer?.borderWidth = 1 + layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor layer?.cornerRadius = 8 layer?.masksToBounds = true toolTip = "Click to seek · drag to scratch · Option-click to comment" diff --git a/juke-wizard/Sources/JukeWizard/WorkStatus.swift b/juke-wizard/Sources/JukeWizard/WorkStatus.swift --- a/juke-wizard/Sources/JukeWizard/WorkStatus.swift +++ b/juke-wizard/Sources/JukeWizard/WorkStatus.swift @@ -68,7 +68,9 @@ guard let text = String(data: data, 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 } + guard markers.contains(where: command.contains), + !command.contains("JukeWizard"), + !command.contains("/MenuBand") 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 diff --git a/juke-wizard/Sources/JukeWizard/main.swift b/juke-wizard/Sources/JukeWizard/main.swift deleted file mode 100644 --- a/juke-wizard/Sources/JukeWizard/main.swift +++ /dev/null @@ -1,133 +0,0 @@ -// JukeWizard — a native macOS popup for selecting, rating, and annotating -// tracks. Sibling of wave-wizard + clip-wizard. Play a queue of audio -// (a folder, an .m3u/.m3u8 playlist, or loose files), leave 1–5 stars, -// freeform notes, and comments pinned to timestamps — all saved beside -// each track as .juke.json so the notes survive re-renders. -// -// Usage: -// jukewizard [ ...] [--watch ] -// bin/jukewizard --queue focused ordered queue -// jukewizard --spotify-search "artist or track" headless Spotify search -// jukewizard --primpats open DJ mode with two floating bass-sine records -// jukewizard --beats open one floating C-rendered scratch lab record -// jukewizard --background start resident without raising the full window -// (no args → opens ~/Desktop/MASTER-playlist.m3u8 if present) -// -// --watch auto-pop: when a fresh audio file lands here, add it -// to the queue and start playing (repeatable). -import AppKit - -final class JukeAppDelegate: NSObject, NSApplicationDelegate { - var controller: JukeController? - var controlServer: JukeControlServer? - - func applicationDidFinishLaunching(_ notification: Notification) { - DockIcon.install(prefix: "jukewizard") - let args = Array(CommandLine.arguments.dropFirst()) - var watch: [String] = [] - var paths: [String] = [] - var selectPath: String? = nil - var spotifySearch: String? = nil - var startPrimpats = false - var startBeats = false - var launchInBackground = false - var i = 0 - while i < args.count { - if args[i] == "--watch", i + 1 < args.count { watch.append(args[i + 1]); i += 2; continue } - if args[i] == "--select", i + 1 < args.count { selectPath = args[i + 1]; i += 2; continue } - if args[i] == "--spotify-search", i + 1 < args.count { spotifySearch = args[i + 1]; i += 2; continue } - if args[i] == "--primpats" { startPrimpats = true; i += 1; continue } - if args[i] == "--beats" { startBeats = true; i += 1; continue } - if args[i] == "--background" { launchInBackground = true; i += 1; continue } - paths.append(args[i]); i += 1 - } - var root = URL(fileURLWithPath: #filePath) - for _ in 0..<4 { root.deleteLastPathComponent() } - let aesthetic = root.appendingPathComponent("pop/out/pop-library.json").path - if paths.isEmpty { - let master = NSHomeDirectory() + "/Desktop/MASTER-playlist.m3u8" - if FileManager.default.fileExists(atPath: aesthetic) { paths = [aesthetic] } - else if FileManager.default.fileExists(atPath: master) { paths = [master] } - } - let masterPath = URL(fileURLWithPath: aesthetic).standardizedFileURL.path - let includesMasterLibrary = paths.contains { - URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath) - .standardizedFileURL.path == masterPath - } - let scopedInputs = includesMasterLibrary ? [] : paths - let playlistName: String? = scopedInputs.isEmpty ? nil : { - if scopedInputs.count > 1 { return "\(scopedInputs.count)-track playlist" } - let url = URL(fileURLWithPath: (scopedInputs[0] as NSString).expandingTildeInPath) - if ["m3u", "m3u8"].contains(url.pathExtension.lowercased()) { - return url.deletingPathExtension().lastPathComponent - } - return url.hasDirectoryPath ? url.lastPathComponent : "Playlist" - }() - let library = Library(inputs: paths) - // Make sure the requested track is in the queue even if it's a draft - // not yet in the library index — so we can always select + play it. - if let sp = selectPath { - let u = URL(fileURLWithPath: (sp as NSString).expandingTildeInPath) - if !library.tracks.contains(where: { $0.url.standardizedFileURL.path == u.standardizedFileURL.path }) { - let lane = u.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent - library.addFile(u, lane: lane) - } - } - controller = JukeController(library: library, watch: watch, select: selectPath, - spotifySearch: spotifySearch, - startPrimpats: startPrimpats, - startBeats: startBeats, - playlistName: playlistName, - fullLibraryPath: aesthetic) - if let controller { - controlServer = JukeControlServer(controller: controller) - controlServer?.start() - } - if launchInBackground { - controller?.window?.orderOut(nil) - } else { - controller?.showWindow(nil) - if controller?.window?.isMiniaturized == true { controller?.window?.deminiaturize(nil) } - controller?.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - // Launch Services can re-apply the previous process's minimized Dock - // state just after didFinishLaunching. Restore once more on the next - // run-loop turn so a relaunch can never masquerade as a crash. - if launchInBackground { - return - } else if startBeats { - DispatchQueue.main.async { [weak self] in self?.controller?.showDetachedBeats() } - } else if startPrimpats { - DispatchQueue.main.async { [weak self] in self?.controller?.showDetachedPrimpats() } - } else { - DispatchQueue.main.async { [weak self] in self?.controller?.quickOpenFull() } - } - } - - func applicationWillTerminate(_ notification: Notification) { controlServer?.stop() } - - // Stay resident while its compact spinning CD is present in the menu bar. - 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) } - if controller?.window?.isMiniaturized == true { controller?.window?.deminiaturize(nil) } - controller?.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - return true - } - - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - controller?.makeDockMenu() - } -} - -let app = NSApplication.shared -let delegate = JukeAppDelegate() -app.delegate = delegate -app.setActivationPolicy(.regular) -app.run() diff --git a/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift b/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift --- a/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift +++ b/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift @@ -1,6 +1,6 @@ import AVFoundation import XCTest -@testable import JukeWizard +@testable import MenuBandJuke final class DJPrimpatsTests: XCTestCase { func testCatalogRendersDeterministicLoopableStereoTracks() throws { diff --git a/juke-wizard/Tests/JukeWizardTests/JukeSourceTests.swift b/juke-wizard/Tests/JukeWizardTests/JukeSourceTests.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Tests/JukeWizardTests/JukeSourceTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import MenuBandJuke + +final class JukeSourceTests: XCTestCase { + func testSourceOrderAndLabels() { + XCTAssertEqual(JukeSource.allCases.map { $0.label(machineName: "Jeffrey’s MacBook Neo") }, + ["Neo", "Aesthetic", "Spotify", "Apple Music"]) + } + + func testDetachmentFollowsMediaRights() { + XCTAssertTrue(JukeSource.local.canDetachRecords) + XCTAssertTrue(JukeSource.aesthetic.canDetachRecords) + XCTAssertFalse(JukeSource.spotify.canDetachRecords) + XCTAssertFalse(JukeSource.appleMusic.canDetachRecords) + } + + func testShortMachineNamePreservesShortHostnames() { + XCTAssertEqual(JukeSource.shortMachineName("blueberry.local"), "blueberry") + XCTAssertEqual(JukeSource.shortMachineName("Mac.localdomain"), "Mac") + XCTAssertEqual(JukeSource.shortMachineName(""), "This Mac") + } +} diff --git a/juke-wizard/Tests/JukeWizardTests/MenuBarCDTests.swift b/juke-wizard/Tests/JukeWizardTests/MenuBarCDTests.swift deleted file mode 100644 --- a/juke-wizard/Tests/JukeWizardTests/MenuBarCDTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import XCTest -@testable import JukeWizard - -final class MenuBarCDTests: XCTestCase { - func testCreditShowsArtistBeforeTrackTitle() { - XCTAssertEqual( - MenuBarCD.credit(artist: "Aesthetic Dot Computer", title: "Color Test"), - "Aesthetic Dot Computer — Color Test" - ) - } - - func testCreditOmitsMissingFieldsAndWhitespace() { - XCTAssertEqual(MenuBarCD.credit(artist: nil, title: " Color Test "), "Color Test") - XCTAssertEqual(MenuBarCD.credit(artist: " Aesthetic ", title: ""), "Aesthetic") - } -} diff --git a/juke-wizard/bin/jukewizard b/juke-wizard/bin/jukewizard --- a/juke-wizard/bin/jukewizard +++ b/juke-wizard/bin/jukewizard @@ -1,82 +1,74 @@ #!/bin/sh -# juke-wizard/bin/jukewizard — build (cached) + launch JukeWizard. -# -# JukeWizard is the MASTER JUKE: it ALWAYS loads the whole /pop library so -# every track is visible. Open it with no args to browse, or pass a specific -# track to jump straight to it — the full library still loads underneath. -# Each track shows its cover art, per-service listen links (Spotify/Apple/ -# YouTube/DistroKid), and any reels/videos made for it (from the lane's out/). -# The sidebar sort menu reorders by newest-rendered, rating, title, etc. -# -# 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 --primpats # two floating bass-sine records -# juke-wizard/bin/jukewizard --beats # two floating practice beats -# juke-wizard/bin/jukewizard # load library + add these too -# juke-wizard/bin/jukewizard ... --watch # auto-pop new renders -# -# Runs from anywhere; resolves the package from its own location. -set -e +# Compatibility command for Juke inside Menu Band. This script never builds +# or launches a second macOS application. +set -eu + HERE="$(cd "$(dirname "$0")" && pwd)" -WIZ="$HERE/.." -REPO="$(cd "$WIZ/.." && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +MENU_BAND_APP="${MENU_BAND_APP:-$HOME/Applications/Menu Band.app}" +CONTROL="$HERE/jukewizard-control.mjs" -if [ "${1:-}" = "login" ]; then - shift - if command -v ac-login >/dev/null 2>&1; then exec ac-login "$@"; fi - exec node "$REPO/tezos/ac-login.mjs" "$@" -fi -if [ "${1:-}" = "cloud" ]; then - shift - exec node "$HERE/juke-cloud.mjs" "$@" -fi -if [ "${1:-}" = "--help" ] || [ "${1:-}" = "help" ]; then - node "$HERE/juke-cloud.mjs" --help - exit 0 -fi +usage() { + echo "usage: jukewizard [open|status|list|play|select|pause|toggle|seek|speed|next|previous|source|detach|login|cloud]" +} -# Probeable transport commands talk to the already-running native player and -# emit stable JSON. The MCP is deliberately only a thin wrapper over this CLI. -case "${1:-}" in - status|list|play|select|pause|toggle|seek|speed|next|previous|prev) - exec node "$HERE/jukewizard-control.mjs" "$@" - ;; -esac -swift build -c release --package-path "$WIZ" 2>&1 | grep -v '^$' | tail -2 || true - -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 +open_juke() { + if [ -d "$MENU_BAND_APP" ]; then + /usr/bin/open -g "$MENU_BAND_APP" + else + /usr/bin/open -g -b computer.aestheticcomputer.menuband fi - FIRST="$1" - case "$FIRST" in /*) SEL="$FIRST";; *) SEL="$(pwd)/$FIRST";; esac - exec "$BIN" "$@" --select "$SEL" -fi + # A cold Menu Band launch needs a moment to register its distributed- + # notification observer. Retry the idempotent open message until Juke's + # control socket proves the embedded controller is alive. AppleScriptObjC + # uses Foundation directly, so this works on Macs without developer tools. + i=0 + while [ "$i" -lt 50 ]; do + if [ $((i % 5)) -eq 0 ]; then + /usr/bin/osascript <<'APPLESCRIPT' +use framework "Foundation" +current application's NSDistributedNotificationCenter's defaultCenter()'s postNotificationName:"computer.aestheticcomputer.menuband.showJuke" object:(missing value) +delay 0.1 +APPLESCRIPT + fi + if node "$CONTROL" status >/dev/null 2>&1; then + return 0 + fi + /bin/sleep 0.1 + i=$((i + 1)) + done + return 1 +} -# No args → just the master library. -if [ "$#" -eq 0 ]; then - exec "$BIN" "$LIB" -fi +run_control() { + if ! node "$CONTROL" status >/dev/null 2>&1; then + open_juke + fi + exec node "$CONTROL" "$@" +} -# A specific audio file → load the master library AND select/play it -# (absolutized so it matches the library entry; added to the queue if it's a -# fresh draft not in the index yet). -if [ -f "$1" ]; then - case "$1" in /*) SEL="$1";; *) SEL="$(pwd)/$1";; esac - exec "$BIN" "$LIB" "$@" --select "$SEL" -fi - -# A folder / playlist / flags → load the master library and add them too. -exec "$BIN" "$LIB" "$@" +case "${1:-open}" in + open) + open_juke + ;; + login) + shift + if command -v ac-login >/dev/null 2>&1; then exec ac-login "$@"; fi + exec node "$REPO/tezos/ac-login.mjs" "$@" + ;; + cloud) + shift + exec node "$HERE/juke-cloud.mjs" "$@" + ;; + status|list|play|select|pause|toggle|seek|speed|next|previous|prev|source|detach) + run_control "$@" + ;; + help|--help|-h) + usage + ;; + *) + usage >&2 + echo "jukewizard: Juke is owned by Menu Band; standalone file/queue launches were retired." >&2 + exit 2 + ;; +esac diff --git a/juke-wizard/bin/jukewizard-control.mjs b/juke-wizard/bin/jukewizard-control.mjs --- a/juke-wizard/bin/jukewizard-control.mjs +++ b/juke-wizard/bin/jukewizard-control.mjs @@ -13,12 +13,12 @@ client.setEncoding("utf8"); client.setTimeout(3000); client.on("connect", () => client.end(`${JSON.stringify(command)}\n`)); client.on("data", (chunk) => { response += chunk; if (response.length > 4_000_000) client.destroy(new Error("response too large")); }); - client.on("timeout", () => client.destroy(new Error("JukeWizard control timed out"))); - client.on("error", (error) => reject(new Error(`JukeWizard is not reachable at ${SOCKET}: ${error.message}`))); + client.on("timeout", () => client.destroy(new Error("Menu Band Juke control timed out"))); + client.on("error", (error) => reject(new Error(`Menu Band Juke is not reachable at ${SOCKET}: ${error.message}`))); client.on("end", () => { try { const value = JSON.parse(response.trim()); - if (!value.ok) reject(new Error(value.error || "JukeWizard command failed")); + if (!value.ok) reject(new Error(value.error || "Menu Band Juke command failed")); else resolvePromise(value); } catch (error) { reject(error); } }); @@ -27,18 +27,22 @@ } export function parse(argv) { const [name = "status", ...args] = argv; - if (["status", "list", "pause", "toggle", "next", "previous", "prev"].includes(name)) { + if (["status", "list", "pause", "toggle", "next", "previous", "prev", "detach"].includes(name)) { return { command: name === "prev" ? "previous" : name, ...(name === "list" && args[0] ? { limit: Number(args[0]) } : {}) }; } if (name === "seek") return { command: "seek", seconds: Number(args[0]) }; if (name === "speed") return { command: "speed", speed: Number(args[0]) }; + if (name === "source") { + if (!args[0]) throw new Error("source requires local, aesthetic, spotify, or appleMusic"); + return { command: "source", source: args[0] }; + } if (name === "play" || name === "select") { if (!args.length) return { command: name }; if (args[0] === "--title") return { command: name, title: args.slice(1).join(" ") }; if (args[0] === "--index") return { command: name, index: Number(args[1]) }; return { command: name, path: resolve(args.join(" ")) }; } - throw new Error("usage: jukewizard {status|list [limit]|play [path|--title title|--index n]|select ...|pause|toggle|seek seconds|speed 0.5..1.5|next|previous}"); + throw new Error("usage: jukewizard {status|list [limit]|play [path|--title title|--index n]|select ...|pause|toggle|seek seconds|speed 0.5..1.5|next|previous|source name|detach}"); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/juke-wizard/bin/jukewizard-installed b/juke-wizard/bin/jukewizard-installed --- a/juke-wizard/bin/jukewizard-installed +++ b/juke-wizard/bin/jukewizard-installed @@ -1,18 +1,69 @@ #!/bin/sh -# Stable launcher for a JukeWizard installed by ../install.sh. +# Installed compatibility command for Juke inside Menu Band. It never launches +# a separate JukeWizard process. set -eu -INSTALL_ROOT="${JUKEWIZARD_HOME:-$HOME/.local/lib/jukewizard}" -if [ "${1:-}" = "login" ]; then - shift - if command -v ac-login >/dev/null 2>&1; then exec ac-login "$@"; fi - exec node "$INSTALL_ROOT/ac-login.mjs" "$@" -fi -if [ "${1:-}" = "cloud" ]; then - shift - exec node "$INSTALL_ROOT/juke-cloud.mjs" "$@" -fi -if [ "${1:-}" = "--help" ] || [ "${1:-}" = "help" ]; then - node "$INSTALL_ROOT/juke-cloud.mjs" --help - exit 0 -fi -exec "$INSTALL_ROOT/JukeWizard" "$@" + +INSTALL_ROOT="${MENU_BAND_JUKE_CLI_HOME:-$HOME/.local/lib/menuband-juke}" +MENU_BAND_APP="${MENU_BAND_APP:-$HOME/Applications/Menu Band.app}" +CONTROL="$INSTALL_ROOT/jukewizard-control.mjs" + +usage() { + echo "usage: jukewizard [open|status|list|play|select|pause|toggle|seek|speed|next|previous|source|detach|login|cloud]" +} + +open_juke() { + if [ -d "$MENU_BAND_APP" ]; then + /usr/bin/open -g "$MENU_BAND_APP" + else + /usr/bin/open -g -b computer.aestheticcomputer.menuband + fi + i=0 + while [ "$i" -lt 50 ]; do + if [ $((i % 5)) -eq 0 ]; then + /usr/bin/osascript <<'APPLESCRIPT' +use framework "Foundation" +current application's NSDistributedNotificationCenter's defaultCenter()'s postNotificationName:"computer.aestheticcomputer.menuband.showJuke" object:(missing value) +delay 0.1 +APPLESCRIPT + fi + if node "$CONTROL" status >/dev/null 2>&1; then + return 0 + fi + /bin/sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +run_control() { + if ! node "$CONTROL" status >/dev/null 2>&1; then + open_juke + fi + exec node "$CONTROL" "$@" +} + +case "${1:-open}" in + open) + open_juke + ;; + login) + shift + if command -v ac-login >/dev/null 2>&1; then exec ac-login "$@"; fi + exec node "$INSTALL_ROOT/ac-login.mjs" "$@" + ;; + cloud) + shift + exec node "$INSTALL_ROOT/juke-cloud.mjs" "$@" + ;; + status|list|play|select|pause|toggle|seek|speed|next|previous|prev|source|detach) + run_control "$@" + ;; + help|--help|-h) + usage + ;; + *) + usage >&2 + echo "jukewizard: Juke is owned by Menu Band; standalone file/queue launches were retired." >&2 + exit 2 + ;; +esac diff --git a/juke-wizard/design/renders/source-aesthetic.png b/juke-wizard/design/renders/source-aesthetic.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-aesthetic.png diff --git a/juke-wizard/design/renders/source-apple.png b/juke-wizard/design/renders/source-apple.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-apple.png diff --git a/juke-wizard/design/renders/source-browser-sheet.png b/juke-wizard/design/renders/source-browser-sheet.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-browser-sheet.png diff --git a/juke-wizard/design/renders/source-local.png b/juke-wizard/design/renders/source-local.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-local.png diff --git a/juke-wizard/design/renders/source-spotify.png b/juke-wizard/design/renders/source-spotify.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-spotify.png diff --git a/juke-wizard/design/source-browser-notes.md b/juke-wizard/design/source-browser-notes.md new file mode 100644 --- /dev/null +++ b/juke-wizard/design/source-browser-notes.md @@ -0,0 +1,25 @@ +# Menu Band Juke source browser + +`Neo` → `Aesthetic` → `Spotify` → `Apple Music` + +- `Neo` is the current computer name, shortened from `Jeffrey’s MacBook Neo`. +- `Aesthetic` contains cloud releases. Publish and account actions live inside this tab. +- Spotify and Apple Music each own their search, connection state, attribution, and errors. +- Browsing another tab never changes the playing track. Only Play changes the persistent transport. +- No global Cloud button and no mixed `All` tab. +- DJ, waveform editing, notes, and local-file actions appear only where the source permits them. + +## FOSS case studies + +| Project | Source model | Keep | Avoid | +| --- | --- | --- | --- | +| [Music Assistant](https://developers.music-assistant.io/) | Filesystem, SMB, Spotify, YouTube Music, and other services implement the same music-provider contract. | One provider interface with explicit capabilities. | Pretending every provider supports the same playback or mutation features. | +| [OwnTone](https://owntone.github.io/owntone-server/control-clients/web/) | One browser and queue span local music, files, radio, podcasts, and Spotify. | Persistent playback while browse context changes. | Burying source identity after content enters the queue. | +| [Moosync](https://github.com/Moosync/Moosync-electron) | Local, Spotify, and YouTube share one desktop shell with source filters. | Provider-scoped search and one visual grammar. | Its repository is archived; use it as interaction precedent, not a dependency. | +| [Strawberry](https://github.com/strawberrymusicplayer/strawberry) | Local collection and streaming services live in separate navigation branches. | Clear source boundaries and service-specific setup. | Making remote providers feel like secondary utilities in a dense sidebar. | + +## Integration boundaries + +- Apple Music is viable as a first-class macOS source through [MusicKit for Swift](https://developer.apple.com/documentation/musickit): catalog search, library access, and playback are supported after user authorization. Menu Band needs the MusicKit App Service and `NSAppleMusicUsageDescription` before the tab ships. +- Spotify stays a browse/playback source. Its [developer policy](https://developer.spotify.com/policy) prohibits mixing, remixing, overlapping, or integrating Spotify content with another service, so Menu Band must not expose Spotify tracks to DJ decks or effects. +- Aesthetic and local files can keep the full Juke toolset because Menu Band controls those media paths. diff --git a/juke-wizard/design/source-browser-prototype.html b/juke-wizard/design/source-browser-prototype.html new file mode 100644 --- /dev/null +++ b/juke-wizard/design/source-browser-prototype.html @@ -0,0 +1,527 @@ + + + + + + Menu Band Juke — source browser sketch + + + +
+
+ + Menu Band Juke +
+ + + +
+
+
+ + +
+
+
+ + +
+ +
+
+
+
+
Slow Tide
+
Neo · Aesthetic Computer
+
+
+
+ + + +
+
+
+
+ + + + diff --git a/juke-wizard/install.sh b/juke-wizard/install.sh --- a/juke-wizard/install.sh +++ b/juke-wizard/install.sh @@ -1,23 +1,7 @@ #!/bin/sh -# Build and install a self-contained JukeWizard CLI bundle for this Mac. +# Juke is a Menu Band feature; this compatibility entry point installs the +# owning application and its control-only `jukewizard` shell command. set -eu ROOT="$(cd "$(dirname "$0")" && pwd)" -INSTALL_ROOT="${JUKEWIZARD_HOME:-$HOME/.local/lib/jukewizard}" -BIN_DIR="${JUKEWIZARD_BIN_DIR:-$HOME/.local/bin}" - -swift build -c release --package-path "$ROOT" -BUILD_BIN="$(swift build -c release --package-path "$ROOT" --show-bin-path)" -BUNDLE="$BUILD_BIN/JukeWizard_JukeWizard.bundle" - -test -x "$BUILD_BIN/JukeWizard" -test -d "$BUNDLE" -/bin/mkdir -p "$INSTALL_ROOT" "$BIN_DIR" -/usr/bin/install -m 0755 "$BUILD_BIN/JukeWizard" "$INSTALL_ROOT/JukeWizard" -/usr/bin/ditto "$BUNDLE" "$INSTALL_ROOT/JukeWizard_JukeWizard.bundle" -/usr/bin/install -m 0755 "$ROOT/bin/juke-cloud.mjs" "$INSTALL_ROOT/juke-cloud.mjs" -/usr/bin/install -m 0755 "$ROOT/../tezos/ac-login.mjs" "$INSTALL_ROOT/ac-login.mjs" -/usr/bin/install -m 0755 "$ROOT/bin/jukewizard-installed" "$BIN_DIR/jukewizard" - -echo "installed JukeWizard -> $INSTALL_ROOT/JukeWizard" -echo "launcher -> $BIN_DIR/jukewizard" +exec "$ROOT/../slab/menuband/install.sh" diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist --- a/slab/menuband/Info.plist +++ b/slab/menuband/Info.plist @@ -44,7 +44,7 @@ LSApplicationCategoryType public.app-category.music LSMinimumSystemVersion - 11.0 + 12.0 LSUIElement NSBluetoothAlwaysUsageDescription @@ -60,6 +60,8 @@ NSHumanReadableCopyright By aesthetic.computer NSLocalNetworkUsageDescription Menu Band finds nearby Menu Bands so two machines can play together in sync over peer-to-peer Wi-Fi and Bluetooth, no router needed. + NSAppleMusicUsageDescription + Menu Band lets you browse and play your Apple Music library on its main deck. NSMicrophoneUsageDescription Menu Band records short clips from your microphone when you hold ` so you can play them back as a sampling voice. NSSpeechRecognitionUsageDescription diff --git a/slab/menuband/Package.swift b/slab/menuband/Package.swift --- a/slab/menuband/Package.swift +++ b/slab/menuband/Package.swift @@ -3,9 +3,10 @@ import PackageDescription let package = Package( name: "MenuBand", - platforms: [.macOS(.v11)], + platforms: [.macOS(.v12)], dependencies: [ .package(path: "../macos-audio"), + .package(path: "../../juke-wizard"), ], targets: [ // Tiny always-running daemon whose only job is to watch for the @@ -34,6 +35,7 @@ name: "MenuBand", dependencies: [ "CGMSynth", .product(name: "ACMacAudio", package: "macos-audio"), + .product(name: "MenuBandJuke", package: "juke-wizard"), ], path: "Sources/MenuBand", exclude: [ diff --git a/slab/menuband/SCORE.md b/slab/menuband/SCORE.md --- a/slab/menuband/SCORE.md +++ b/slab/menuband/SCORE.md @@ -16,6 +16,25 @@ Lives at `slab/menuband/`. Distinct from `slab/menubar-swift/` (the Claude session menubar; different status item, different process). +## Juke + +The full JukeWizard listening surface now ships as **Juke** inside the +direct-download Menu Band: library playback, ratings and timestamped notes, +Spotify, DJ decks, room audio, cloud, work status, and the local control socket. +Choose the spinning disc in the popover footer. Its accessible name remains +“Open Menu Band Juke.” Menu Band owns the process and status item; Juke opens +as a lazy window and does not create another menu-bar item. + +The implementation remains in `juke-wizard/` as the reusable +`MenuBandJuke` Swift product. It has no executable product, application menu, +Dock identity, or status item of its own. The `jukewizard` shell command is a +control/open compatibility doorway into the running Menu Band process. +`install.sh` retires the former standalone binary and LaunchAgent under +`~/.local/share/menuband/migrations/` for recovery. + +The sandboxed App Store subset does not include Juke because its filesystem, +local-socket, and process-audio features exceed that build's current contract. + ## Layout ``` diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -1,6 +1,9 @@ import AppKit import AVFoundation import Carbon +#if !MAC_APP_STORE +import MenuBandJuke +#endif extension Notification.Name { /// Posted whenever the set of currently-sounding notes changes, so the @@ -13,6 +16,11 @@ private var statusItem: NSStatusItem! /// The spinning album-art disc owned by Menu Band's CDJ Radio deck. private var cdjStatusItem: MenuBandCDJStatusItem? private let menuBand = MenuBandController() +#if !MAC_APP_STORE + /// The former JukeWizard, now a first-class window and service inside this + /// process. It starts lazily when the user chooses Juke. + private let juke = MenuBandJuke() +#endif /// Live conductible drone/arp/drum loop (see MenuBandEngine + the /// `engine.*` distributed-notification handlers). private lazy var engine = MenuBandEngine(menuBand: menuBand) @@ -1000,6 +1008,15 @@ name: NSNotification.Name("computer.aestheticcomputer.menuband.showPopover"), object: nil ) +#if !MAC_APP_STORE + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleShowJukeNotification(_:)), + name: NSNotification.Name("computer.aestheticcomputer.menuband.showJuke"), + object: nil + ) +#endif + // Sibling remote: toggle the popover's instrument-chart // disclosure (same path as pressing the instrument name). // Lets the shell exercise the expand/collapse resize without @@ -1423,6 +1440,13 @@ } vc.isPlayPaletteShown = { [weak self] in self?.pianoWaveformWindowDelegate.isShown ?? false } +#if !MAC_APP_STORE + vc.onJukeToggle = { [weak self] in + guard let self else { return } + if self.isPopoverPanelShown { self.closePopover() } + self.juke.toggle() + } +#endif // Click on the mini visualizer strip → hide the popover // (without dismissing the floating panel — `closePopover`'s // default tears down both) and transition the floating @@ -2528,6 +2552,9 @@ alert.runModal() } func applicationWillTerminate(_ notification: Notification) { +#if !MAC_APP_STORE + juke.stop() +#endif #if !MAC_APP_STORE setTrackpadFighterSuppressed(false) #endif @@ -3585,6 +3612,16 @@ self.showPopover() } } } + +#if !MAC_APP_STORE + @objc private func handleShowJukeNotification(_ note: Notification) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.isPopoverPanelShown { self.closePopover() } + self.juke.open() + } + } +#endif @objc private func handleToggleChartNotification(_ note: Notification) { DispatchQueue.main.async { [weak self] in diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -92,6 +92,117 @@ } } } +/// A small physical entry point for Juke. The offset glint makes rotation +/// legible; Reduce Motion users keep the same disc without animation. +private final class SpinningDiscGlyph: NSView { + private let spinningLayer = CALayer() + private var renderedSize = NSSize.zero + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.addSublayer(spinningLayer) + } + + required init?(coder: NSCoder) { fatalError() } + + override var isOpaque: Bool { false } + + private static func renderDisc(size: NSSize) -> CGImage? { + guard size.width > 0, size.height > 0 else { return nil } + let image = NSImage(size: size) + image.lockFocus() + + let bounds = NSRect(origin: .zero, size: size) + let discRect = bounds.insetBy(dx: 1, dy: 1) + let disc = NSBezierPath(ovalIn: discRect) + NSGradient(colors: [ + NSColor(srgbRed: 0.55, green: 0.86, blue: 1.0, alpha: 1), + NSColor.white, + NSColor(srgbRed: 0.48, green: 0.60, blue: 0.92, alpha: 1), + ])?.draw(in: disc, angle: 32) + + NSColor.white.withAlphaComponent(0.72).setStroke() + let groove = NSBezierPath(ovalIn: discRect.insetBy(dx: 3.2, dy: 3.2)) + groove.lineWidth = 0.7 + groove.stroke() + + NSColor(srgbRed: 0.18, green: 0.38, blue: 0.75, alpha: 0.95).setFill() + NSBezierPath(ovalIn: NSRect(x: bounds.midX + 2.8, + y: bounds.midY + 3.7, + width: 2.4, height: 2.4)).fill() + + NSColor(white: 0.12, alpha: 0.9).setFill() + NSBezierPath(ovalIn: NSRect(x: bounds.midX - 2, + y: bounds.midY - 2, + width: 4, height: 4)).fill() + NSColor.white.withAlphaComponent(0.85).setFill() + NSBezierPath(ovalIn: NSRect(x: bounds.midX - 0.8, + y: bounds.midY - 0.8, + width: 1.6, height: 1.6)).fill() + + image.unlockFocus() + var proposed = bounds + return image.cgImage(forProposedRect: &proposed, context: nil, hints: nil) + } + + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + spinningLayer.bounds = bounds + spinningLayer.position = NSPoint(x: bounds.midX, y: bounds.midY) + spinningLayer.contentsGravity = .resizeAspect + spinningLayer.contentsScale = window?.backingScaleFactor ?? 2 + if renderedSize != bounds.size { + spinningLayer.contents = Self.renderDisc(size: bounds.size) + renderedSize = bounds.size + } + CATransaction.commit() + startSpinningIfNeeded() + } + + private func startSpinningIfNeeded() { + guard window != nil, + !bounds.isEmpty, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + spinningLayer.animation(forKey: "juke.disc.spin") == nil else { return } + let spin = CABasicAnimation(keyPath: "transform.rotation.z") + spin.fromValue = 0 + spin.toValue = CGFloat.pi * 2 + spin.duration = 2.8 + spin.repeatCount = .infinity + spin.timingFunction = CAMediaTimingFunction(name: .linear) + spinningLayer.add(spin, forKey: "juke.disc.spin") + } +} + +private final class SpinningDiscButton: HoverFeedbackButton { + private let disc = SpinningDiscGlyph(frame: .zero) + private var positionedForSize = NSSize.zero + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + title = "" + addSubview(disc) + } + + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: NSSize { NSSize(width: 38, height: 26) } + + override func layout() { + super.layout() + let side: CGFloat = 22 + if positionedForSize != bounds.size { + disc.frame = NSRect(x: bounds.midX - side / 2, + y: bounds.midY - side / 2, + width: side, height: side) + positionedForSize = bounds.size + } + } +} + /// NSButton subclass for chip-shaped link buttons — paints a layer-backed /// fill/border, swaps to a brighter "hover" pair when the cursor enters, /// and switches the cursor to a pointing hand. Used by the Why-this-Keymap @@ -206,6 +317,7 @@ /// instrument palette collapses / expands. var onFocusShortcutChange: ((MenuBandShortcut) -> Bool)? var onFocusShortcutRecordingChanged: ((Bool) -> Void)? var onPlayPaletteToggle: (() -> Void)? + var onJukeToggle: (() -> Void)? var onPlayPaletteShortcutChange: ((MenuBandShortcut) -> Bool)? var onPlayPaletteShortcutRecordingChanged: ((Bool) -> Void)? var isPlayPaletteShown: (() -> Bool)? @@ -1023,6 +1135,23 @@ ]) keymapButton.toolTip = "Open the full-screen keymap (piano + QWERTY)" Self.outlineFooterButton(keymapButton, color: Self.keymapOutlineColor) +#if !MAC_APP_STORE + // Juke is the complete listening/DJ/library surface formerly shipped + // as JukeWizard. It is a window of Menu Band, not another status item. + let jukeButton = SpinningDiscButton(frame: .zero) + jukeButton.bezelStyle = .inline + jukeButton.isBordered = false + jukeButton.controlSize = .small + jukeButton.target = self + jukeButton.action = #selector(openJuke(_:)) + jukeButton.toolTip = "Open Menu Band Juke" + jukeButton.setAccessibilityLabel("Open Menu Band Juke") + jukeButton.alphaValue = 0.88 + jukeButton.onHoverChange = { [weak jukeButton] hovered in + jukeButton?.alphaValue = hovered ? 1 : 0.88 + } +#endif + // (Gamepad config moved to the full-screen Keymap overlay's bottom-right // corner — see ExpandedPianoWaveformView.installGamepadCluster. It lives // next to the large QWERTY/piano where a controller player is actually @@ -1054,6 +1183,9 @@ let quitSpacer = NSView() quitSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) quitRow.addArrangedSubview(aboutButton) quitRow.addArrangedSubview(keymapButton) +#if !MAC_APP_STORE + quitRow.addArrangedSubview(jukeButton) +#endif quitRow.addArrangedSubview(quitSpacer) quitRow.addArrangedSubview(quit) stack.addArrangedSubview(quitRow) @@ -2481,6 +2613,10 @@ } @objc func showJamPanel(_ sender: Any?) { JamWindowController.show() + } + + @objc private func openJuke(_ sender: Any?) { + onJukeToggle?() } @objc private func openNotepat() { diff --git a/slab/menuband/install.sh b/slab/menuband/install.sh --- a/slab/menuband/install.sh +++ b/slab/menuband/install.sh @@ -119,8 +119,8 @@ # the multi-arch path requires a full Xcode toolchain (xcbuild + the # Metal toolchain). On a CLT-only machine SwiftPM happily cross- # compiles each slice via --triple, so we build each separately and # lipo them together — works equally well with full Xcode or CLT. -ARM_TRIPLE="arm64-apple-macosx11.0" -X86_TRIPLE="x86_64-apple-macosx11.0" +ARM_TRIPLE="arm64-apple-macosx12.0" +X86_TRIPLE="x86_64-apple-macosx12.0" say "building MenuBand arm64 slice" swift build -c release --triple "${ARM_TRIPLE}" >/dev/null @@ -209,6 +209,17 @@ # and anything at the bundle root fails `codesign --strict` ("unsealed # contents present in the bundle root") and breaks notarization. rm -rf "${APP_DIR}/${PKG_BUNDLE_NAME}" "${APP_RES}/${PKG_BUNDLE_NAME}" cp -R "${PKG_BUNDLE_SRC}/." "${APP_RES}/" +fi + +# Menu Band Juke is linked into this process and reads assets from the app's +# signed Resources/Assets directory. Copying source assets explicitly avoids +# SwiftPM's generated accessor and its embedded local build path. +JUKE_ASSETS_SRC="${SCRIPT_DIR}/../../juke-wizard/Sources/JukeWizard/Assets" +if [[ -d "${JUKE_ASSETS_SRC}" ]]; then + rm -rf "${APP_RES:?}/Assets" + cp -R "${JUKE_ASSETS_SRC}" "${APP_RES}/Assets" +else + warn "Menu Band Juke assets missing at ${JUKE_ASSETS_SRC}" fi # --- Apple Help book --- @@ -373,6 +384,51 @@ sed "s|@HOME@|${REPO_HOME}|g" "${PLIST_TMPL}" > "${PLIST_PATH}" sed "s|@HOME@|${REPO_HOME}|g" "${LAUNCHER_PLIST_TMPL}" > "${LAUNCHER_PLIST_PATH}" ok "plists written" +# Juke lives inside Menu Band. Retire every standalone process artifact so the +# app menu, Dock identity, and status item always belong to Menu Band. Keep the +# old artifacts as recoverable migration receipts. +LEGACY_JUKE_LABEL="computer.aesthetic.jukewizard" +LEGACY_JUKE_PLIST="${LAUNCH_AGENTS}/${LEGACY_JUKE_LABEL}.plist" +MIGRATION_DIR="${REPO_HOME}/.local/share/menuband/migrations" +mkdir -p "${MIGRATION_DIR}" +if launchctl print "gui/$(id -u)/${LEGACY_JUKE_LABEL}" >/dev/null 2>&1; then + launchctl bootout "gui/$(id -u)/${LEGACY_JUKE_LABEL}" 2>/dev/null || true +fi +if [[ -f "${LEGACY_JUKE_PLIST}" ]]; then + mv "${LEGACY_JUKE_PLIST}" "${MIGRATION_DIR}/${LEGACY_JUKE_LABEL}.plist" + ok "retired standalone JukeWizard LaunchAgent (receipt kept in ${MIGRATION_DIR})" +fi + +LEGACY_JUKE_ROOT="${REPO_HOME}/.local/lib/jukewizard" +LEGACY_JUKE_BIN="${LEGACY_JUKE_ROOT}/JukeWizard" +if [[ -f "${LEGACY_JUKE_BIN}" ]]; then + LEGACY_JUKE_RECEIPT="${MIGRATION_DIR}/JukeWizard-standalone" + if [[ -e "${LEGACY_JUKE_RECEIPT}" ]]; then + LEGACY_JUKE_RECEIPT="${LEGACY_JUKE_RECEIPT}-$(date +%Y%m%d-%H%M%S)" + fi + mv "${LEGACY_JUKE_BIN}" "${LEGACY_JUKE_RECEIPT}" + ok "retired standalone JukeWizard binary (receipt kept in ${MIGRATION_DIR})" +fi +LEGACY_JUKE_BUNDLE="${LEGACY_JUKE_ROOT}/JukeWizard_JukeWizard.bundle" +if [[ -d "${LEGACY_JUKE_BUNDLE}" ]]; then + LEGACY_BUNDLE_RECEIPT="${MIGRATION_DIR}/JukeWizard_JukeWizard.bundle" + if [[ -e "${LEGACY_BUNDLE_RECEIPT}" ]]; then + LEGACY_BUNDLE_RECEIPT="${LEGACY_BUNDLE_RECEIPT}-$(date +%Y%m%d-%H%M%S)" + fi + mv "${LEGACY_JUKE_BUNDLE}" "${LEGACY_BUNDLE_RECEIPT}" +fi + +# Preserve the familiar command as a shell/Node control surface. It opens or +# talks to Menu Band Juke and contains no native application executable. +JUKE_CLI_ROOT="${REPO_HOME}/.local/lib/menuband-juke" +JUKE_CLI_BIN_DIR="${REPO_HOME}/.local/bin" +mkdir -p "${JUKE_CLI_ROOT}" "${JUKE_CLI_BIN_DIR}" +/usr/bin/install -m 0755 "${SCRIPT_DIR}/../../juke-wizard/bin/juke-cloud.mjs" "${JUKE_CLI_ROOT}/juke-cloud.mjs" +/usr/bin/install -m 0755 "${SCRIPT_DIR}/../../juke-wizard/bin/jukewizard-control.mjs" "${JUKE_CLI_ROOT}/jukewizard-control.mjs" +/usr/bin/install -m 0755 "${SCRIPT_DIR}/../../tezos/ac-login.mjs" "${JUKE_CLI_ROOT}/ac-login.mjs" +/usr/bin/install -m 0755 "${SCRIPT_DIR}/../../juke-wizard/bin/jukewizard-installed" "${JUKE_CLI_BIN_DIR}/jukewizard" +ok "installed Menu Band Juke control command → ${JUKE_CLI_BIN_DIR}/jukewizard" + say "(re)starting launch agents in place (kickstart, not load — avoids the macOS background nag)" _uid="$(id -u)" for _svc in "computer.aestheticcomputer.menuband|${PLIST_PATH}" \ @@ -385,16 +441,42 @@ launchctl bootstrap "gui/${_uid}" "${_plist}" 2>/dev/null || launchctl load "${_plist}" fi done sleep 1 -if launchctl list | grep -q computer.aestheticcomputer.menuband; then - ok "computer.aestheticcomputer.menuband is running" -else - warn "launchctl did not register the agent — check /tmp/menuband.err" -fi -if launchctl list | grep -q computer.aestheticcomputer.menubandlauncher; then - ok "computer.aestheticcomputer.menubandlauncher is running" -else - warn "launcher agent did not register — check /tmp/menubandlauncher.err" -fi + +# Replacing a signed executable in place occasionally leaves launchd's cached +# lightweight-code-requirement (LWCR) tied to the previous signature. The job +# stays registered but exits 78 before the program starts. Keep the quiet +# kickstart path above; only refresh registration when the service is not +# actually running after that restart. +ensure_service_running() { + local label="$1" plist="$2" log="$3" attempt=0 + while (( attempt < 10 )); do + if launchctl print "gui/${_uid}/${label}" 2>/dev/null | grep -q 'state = running'; then + ok "${label} is running" + return 0 + fi + sleep 0.2 + attempt=$((attempt + 1)) + done + + warn "${label} retained a stale launch record — refreshing it once" + launchctl bootout "gui/${_uid}/${label}" 2>/dev/null || true + launchctl bootstrap "gui/${_uid}" "${plist}" 2>/dev/null || launchctl load "${plist}" + launchctl kickstart -k "gui/${_uid}/${label}" 2>/dev/null || true + attempt=0 + while (( attempt < 20 )); do + if launchctl print "gui/${_uid}/${label}" 2>/dev/null | grep -q 'state = running'; then + ok "${label} is running" + return 0 + fi + sleep 0.2 + attempt=$((attempt + 1)) + done + warn "${label} is registered but not running — check ${log}" + return 1 +} + +ensure_service_running computer.aestheticcomputer.menuband "${PLIST_PATH}" /tmp/menuband.err +ensure_service_running computer.aestheticcomputer.menubandlauncher "${LAUNCHER_PLIST_PATH}" /tmp/menubandlauncher.err printf "\n%sdone.%s\n" "${BOLD}" "${RESET}" echo " bundle: ${APP_DIR}"