Initial Commit

This commit is contained in:
Mark Gregory
2026-11-03 12:53:05 +00:00
commit 83acfc63c9
136 changed files with 19465 additions and 0 deletions
@@ -0,0 +1,68 @@
{
"images": [
{
"idiom": "mac",
"size": "16x16",
"scale": "1x",
"filename": "icon_16x16@1x.png"
},
{
"idiom": "mac",
"size": "16x16",
"scale": "2x",
"filename": "icon_16x16@2x.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "1x",
"filename": "icon_32x32@1x.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "2x",
"filename": "icon_32x32@2x.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "1x",
"filename": "icon_128x128@1x.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "2x",
"filename": "icon_128x128@2x.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "1x",
"filename": "icon_256x256@1x.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "2x",
"filename": "icon_256x256@2x.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "1x",
"filename": "icon_512x512@1x.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "2x",
"filename": "icon_512x512@2x.png"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,12 @@
{
"images": [
{
"idiom": "universal",
"filename": "KJMixBrand.png"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+6
View File
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+69
View File
@@ -0,0 +1,69 @@
import Foundation
import CoreGraphics
/// CD+G packet decoder. Packet parity is ignored; malformed coordinates are rejected.
struct CDGDecoder: Sendable {
private(set) var pixels = [UInt8](repeating: 0, count: 300 * 216)
private(set) var palette = [UInt32](repeating: 0x000000FF, count: 16)
private(set) var packetIndex = 0
private(set) var horizontalOffset = 0
private(set) var verticalOffset = 0
private var transparent: UInt8?
mutating func render(_ data: Data, at seconds: Double, cancelled: () -> Bool = { false }) {
let target = min(data.count / 24, max(0, Int(max(0, seconds) * 300)))
if target < packetIndex { self = CDGDecoder() }
while packetIndex < target {
if packetIndex % 300 == 0 && cancelled() { return }
apply(Array(data[(packetIndex * 24)..<(packetIndex * 24 + 24)]))
packetIndex += 1
}
}
mutating func apply(_ packet: [UInt8]) {
guard packet.count == 24, packet[0] & 63 == 9 else { return }
let d = packet[4..<20].map { Int($0 & 63) }
switch packet[1] & 63 {
case 1:
if d[1] == 0 { pixels = .init(repeating: UInt8(d[0] & 15), count: pixels.count) }
case 2:
for y in 0..<216 { for x in 0..<300 where x < 6 || x >= 294 || y < 12 || y >= 204 { pixels[y * 300 + x] = UInt8(d[0] & 15) } }
case 6, 38:
let row = d[2] & 31, col = d[3] & 63
guard row < 18, col < 50 else { return }
for y in 0..<12 { for x in 0..<6 {
let color = UInt8(d[(y + 4)] & (1 << (5 - x)) == 0 ? d[0] & 15 : d[1] & 15)
let i = (row * 12 + y) * 300 + col * 6 + x
if packet[1] & 63 == 38 { pixels[i] ^= color } else { pixels[i] = color }
} }
case 20, 24:
horizontalOffset = min(d[1] & 7, 5); verticalOffset = min(d[2] & 15, 11)
let hx = (d[1] >> 4) & 3, vy = (d[2] >> 4) & 3
let dx = hx == 1 ? 6 : hx == 2 ? -6 : 0
let dy = vy == 1 ? 12 : vy == 2 ? -12 : 0
let old = pixels
for y in 0..<216 { for x in 0..<300 {
let sx = x - dx, sy = y - dy
if packet[1] & 63 == 24 || (sx >= 0 && sx < 300 && sy >= 0 && sy < 216) {
pixels[y * 300 + x] = old[((sy + 216) % 216) * 300 + (sx + 300) % 300]
} else { pixels[y * 300 + x] = UInt8(d[0] & 15) }
} }
case 28: transparent = UInt8(d[0] & 15)
case 30, 31:
let start = packet[1] & 63 == 30 ? 0 : 8
for i in 0..<8 {
let v = (d[i * 2] << 6) | d[i * 2 + 1]
palette[start + i] = UInt32(((v >> 8) & 15) * 17) << 24 | UInt32(((v >> 4) & 15) * 17) << 16 | UInt32((v & 15) * 17) << 8 | 255
}
default: break
}
}
func image() -> CGImage? {
var rgba = [UInt8](); rgba.reserveCapacity(288 * 192 * 4)
for y in 12..<204 { for x in 6..<294 {
let index = pixels[(y + verticalOffset) * 300 + x + horizontalOffset]
let c = palette[Int(index)]
rgba += [UInt8((c >> 24) & 255), UInt8((c >> 16) & 255), UInt8((c >> 8) & 255), index == transparent ? 0 : 255]
} }
guard let provider = CGDataProvider(data: Data(rgba) as CFData) else { return nil }
return CGImage(width: 288, height: 192, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 288 * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)
}
}
+588
View File
@@ -0,0 +1,588 @@
import SwiftUI
import AVFoundation
import AppKit
struct StudioSettings: Codable, Equatable {
var autoAdvance = true
var repeatTrack = false
var graphicsOffset: Double = 0
var jogSecondsPerTurn: Double = 2
var nudgeSeconds: Double = 0.05
var backgroundPath: String?
var backgroundBookmark: Data?
var backgroundTrim: Double = 0.3
var backgroundLoop = true
var backgroundFadeIn: Double? = 0
var backgroundFadeOut: Double? = 0
var automaticBackground: Bool? = true
var backgroundAutoEnabled: Bool { automaticBackground ?? true }
var serverPort = 8080
var sanitized: Self {
var value = self
value.graphicsOffset = graphicsOffset.isFinite ? min(5,max(-5,graphicsOffset)) : 0
value.jogSecondsPerTurn = jogSecondsPerTurn.isFinite ? min(20,max(0.1,jogSecondsPerTurn)) : 2
value.nudgeSeconds = nudgeSeconds.isFinite ? min(2,max(0.005,nudgeSeconds)) : 0.05
value.backgroundTrim = backgroundTrim.isFinite ? min(1,max(0,backgroundTrim)) : 0.3
value.backgroundFadeIn = (backgroundFadeIn ?? 0).isFinite ? min(15,max(0,backgroundFadeIn ?? 0)) : 0
value.backgroundFadeOut = (backgroundFadeOut ?? 0).isFinite ? min(15,max(0,backgroundFadeOut ?? 0)) : 0
value.serverPort = min(65535,max(1024,serverPort));return value
}
}
/// Independent background audio deck. Singer playback always pauses it before the main node starts.
@MainActor final class BackgroundDeck: ObservableObject {
@Published private(set) var title = "No background track"
@Published private(set) var playing = false
@Published private(set) var time: Double = 0
@Published private(set) var duration: Double = 0
@Published private(set) var interruptedBySinger = false
@Published private(set) var status = "Choose an audio file"
var trim: Double = 0.3 { didSet { applyVolume() } }
var fadeInSeconds: Double = 0
var fadeOutSeconds: Double = 0
private var fadeTask: Task<Void,Never>?
private var fadeLevel: Double = 1
private var singerFadeDeadline: Double?
var repeating = true
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
private let eq = AVAudioUnitEQ(numberOfBands:4)
private(set) var mix = Player.GlobalMix(volume:0.8,low:0,mid:0,high:0,filter:0,enabled:true)
private var file: AVAudioFile?
private var accessURL: URL?
private var scoped = false
private var generation = UUID()
private var anchor: Double = 0
private var timer: Timer?
var loaded: Bool { file != nil }
var outputGain: Float { engine.mainMixerNode.outputVolume }
init() { engine.attach(node);engine.attach(eq) }
func load(_ url: URL,title: String? = nil) throws {
unload()
scoped = url.startAccessingSecurityScopedResource();accessURL = url
do {
let file = try AVAudioFile(forReading:url)
guard file.length > 0 else { throw LibraryError.message("Background file is empty") }
self.file = file;duration = Double(file.length)/file.processingFormat.sampleRate
self.title = title ?? url.deletingPathExtension().lastPathComponent
engine.connect(node,to:eq,format:file.processingFormat);engine.connect(eq,to:engine.mainMixerNode,format:file.processingFormat)
apply(mix:mix);schedule(at:0);status = "Ready"
} catch { unload();status = "Unavailable: "+error.localizedDescription;throw error }
}
func apply(mix: Player.GlobalMix) {
self.mix = mix;applyVolume()
AudioTone.configure(eq,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:mix.low,mid:mix.mid,high:mix.high,filter:mix.filter,enabled:mix.enabled)
}
private func applyVolume() { engine.mainMixerNode.outputVolume = Float(min(1,max(0,mix.volume*trim*fadeLevel))) }
private func schedule(at seconds: Double) {
guard let file else { return };generation = UUID();let token = generation
node.stop();let rate = file.processingFormat.sampleRate,start = min(file.length,max(0,AVAudioFramePosition(seconds*rate)))
anchor = Double(start)/rate;time = anchor
guard start < file.length else { return }
node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in
Task { @MainActor in
guard let self,self.generation == token else { return }
if self.repeating && self.playing { self.schedule(at:0);self.node.play() }
else { self.playing = false;self.time = self.duration;self.status = "Finished · Press Play to restart";self.timer?.invalidate();self.timer = nil }
}
}
}
private func cancelFade() { fadeTask?.cancel();fadeTask = nil;singerFadeDeadline = nil }
private func fade(to target: Double,seconds: Double,completion: (() -> Void)? = nil) {
cancelFade()
guard seconds.isFinite,seconds > 0 else { fadeLevel = target;applyVolume();completion?();return }
let initial = fadeLevel,began = ProcessInfo.processInfo.systemUptime
fadeTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self else { return }
let fraction = min(1,(ProcessInfo.processInfo.systemUptime-began)/seconds)
self.fadeLevel = initial+(target-initial)*fraction;self.applyVolume()
if fraction >= 1 { self.fadeTask = nil;completion?();return }
do { try await Task.sleep(nanoseconds:16_000_000) } catch { return }
}
}
}
func fadeOut(stop: Bool) {
guard playing || node.isPlaying else { if stop { self.stop() };return }
tick();playing = false;timer?.invalidate();timer = nil
if stop { time = 0 }
status = "Fading out"
fade(to:0,seconds:fadeOutSeconds) { [weak self] in
guard let self else { return }
if stop { self.stop() } else { self.pause() }
}
}
func start() throws {
guard file != nil else { throw LibraryError.message("Choose a background track first") }
if time >= duration { schedule(at:0) }
if !engine.isRunning { engine.prepare();try engine.start() }
cancelFade();if !node.isPlaying { fadeLevel = fadeInSeconds > 0 ? 0 : 1;applyVolume() }
node.play();playing = true;interruptedBySinger = false;status = "Background music playing"
fade(to:1,seconds:fadeInSeconds)
timer?.invalidate();timer = Timer(timeInterval:0.1,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } }
if let timer { RunLoop.main.add(timer,forMode:.common) }
}
func seek(_ seconds: Double) {
guard loaded,seconds.isFinite else { return }
let resume = playing,target = min(duration,max(0,seconds))
cancelFade();fadeLevel = resume ? 1 : 0;applyVolume()
status = resume ? "Background music playing" : (interruptedBySinger ? "Paused for singer" : "Ready at selected position")
schedule(at:target)
if resume,target < duration { node.play() }
else if target >= duration {
playing = false;timer?.invalidate();timer = nil;status = "At end · Press Play to restart"
}
}
private func tick() {
if node.isPlaying,let file,let render = node.lastRenderTime,let played = node.playerTime(forNodeTime:render) { time = min(duration,anchor+Double(played.sampleTime)/file.processingFormat.sampleRate) }
}
/// Return the remaining audible fade time; the singer node waits before starting.
func prepareForSinger() -> Double {
if let deadline = singerFadeDeadline { return max(0,deadline-ProcessInfo.processInfo.systemUptime) }
guard playing || node.isPlaying else { return 0 }
let seconds = fadeOutSeconds.isFinite ? min(15,max(0,fadeOutSeconds)) : 0
guard seconds > 0 else { pauseForSinger();return 0 }
tick();playing = false;interruptedBySinger = true;timer?.invalidate();timer = nil
status = "Fading out for singer"
fade(to:0,seconds:seconds) { [weak self] in self?.pauseForSinger() }
singerFadeDeadline = ProcessInfo.processInfo.systemUptime+seconds
return seconds
}
func pauseForSinger() { guard playing || node.isPlaying else { return };cancelFade();tick();node.pause();fadeLevel = 0;applyVolume();playing = false;interruptedBySinger = true;status = "Paused for singer";timer?.invalidate();timer = nil }
func pause() { guard playing || node.isPlaying else { return };cancelFade();tick();node.pause();fadeLevel = 0;applyVolume();playing = false;interruptedBySinger = false;status = "Paused · Press Play to resume";timer?.invalidate();timer = nil }
func stop() { cancelFade();fadeLevel = 0;applyVolume();node.stop();playing = false;interruptedBySinger = false;timer?.invalidate();timer = nil;schedule(at:0);status = file == nil ? "Choose an audio file" : "Stopped · Ready" }
func unload() {
cancelFade();fadeLevel = 0;generation = UUID();node.stop();engine.stop();timer?.invalidate();timer = nil
engine.disconnectNodeOutput(node);engine.disconnectNodeOutput(eq);file = nil
if scoped,let accessURL { accessURL.stopAccessingSecurityScopedResource() };scoped = false;accessURL = nil
title = "No background track";playing = false;interruptedBySinger = false;time = 0;duration = 0;status = "Choose an audio file"
}
deinit { fadeTask?.cancel();timer?.invalidate();if scoped,let accessURL { accessURL.stopAccessingSecurityScopedResource() } }
}
@MainActor extension AppModel {
func applyStudioSettings(save: Bool,previous: StudioSettings? = nil) {
let value = studio.sanitized
if previous == nil || previous?.autoAdvance != value.autoAdvance { autoAdvance = value.autoAdvance }
if previous == nil || previous?.repeatTrack != value.repeatTrack { player.repeating = value.repeatTrack }
if previous == nil || previous?.graphicsOffset != value.graphicsOffset { player.graphicsOffset = value.graphicsOffset }
player.jogSecondsPerTurn = value.jogSecondsPerTurn;player.nudgeStep = value.nudgeSeconds
backgroundDeck.fadeInSeconds = value.backgroundFadeIn ?? 0;backgroundDeck.fadeOutSeconds = value.backgroundFadeOut ?? 0
backgroundDeck.trim = value.backgroundTrim;backgroundDeck.repeating = value.backgroundLoop;backgroundDeck.apply(mix:player.globalMix)
if save,let data = try? JSONEncoder().encode(value) { do { try data.write(to:root.appendingPathComponent("StudioSettings.json"),options:.atomic) } catch { self.error = error.localizedDescription } }
if previous == nil || previous?.backgroundPath != value.backgroundPath || previous?.backgroundBookmark != value.backgroundBookmark { restoreBackground() }
if previous != nil { scheduleBackgroundHandoff() }
}
func restoreBackground() {
guard let path = studio.backgroundPath else { backgroundDeck.unload();return }
var url = URL(fileURLWithPath:path)
if let bookmark = studio.backgroundBookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:.withSecurityScope,relativeTo:nil,bookmarkDataIsStale:&stale) { url = resolved } }
do { try backgroundDeck.load(url) } catch { /* The deck shows its unavailable state without interrupting navigation. */ }
}
func chooseBackground() {
let panel = NSOpenPanel();panel.title = "Choose Background Music";panel.allowedContentTypes = [.audio];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false
guard panel.runModal() == .OK,let url = panel.url else { return }
var next = studio;next.backgroundPath = url.path
next.backgroundBookmark = try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil);studio = next
}
func scheduleBackgroundHandoff() {
guard backgroundAutomationReady else { return }
backgroundHandoff?.cancel()
backgroundHandoff = Task { @MainActor [weak self] in
do { try await Task.sleep(nanoseconds:250_000_000) } catch { return }
guard let self,self.studio.backgroundAutoEnabled,self.backgroundDeck.loaded,
!self.backgroundDeck.playing,(!self.player.playing || self.player.isShowScreen) else { return }
self.perform { try self.backgroundDeck.start() }
}
}
func pauseBackground() { studio.automaticBackground = false;backgroundHandoff?.cancel();backgroundDeck.fadeOut(stop:false) }
func stopBackground() { studio.automaticBackground = false;backgroundHandoff?.cancel();backgroundDeck.fadeOut(stop:true) }
func playBackground() {
guard !player.playing || player.isShowScreen else { error = "Stop or pause the singer deck before starting background music";return }
perform { try backgroundDeck.start() }
}
func placeSinger(_ singer: Int64,before: Int64?) { perform { guard let currentVenue,let library else { return };try library.placeRotation(singer:singer,venue:currentVenue,before:before);try refresh() } }
}
struct BackgroundDeckStrip: View {
@ObservedObject var model: AppModel
@ObservedObject var deck: BackgroundDeck
private func stamp(_ seconds: Double) -> String { let value = Int(max(0,seconds));return String(format:"%d:%02d",value/60,value%60) }
var body: some View {
HStack(spacing:12) {
Image(systemName:"music.note").font(.title3).foregroundStyle(deck.playing ? .green : .secondary)
VStack(alignment:.leading,spacing:3) {
Text(deck.loaded ? deck.title : "Background Music").font(.system(size:12,weight:.semibold)).lineLimit(1)
Text(deck.status).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1)
if deck.loaded {
HStack(spacing:6) {
Text(stamp(deck.time)).monospacedDigit()
Slider(value:Binding(get:{deck.time},set:{deck.seek($0)}),in:0...max(0.001,deck.duration)).controlSize(.small).accessibilityLabel("Background track position").help("Drag to find the background track’s starting point; paused music stays paused")
Text(stamp(deck.duration)).monospacedDigit()
}.font(.system(size:10)).foregroundStyle(.secondary)
}
}.frame(maxWidth:.infinity,alignment:.leading)
Toggle("Auto",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})).toggleStyle(.checkbox).help("Start background music between singers; pause it before singer audio starts")
Button("Choose…") { model.chooseBackground() }.controlSize(.small)
Button { if deck.playing { model.pauseBackground() } else { model.playBackground() } } label: { Image(systemName:deck.playing ? "pause.fill" : "play.fill").frame(width:23) }.disabled(!deck.loaded).help(deck.playing ? "Pause background music" : "DJ: start or resume background music")
Button { model.stopBackground() } label: { Image(systemName:"stop.fill") }.disabled(!deck.loaded).help("Stop background and return to start")
Slider(value:$model.studio.backgroundTrim,in:0...1).frame(width:85).controlSize(.small).help("Background level relative to global master").accessibilityLabel("Background level")
Text("\(Int(model.studio.backgroundTrim*100))%").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:30)
}.padding(.horizontal,12).padding(.vertical,8).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9))
}
}
struct DJDeckControls: View {
@ObservedObject var player: Player
@State private var angle: Double?
@State private var resume = false
@State private var song: Int64?
private func stamp(_ value: Double) -> String { String(format:"%d:%05.2f",Int(max(0,value))/60,max(0,value).truncatingRemainder(dividingBy:60)) }
var body: some View {
HStack(spacing:7) {
ZStack {
Circle().fill(Color.white.opacity(0.035));Circle().strokeBorder(Color.white.opacity(0.16),lineWidth:1)
Circle().trim(from:0.05,to:0.95).stroke(Color.accentColor.opacity(0.65),style:StrokeStyle(lineWidth:2,lineCap:.round)).padding(5)
Text("JOG").font(.system(size:8,weight:.bold)).foregroundStyle(.secondary)
}.frame(width:43,height:43).contentShape(Circle()).gesture(DragGesture(minimumDistance:0).onChanged { event in
guard player.canJog else { return };let next = atan2(event.location.y-21.5,event.location.x-21.5)
if let angle { var delta = next-angle;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta/(2 * .pi)*player.jogSecondsPerTurn) }
else { resume = player.playing;song = player.currentSongID;if player.playing { player.toggle() } }
angle = next
}.onEnded { _ in angle = nil;if resume,player.currentSongID == song,!player.playing { player.toggle() };resume = false })
.help("Drag around the jog wheel for precise positioning. Playback resumes on release.").accessibilityLabel("Jog wheel").accessibilityAdjustableAction { player.jog($0 == .increment ? player.nudgeStep : -player.nudgeStep) }
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill") }.help("Play / pause main deck")
Button { player.jog(-player.nudgeStep) } label: { Image(systemName:"backward.frame") }.help("Nudge backward")
Button { player.jog(player.nudgeStep) } label: { Image(systemName:"forward.frame") }.help("Nudge forward")
Divider().frame(height:24)
VStack(spacing:2) { HStack(spacing:4) { Button("Cue") { player.returnToCue() }.help("Pause and return to cue");Button { player.setCue() } label: { Image(systemName:"flag.fill") }.help("Set cue at current position") };Text(stamp(player.cuePosition)).font(.system(size:8,design:.monospaced)).foregroundStyle(.secondary) }
HStack(spacing:3) { ForEach(0..<4) { index in Button("\(index+1)") { player.hotCue(index) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : Color.accentColor).help(player.hotCues[index] == nil ? "Set hot cue \(index+1)" : "Jump to hot cue \(index+1)")
.contextMenu { Button("Set Here") { player.hotCue(index,clear:true);player.hotCue(index) };Button("Clear") { player.hotCue(index,clear:true) } }
} }
Spacer(minLength:0)
Button("In") { player.markLoopIn() }.foregroundStyle(player.loopIn == nil ? Color.primary : Color.accentColor).help("Set loop start")
Button("Out") { player.markLoopOut() }.disabled(player.loopIn == nil).help("Set loop end and enable loop")
Button { player.loopEnabled.toggle() } label: { Image(systemName:"repeat") }.disabled(player.loopOut == nil).foregroundStyle(player.loopEnabled ? Color.accentColor : Color.secondary).help("Toggle A/B loop").contextMenu { Button("Clear Loop") { player.clearLoop() } }
Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.help("Stop immediately, keep track and waveform loaded")
Button { player.fadeOutAndStop() } label: { Image(systemName:"speaker.slash") }.help("Fade out and unload")
}.buttonStyle(.borderless).controlSize(.small).font(.system(size:10,weight:.medium)).disabled(!player.canJog)
}
}
struct StudioSettingsPane: View {
@ObservedObject var model: AppModel
@State private var port = ""
var body: some View {
ScrollView {
VStack(alignment:.leading,spacing:22) {
HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() }
GroupBox("Playback & Jog") {
VStack(alignment:.leading,spacing:12) {
Toggle("Automatically play the next queued singer",isOn:$model.studio.autoAdvance)
Toggle("Repeat on startup",isOn:$model.studio.repeatTrack)
HStack { Text("Jog sensitivity");Slider(value:$model.studio.jogSecondsPerTurn,in:0.25...10,step:0.25);Text(String(format:"%.2fs / turn",model.studio.jogSecondsPerTurn)).monospacedDigit().frame(width:100) }
Picker("Nudge step",selection:$model.studio.nudgeSeconds) { Text("10 ms").tag(0.01);Text("50 ms").tag(0.05);Text("100 ms").tag(0.1);Text("500 ms").tag(0.5) }.frame(maxWidth:320)
HStack { Text("Video / CDG offset");Slider(value:$model.studio.graphicsOffset,in:-2...2,step:0.05);Text(String(format:"%+.2fs",model.studio.graphicsOffset)).monospacedDigit().frame(width:65) }
Text("Option-scroll over the waveform to jog. Cue, hot cues and loops belong to the loaded deck and reset on track change.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
GroupBox("Hosting · \(model.currentVenueName)") {
VStack(alignment:.leading,spacing:12) {
Toggle("Fade song endings",isOn:Binding(get:{model.hostSettings.autoFade},set:{model.hostSettings.autoFade = $0;model.saveHostSettings()}))
Stepper("Fade duration: \(Int(model.hostSettings.fadeSeconds)) seconds",value:Binding(get:{model.hostSettings.fadeSeconds},set:{model.hostSettings.fadeSeconds = $0;model.saveHostSettings()}),in:1...15,step:1)
Picker("Between singers",selection:Binding(get:{model.hostSettings.intermission},set:{model.hostSettings.intermission = $0;model.saveHostSettings()})) { Text("No automatic slide").tag(nil as Int64?);ForEach(model.showScreens) { Text($0.title).tag(Optional($0.id)) } }
}.padding(10)
}.disabled(model.currentVenue == nil)
GroupBox("Background Music") {
VStack(alignment:.leading,spacing:12) {
HStack { Text(model.studio.backgroundPath.map{URL(fileURLWithPath:$0).lastPathComponent} ?? "No track selected").lineLimit(1);Spacer();Button("Choose Track…") { model.chooseBackground() };Button("Clear") { model.studio.backgroundPath = nil;model.studio.backgroundBookmark = nil }.disabled(model.studio.backgroundPath == nil) }
Toggle("Automatically play background music between singers",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0}))
Toggle("Loop background track",isOn:$model.studio.backgroundLoop)
HStack { Text("Fade in");Slider(value:Binding(get:{model.studio.backgroundFadeIn ?? 0},set:{model.studio.backgroundFadeIn = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeIn ?? 0)).monospacedDigit().frame(width:65) }
HStack { Text("Fade out");Slider(value:Binding(get:{model.studio.backgroundFadeOut ?? 0},set:{model.studio.backgroundFadeOut = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeOut ?? 0)).monospacedDigit().frame(width:65) }
Text("Play uses Fade in; background Pause and Stop use Fade out. Zero disables a fade. Singer audio waits for the background fade-out to finish.").font(.caption).foregroundStyle(.secondary)
HStack { Text("Background level");Slider(value:$model.studio.backgroundTrim,in:0...1);Text("\(Int(model.studio.backgroundTrim*100))%").monospacedDigit() }
Text("Auto resumes background music after the singer stops, pauses, or finishes. Starting a singer fades it out first. Manual background Pause or Stop switches Auto off. Restoring a saved track at launch does not autoplay. Global EQ and master apply to both decks.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
GlobalMixerSettings(player:model.player)
GroupBox("Singer Web & Library") {
VStack(alignment:.leading,spacing:12) {
HStack { Text("Default server port");TextField("8080",text:$port).frame(width:90);Button("Apply Port") { if let value = Int(port),(1024...65535).contains(value) { model.studio.serverPort = value } else { model.error = "Use a port from 1024 to 65535" } } }
Text("Used next time you start Singer Web. The server remains off until started explicitly.").font(.caption).foregroundStyle(.secondary)
HStack { Button("Add Drive / Folder…") { model.chooseMediaLocation() };Button("Show Library Data") { NSWorkspace.shared.open(model.root) } }
Text("Audio output follows the Mac’s current sound output. Configure hardware output in macOS Sound settings.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
}.padding(22)
}.background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(model.studio.serverPort) }
}
}
private struct GlobalMixerSettings: View {
@ObservedObject var player: Player
var body: some View {
GroupBox("Global Master & EQ") {
VStack(alignment:.leading,spacing:10) {
Toggle("Enable EQ",isOn:$player.toneEnabled)
HStack { Text("Master").frame(width:45,alignment:.leading);Slider(value:$player.volume,in:0...1);Text("\(Int(player.volume*100))%").monospacedDigit().frame(width:45) }
HStack { Text("Low").frame(width:45,alignment:.leading);Slider(value:$player.lowGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.lowGain)).monospacedDigit().frame(width:60) }
HStack { Text("Mid").frame(width:45,alignment:.leading);Slider(value:$player.midGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.midGain)).monospacedDigit().frame(width:60) }
HStack { Text("High").frame(width:45,alignment:.leading);Slider(value:$player.highGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.highGain)).monospacedDigit().frame(width:60) }
}.padding(10)
}
}
}
struct WaveformJogWheel: NSViewRepresentable {
let player: Player
func makeNSView(context: Context) -> WheelView { let view = WheelView();view.player = player;view.install();return view }
func updateNSView(_ view: WheelView,context: Context) { view.player = player }
static func dismantleNSView(_ view: WheelView,coordinator: ()) { view.remove() }
final class WheelView: NSView {
weak var player: Player?
private var monitor: Any?
override func hitTest(_ point: NSPoint) -> NSView? { nil }
func install() {
monitor = NSEvent.addLocalMonitorForEvents(matching:.scrollWheel) { [weak self] event in
guard let self,event.window === self.window,self.window != nil,event.modifierFlags.contains(.option),self.bounds.contains(self.convert(event.locationInWindow,from:nil)),let player = self.player,player.canJog else { return event }
player.jog(-Double(event.scrollingDeltaY)/120*player.jogSecondsPerTurn);return nil
}
}
func remove() { if let monitor { NSEvent.removeMonitor(monitor) };monitor = nil }
deinit { if let monitor { NSEvent.removeMonitor(monitor) } }
}
}
struct RotationDrag: Codable {
var session: String;var venue: Int64;var singer: Int64
var text: String { "KJMix.rotation.v1:"+((try? JSONEncoder().encode(self)) ?? Data()).base64EncodedString() }
static func parse(_ text: String) -> Self? { guard text.hasPrefix("KJMix.rotation.v1:"),let data = Data(base64Encoded:String(text.dropFirst("KJMix.rotation.v1:".count))) else { return nil };return try? JSONDecoder().decode(Self.self,from:data) }
}
@MainActor extension AppModel {
func rotationReference(_ singer: Int64) -> RotationDrag? { currentVenue.map{.init(session:rotationSession,venue:$0,singer:singer)} }
func applyRotationDrop(_ reference: RotationDrag,before: Int64?) throws {
guard reference.session == rotationSession,reference.venue == currentVenue,rotationSingers.contains(where:{$0.id == reference.singer}),let library else { throw LibraryError.message("Drag a singer from the current venue’s rotation") }
if let before,!rotationSingers.contains(where:{$0.id == before}) { try library.markHere(singer:before,venue:reference.venue,here:false) }
try library.placeRotation(singer:reference.singer,venue:reference.venue,before:before);try refresh();synchronizeManualSingerOrder();resetRound()
}
func synchronizeManualSingerOrder() {
guard !hostSettings.smart,let venue = currentVenue,let library else { return }
perform { try library.orderQueueByRoster(venue:venue);try refresh() }
}
func startRotation() {
guard currentVenue != nil else { error = "Choose a venue, then add singers and songs to Tonight.";return }
if rotationDeckSongID != nil,rotationDeckSongID == player.currentSongID,rotationDeckVenue == currentVenue,player.canJog,player.time < player.duration {
studio.autoAdvance = true
if !player.playing { player.toggle() };rotationPlaybackActive = true
return
}
guard !rotationPlan.isEmpty else {
error = queue.isEmpty ? "Add a song to a singer’s Tonight list using the song browser below." : "Mark a singer with pending songs Here, or select Manual Queue."
return
}
studio.autoAdvance = true;playQueueNext()
}
func pauseRotation() { if rotationPlaybackActive,player.playing { player.toggle() } }
func stopRotation() { studio.autoAdvance = false;rotationPlaybackActive = false;player.stopDeck() }
func reorderRotationSinger(_ singer: Int64,by offset: Int) {
perform {
guard let library,let venue = currentVenue else { return }
for person in venueSingers where !rotationSingers.contains(where:{$0.id == person.id}) { try library.markHere(singer:person.id,venue:venue,here:false) }
try library.moveRotation(singer:singer,venue:venue,by:offset);try refresh();synchronizeManualSingerOrder();resetRound()
}
}
func applyRosterDropText(_ text: String,target: Int64?) async throws {
if let reference = RotationDrag.parse(text) { try applyRotationDrop(reference,before:target) }
else if let target,let venue = currentVenue {
let reference = try await SongDragReference.load(NSItemProvider(object:text as NSString))
try placeDroppedSong(reference,singer:target,venue:venue,before:nil)
} else { throw LibraryError.message("Drop a singer here to reorder, or a library song onto a singer.") }
}
func acceptRosterDrop(_ providers: [NSItemProvider],target: Int64?) -> Bool {
let items = providers.filter{$0.canLoadObject(ofClass:NSString.self)}
guard !items.isEmpty else { return false }
for item in items {
_ = item.loadObject(ofClass:NSString.self) { [weak self] value,_ in
guard let text = value as? String else { return }
Task { @MainActor in
do { try await self?.applyRosterDropText(text,target:target) }
catch { self?.error = error.localizedDescription }
}
}
}
return true
}
func playPerformance(_ entry: Int64) {
perform { guard let row = queue.first(where:{$0.id == entry}),let song = songs.first(where:{$0.id == row.songID}),let library else { return };selectedSinger = row.singerID;playingIntermission = false;try play(song,singer:row.singerID);try library.removeQueue(row.id);try refresh();rotationPlaybackActive = true;rotationDeckSongID = song.id;rotationDeckVenue = currentVenue }
}
}
struct RotationDashboard: View {
@ObservedObject var model: AppModel
@State private var addingSongs = false
private var roster: [PersonPlace] {
let ids = model.rotationSingers.map(\.id)
return ids.compactMap{id in model.venueSingers.first{$0.id == id}}+model.venueSingers.filter{!ids.contains($0.id)}
}
private var selected: PersonPlace? { model.venueSingers.first{$0.id == model.selectedSinger} }
var body: some View {
VStack(spacing:14) {
HStack(spacing:12) {
Picker("Rotation mode",selection:$model.hostSettings.smart) { Text("Manual Queue").tag(false);Text("Round Robin").tag(true) }.pickerStyle(.segmented).labelsHidden().frame(width:255)
Toggle("Auto advance",isOn:$model.studio.autoAdvance).toggleStyle(.checkbox).font(.caption)
Spacer();Menu("Add Singer") { Button("New Singer…") { model.beginSinger() };ForEach(model.singers.filter{person in !model.venueSingers.contains{$0.id == person.id}}) { person in Button(person.name) { model.membership(singer:person.id,included:true);model.markHere(person.id,true);model.selectedSinger = person.id;addingSongs = true } } };Button("Add Songs…") { if model.selectedSinger == nil { model.selectedSinger = roster.first?.id };addingSongs = true }.disabled(roster.isEmpty)
}
RotationTransport(model:model,player:model.player)
HStack {
Text("Up next:").foregroundStyle(.secondary)
Text(model.rotationPlan.first.map { $0.singer+" · "+$0.title } ?? "Add songs below to begin").lineLimit(1)
Spacer();Text("\(model.rotationSingers.filter(\.here).count) singers Here").foregroundStyle(.secondary)
}.font(.caption)
HSplitView {
VStack(alignment:.leading,spacing:8) {
HStack { Text("Singers").font(.headline);Spacer();Button { model.beginSinger() } label: { Image(systemName:"person.badge.plus") }.buttonStyle(.borderless).help("Add a singer") }
ScrollView {
LazyVStack(spacing:5) {
ForEach(Array(roster.enumerated()),id:\.element.id) { index,singer in rosterRow(singer,index:index) }
if roster.isEmpty { Text("Choose a venue and add singers to begin.").font(.callout).foregroundStyle(.secondary).padding(16) }
Text("Drag a singer here to place last").font(.caption2).foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(10)
.onDrop(of:["public.text"],isTargeted:nil) { model.acceptRosterDrop($0,target:nil) }
}
}
Text(model.hostSettings.smart ? "One turn each · Empty singers keep their place" : "Manual queue order decides who sings next").font(.caption2).foregroundStyle(.secondary)
}.padding(12).frame(minWidth:210,idealWidth:250,maxWidth:300).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10))
VStack(alignment:.leading,spacing:8) {
HStack { Picker("Rotation panel",selection:$addingSongs) { Text("Upcoming").tag(false);Text("Add Songs").tag(true) }.pickerStyle(.segmented).labelsHidden().frame(width:220);Spacer();Text("\(model.rotationPlan.count) pending").font(.caption).foregroundStyle(.secondary)
Menu { Button("Reset Round") { model.resetRound() };Button("Mark All Here") { model.markAllSingersHere() } } label: { Image(systemName:"ellipsis.circle") }.menuStyle(.borderlessButton).frame(width:18)
}
if addingSongs {
RotationSongBrowser(model:model)
} else {
ScrollView {
LazyVStack(spacing:0) {
ForEach(Array(model.rotationPlan.enumerated()),id:\.element.id) { index,row in
HStack(spacing:10) {
Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:20)
VStack(alignment:.leading,spacing:4) { Text(row.singer).font(.system(size:12,weight:.semibold));Text(row.title).font(.caption).foregroundStyle(.secondary).lineLimit(1) };Spacer()
if !model.hostSettings.smart {
Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"chevron.up") }.disabled(index == 0)
Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"chevron.down") }.disabled(index == model.rotationPlan.count-1)
}
Button { model.playPerformance(row.id) } label: { Image(systemName:"play.fill") }.help("Play this performance now")
Menu { Button("Remove Performance",role:.destructive) { model.removeQueue(row.id) };Menu("Assign Singer") { Button("No Singer / Filler") { model.assignQueue(row.id,singer:nil) };ForEach(model.venueSingers) { singer in Button(singer.name) { model.assignQueue(row.id,singer:singer.id) } } } } label: { Image(systemName:"ellipsis") }.menuStyle(.borderlessButton).frame(width:15)
}.buttonStyle(.borderless).padding(.vertical,10).padding(.horizontal,8).background(index == 0 ? Color.accentColor.opacity(0.06) : .clear)
Divider().opacity(0.4)
}
if model.rotationPlan.isEmpty { VStack(spacing:10) { Image(systemName:"text.badge.plus").font(.title);Text("No upcoming performances").font(.headline);Text("Select a singer, add songs to Tonight, and mark them Here for round robin.").font(.caption).multilineTextAlignment(.center) }.foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(22) }
}
}
}
}.padding(12).frame(minWidth:360).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10))
}
}.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark)
.onChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() }
.onChange(of:model.rotationPlaybackActive) { active in if active { addingSongs = false } }
}
private func rosterRow(_ singer: PersonPlace,index: Int) -> some View {
HStack(spacing:7) {
Toggle("Here",isOn:Binding(get:{model.rotationSingers.contains{$0.id == singer.id && $0.here}},set:{model.markHere(singer.id,$0)})).labelsHidden().toggleStyle(.checkbox).help("Singer is here tonight")
Button { model.selectedSinger = singer.id } label: { VStack(alignment:.leading,spacing:3) { Text(singer.name).font(.system(size:12,weight:.medium)).lineLimit(1);Text("\(model.queue.filter{$0.singerID == singer.id}.count) pending").font(.caption2).foregroundStyle(.secondary) }.frame(maxWidth:.infinity,alignment:.leading).contentShape(Rectangle()) }.buttonStyle(.plain)
Button { model.selectedSinger = singer.id;addingSongs = true } label: { Label("Songs",systemImage:"plus.circle").font(.caption).fixedSize() }.buttonStyle(.borderless).help("Add songs for \(singer.name)")
VStack(spacing:3) {
Button { model.reorderRotationSinger(singer.id,by:-1) } label: { Image(systemName:"chevron.up") }.disabled(index == 0).accessibilityIdentifier("rotation.up.\(singer.id)")
Button { model.reorderRotationSinger(singer.id,by:1) } label: { Image(systemName:"chevron.down") }.disabled(index == roster.count-1).accessibilityIdentifier("rotation.down.\(singer.id)")
}.font(.system(size:10)).buttonStyle(.borderless)
Image(systemName:"line.3.horizontal").font(.caption).foregroundStyle(.secondary).frame(width:28,height:36).contentShape(Rectangle())
.onDrag {
if !model.rotationSingers.contains(where:{$0.id == singer.id}) { model.markHere(singer.id,false) }
return NSItemProvider(object:(model.rotationReference(singer.id)?.text ?? "") as NSString)
}.help("Drag to reorder singers")
}.padding(9).background(model.selectedSinger == singer.id ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:7))
.onDrop(of:["public.text"],isTargeted:nil) { model.acceptRosterDrop($0,target:singer.id) }
.contextMenu { Button("Manage Tonight…") { model.selectedSinger = singer.id;addingSongs = true };Button("Earlier") { model.reorderRotationSinger(singer.id,by:-1) };Button("Later") { model.reorderRotationSinger(singer.id,by:1) } }
}
}
private struct RotationNowCard: View {
@ObservedObject var player: Player
let singer: String?
var body: some View {
VStack(alignment:.leading,spacing:5) {
HStack { Circle().fill(player.playing ? Color.green : Color.secondary).frame(width:5,height:5);Text(player.playing ? "ON STAGE" : "DECK READY").font(.system(size:9,weight:.bold)).foregroundStyle(.secondary);Spacer();Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.buttonStyle(.borderless).disabled(!player.canJog) }
Text(player.currentSongID == nil ? "No singer playing" : singer ?? "Main Deck").font(.headline).lineLimit(1)
Text(player.title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}.frame(maxWidth:.infinity,alignment:.leading).padding(12).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9))
}
}
private struct RotationTonightSheet: View {
@ObservedObject var model: AppModel
let singer: PersonPlace
@State private var query = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:14) {
Text("Tonight · "+singer.name).font(.title2.bold())
TextField("Search the library",text:$query).textFieldStyle(.roundedBorder)
List(model.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}) { song in
HStack {
Toggle(isOn:Binding(get:{model.tonightSongIDs[singer.id]?.contains(song.id) == true},set:{if $0 { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) }})) { VStack(alignment:.leading,spacing:3) { Text(song.title);Text(song.artist).font(.caption).foregroundStyle(.secondary) } }.toggleStyle(.checkbox)
Spacer()
if model.queue.contains(where:{$0.singerID == singer.id && $0.songID == song.id}) { Text("Queued").font(.caption).foregroundStyle(.green) }
else if model.tonightSongIDs[singer.id]?.contains(song.id) == true { Button("Queue Again") { model.addToTonight(song.id,singer:singer.id) }.controlSize(.small) }
}
}.frame(height:330)
HStack { Text("Adding a song marks this singer Here and puts it in the rotation.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:570).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark)
}
}
@MainActor extension AppModel {
func markAllSingersHere() {
perform {
guard let library,let currentVenue else { return };try library.execute("BEGIN IMMEDIATE")
do { for singer in venueSingers { try library.markHere(singer:singer.id,venue:currentVenue,here:true) };try library.execute("COMMIT") } catch { try? library.execute("ROLLBACK");throw error };try refresh()
}
}
}
struct RotationTransport: View {
@ObservedObject var model: AppModel
@ObservedObject var player: Player
var body: some View {
HStack(spacing:12) {
Button { model.startRotation() } label: { Label(model.rotationPlaybackActive && !player.playing ? "Resume Rotation" : "Start Rotation",systemImage:"play.fill") }.buttonStyle(.borderedProminent).disabled(model.rotationPlaybackActive && player.playing).accessibilityIdentifier("rotation.start")
Button { model.pauseRotation() } label: { Label("Pause",systemImage:"pause.fill") }.disabled(!model.rotationPlaybackActive || !player.playing)
Button { model.stopRotation() } label: { Label("Stop",systemImage:"stop.fill") }.disabled(!model.rotationPlaybackActive)
Button { model.studio.autoAdvance = true;model.playQueueNext() } label: { Label("Next Singer",systemImage:"forward.end.fill") }.disabled(model.rotationPlan.isEmpty)
Spacer()
Text(player.waitingForBackground ? "Fading background…" : model.rotationPlaybackActive ? (player.playing ? "Rotation playing" : "Rotation paused") : "Ready to start").font(.caption).foregroundStyle(.secondary)
}.controlSize(.small)
}
}
struct RotationSongBrowser: View {
@ObservedObject var model: AppModel
@State private var query = ""
var body: some View {
VStack(alignment:.leading,spacing:7) {
HStack {
Text("Add songs to Tonight").font(.system(size:12,weight:.semibold));Spacer()
Picker("Singer",selection:$model.selectedSinger) { Text("Choose singer…").tag(nil as Int64?);ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:150)
}
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
ScrollView {
LazyVStack(spacing:3) {
ForEach(model.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}) { song in
HStack {
VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:12,weight:.medium)).lineLimit(1);Text(song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
let queued = model.queue.contains{$0.songID == song.id && $0.singerID == model.selectedSinger}
Button(queued ? "Queued" : "Add to Tonight") { model.addToTonight(song.id) }.disabled(model.selectedSinger == nil || queued).controlSize(.small).accessibilityIdentifier("rotation.add.\(song.id)")
}.padding(.horizontal,8).padding(.vertical,5)
.onDrag { model.dragReference(song.id).provider() }
}
if model.songs.isEmpty { Text("Import songs into Library first.").foregroundStyle(.secondary).padding(10) }
}
}
}.padding(10).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9))
.onAppear { if model.selectedSinger == nil { model.selectedSinger = model.venueSingers.first?.id } }
}
}
+214
View File
@@ -0,0 +1,214 @@
import SwiftUI
import AppKit
struct RotationSinger: Identifiable, Equatable { var id: Int64;var name: String;var here: Bool }
struct HostSettings: Codable, Equatable {
var smart = false
var cursor: Int64?
var fadeSeconds: Double = 3
var autoFade = true
var intermission: Int64?
}
enum SingerRotation {
/// Stable cyclic interleave of pending performances. Empty singers retain their roster slot.
static func plan(queue: [QueueEntry],roster: [RotationSinger],after cursor: Int64?) -> [QueueEntry] {
var singers = roster.map(\.id)
if let cursor,let i = singers.firstIndex(of:cursor) { singers = Array(singers.dropFirst(i+1))+Array(singers.prefix(i+1)) }
let here = Set(roster.filter(\.here).map(\.id));singers = singers.filter{here.contains($0)}
var groups = Dictionary(grouping:queue.filter{$0.singerID != nil},by:{$0.singerID!})
var result = [QueueEntry]()
while true {
var added = false
for singer in singers where groups[singer]?.isEmpty == false { result.append(groups[singer]!.removeFirst());added = true }
if !added { break }
}
return result+queue.filter{$0.singerID == nil}
}
}
struct ShowScreen: Identifiable, Equatable {
var id: Int64 = 0
var title = "Welcome to karaoke"
var body = "Welcome to {venue}\n\nNext up: {next}"
var footer = "Find your next song · Enjoy the show"
var tint = "indigo"
var seconds: Double = 12
func expanded(_ value: String,venue: String,singer: String,next: String,song: String) -> String {
value.replacingOccurrences(of:"{venue}",with:venue).replacingOccurrences(of:"{singer}",with:singer).replacingOccurrences(of:"{next}",with:next).replacingOccurrences(of:"{song}",with:song)
}
@MainActor func render(venue: String,singer: String,next: String,song: String) -> CGImage? {
let size = NSSize(width:1920,height:1080)
let image = NSImage(size:size,flipped:false) { rect in
let top: NSColor = tint == "teal" ? NSColor(srgbRed:0.02,green:0.24,blue:0.27,alpha:1) : tint == "rose" ? NSColor(srgbRed:0.26,green:0.06,blue:0.18,alpha:1) : NSColor(srgbRed:0.10,green:0.08,blue:0.28,alpha:1)
NSGradient(starting:top,ending:NSColor(srgbRed:0.015,green:0.02,blue:0.04,alpha:1))!.draw(in:rect,angle:270)
let paragraph = NSMutableParagraphStyle();paragraph.alignment = .center;paragraph.lineBreakMode = .byWordWrapping
func text(_ value: String,_ rect: NSRect,_ font: NSFont,_ color: NSColor) { (value as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:color,.paragraphStyle:paragraph]) }
text("KJMix Pro",NSRect(x:160,y:900,width:1600,height:60),.systemFont(ofSize:30,weight:.semibold),.white.withAlphaComponent(0.6))
text(title,NSRect(x:160,y:730,width:1600,height:140),.systemFont(ofSize:64,weight:.bold),.white)
text(expanded(body,venue:venue,singer:singer,next:next,song:song),NSRect(x:170,y:220,width:1580,height:460),.systemFont(ofSize:48,weight:.medium),.white)
text(expanded(footer,venue:venue,singer:singer,next:next,song:song),NSRect(x:160,y:90,width:1600,height:100),.systemFont(ofSize:27,weight:.regular),.white.withAlphaComponent(0.65))
return true
}
guard let bitmap = NSBitmapImageRep(bitmapDataPlanes:nil,pixelsWide:1920,pixelsHigh:1080,bitsPerSample:8,samplesPerPixel:4,hasAlpha:true,isPlanar:false,colorSpaceName:.deviceRGB,bytesPerRow:0,bitsPerPixel:0),let context = NSGraphicsContext(bitmapImageRep:bitmap) else { return nil }
NSGraphicsContext.saveGraphicsState();NSGraphicsContext.current = context
image.draw(in:NSRect(origin:.zero,size:size),from:.zero,operation:.copy,fraction:1)
NSGraphicsContext.restoreGraphicsState();return bitmap.cgImage
}
}
@MainActor extension AppModel {
var rotationPlan: [QueueEntry] { hostSettings.smart ? SingerRotation.plan(queue:queue,roster:rotationSingers,after:hostSettings.cursor) : queue }
func saveHostSettings() {
perform {
guard let library,let currentVenue else { return }
try library.setSetting("host.\(currentVenue)",String(decoding:JSONEncoder().encode(hostSettings),as:UTF8.self))
player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade
}
}
func markHere(_ singer: Int64,_ here: Bool) { perform { guard let library,let currentVenue else { return };try library.markHere(singer:singer,venue:currentVenue,here:here);try refresh() } }
func moveSinger(_ singer: Int64,by offset: Int) { perform { guard let library,let currentVenue else { return };try library.moveRotation(singer:singer,venue:currentVenue,by:offset);try refresh() } }
func resetRound() { hostSettings.cursor = nil;saveHostSettings() }
func assignQueue(_ entry: Int64,singer: Int64?) { perform { guard let library,let currentVenue else { return };try library.assignQueue(entry,singer:singer,venue:currentVenue);try refresh() } }
func saveScreen(_ screen: ShowScreen) { perform { try library?.saveScreen(screen);try refresh() } }
func playScreen(_ screen: ShowScreen) throws {
let next = rotationPlan.first{$0.songID != screen.id}
guard let image = screen.render(venue:currentVenueName,singer:singers.first{$0.id == playbackSinger}?.name ?? "",next:next?.singer ?? "You",song:next?.title ?? "") else { throw LibraryError.message("Could not render the slide") }
player.showScreen(image,seconds:screen.seconds,title:screen.title,songID:screen.id)
}
func advanceShow() {
if let id = hostSettings.intermission,let screen = showScreens.first(where:{$0.id == id}),!rotationPlan.isEmpty,!playingIntermission {
playingIntermission = true;perform { try playScreen(screen) }
} else { playingIntermission = false;playQueueNext() }
}
func rateSong(_ song: Song,_ rating: Int) {
perform {
guard let library else { return }
if let selectedSinger { var value = try library.preference(song:song.id,singer:selectedSinger);value.rating = rating;try library.savePreference(song:song.id,singer:selectedSinger,value:value) }
else { try library.rate(song.id,rating) };try refresh()
}
}
func displayedRating(_ song: Song) -> Int { selectedSinger == nil ? song.rating : preference(song.id).rating }
func batchMetadata(_ ids: Set<Int64>,patch: MetadataPatch) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.batchMetadata(ids,patch:patch);try refresh() }
}
struct RotationPane: View {
@ObservedObject var model: AppModel
var body: some View { RotationDashboard(model:model) }
}
struct ShowScreensPane: View {
@ObservedObject var model: AppModel
@State private var edit: ShowScreen?
var body: some View {
VStack(alignment:.leading,spacing:16) {
HStack { Text("Intermission slides").font(.headline);Spacer();Button("Create ShowScreen…") { edit = ShowScreen() } }
Text("Create timed slides that play like songs. Add them to the manual queue or choose one between singers in Rotation.").foregroundStyle(.secondary)
List(model.showScreens) { screen in
HStack { Image(systemName:"rectangle.on.rectangle").foregroundStyle(.tint);VStack(alignment:.leading) { Text(screen.title);Text("\(Int(screen.seconds)) seconds").font(.caption).foregroundStyle(.secondary) };Spacer();Button("Edit") { edit = screen };Button("Show Now") { model.perform { try model.playScreen(screen) } };Button("Queue") { model.enqueueScreen(screen.id) }.disabled(model.currentVenue == nil) }
}
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(item:$edit) { screen in ShowScreenEditor(model:model,screen:screen) }
}
}
private struct ShowScreenEditor: View {
@ObservedObject var model: AppModel
@State var screen: ShowScreen
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text(screen.id == 0 ? "Create ShowScreen" : "Edit ShowScreen").font(.title2.bold())
TextField("Title",text:$screen.title)
TextEditor(text:$screen.body).font(.body).frame(height:130).border(.quaternary)
TextField("Footer",text:$screen.footer)
Text("Placeholders: {venue}, {singer}, {next}, {song}").font(.caption).foregroundStyle(.secondary)
HStack { Picker("Colour",selection:$screen.tint) { Text("Indigo").tag("indigo");Text("Teal").tag("teal");Text("Rose").tag("rose") };Stepper("\(Int(screen.seconds)) seconds",value:$screen.seconds,in:3...300,step:1) }
HStack { Button("Cancel") { dismiss() };Spacer();Button("Save") { model.saveScreen(screen);if model.error == nil { dismiss() } }.keyboardShortcut(.defaultAction).disabled(screen.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(24).frame(width:530).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder)
}
}
@MainActor extension AppModel {
func enqueueScreen(_ id: Int64) { perform { try library?.enqueue(song:id,singer:nil,venue:currentVenue);try refresh() } }
func writeFileTags(_ ids: Set<Int64>) {
guard !tagsBusy else { return }
guard !ids.contains(player.currentSongID ?? -1) else { error = "Stop the selected song before writing its file tags";return }
let selected = songs.filter{ids.contains($0.id)},backup = root.appendingPathComponent("TagBackups")
tagsBusy = true;tagsStatus = "Writing tags…"
Task {
var count = 0,issues = [String]()
for song in selected {
do {
guard player.currentSongID != song.id else { throw LibraryError.message("Song is now playing") }
try await FileTagWriter.write(song,backupDirectory:backup)
try library?.execute("UPDATE songs SET fingerprint='' WHERE id=?",[String(song.id)]);count += 1
} catch { issues.append(song.title+": "+error.localizedDescription) }
}
tagsBusy = false;tagsStatus = "Tags written: \(count) · Issues: \(issues.count)"
if !issues.isEmpty { error = issues.prefix(8).joined(separator:"\n") };perform { try refresh() }
}
}
}
struct PlaylistWorkspace: View {
@ObservedObject var model: AppModel
@Binding var query: String
@State private var selected: Int64?
@State private var name = ""
@State private var sort = "Saved Order"
@State private var ascending = true
@State private var adding = false
@State private var addIDs = Set<Int64>()
@State private var addQuery = ""
@State private var delete = false
private var rows: [Song] {
let ids = selected.flatMap{try? model.library?.playlistSongIDs($0)} ?? []
let index = Dictionary(uniqueKeysWithValues:model.songs.map{($0.id,$0)})
let songs = ids.compactMap{index[$0]}.filter{SongSearch.matches($0,query:query)}
guard sort != "Saved Order" else { return songs }
return songs.sorted { a,b in
let first: Bool
if sort == "Duration" { first = a.duration == b.duration ? a.id < b.id : a.duration < b.duration }
else if sort == "Rating" { first = model.displayedRating(a) == model.displayedRating(b) ? a.id < b.id : model.displayedRating(a) < model.displayedRating(b) }
else { let av = sort == "Artist" ? a.artist : sort == "Album" ? a.album : a.title,bv = sort == "Artist" ? b.artist : sort == "Album" ? b.album : b.title;let comparison = av.localizedStandardCompare(bv);first = comparison == .orderedSame ? a.id < b.id : comparison == .orderedAscending }
return ascending ? first : !first
}
}
var body: some View {
VStack(alignment:.leading,spacing:16) {
HStack {
Picker("Playlist",selection:$selected) { Text("Choose a playlist…").tag(nil as Int64?);ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } }.frame(maxWidth:300)
TextField("Playlist name",text:$name).textFieldStyle(.roundedBorder)
Button("Create") { model.createPlaylist(name);selected = model.playlists.first{$0.name == name.trimmingCharacters(in:.whitespacesAndNewlines)}?.id;name = "" }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty)
Menu { Button("Rename") { if let selected { model.rename("playlists",id:selected,name:name) } }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty);Button("Delete…",role:.destructive) { delete = true } } label: { Image(systemName:"ellipsis.circle") }.disabled(selected == nil)
}
HStack {
Picker("Sort",selection:$sort) { ForEach(["Saved Order","Title","Artist","Album","Duration","Rating"],id:\.self) { Text($0) } }.frame(width:200)
Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.disabled(sort == "Saved Order").help("Reverse sort")
Text("\(rows.count) songs").font(.caption).foregroundStyle(.secondary);Spacer();Button("Add Songs…") { adding = true }.disabled(selected == nil)
}
List(Array(rows.enumerated()),id:\.element.id) { index,song in
HStack {
Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:22)
VStack(alignment:.leading,spacing:4) { Text(song.title);Text(song.artist+" · "+song.album).font(.caption).foregroundStyle(.secondary) };Spacer()
Button("Play") { model.perform { try model.play(song) } }
Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil)
Button { move(song.id,-1) } label: { Image(systemName:"chevron.up") }.disabled(sort != "Saved Order" || !query.isEmpty || index == 0)
Button { move(song.id,1) } label: { Image(systemName:"chevron.down") }.disabled(sort != "Saved Order" || !query.isEmpty || index == rows.count-1)
Button { model.perform { if let selected { try model.library?.removeFromPlaylist(selected,song:song.id);try model.refresh() } } } label: { Image(systemName:"minus.circle") }.help("Remove from playlist")
}.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() }
}.listStyle(.inset)
Text("Use toolbar search for this playlist. Quoted phrases, artist:, album:, title:, id: and exact = searches are supported. Saved order can be changed with the arrow buttons when search is empty.").font(.caption).foregroundStyle(.secondary)
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(isPresented:$adding) { addSheet }
.confirmationDialog("Delete this playlist? Library songs are kept.",isPresented:$delete) { Button("Delete Playlist",role:.destructive) { if let selected { model.delete("playlists",id:selected);self.selected = nil } } }
.onChange(of:sort) { _ in saveSort() }.onChange(of:ascending) { _ in saveSort() }
.onChange(of:selected) { id in
if let id,let value = try? model.library?.setting("playlist.sort.\(id)") { let parts = value.components(separatedBy:"|");sort = parts[0];ascending = parts.count < 2 || parts[1] == "asc" } else { sort = "Saved Order";ascending = true }
}
}
private func move(_ song: Int64,_ by: Int) { model.perform { if let selected { try model.library?.reorderPlaylist(selected,song:song,by:by);try model.refresh() } } }
private func saveSort() { if let selected { model.perform { try model.library?.setSetting("playlist.sort.\(selected)",sort+"|"+(ascending ? "asc" : "desc")) } } }
private var addSheet: some View {
VStack(alignment:.leading,spacing:16) {
Text("Add Songs to Playlist").font(.title2.bold());TextField("Search library",text:$addQuery).textFieldStyle(.roundedBorder)
List(model.songs.filter{SongSearch.matches($0,query:addQuery)}) { song in Toggle(song.title,isOn:Binding(get:{addIDs.contains(song.id)},set:{if $0 { addIDs.insert(song.id) } else { addIDs.remove(song.id) }})).toggleStyle(.checkbox) }.frame(height:320)
HStack { Button("Cancel") { adding = false };Spacer();Button("Add \(addIDs.count) Songs") { model.perform { if let selected { for id in addIDs.sorted() { try model.library?.addToPlaylist(selected,song:id) };try model.refresh() } };adding = false;addIDs = [] }.disabled(addIDs.isEmpty) }
}.padding(24).frame(width:550)
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
import CryptoKit
import AVFoundation
struct Importer {
let media: URL
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false }) throws -> [Int64] {
try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true)
var files = urls
var temporary = [URL]()
defer { for url in temporary { try? FileManager.default.removeItem(at: url) } }
var songIDs = Set<Int64>()
for zip in urls where zip.pathExtension.lowercased() == "zip" {
if cancelled() { throw CancellationError() }
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
temporary.append(folder)
// Validate names before extraction; reject path traversal and symlinks afterwards.
let details = try process(["-Z", "-l", zip.path])
guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") }
let list = try process(["-Z1", zip.path])
let names = list.split(separator: "\n").map(String.init)
guard !names.isEmpty, names.allSatisfy({ !$0.hasPrefix("/") && !$0.split(separator: "/").contains("..") && !$0.contains("\\") }) else { throw LibraryError.message("Unsafe or empty ZIP") }
_ = try process(["-q", zip.path, "-d", folder.path])
let extracted = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isSymbolicLinkKey,.isRegularFileKey])!
for case let url as URL in extracted {
let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey])
guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") }
if values.isRegularFile == true { files.append(url) }
}
}
for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) {
let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil)
if let audio = siblings.first(where: { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) }
}
var imported = 0
for audio in Set(files) where ["mp3","wav","m4a","mp4","m4v","mov"].contains(audio.pathExtension.lowercased()) {
if cancelled() { throw CancellationError() }
let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil)
let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame }
let hash = try audioFingerprint(audio,cancelled:cancelled)
let copy = copyMedia || temporary.contains { audio.resolvingSymlinksInPath().path.hasPrefix($0.resolvingSymlinksInPath().path+"/") }
let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL
if copy && !FileManager.default.fileExists(atPath:dest.path) { try FileManager.default.copyItem(at:audio,to:dest) }
var graphics: String?
if let cdg {
let target = copy ? media.appendingPathComponent(hash+".cdg") : cdg.standardizedFileURL
if copy && !FileManager.default.fileExists(atPath:target.path) { try FileManager.default.copyItem(at:cdg,to:target) }
graphics = target.path
}
imported += 1
let metadata = AVURLAsset(url:audio).commonMetadata
func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" }
let taggedTitle = tag(.commonKeyTitle)
let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy)
songIDs.insert(id)
}
guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") }
return Array(songIDs)
}
private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String {
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
var hasher = SHA256()
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) }
return hasher.finalize().map { String(format:"%02x",$0) }.joined()
}
private func audioDuration(_ url: URL) -> Double {
guard let file = try? AVAudioFile(forReading:url) else { return 0 }
return Double(file.length)/file.processingFormat.sampleRate
}
private func process(_ arguments: [String]) throws -> String {
let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments
let out = Pipe(); task.standardOutput = out
let err = FileHandle.nullDevice; task.standardError = err
try task.run()
let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit()
guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") }
return String(decoding: bytes, as: UTF8.self)
}
}
File diff suppressed because it is too large Load Diff
+538
View File
@@ -0,0 +1,538 @@
import Foundation
import SQLite3
struct Song: Identifiable, Hashable {
var id: Int64
var title: String
var audio: String
var cdg: String?
var rating: Int
var plays: Int
var artist: String = ""
var album: String = ""
var label: String = ""
var duration: Double = 0
var fingerprint: String = ""
}
struct MediaLocation: Identifiable, Hashable, Sendable {
var id: Int64
var name: String
var path: String
var bookmark: Data?
var external: Bool
var lastScan: String
var lastError: String
var songCount: Int
var online: Bool { var directory: ObjCBool = false;return FileManager.default.fileExists(atPath:path,isDirectory:&directory) && directory.boolValue }
}
struct IndexedSourceFile {
var size: Int64
var modified: Double
var songs: [Int64]
}
struct MusicStore: Identifiable, Hashable {
var id: Int64
var name: String
var url: String
var searchTemplate: String
func destination(query: String = "") throws -> URL {
let query = query.trimmingCharacters(in:.whitespacesAndNewlines)
let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? ""
let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded)
guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") }
return result
}
}
struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String }
struct SongPreference: Equatable, Codable {
var pitch: Double = 0
var tempo: Double = 1
var rating: Int = 0
}
struct Performance: Identifiable {
var id: Int64
var song: String
var singer: String
var venue: String
var date: String
var singerID: Int64?
var songID: Int64?
}
struct QueueEntry: Identifiable {
var id: Int64
var songID: Int64
var singerID: Int64?
var title: String
var singer: String
}
enum LibraryError: LocalizedError {
case message(String)
var errorDescription: String? { if case .message(let s) = self { return s }; return nil }
}
final class Library {
private var db: OpaquePointer?
init(url: URL) throws {
guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") }
sqlite3_busy_timeout(db,5000)
guard sqlite3_exec(db,"PRAGMA journal_mode=WAL",nil,nil,nil) == SQLITE_OK else { throw error() }
try execute("PRAGMA foreign_keys=ON")
try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)")
try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)")
try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)")
try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)")
// Additive migration: preserve older libraries and infer existing memberships from history.
for column in ["artist", "album", "label"] {
if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") }
}
try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))")
if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") }
try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)")
try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))")
try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)")
try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)")
try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))")
try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))")
if try schemaVersion() < 4 {
try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song")
try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences")
}
if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") }
if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") }
if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") }
try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))")
try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')")
if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") }
try execute("CREATE TABLE IF NOT EXISTS singer_tonight(venue INTEGER NOT NULL,singer INTEGER NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(venue,singer,song),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)")
if try schemaVersion() < 7 {
try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) SELECT queue.venue,queue.singer,queue.song,MIN(queue.position) FROM queue JOIN venue_singers ON venue_singers.venue=queue.venue AND venue_singers.singer=queue.singer WHERE queue.singer IS NOT NULL GROUP BY queue.venue,queue.singer,queue.song")
}
if try !columns("songs").contains("fingerprint") { try execute("ALTER TABLE songs ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''") }
try execute("CREATE INDEX IF NOT EXISTS songs_fingerprint ON songs(fingerprint)")
try execute("CREATE TABLE IF NOT EXISTS media_sources(id INTEGER PRIMARY KEY,name TEXT NOT NULL,path TEXT NOT NULL UNIQUE,bookmark TEXT,external INTEGER NOT NULL DEFAULT 0,last_scan TEXT NOT NULL DEFAULT '',last_error TEXT NOT NULL DEFAULT '')")
try execute("CREATE TABLE IF NOT EXISTS source_files(source INTEGER NOT NULL REFERENCES media_sources(id) ON DELETE CASCADE,path TEXT NOT NULL,size INTEGER NOT NULL,modified REAL NOT NULL,PRIMARY KEY(source,path))")
try execute("CREATE TABLE IF NOT EXISTS source_songs(source INTEGER NOT NULL,path TEXT NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,PRIMARY KEY(source,path,song),FOREIGN KEY(source,path) REFERENCES source_files(source,path) ON DELETE CASCADE)")
if try schemaVersion() < 8 {
for song in try songs() where song.fingerprint.isEmpty {
let url = URL(fileURLWithPath:song.audio),hash = URL(fileURLWithPath:song.audio).deletingPathExtension().lastPathComponent
if url.deletingLastPathComponent().lastPathComponent == "Media",hash.count == 64,hash.allSatisfy({ $0.isHexDigit }) { try execute("UPDATE songs SET fingerprint=? WHERE id=?",[hash,String(song.id)]) }
}
}
try execute("CREATE TABLE IF NOT EXISTS singer_rotation(venue INTEGER NOT NULL,singer INTEGER NOT NULL,position INTEGER NOT NULL,here INTEGER NOT NULL DEFAULT 1,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)")
try execute("CREATE TABLE IF NOT EXISTS show_screens(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,body TEXT NOT NULL,footer TEXT NOT NULL,tint TEXT NOT NULL)")
try execute("CREATE TABLE IF NOT EXISTS singer_access(venue INTEGER NOT NULL,singer INTEGER NOT NULL,token TEXT NOT NULL UNIQUE,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)")
try execute("PRAGMA user_version=9")
}
deinit { sqlite3_close(db) }
private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer {
var s: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() }
for (i,v) in values.enumerated() {
let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1))
if code != SQLITE_OK { sqlite3_finalize(s); throw error() }
}
return s
}
private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) }
func execute(_ sql: String, _ values: [String?] = []) throws {
let s = try statement(sql, values); defer { sqlite3_finalize(s) }
guard sqlite3_step(s) == SQLITE_DONE else { throw error() }
}
func songs() throws -> [Song] {
let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) }
var result = [Song]()
while true {
let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() }
result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10)))
}
return result
}
@discardableResult func add(title: String,audio: String,cdg: String?,artist: String = "",album: String = "",label: String = "",duration: Double = 0,fingerprint: String = "",preferIncomingFile: Bool = false) throws -> Int64 {
var path = audio,graphics = cdg
if !fingerprint.isEmpty {
let statement = try self.statement("SELECT id,audio,cdg FROM songs WHERE audio=? OR fingerprint=? ORDER BY CASE WHEN audio=? THEN 0 ELSE 1 END,id LIMIT 1",[audio,fingerprint,audio])
if sqlite3_step(statement) == SQLITE_ROW {
let id = sqlite3_column_int64(statement,0),existing = text(statement,1)
let oldGraphics = sqlite3_column_type(statement,2) == SQLITE_NULL ? nil : text(statement,2)
if existing != audio,URL(fileURLWithPath:existing).pathExtension.lowercased() == URL(fileURLWithPath:audio).pathExtension.lowercased() {
if preferIncomingFile || !FileManager.default.fileExists(atPath:existing) {
sqlite3_finalize(statement)
try execute("UPDATE songs SET audio=?,cdg=? WHERE id=?",[audio,cdg,String(id)])
} else {
path = existing
if let oldGraphics,FileManager.default.fileExists(atPath:oldGraphics) { graphics = oldGraphics }
sqlite3_finalize(statement)
}
} else { sqlite3_finalize(statement) }
} else { sqlite3_finalize(statement) }
}
try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration,fingerprint) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END,fingerprint=CASE WHEN excluded.fingerprint!='' THEN excluded.fingerprint ELSE songs.fingerprint END",[title,path,graphics,artist,album,label,String(duration),fingerprint])
let result = try statement("SELECT id FROM songs WHERE audio=?",[path]);defer { sqlite3_finalize(result) }
guard sqlite3_step(result) == SQLITE_ROW else { throw error() };return sqlite3_column_int64(result,0)
}
func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) }
func entities(_ table: String) throws -> [PersonPlace] {
precondition(["singers","venues","playlists"].contains(table))
let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) }
var rows = [PersonPlace]()
while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) }
return rows
}
func addEntity(_ table: String, name: String) throws {
precondition(["singers","venues","playlists"].contains(table))
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { throw LibraryError.message("Enter a name") }
try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name])
}
func record(_ song: Int64, singer: Int64?, venue: Int64?) throws {
try execute("BEGIN IMMEDIATE")
do {
if let singer { try assignSong(song,singer:singer) }
try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)])
try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)])
try execute("COMMIT")
} catch { try? execute("ROLLBACK"); throw error }
}
private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" }
private func schemaVersion() throws -> Int {
let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) }
return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0
}
private func columns(_ table: String) throws -> Set<String> {
let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) }
var result = Set<String>(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result
}
func members(of venue: Int64) throws -> [PersonPlace] {
let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) }
var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result
}
func setMembership(singer: Int64, venue: Int64, included: Bool) throws {
try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)])
if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) }
}
func currentVenue() throws -> Int64? {
let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) }
return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil
}
func selectVenue(_ id: Int64?) throws {
if let id {
guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") }
}
try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)])
}
func rename(_ table: String, id: Int64, name: String) throws {
precondition(["singers","venues","playlists"].contains(table))
let name = name.trimmingCharacters(in:.whitespacesAndNewlines)
guard !name.isEmpty else { throw LibraryError.message("Enter a name") }
try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)])
}
func deleteEntity(_ table: String, id: Int64) throws {
precondition(["singers","venues","playlists"].contains(table))
try execute("BEGIN IMMEDIATE")
do {
if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) }
if table == "venues" {
try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)])
try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)])
}
try execute("DELETE FROM \(table) WHERE id=?",[String(id)])
try execute("COMMIT")
} catch { try? execute("ROLLBACK"); throw error }
}
func preference(song: Int64, singer: Int64) throws -> SongPreference {
let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) }
guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) }
return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2)))
}
func savePreference(song: Int64, singer: Int64, value: SongPreference) throws {
guard value.pitch.isFinite,value.tempo.isFinite,(-12...12).contains(value.pitch),(0.5...1.5).contains(value.tempo),(0...5).contains(value.rating) else { throw LibraryError.message("Invalid singer settings") }
try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)])
}
func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] {
let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) }
var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result
}
func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws {
guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") }
if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") }
try execute("BEGIN IMMEDIATE")
do {
if let singer {
try assignSong(song,singer:singer)
try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)])
}
try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)])
try execute("COMMIT")
} catch { try? execute("ROLLBACK");throw error }
}
func queue(venue: Int64?) throws -> [QueueEntry] {
guard let venue else { return [] }
let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) }
var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result
}
func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) }
func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws {
var rows = try queue(venue:venue)
guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return }
rows.swapAt(index,index+offset)
try execute("BEGIN IMMEDIATE")
do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") }
catch { try? execute("ROLLBACK"); throw error }
}
func addToPlaylist(_ playlist: Int64, song: Int64) throws {
try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)])
}
func playlistSongIDs(_ playlist: Int64) throws -> [Int64] {
let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) }
var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids
}
func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) }
func assignSong(_ song: Int64, singer: Int64) throws {
try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)])
}
func removeSingerSong(_ song: Int64, singer: Int64) throws {
try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)])
}
func singerSongIDs() throws -> [Int64:[Int64]] {
let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) }
var result = [Int64:[Int64]]()
while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) }
return result
}
func defaultPreference(song: Int64) throws -> SongPreference {
let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) }
guard sqlite3_step(s) == SQLITE_ROW else { return .init() }
return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0)
}
func saveDefaultPreference(song: Int64, value: SongPreference) throws {
try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)])
}
func updateDuration(_ song: Int64, seconds: Double) throws {
guard seconds.isFinite && seconds >= 0 else { return }
try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)])
}
func setFavourite(song: Int64, singer: Int64, included: Bool) throws {
if included { try assignSong(song,singer:singer) }
try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)])
}
func favouriteSongIDs() throws -> [Int64:Set<Int64>] {
let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) }
var result = [Int64:Set<Int64>]()
while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result
}
func stores() throws -> [MusicStore] {
let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) }
var result = [MusicStore]()
while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result
}
func saveStore(_ store: MusicStore) throws {
let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines)
guard !name.isEmpty else { throw LibraryError.message("Enter a store name") }
let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search)
_ = try cleaned.destination()
if !search.isEmpty {
guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") }
_ = try cleaned.destination(query:"test")
}
if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) }
else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) }
}
func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) }
func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws {
let title = title.trimmingCharacters(in:.whitespacesAndNewlines)
guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") }
try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)])
guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") }
}
func tonightSongIDs(venue: Int64?) throws -> [Int64:[Int64]] {
guard let venue else { return [:] }
let s = try statement("SELECT singer,song FROM singer_tonight WHERE venue=? ORDER BY position,song",[String(venue)]);defer { sqlite3_finalize(s) }
var result = [Int64:[Int64]]()
while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) };return result
}
func addTonight(song: Int64,singer: Int64,venue: Int64,transaction: Bool = true) throws {
guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not a member of the current venue") }
if transaction { try execute("BEGIN IMMEDIATE") }
do {
try assignSong(song,singer:singer)
try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)])
try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)])
if transaction { try execute("COMMIT") }
} catch { if transaction { try? execute("ROLLBACK") };throw error }
}
func removeTonight(song: Int64,singer: Int64,venue: Int64) throws {
try execute("BEGIN IMMEDIATE")
do {
try execute("DELETE FROM singer_tonight WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)])
try execute("DELETE FROM queue WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)])
try execute("COMMIT")
} catch { try? execute("ROLLBACK");throw error }
}
/// Atomically adds/copies or reorders a request, retaining other singers' queue slots.
func placeTonight(song: Int64,singer: Int64,venue: Int64,before: Int64?,addIfNeeded: Bool) throws {
guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not in the target venue") }
try execute("BEGIN IMMEDIATE")
do {
var ids = try tonightSongIDs(venue:venue)[singer] ?? []
if let before, !ids.contains(before) { throw LibraryError.message("The drop target changed. Please drop again.") }
if !ids.contains(song) && !addIfNeeded { throw LibraryError.message("This Tonight request no longer exists") }
if addIfNeeded {
try assignSong(song,singer:singer)
try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,0)",[String(venue),String(singer),String(song)])
try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)])
}
if before != song {
ids.removeAll { $0 == song }
if let before,let index = ids.firstIndex(of:before) { ids.insert(song,at:index) } else { ids.append(song) }
}
for (index,id) in ids.enumerated() { try execute("UPDATE singer_tonight SET position=? WHERE venue=? AND singer=? AND song=?",[String(index),String(venue),String(singer),String(id)]) }
let all = try queue(venue:venue)
let slots = all.indices.filter { all[$0].singerID == singer }
let rank = Dictionary(uniqueKeysWithValues:ids.enumerated().map { ($0.element,$0.offset) })
let requests = slots.enumerated().map { (original:$0.offset,row:all[$0.element]) }.sorted {
let a = rank[$0.row.songID] ?? Int.max,b = rank[$1.row.songID] ?? Int.max
return a == b ? $0.original < $1.original : a < b
}
var ordered = all
for (slot,request) in zip(slots,requests) { ordered[slot] = request.row }
for (index,row) in ordered.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(index),String(row.id)]) }
try execute("COMMIT")
} catch { try? execute("ROLLBACK");throw error }
}
func mediaLocations() throws -> [MediaLocation] {
let s = try statement("SELECT media_sources.id,name,media_sources.path,bookmark,external,last_scan,last_error,(SELECT COUNT(DISTINCT song) FROM source_songs WHERE source=media_sources.id) FROM media_sources ORDER BY name",[]);defer { sqlite3_finalize(s) }
var result = [MediaLocation]()
while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),path:text(s,2),bookmark:Data(base64Encoded:text(s,3)),external:sqlite3_column_int(s,4) != 0,lastScan:text(s,5),lastError:text(s,6),songCount:Int(sqlite3_column_int(s,7)))) };return result
}
func saveMediaLocation(name: String,url: URL,bookmark: Data?,external: Bool) throws -> Int64 {
try execute("INSERT INTO media_sources(name,path,bookmark,external) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET name=excluded.name,bookmark=COALESCE(excluded.bookmark,media_sources.bookmark),external=excluded.external",[name,url.standardizedFileURL.path,bookmark?.base64EncodedString(),external ? "1" : "0"])
let s = try statement("SELECT id FROM media_sources WHERE path=?",[url.standardizedFileURL.path]);defer { sqlite3_finalize(s) };guard sqlite3_step(s) == SQLITE_ROW else { throw error() };return sqlite3_column_int64(s,0)
}
func removeMediaLocation(_ id: Int64) throws { try execute("DELETE FROM media_sources WHERE id=?",[String(id)]) }
func sourceSnapshot(_ source: Int64) throws -> [String:IndexedSourceFile] {
let s = try statement("SELECT source_files.path,size,modified,song FROM source_files LEFT JOIN source_songs ON source_songs.source=source_files.source AND source_songs.path=source_files.path WHERE source_files.source=?",[String(source)]);defer { sqlite3_finalize(s) }
var result = [String:IndexedSourceFile]()
while sqlite3_step(s) == SQLITE_ROW {
let path = text(s,0)
if result[path] == nil { result[path] = .init(size:sqlite3_column_int64(s,1),modified:sqlite3_column_double(s,2),songs:[]) }
if sqlite3_column_type(s,3) != SQLITE_NULL { result[path]?.songs.append(sqlite3_column_int64(s,3)) }
};return result
}
func recordSourceFile(source: Int64,path: String,size: Int64,modified: Double,songs: [Int64]) throws {
try execute("BEGIN IMMEDIATE")
do {
try execute("INSERT INTO source_files(source,path,size,modified) VALUES(?,?,?,?) ON CONFLICT(source,path) DO UPDATE SET size=excluded.size,modified=excluded.modified",[String(source),path,String(size),String(modified)])
try execute("DELETE FROM source_songs WHERE source=? AND path=?",[String(source),path])
for song in Set(songs) { try execute("INSERT INTO source_songs(source,path,song) VALUES(?,?,?)",[String(source),path,String(song)]) }
try execute("COMMIT")
} catch { try? execute("ROLLBACK");throw error }
}
func finishSourceScan(_ source: Int64,path: String,message: String) throws {
try execute("UPDATE media_sources SET path=?,last_scan=?,last_error=? WHERE id=?",[path,ISO8601DateFormatter().string(from:Date()),message,String(source)])
}
func song(_ id: Int64) throws -> Song? {
let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint FROM songs WHERE id=?",[String(id)]);defer { sqlite3_finalize(s) }
guard sqlite3_step(s) == SQLITE_ROW else { return nil }
return .init(id:sqlite3_column_int64(s,0),title:text(s,1),audio:text(s,2),cdg:sqlite3_column_type(s,3) == SQLITE_NULL ? nil : text(s,3),rating:Int(sqlite3_column_int(s,4)),plays:Int(sqlite3_column_int(s,5)),artist:text(s,6),album:text(s,7),label:text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10))
}
}
extension Library {
func setting(_ key: String) throws -> String? {
let s = try statement("SELECT value FROM settings WHERE key=?",[key]);defer { sqlite3_finalize(s) }
return sqlite3_step(s) == SQLITE_ROW ? text(s,0) : nil
}
func setSetting(_ key: String,_ value: String?) throws {
try execute("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[key,value])
}
func rotation(_ venue: Int64) throws -> [RotationSinger] {
let s = try statement("SELECT singer_rotation.singer,singers.name,singer_rotation.here FROM singer_rotation JOIN singers ON singers.id=singer_rotation.singer WHERE venue=? ORDER BY position,singer_rotation.singer",[String(venue)]);defer { sqlite3_finalize(s) }
var rows = [RotationSinger]()
while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),here:sqlite3_column_int(s,2) != 0)) };return rows
}
func markHere(singer: Int64,venue: Int64,here: Bool) throws {
guard try members(of:venue).contains(where:{$0.id == singer}) else { throw LibraryError.message("Singer is not at this venue") }
try execute("INSERT INTO singer_rotation(venue,singer,position,here) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_rotation WHERE venue=?),?) ON CONFLICT(venue,singer) DO UPDATE SET here=excluded.here,position=CASE WHEN singer_rotation.here=0 AND excluded.here=1 THEN excluded.position ELSE singer_rotation.position END",[String(venue),String(singer),String(venue),here ? "1" : "0"])
}
func moveRotation(singer: Int64,venue: Int64,by offset: Int) throws {
var rows = try rotation(venue);guard let i = rows.firstIndex(where:{$0.id == singer}),rows.indices.contains(i+offset) else { return };rows.swapAt(i,i+offset)
try execute("BEGIN IMMEDIATE")
do { for (i,row) in rows.enumerated() { try execute("UPDATE singer_rotation SET position=? WHERE venue=? AND singer=?",[String(i),String(venue),String(row.id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error }
}
func assignQueue(_ entry: Int64,singer: Int64?,venue: Int64) throws {
guard let row = try queue(venue:venue).first(where:{$0.id == entry}) else { throw LibraryError.message("Queue entry changed") }
if let singer { guard try members(of:venue).contains(where:{$0.id == singer}) else { throw LibraryError.message("Singer is not at this venue") };try assignSong(row.songID,singer:singer) }
try execute("UPDATE queue SET singer=? WHERE id=? AND venue=?",[singer.map(String.init),String(entry),String(venue)])
}
func reorderPlaylist(_ playlist: Int64,song: Int64,by offset: Int) throws {
var ids = try playlistSongIDs(playlist);guard let i = ids.firstIndex(of:song),ids.indices.contains(i+offset) else { return };ids.swapAt(i,i+offset)
try execute("BEGIN IMMEDIATE")
do { for (i,id) in ids.enumerated() { try execute("UPDATE playlist_songs SET position=? WHERE playlist=? AND song=?",[String(i),String(playlist),String(id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error }
}
func batchMetadata(_ ids: Set<Int64>,patch: MetadataPatch) throws {
let updates = try ids.map { id -> Song in guard let song = try song(id) else { throw LibraryError.message("A selected song no longer exists") };return try patch.applying(to:song) }
try execute("BEGIN IMMEDIATE")
do { for song in updates { try updateMetadata(song:song.id,title:song.title,artist:song.artist,album:song.album,label:song.label,rating:song.rating) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error }
}
func screens() throws -> [ShowScreen] {
let s = try statement("SELECT songs.id,songs.title,show_screens.body,show_screens.footer,show_screens.tint,songs.duration FROM show_screens JOIN songs ON songs.id=show_screens.song ORDER BY songs.title COLLATE NOCASE",[]);defer { sqlite3_finalize(s) }
var rows = [ShowScreen]();while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id:sqlite3_column_int64(s,0),title:text(s,1),body:text(s,2),footer:text(s,3),tint:text(s,4),seconds:sqlite3_column_double(s,5))) };return rows
}
@discardableResult func saveScreen(_ screen: ShowScreen) throws -> Int64 {
guard !screen.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty,(3...300).contains(screen.seconds),screen.body.count <= 4000,screen.footer.count <= 500 else { throw LibraryError.message("Give the slide a title and a duration from 3 to 300 seconds") }
try execute("BEGIN IMMEDIATE")
do {
let id: Int64
if screen.id == 0 { id = try add(title:screen.title,audio:"showscreen://"+UUID().uuidString,cdg:nil,duration:screen.seconds) }
else { guard try screens().contains(where:{$0.id == screen.id}) else { throw LibraryError.message("Slide no longer exists") };id = screen.id;try execute("UPDATE songs SET title=?,duration=? WHERE id=?",[screen.title,String(screen.seconds),String(id)]) }
try execute("INSERT INTO show_screens(song,body,footer,tint) VALUES(?,?,?,?) ON CONFLICT(song) DO UPDATE SET body=excluded.body,footer=excluded.footer,tint=excluded.tint",[String(id),screen.body,screen.footer,screen.tint]);try execute("COMMIT");return id
} catch { try? execute("ROLLBACK");throw error }
}
func singerToken(singer: Int64,venue: Int64,renew: Bool = false) throws -> String {
guard try members(of:venue).contains(where:{$0.id == singer}) else { throw LibraryError.message("Singer is not at this venue") }
if !renew {
let s = try statement("SELECT token FROM singer_access WHERE venue=? AND singer=?",[String(venue),String(singer)]);defer { sqlite3_finalize(s) }
if sqlite3_step(s) == SQLITE_ROW { return text(s,0) }
}
let token = UUID().uuidString.replacingOccurrences(of:"-",with:"")+UUID().uuidString.replacingOccurrences(of:"-",with:"")
try execute("INSERT INTO singer_access(venue,singer,token) VALUES(?,?,?) ON CONFLICT(venue,singer) DO UPDATE SET token=excluded.token",[String(venue),String(singer),token]);return token
}
func singerForToken(_ token: String,venue: Int64) throws -> Int64? {
guard token.count == 64 else { return nil }
let s = try statement("SELECT singer FROM singer_access WHERE venue=? AND token=?",[String(venue),token]);defer { sqlite3_finalize(s) }
return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil
}
func revokeSinger(singer: Int64,venue: Int64) throws { try execute("DELETE FROM singer_access WHERE singer=? AND venue=?",[String(singer),String(venue)]) }
}
extension Library {
/// One bulk read replaces per-row database queries during navigation and drawing.
func tuningSnapshot(singer: Int64?) throws -> [Int64:SongPreference] {
let defaults = try statement("SELECT song,pitch,tempo FROM song_settings",[]);defer { sqlite3_finalize(defaults) }
var result = [Int64:SongPreference]()
while sqlite3_step(defaults) == SQLITE_ROW { result[sqlite3_column_int64(defaults,0)] = .init(pitch:sqlite3_column_double(defaults,1),tempo:sqlite3_column_double(defaults,2)) }
if let singer {
let values = try statement("SELECT song,pitch,tempo,rating FROM preferences WHERE singer=?",[String(singer)]);defer { sqlite3_finalize(values) }
while sqlite3_step(values) == SQLITE_ROW { result[sqlite3_column_int64(values,0)] = .init(pitch:sqlite3_column_double(values,1),tempo:sqlite3_column_double(values,2),rating:Int(sqlite3_column_int(values,3))) }
};return result
}
func orderQueueByRoster(venue: Int64) throws {
let roster = try rotation(venue).map(\.id),rows = try queue(venue:venue)
let rank = Dictionary(uniqueKeysWithValues:roster.enumerated().map{($0.element,$0.offset)})
let ordered = rows.enumerated().sorted {
let a = $0.element.singerID.flatMap{rank[$0]} ?? Int.max
let b = $1.element.singerID.flatMap{rank[$0]} ?? Int.max
return a == b ? $0.offset < $1.offset : a < b
}.map(\.element)
try execute("BEGIN IMMEDIATE")
do { for (index,row) in ordered.enumerated() { try execute("UPDATE queue SET position=? WHERE id=? AND venue=?",[String(index),String(row.id),String(venue)]) };try execute("COMMIT") }
catch { try? execute("ROLLBACK");throw error }
}
func placeRotation(singer: Int64,venue: Int64,before: Int64?) throws {
var ids = try rotation(venue).map(\.id)
guard ids.contains(singer),before == nil || ids.contains(before!) else { throw LibraryError.message("Rotation changed; choose the singer again") }
if before == singer { return };ids.removeAll{$0 == singer};if let before,let i = ids.firstIndex(of:before) { ids.insert(singer,at:i) } else { ids.append(singer) }
try execute("BEGIN IMMEDIATE")
do { for (i,id) in ids.enumerated() { try execute("UPDATE singer_rotation SET position=? WHERE venue=? AND singer=?",[String(i),String(venue),String(id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error }
}
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
struct SourceScanProgress: Sendable {
var source: Int64
var files = 0
var indexed = 0
var generated = 0
var cached = 0
var issues = 0
var current = "Starting…"
var messages = [String]()
var cancelled = false
var finished = false
}
/// One utility-priority worker/SQLite connection. Audio stays on the source volume;
/// ZIP members use managed media because temporary extraction cannot be referenced.
enum LibraryScanner {
static func scan(_ source: MediaLocation,database: URL,media: URL,cache: URL,forceWaveforms: Bool = false,cancelled: () -> Bool = { false },progress: (SourceScanProgress) -> Void = { _ in }) throws -> SourceScanProgress {
var root = URL(fileURLWithPath:source.path)
if let bookmark = source.bookmark,!bookmark.isEmpty {
var stale = false
if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { root = resolved }
}
let scoped = root.startAccessingSecurityScopedResource();defer { if scoped { root.stopAccessingSecurityScopedResource() } }
var directory: ObjCBool = false
guard FileManager.default.fileExists(atPath:root.path,isDirectory:&directory),directory.boolValue else { throw LibraryError.message("Drive or folder is unavailable. Reconnect it and scan again.") }
let db = try Library(url:database),importer = Importer(media:media)
let snapshot = try db.sourceSnapshot(source.id)
var songs = Dictionary(uniqueKeysWithValues:try db.songs().map { ($0.id,$0) })
var state = SourceScanProgress(source:source.id),lastReport = Date.distantPast
var seenSongs = Set<Int64>()
var enumerationMessages = [String]()
guard let enumerator = FileManager.default.enumerator(at:root,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey],options:[.skipsHiddenFiles,.skipsPackageDescendants],errorHandler:{ url,error in
if enumerationMessages.count < 20 { enumerationMessages.append(url.lastPathComponent+": "+error.localizedDescription) };return true
}) else { throw LibraryError.message("Cannot read this folder") }
for case let file as URL in enumerator {
if cancelled() { state.cancelled = true;break }
guard ["mp3","wav","m4a","mp4","m4v","mov","zip"].contains(file.pathExtension.lowercased()) else { continue }
let values = try? file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
guard values?.isRegularFile == true,values?.isSymbolicLink != true else { continue }
state.files += 1;state.current = file.lastPathComponent
do {
let attributes = try FileManager.default.attributesOfItem(atPath:file.path)
let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0,modified = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
let ids: [Int64]
if let old = snapshot[file.path],old.size == size,old.modified == modified,!old.songs.isEmpty,old.songs.allSatisfy({ id in songs[id].map { FileManager.default.fileExists(atPath:$0.audio) } ?? false }) {
ids = old.songs
} else {
ids = try importer.run([file],library:db,copyMedia:false,cancelled:cancelled)
try db.recordSourceFile(source:source.id,path:file.path,size:size,modified:modified,songs:ids)
// Refresh after deduplication/relocation, without sharing the UI connection.
for id in ids { if let song = try db.song(id) { songs[id] = song } }
}
seenSongs.formUnion(ids);state.indexed = seenSongs.count
for id in ids {
if cancelled() { throw CancellationError() }
guard let song = songs[id] else { continue }
let audio = URL(fileURLWithPath:song.audio)
if !forceWaveforms,(try? WaveformCache.load(audio:audio,directory:cache)) != nil { state.cached += 1 }
else {
let analysis = try AudioWaveform.analyse(url:audio,cancelled:cancelled)
try WaveformCache.save(analysis,audio:audio,directory:cache);state.generated += 1
}
}
} catch is CancellationError { state.cancelled = true;break }
catch {
state.issues += 1
if state.messages.count < 100 { state.messages.append(file.lastPathComponent+": "+error.localizedDescription) }
}
if Date().timeIntervalSince(lastReport) >= 0.12 { progress(state);lastReport = Date() }
}
state.issues += enumerationMessages.count;state.messages += enumerationMessages
state.finished = true
state.current = state.cancelled ? "Cancelled — completed work kept" : "Scan complete"
if !FileManager.default.fileExists(atPath:root.path) { state.issues += 1;state.messages.append("The source became unavailable during the scan") }
let message = state.cancelled ? "Cancelled; completed work kept" : state.issues > 0 ? "\(state.issues) file/read issues; see scan details" : ""
try db.finishSourceScan(source.id,path:root.path,message:message)
progress(state);return state
}
}
+267
View File
@@ -0,0 +1,267 @@
import SwiftUI
import AVFoundation
struct MetadataPatch {
var title: String?;var artist: String?;var album: String?;var label: String?;var rating: Int?
var replaceField = "title";var find: String?;var replacement = ""
func applying(to original: Song) throws -> Song {
var song = original
if let title { song.title = title };if let artist { song.artist = artist };if let album { song.album = album };if let label { song.label = label };if let rating { song.rating = rating }
if let find,!find.isEmpty {
switch replaceField {
case "title":song.title = song.title.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive)
case "artist":song.artist = song.artist.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive)
case "album":song.album = song.album.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive)
case "label":song.label = song.label.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive)
default:throw LibraryError.message("Unknown metadata field")
}
}
guard !song.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty,(0...5).contains(song.rating) else { throw LibraryError.message("Every song needs a title and a rating from 0 to 5") };return song
}
}
enum SongSearch {
static func matches(_ song: Song,query: String) -> Bool {
if query.isEmpty { return true }
if query.allSatisfy(\.isNumber),let id = Int64(query) { return song.id == id }
var tokens = [String](),current = "",quoted = false
for char in query {
if char == "\"" { quoted.toggle() }
else if !quoted && (char.isWhitespace || char == ",") { if !current.isEmpty { tokens.append(current);current = "" } }
else { current.append(char) }
}
if !current.isEmpty { tokens.append(current) }
return tokens.allSatisfy { token in
let escaped = token.replacingOccurrences(of:"::",with:"\u{1f}")
let parts = escaped.split(separator:":",maxSplits:1,omittingEmptySubsequences:false).map{String($0).replacingOccurrences(of:"\u{1f}",with:":")}
let fields: [String],term: String
if parts.count == 2 {
term = parts[1]
switch parts[0].lowercased() {
case "title","song","s":fields = [song.title]
case "artist","a":fields = [song.artist]
case "album":fields = [song.album]
case "label":fields = [song.label]
case "id":fields = [String(song.id)]
case "rating":fields = [String(song.rating)]
default:fields = [song.title,song.artist,song.album,song.label];return fields.contains{$0.localizedCaseInsensitiveContains(token)}
}
} else { fields = [song.title,song.artist,song.album,song.label];term = parts[0] }
if term.hasPrefix("=") { return fields.contains{$0.compare(String(term.dropFirst()),options:[.caseInsensitive,.diacriticInsensitive]) == .orderedSame} }
return fields.contains{$0.localizedCaseInsensitiveContains(term)}
}
}
}
struct MetadataCandidate: Identifiable, Decodable {
var trackId: Int64;var trackName: String;var artistName: String;var collectionName: String?;var trackViewUrl: String?
var id: Int64 { trackId }
}
actor OnlineMetadata {
static let shared = OnlineMetadata()
private var cache = [String:[MetadataCandidate]]()
private var nextRequest = Date.distantPast
func search(_ text: String) async throws -> [MetadataCandidate] {
let query = text.trimmingCharacters(in:.whitespacesAndNewlines)
guard query.count >= 2,query.count <= 300 else { throw LibraryError.message("Search with 2–300 characters") }
if let cached = cache[query] { return cached }
let slot = max(Date(),nextRequest);nextRequest = slot.addingTimeInterval(3.1)
let delay = slot.timeIntervalSinceNow;if delay > 0 { try await Task.sleep(nanoseconds:UInt64(delay*1_000_000_000)) };try Task.checkCancellation()
var url = URLComponents(string:"https://itunes.apple.com/search")!
url.queryItems = [.init(name:"term",value:query),.init(name:"media",value:"music"),.init(name:"entity",value:"song"),.init(name:"country",value:"GB"),.init(name:"limit",value:"25")]
var request = URLRequest(url:url.url!);request.timeoutInterval = 20;request.setValue("KJMixPro/0.5.0",forHTTPHeaderField:"User-Agent")
let (data,response) = try await URLSession.shared.data(for:request)
guard let response = response as? HTTPURLResponse,response.statusCode == 200,data.count < 2_000_000 else { throw LibraryError.message("Metadata service is unavailable. Please try later.") }
struct Results: Decodable { var results: [MetadataCandidate] }
let result = try JSONDecoder().decode(Results.self,from:data).results
cache[query] = result;return result
}
}
/// Keeps MP3 audio bytes and unrelated supported ID3 frames. Unsupported tag layouts fail safely.
enum FileTagWriter {
static func write(_ song: Song,backupDirectory: URL) async throws {
let original = URL(fileURLWithPath:song.audio),ext = original.pathExtension.lowercased()
guard ["mp3","m4a","mp4","m4v","mov"].contains(ext),!song.audio.hasPrefix("showscreen:") else { throw LibraryError.message("File tag writing supports MP3, M4A and MP4/MOV; other formats keep database metadata") }
try FileManager.default.createDirectory(at:backupDirectory,withIntermediateDirectories:true)
let backup = backupDirectory.appendingPathComponent(UUID().uuidString+"-"+original.lastPathComponent)
try FileManager.default.copyItem(at:original,to:backup)
let temp = original.deletingLastPathComponent().appendingPathComponent(".kjmix-tags-"+UUID().uuidString+"."+ext)
defer { try? FileManager.default.removeItem(at:temp) }
if ext == "mp3" { try await Task.detached(priority:.utility) { try writeMP3(song,original:original,destination:temp) }.value }
else {
let asset = AVURLAsset(url:original)
guard let exporter = AVAssetExportSession(asset:asset,presetName:AVAssetExportPresetPassthrough) else { throw LibraryError.message("This file cannot be tagged without re-encoding") }
let type: AVFileType = ext == "m4a" ? .m4a : ext == "mov" ? .mov : .mp4
guard exporter.supportedFileTypes.contains(type) else { throw LibraryError.message("This container cannot preserve its media during tag export") }
exporter.outputURL = temp;exporter.outputFileType = type
let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher]
var items = asset.metadata.filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))}
for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] {
let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item)
}
exporter.metadata = items
await withCheckedContinuation { continuation in exporter.exportAsynchronously { continuation.resume() } }
guard exporter.status == .completed else { throw exporter.error ?? LibraryError.message("Tag export failed; original preserved") }
}
_ = try FileManager.default.replaceItemAt(original,withItemAt:temp)
}
static func writeMP3(_ song: Song,original: URL,destination: URL) throws {
let input = try FileHandle(forReadingFrom:original);defer { try? input.close() }
let header = try input.read(upToCount:10) ?? Data()
var oldSize = 0,version: UInt8 = 4,frames = Data()
let replaced: Set<String> = ["TIT2","TPE1","TALB","TPUB"]
if header.count == 10,String(decoding:header.prefix(3),as:UTF8.self) == "ID3" {
version = header[3]
guard [3,4].contains(version),header[5] == 0,header[6...9].allSatisfy({$0 < 128}) else { throw LibraryError.message("Unsupported ID3 layout; original preserved") }
oldSize = header[6...9].reduce(0){($0<<7)|Int($1)}
guard oldSize <= 16_777_216 else { throw LibraryError.message("ID3 tag is too large") }
let tag = try input.read(upToCount:oldSize) ?? Data();guard tag.count == oldSize else { throw LibraryError.message("Truncated ID3 tag") }
var i = 0
while i+10 <= tag.count,tag[i] != 0 {
let name = String(decoding:tag[i..<i+4],as:UTF8.self)
guard name.count == 4,name.allSatisfy({$0.isASCII && ($0.isUppercase || $0.isNumber)}) else { throw LibraryError.message("Malformed ID3 frame") }
let sizeBytes = tag[i+4..<i+8]
if version == 4 && !sizeBytes.allSatisfy({$0 < 128}) { throw LibraryError.message("Malformed ID3 frame size") }
let size = sizeBytes.reduce(0){($0 << (version == 4 ? 7 : 8))|Int($1)}
guard size >= 0,i+10+size <= tag.count else { throw LibraryError.message("Truncated ID3 frame") }
if !replaced.contains(name) { frames.append(tag[i..<i+10+size]) };i += 10+size
}
}
func sizeData(_ value: Int,sync: Bool) -> Data { let shift = sync ? 7 : 8,mask = sync ? 127 : 255;return Data((0..<4).reversed().map{UInt8((value >> ($0*shift)) & mask)}) }
for (name,text) in [("TIT2",song.title),("TPE1",song.artist),("TALB",song.album),("TPUB",song.label)] {
let payload: Data = version == 4 ? Data([3])+Data(text.utf8) : Data([1])+((text.data(using:.utf16)) ?? Data())
frames.append(Data(name.utf8));frames.append(sizeData(payload.count,sync:version == 4));frames.append(Data([0,0]));frames.append(payload)
}
guard FileManager.default.createFile(atPath:destination.path,contents:nil) else { throw LibraryError.message("Cannot create tagged file") }
let output = try FileHandle(forWritingTo:destination);defer { try? output.close() }
try output.write(contentsOf:Data("ID3".utf8)+Data([version,0,0])+sizeData(frames.count,sync:true)+frames)
try input.seek(toOffset:UInt64(oldSize > 0 || (header.count >= 3 && String(decoding:header.prefix(3),as:UTF8.self) == "ID3") ? oldSize+10 : 0))
while let data = try input.read(upToCount:1_048_576),!data.isEmpty { try Task.checkCancellation();try output.write(contentsOf:data) }
}
}
struct BatchMetadataSheet: View {
@ObservedObject var model: AppModel
let ids: Set<Int64>
@State private var patch = MetadataPatch()
@State private var fields = Set<String>()
@State private var title = ""
@State private var artist = ""
@State private var album = ""
@State private var label = ""
@State private var find = ""
@State private var replacement = ""
@State private var field = "title"
@State private var rating = 0
@State private var error: String?
@Environment(\.dismiss) private var dismiss
private var effective: MetadataPatch { .init(title:fields.contains("title") ? title : nil,artist:fields.contains("artist") ? artist : nil,album:fields.contains("album") ? album : nil,label:fields.contains("label") ? label : nil,rating:fields.contains("rating") ? rating : nil,replaceField:field,find:find.isEmpty ? nil : find,replacement:replacement) }
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text("Edit \(ids.count) Songs").font(.title2.bold())
Text("Only checked fields change. Unchecked fields and singer settings remain unchanged. Changes update your library; use Write File Tags separately to update media files.").font(.callout).foregroundStyle(.secondary)
fieldRow("Title",key:"title",text:$title);fieldRow("Artist",key:"artist",text:$artist);fieldRow("Album",key:"album",text:$album);fieldRow("Label",key:"label",text:$label)
HStack { Toggle("Rating",isOn:binding("rating")).frame(width:95);Picker("Rating",selection:$rating) { ForEach(0...5,id:\.self) { Text("\($0) stars").tag($0) } }.labelsHidden().disabled(!fields.contains("rating")) }
Divider()
HStack { Text("Find & replace");Picker("Field",selection:$field) { Text("Title").tag("title");Text("Artist").tag("artist");Text("Album").tag("album");Text("Label").tag("label") }.labelsHidden() }
HStack { TextField("Find (case insensitive)",text:$find);TextField("Replace with",text:$replacement) }
VStack(alignment:.leading,spacing:5) {
Text("Preview").font(.headline)
ForEach(model.songs.filter{ids.contains($0.id)}.prefix(4)) { song in
let next = try? effective.applying(to:song)
Text(next.map{"\(song.title) → \($0.title) · \($0.artist) · \($0.album)"} ?? "Invalid result: a title cannot be empty").font(.caption).lineLimit(2)
}
}.padding(12).frame(maxWidth:.infinity,alignment:.leading).background(Color.primary.opacity(0.05),in:RoundedRectangle(cornerRadius:8))
if let error { Text(error).foregroundStyle(.red) }
HStack { Button("Cancel") { dismiss() };Spacer();Button("Apply to \(ids.count) Songs") { do { try model.batchMetadata(ids,patch:effective);dismiss() } catch { self.error = error.localizedDescription } }.keyboardShortcut(.defaultAction).disabled(fields.isEmpty && find.isEmpty) }
}.padding(24).frame(width:590).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder)
}
private func binding(_ key: String) -> Binding<Bool> { Binding(get:{fields.contains(key)},set:{if $0 { fields.insert(key) } else { fields.remove(key) }}) }
private func fieldRow(_ name: String,key: String,text: Binding<String>) -> some View { HStack { Toggle(name,isOn:binding(key)).frame(width:95,alignment:.leading);TextField(name,text:text).disabled(!fields.contains(key)) } }
}
struct MetadataLookupSheet: View {
@ObservedObject var model: AppModel
let song: Song
@State private var query = ""
@State private var candidates = [MetadataCandidate]()
@State private var busy = false
@State private var error: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text("Find Recording Metadata").font(.title2.bold())
Text("Search Apple’s public catalogue for title, artist and album suggestions. Review the recording carefully: karaoke versions may differ. This does not purchase music or provide karaoke audio.").foregroundStyle(.secondary)
HStack { TextField("Title and artist",text:$query).textFieldStyle(.roundedBorder).onSubmit(search);Button(busy ? "Searching…" : "Search",action:search).disabled(busy) }
if let error { Text(error).foregroundStyle(.red) }
List(candidates) { item in
HStack { VStack(alignment:.leading,spacing:4) { Text(item.trackName).fontWeight(.medium);Text(item.artistName+" · "+(item.collectionName ?? "")).font(.caption).foregroundStyle(.secondary) };Spacer()
if let value = item.trackViewUrl,let url = URL(string:value),url.scheme == "https" { Link("View",destination:url) }
Button("Use These Tags") {
do { try model.batchMetadata([song.id],patch:.init(title:item.trackName,artist:item.artistName,album:item.collectionName));dismiss() } catch { self.error = error.localizedDescription }
}
}.padding(.vertical,5)
}.frame(height:300)
HStack { Text("Suggestions update library metadata only.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } }
}.padding(24).frame(width:650).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { query = song.title+" "+song.artist }
}
private func search() { guard !busy else { return };busy = true;error = nil;Task { do { candidates = try await OnlineMetadata.shared.search(query) } catch { self.error = error.localizedDescription };busy = false } }
}
struct StoreCatalogueItem: Identifiable, Codable, Hashable {
var id: String
var title: String
var artist: String
var album: String?
var format: String?
var price: String?
var url: String?
var previewURL: String?
func destination(preview: Bool = false) throws -> URL {
guard let value = preview ? previewURL : url,let url = URL(string:value),["http","https"].contains(url.scheme?.lowercased() ?? ""),url.host != nil,url.user == nil,url.password == nil else { throw LibraryError.message("This catalogue entry has no valid website link") };return url
}
static func decode(_ data: Data) throws -> [Self] {
guard data.count <= 10_000_000 else { throw LibraryError.message("Catalogue files are limited to 10 MB") }
let items = try JSONDecoder().decode([Self].self,from:data)
guard items.count <= 25000,Set(items.map(\.id)).count == items.count,items.allSatisfy({!$0.id.isEmpty && !$0.title.isEmpty && $0.title.count <= 1000}) else { throw LibraryError.message("Use unique catalogue IDs and nonempty song titles; maximum 25,000 entries") }
for item in items { if item.url != nil { _ = try item.destination() };if item.previewURL != nil { _ = try item.destination(preview:true) } };return items
}
}
@MainActor extension AppModel {
func installStoreProfiles() {
perform {
let profiles: [(String,String)] = [("Party Tyme Downloads","https://www.partytyme.net/songshop/cat/"),("Party Tyme Pro Streaming","https://player.partytymeplayer.com/"),("KaraokeCloud","https://karaokecloud.com/"),("KaraokeCloud Pro","https://karaokecloud.com/become-a-karaoke-jockey.html")]
for (name,url) in profiles where !musicStores.contains(where:{$0.name.caseInsensitiveCompare(name) == .orderedSame}) { try library?.saveStore(.init(id:0,name:name,url:url,searchTemplate:"")) };try refresh()
}
}
}
struct StoreCatalogueSheet: View {
@ObservedObject var model: AppModel
let store: MusicStore
@State private var items = [StoreCatalogueItem]()
@State private var query = ""
@State private var sort = "Title"
@State private var message = ""
@Environment(\.dismiss) private var dismiss
private var rows: [StoreCatalogueItem] { items.filter{query.isEmpty || ($0.title+" "+$0.artist+" "+($0.album ?? "")).localizedCaseInsensitiveContains(query)}.sorted{let a = sort == "Artist" ? $0.artist : sort == "Album" ? ($0.album ?? "") : $0.title,b = sort == "Artist" ? $1.artist : sort == "Album" ? ($1.album ?? "") : $1.title;return a.localizedStandardCompare(b) == .orderedAscending} }
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text(store.name+" · Catalogue").font(.title2.bold())
Text("Import a JSON catalogue supplied by your provider or prepared by you. Website and preview links open in your browser. Direct purchasing, subscription playback, encrypted caching and purchase-history sync are not connected.").foregroundStyle(.secondary)
HStack { TextField("Search catalogue",text:$query).textFieldStyle(.roundedBorder);Picker("Sort",selection:$sort) { Text("Title").tag("Title");Text("Artist").tag("Artist");Text("Album").tag("Album") }.frame(width:170);Button("Import / Reload…",action:importCatalogue) }
List(rows) { item in HStack { VStack(alignment:.leading,spacing:4) { Text(item.title);Text(item.artist+" · "+(item.album ?? "")).font(.caption).foregroundStyle(.secondary) };Spacer();Text(item.format ?? "").font(.caption);Text(item.price ?? "").font(.caption)
if item.previewURL != nil { Button("Preview Website") { open(item,preview:true) } };if item.url != nil { Button("View at Store") { open(item) } }
} }.frame(height:310)
HStack { Text(message.isEmpty ? "\(items.count) catalogue entries saved locally" : message).font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } }
}.padding(24).frame(width:760).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } }
}
private func importCatalogue() {
let panel = NSOpenPanel();panel.allowedContentTypes = [.json];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false
guard panel.runModal() == .OK,let url = panel.url else { return }
do {
let scope = url.startAccessingSecurityScopedResource();defer { if scope { url.stopAccessingSecurityScopedResource() } }
let size = try url.resourceValues(forKeys:[.fileSizeKey]).fileSize ?? 0;guard size <= 10_000_000 else { throw LibraryError.message("Catalogue exceeds 10 MB") }
let data = try Data(contentsOf:url),result = try StoreCatalogueItem.decode(data)
try model.library?.setSetting("store.catalogue.\(store.id)",String(decoding:data,as:UTF8.self));items = result;message = "Catalogue reloaded · \(result.count) entries"
} catch { message = error.localizedDescription }
}
private func open(_ item: StoreCatalogueItem,preview: Bool = false) { do { NSWorkspace.shared.open(try item.destination(preview:preview)) } catch { message = error.localizedDescription } }
}
+458
View File
@@ -0,0 +1,458 @@
import SwiftUI
import AVFoundation
import CryptoKit
import CoreImage
/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing.
@MainActor final class Player: ObservableObject {
@Published var image: CGImage?
var onWillStartPlayback: (() -> Double)?
var onPlaybackReady: (() -> Void)?
private var pendingPlayback: Task<Void,Never>?
@Published private(set) var waitingForBackground = false
var audioPlaybackStarted: Bool { node.isPlaying }
var onGlobalMixChanged: ((GlobalMix) -> Void)?
var jogSecondsPerTurn: Double = 2
var nudgeStep: Double = 0.05
@Published private(set) var cuePosition: Double = 0
@Published private(set) var hotCues: [Double?] = [nil,nil,nil,nil]
@Published private(set) var loopIn: Double?
@Published private(set) var loopOut: Double?
@Published var loopEnabled = false
var isShowScreen: Bool { slideImage != nil }
var canJog: Bool { currentSongID != nil && duration > 0 }
func stopDeck() {
guard canJog else { return }
pauseDeck();loopEnabled = false;seek(0)
}
func setCue() { guard canJog else { return };cuePosition = time }
func returnToCue() { guard canJog else { return };pauseDeck();seek(cuePosition) }
private func pauseDeck() {
cancelPendingPlayback()
if slideImage != nil,playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart);slideAnchor = time }
else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,anchor+Double(current.sampleTime)/file.processingFormat.sampleRate)) }
node.pause();playing = false;timer?.invalidate();timer = nil
}
func hotCue(_ index: Int,clear: Bool = false) {
guard hotCues.indices.contains(index),canJog else { return }
if clear { hotCues[index] = nil }
else if let value = hotCues[index] { seek(value) }
else { hotCues[index] = time }
}
func markLoopIn() { guard canJog else { return };loopIn = time;loopOut = nil;loopEnabled = false }
func markLoopOut() {
guard let loopIn,time-loopIn >= 0.1 else { return };loopOut = time;loopEnabled = true
}
func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil }
func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) }
private var graphicsGeneration = UUID()
private var graphicsTask: Task<Void,Never>?
private var checkpointTask: Task<Void,Never>?
private var checkpoints = [Int:CDGDecoder]()
private var lastGraphicsPacket = -1
@Published private(set) var outputOpacity: Double = 1
@Published private(set) var fading = false
var fadeSeconds: Double = 3
var autoFade = true
private var fadeTask: Task<Void,Never>?
private var slideImage: CGImage?
private var slideStart = ProcessInfo.processInfo.systemUptime
private var slideAnchor: Double = 0
func showScreen(_ image: CGImage,seconds: Double,title: String,songID: Int64) {
stop();slideImage = image;self.image = image;duration = max(3,seconds);self.title = title;currentSongID = songID
slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer()
}
func fadeOutAndStop() {
guard duration > 0,!fading else { return };fading = true
let token = generation,start = ProcessInfo.processInfo.systemUptime,seconds = max(0.05,fadeSeconds),initial = outputOpacity
fadeTask = Task { [weak self] in
while !Task.isCancelled {
guard let self,self.generation == token else { return }
let fraction = min(1,(ProcessInfo.processInfo.systemUptime-start)/seconds)
self.setFade(initial*(1-fraction))
if fraction >= 1 { self.stop();return }
try? await Task.sleep(nanoseconds:16_666_667)
}
}
}
private func setFade(_ value: Double) { let next = min(1,max(0,value));if abs(outputOpacity-next) > 0.00001 { outputOpacity = next;node.volume = Float(next) } }
private var videoPlayer: AVPlayer?
private var videoOutput: AVPlayerItemVideoOutput?
private let videoContext = CIContext()
private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") }
private var restoringMix = true
struct GlobalMix: Codable, Equatable {
var volume: Double; var low: Double; var mid: Double; var high: Double; var filter: Double; var enabled: Bool
}
var globalMix: GlobalMix { .init(volume:volume,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) }
private func saveMix() {
guard !restoringMix, let data = try? JSONEncoder().encode(globalMix) else { return }
try? FileManager.default.createDirectory(at:mixURL.deletingLastPathComponent(),withIntermediateDirectories:true)
try? data.write(to:mixURL,options:.atomic)
onGlobalMixChanged?(globalMix)
}
@Published private(set) var waveform = [Float]()
@Published private(set) var waveformBands = [WaveformBands]()
@Published var lowGain: Double = 0 { didSet { updateTone() } }
@Published var midGain: Double = 0 { didSet { updateTone() } }
@Published var highGain: Double = 0 { didSet { updateTone() } }
@Published var filter: Double = 0 { didSet { updateTone() } }
@Published var toneEnabled = true { didSet { updateTone() } }
@Published private(set) var leftLevel: Float = 0
@Published private(set) var rightLevel: Float = 0
private let tone = AVAudioUnitEQ(numberOfBands:4)
private let meter = AudioLevelMeter()
private var metering = false
@Published private(set) var waveformLoading = false
@Published private(set) var waveformUnavailable = false
@Published private(set) var waveformFromCache = false
@Published private(set) var waveformSaved = false
private let waveformCacheDirectory: URL
private var audioURL: URL?
private var waveformTask: Task<Void, Never>?
private var waveformGeneration = UUID()
@Published var playing = false
@Published var time: Double = 0
@Published var duration: Double = 0
@Published var title = "Select a song"
@Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume); saveMix() } }
@Published var repeating = false
@Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } }
@Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } }
@Published var graphicsOffset: Double = 0 { didSet { if graphicsOffset != oldValue { invalidateGraphics();render() } } }
private(set) var currentSongID: Int64?
var onFinished: (() -> Void)?
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
private let effect = AVAudioUnitTimePitch()
private var file: AVAudioFile?
private var anchor: Double = 0
private var generation = UUID()
private var cdg = Data()
private var decoder = CDGDecoder()
private var timer: Timer?
init(waveformCacheDirectory: URL? = nil) {
self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms")
engine.attach(node);engine.attach(effect);engine.attach(tone)
if let data = try? Data(contentsOf:mixURL),let mix = try? JSONDecoder().decode(GlobalMix.self,from:data) {
volume = min(1,max(0,mix.volume));lowGain = min(12,max(-12,mix.low));midGain = min(12,max(-12,mix.mid));highGain = min(12,max(-12,mix.high));filter = min(1,max(-1,mix.filter));toneEnabled = mix.enabled
}
restoringMix = false;updateTone()
}
func reanalyseWaveform() { if let audioURL { analyseWaveform(audioURL,force:true) } }
func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true }
private func updateTone() {
AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled)
saveMix()
}
func load(_ song: Song, preference: SongPreference = .init()) throws {
stop()
do {
let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio))
let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data()
guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") }
engine.connect(node,to:effect,format:loaded.processingFormat)
engine.connect(effect,to:tone,format:loaded.processingFormat)
engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat)
file = loaded; cdg = graphics; decoder = CDGDecoder();prepareGraphicsCheckpoints()
duration = Double(loaded.length)/loaded.processingFormat.sampleRate
title = song.title; currentSongID = song.id
pitch = preference.pitch; tempo = preference.tempo
updateTone()
let meter = self.meter
engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) }
metering = true
engine.mainMixerNode.outputVolume = Float(volume)
engine.prepare(); try engine.start()
schedule(at:0,play:true); startTimer(); tick()
if ["mp4","m4v","mov"].contains(URL(fileURLWithPath:song.audio).pathExtension.lowercased()) {
let item = AVPlayerItem(url:URL(fileURLWithPath:song.audio))
let output = AVPlayerItemVideoOutput(pixelBufferAttributes:[kCVPixelBufferPixelFormatTypeKey as String:kCVPixelFormatType_32BGRA])
item.add(output);videoOutput = output;videoPlayer = AVPlayer(playerItem:item);videoPlayer?.isMuted = true
}
audioURL = URL(fileURLWithPath:song.audio)
analyseWaveform(audioURL!)
} catch { stop(); throw error }
}
private func analyseWaveform(_ url: URL, force: Bool = false) {
waveformTask?.cancel()
let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false
let directory = waveformCacheDirectory
waveformTask = Task.detached(priority:.utility) { [weak self] in
let result: Result<AudioWaveform.Analysis,Error>
var cached = false,saved = false
do {
if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) {
result = .success(analysis);cached = true;saved = true
} else {
let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled })
if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil }
result = .success(analysis)
}
} catch { result = .failure(error) }
guard !Task.isCancelled else { return }
let loadedFromCache = cached,analysisSaved = saved
await MainActor.run { [weak self] in
guard let self,self.waveformGeneration == token else { return }
self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved
switch result {
case .success(let analysis):self.waveform = analysis.levels;self.waveformBands = analysis.bands
case .failure:self.waveformUnavailable = true
}
}
}
}
private func cancelPendingPlayback() { pendingPlayback?.cancel();pendingPlayback = nil;waitingForBackground = false }
private func startNodePlayback() {
cancelPendingPlayback()
let delay = onWillStartPlayback?() ?? 0
guard delay.isFinite,delay > 0 else { onPlaybackReady?();node.play();return }
waitingForBackground = true;let token = generation
pendingPlayback = Task { @MainActor [weak self] in
do { try await Task.sleep(nanoseconds:UInt64(min(15,delay)*1_000_000_000)+20_000_000) } catch { return }
guard let self,self.generation == token,self.playing else { return }
self.waitingForBackground = false;self.pendingPlayback = nil
self.onPlaybackReady?();self.node.play()
}
}
private func schedule(at seconds: Double, play: Bool) {
guard let file else { return }
cancelPendingPlayback();generation = UUID(); let token = generation
node.stop();if !fading { setFade(1) }
let rate = file.processingFormat.sampleRate
let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate)))
anchor = Double(start)/rate; time = anchor
guard start < file.length else { playing = false; render(); return }
node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in
Task { @MainActor in
guard let self, self.generation == token else { return }
if self.fading { return }
if self.repeating { self.schedule(at:0,play:true) }
else {
self.playing = false; self.time = self.duration; self.render()
self.timer?.invalidate(); self.timer = nil; self.onFinished?()
}
}
}
if play { startNodePlayback() }; playing = play; render()
}
private func startTimer() {
timer?.invalidate()
timer = Timer(timeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } }
if let timer { RunLoop.main.add(timer,forMode:.common) }
}
func toggle() {
if slideImage != nil {
if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return
}
guard file != nil else { return }
if playing { cancelPendingPlayback();tick(); node.pause(); playing = false }
else if time >= duration { schedule(at:0,play:true); startTimer() }
else { startNodePlayback(); playing = true; startTimer() }
}
func stop() {
cancelPendingPlayback()
graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;checkpointTask?.cancel();checkpointTask = nil;checkpoints = [:];lastGraphicsPacket = -1
cuePosition = 0;hotCues = [nil,nil,nil,nil];clearLoop()
fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1);slideImage = nil
videoPlayer?.pause();videoPlayer = nil;videoOutput = nil
waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID()
waveform = [];waveformBands = [];waveformLoading = false;waveformUnavailable = false;waveformFromCache = false;waveformSaved = false;audioURL = nil
generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop()
if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false }
meter.reset(); leftLevel = 0; rightLevel = 0
engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone)
file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song"
}
func seek(_ value: Double) {
guard value.isFinite else { return }
invalidateGraphics()
if loopEnabled,let loopIn,let loopOut,(value < loopIn || value > loopOut) { loopEnabled = false }
if fading { fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1) }
if slideImage != nil,value.isFinite { time = min(duration,max(0,value));slideAnchor = time;slideStart = ProcessInfo.processInfo.systemUptime;return }
guard file != nil, value.isFinite else { return }
schedule(at:min(max(value,0),duration),play:playing); startTimer()
}
private func render() {
if let slideImage { image = slideImage;return }
if let videoPlayer, let videoOutput {
let target = max(0,time+graphicsOffset)
if abs(videoPlayer.currentTime().seconds-target) > 0.08 {
videoPlayer.seek(to:CMTime(seconds:target,preferredTimescale:600),toleranceBefore:.zero,toleranceAfter:.zero)
}
if playing { videoPlayer.rate = Float(tempo) } else { videoPlayer.pause() }
if let pixels = videoOutput.copyPixelBuffer(forItemTime:videoPlayer.currentTime(),itemTimeForDisplay:nil) {
let frame = CIImage(cvPixelBuffer:pixels);image = videoContext.createCGImage(frame,from:frame.extent)
}
} else {
guard !cdg.isEmpty else { if image != nil { image = nil };return }
let seconds = max(0,time+graphicsOffset),target = min(cdg.count/24,Int(seconds*300))
guard target != lastGraphicsPacket || image == nil else { return }
guard graphicsTask == nil else { return }
if target < decoder.packetIndex || target-decoder.packetIndex > 900 {
var start = decoder
if target < decoder.packetIndex { start = checkpoints.keys.filter{$0 <= target}.max().flatMap{checkpoints[$0]} ?? CDGDecoder() }
else if let key = checkpoints.keys.filter({$0 <= target && $0 > decoder.packetIndex}).max(),let saved = checkpoints[key] { start = saved }
let initial = start,data = cdg,token = graphicsGeneration
graphicsTask = Task.detached(priority:.userInitiated) { [weak self] in
var decoded = initial;decoded.render(data,at:seconds,cancelled:{Task.isCancelled})
guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded
await MainActor.run {
guard let self,self.graphicsGeneration == token else { return }
self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil
}
}
} else { decoder.render(cdg,at:seconds);lastGraphicsPacket = target;image = decoder.image() }
}
}
private func tick() {
if slideImage != nil {
if playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart) }
if playing,time >= duration,!fading {
if repeating { time = 0;slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime }
else { playing = false;timer?.invalidate();timer = nil;onFinished?() }
};return
}
let levels = meter.snapshot()
let left = playing ? levels.0 : 0,right = playing ? levels.1 : 0
if leftLevel != left { leftLevel = left };if rightLevel != right { rightLevel = right }
if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) {
let next = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate))
if abs(time-next) > 0.0001 { time = next }
if loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return }
}
if !fading { setFade(autoFade && fadeSeconds > 0 && duration > 0 ? min(1,max(0,(duration-time)/max(0.5,tempo)/fadeSeconds)) : 1) }
render()
}
private func invalidateGraphics() { graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;lastGraphicsPacket = -1 }
private func prepareGraphicsCheckpoints() {
guard !cdg.isEmpty else { return }
let data = cdg,interval = max(3000,(cdg.count/24)/180)
checkpointTask = Task.detached(priority:.utility) { [weak self] in
var decoder = CDGDecoder(),saved = [Int:CDGDecoder]()
for packet in stride(from:0,through:data.count/24,by:interval) {
guard !Task.isCancelled else { return };decoder.render(data,at:Double(packet)/300,cancelled:{Task.isCancelled});saved[decoder.packetIndex] = decoder
}
guard !Task.isCancelled else { return };let completed = saved
await MainActor.run { [weak self] in guard let self,self.cdg == data else { return };self.checkpoints = completed }
}
}
}
/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback.
/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position.
enum AudioWaveform {
struct Analysis: Codable, Sendable { var levels: [Float]; var bands: [WaveformBands] }
static func read(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> [Float] {
try analyse(url:url,bins:bins,cancelled:cancelled).levels
}
static func analyse(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> Analysis {
guard bins > 0 else { return .init(levels:[],bands:[]) }
let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false)
guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) }
var energy = [Double](repeating: 0, count: bins)
var counts = [Int](repeating: 0, count: bins)
var position: Int64 = 0
let channels = Int(file.processingFormat.channelCount)
let rate = file.processingFormat.sampleRate
let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate)
var lowState = [Double](repeating:0,count:channels), highState = lowState
var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows
while position < file.length {
if cancelled() { throw CancellationError() }
try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position)))
let frames = Int(buffer.frameLength)
guard frames > 0, let data = buffer.floatChannelData else { break }
for frame in 0..<frames {
let bin = min(bins-1, Int((position+Int64(frame))*Int64(bins)/file.length))
var sum: Double = 0
for channel in 0..<channels {
let sample = Double(data[channel][frame])
if sample.isFinite {
sum += sample * sample
lowState[channel] = lowAlpha*lowState[channel]+(1-lowAlpha)*sample
highState[channel] = highAlpha*highState[channel]+(1-highAlpha)*sample
let low = lowState[channel], mid = highState[channel]-low, high = sample-highState[channel]
lows[bin] += low*low; mids[bin] += mid*mid; highs[bin] += high*high
}
}
energy[bin] += sum / Double(max(channels,1)); counts[bin] += 1
}
position += Int64(frames)
}
let rms = zip(energy, counts).map { $1 > 0 ? sqrt($0/Double($1)) : 0 }
let peak = rms.max() ?? 0
let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins)
let bands = (0..<bins).map { i in WaveformBands(low:Float(sqrt(lows[i])),mid:Float(sqrt(mids[i])),high:Float(sqrt(highs[i]))) }
return .init(levels:levels,bands:bands)
}
}
struct WaveformBands: Codable, Sendable {
var low: Float
var mid: Float
var high: Float
}
/// Four real audio filters; the filter knob is low-pass left, bypass at centre, high-pass right.
enum AudioTone {
static func configure(_ eq: AVAudioUnitEQ, sampleRate: Double, low: Double, mid: Double, high: Double, filter: Double, enabled: Bool) {
eq.bypass = !enabled
let frequencies = [160.0,1000.0,6000.0]
let types: [AVAudioUnitEQFilterType] = [.lowShelf,.parametric,.highShelf]
for i in 0..<3 {
let band = eq.bands[i];band.filterType = types[i];band.frequency = Float(min(frequencies[i],sampleRate*0.4))
band.bandwidth = 1;band.gain = Float(min(12,max(-12,[low,mid,high][i])));band.bypass = false
}
let value = min(1,max(-1,filter))
let band = eq.bands[3];band.bypass = abs(value) < 0.01;band.bandwidth = 0.7
band.filterType = value < 0 ? .lowPass : .highPass
let frequency = value < 0 ? 20000*pow(0.004,-value) : 20*pow(400,value)
band.frequency = Float(min(sampleRate*0.45,max(20,frequency)))
}
}
/// The audio callback only updates a tiny locked snapshot; UI reads it on the existing clock.
final class AudioLevelMeter: @unchecked Sendable {
private let lock = NSLock()
private var levels: (Float,Float) = (0,0)
func consume(_ buffer: AVAudioPCMBuffer) {
guard let data = buffer.floatChannelData else { return }
let channels = Int(buffer.format.channelCount), frames = Int(buffer.frameLength)
var left: Float = 0, right: Float = 0
for i in 0..<frames {
left = max(left,abs(data[0][i]));right = max(right,abs(data[min(1,channels-1)][i]))
}
lock.lock();levels = (left,right);lock.unlock()
}
func snapshot() -> (Float,Float) { lock.lock();defer { lock.unlock() };return levels }
func reset() { lock.lock();levels = (0,0);lock.unlock() }
}
/// Persistent analysis cache: file path, size, modification time and analyser version form its key.
/// Invalid or obsolete entries are treated as misses, never as playback errors.
enum WaveformCache {
static func location(audio: URL,directory: URL) throws -> URL {
let values = try FileManager.default.attributesOfItem(atPath:audio.path)
let stamp = (values[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
let size = (values[.size] as? NSNumber)?.int64Value ?? 0
let signature = "rgb-rms-v2-4096|\(audio.standardizedFileURL.path)|\(size)|\(stamp)"
let key = SHA256.hash(data:Data(signature.utf8)).map { String(format:"%02x",$0) }.joined()
return directory.appendingPathComponent(key+".waveform")
}
static func load(audio: URL,directory: URL) throws -> AudioWaveform.Analysis {
let url = try location(audio:audio,directory:directory)
let data = try Data(contentsOf:url)
guard data.count < 4_000_000 else { throw LibraryError.message("Invalid waveform cache") }
let analysis = try PropertyListDecoder().decode(AudioWaveform.Analysis.self,from:data)
guard analysis.levels.count == 4096,analysis.bands.count == 4096,
analysis.levels.allSatisfy({ $0.isFinite && $0 >= 0 && $0 <= 1 }),
analysis.bands.allSatisfy({ $0.low.isFinite && $0.mid.isFinite && $0.high.isFinite && $0.low >= 0 && $0.mid >= 0 && $0.high >= 0 }) else { throw LibraryError.message("Invalid waveform cache") }
return analysis
}
static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws {
try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true)
let encoder = PropertyListEncoder();encoder.outputFormat = .binary
try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic)
}
}
+211
View File
@@ -0,0 +1,211 @@
import Foundation
import Network
import SwiftUI
import AppKit
import Darwin
struct SingerHTTPRequest: Sendable {
var method: String;var target: String;var headers: [String:String];var body: Data
}
struct SingerHTTPResponse: Sendable {
var status: Int = 200;var type = "application/json; charset=utf-8";var body: Data
static func json(_ value: Any,status: Int = 200) -> Self { .init(status:status,body:(try? JSONSerialization.data(withJSONObject:value,options:[.sortedKeys])) ?? Data("{}".utf8)) }
var wire: Data {
let reason = [200:"OK",400:"Bad Request",401:"Unauthorized",403:"Forbidden",404:"Not Found",405:"Method Not Allowed",413:"Payload Too Large",500:"Internal Server Error",503:"Service Unavailable"][status] ?? "Error"
let header = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'\r\n\r\n"
return Data(header.utf8)+body
}
}
enum SingerHTTPParser {
static func parse(_ data: Data) throws -> SingerHTTPRequest? {
guard data.count <= 32768 else { throw LibraryError.message("Request too large") }
guard let boundary = data.range(of:Data([13,10,13,10])) else { if data.count > 8192 { throw LibraryError.message("Headers too large") };return nil }
guard boundary.lowerBound <= 8192,let header = String(data:data[..<boundary.lowerBound],encoding:.utf8) else { throw LibraryError.message("Invalid HTTP headers") }
let lines = header.components(separatedBy:"\r\n"),start = lines[0].split(separator:" ").map(String.init)
guard start.count == 3,["HTTP/1.1","HTTP/1.0"].contains(start[2]),start[1].hasPrefix("/"),!start[1].hasPrefix("//"),start[1].count <= 2048 else { throw LibraryError.message("Invalid HTTP request") }
var headers = [String:String]()
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of:":"),!line.hasPrefix(" "),!line.hasPrefix("\t") else { throw LibraryError.message("Invalid HTTP header") }
let name = line[..<colon].lowercased(),value = line[line.index(after:colon)...].trimmingCharacters(in:.whitespaces)
guard !name.isEmpty,headers[name] == nil else { throw LibraryError.message("Duplicate HTTP header") };headers[name] = value
}
guard headers["transfer-encoding"] == nil else { throw LibraryError.message("Chunked requests are unsupported") }
let length: Int
if let text = headers["content-length"] { guard let n = Int(text),n >= 0,n <= 16384 else { throw LibraryError.message("Invalid body size") };length = n } else { length = 0 }
let end = boundary.upperBound+length
guard data.count >= end else { return nil }
guard data.count == end else { throw LibraryError.message("Pipelined requests are unsupported") }
return .init(method:start[0],target:start[1],headers:headers,body:Data(data[boundary.upperBound..<end]))
}
}
private final class SingerTransport: @unchecked Sendable {
let queue = DispatchQueue(label:"KJMixPro.SingerHTTP")
var listener: NWListener?
var connections = [UUID:NWConnection]()
var requestsThisSecond = 0
var second = Date()
let handler: @Sendable (SingerHTTPRequest) async -> SingerHTTPResponse
init(handler: @escaping @Sendable (SingerHTTPRequest) async -> SingerHTTPResponse) { self.handler = handler }
func start(port: UInt16,loopback: Bool,state: @escaping @Sendable (Bool,String?) -> Void) throws {
let parameters = NWParameters.tcp
if loopback { parameters.requiredInterfaceType = .loopback }
let listener = try NWListener(using:parameters,on:NWEndpoint.Port(rawValue:port)!)
self.listener = listener
listener.stateUpdateHandler = { value in
switch value { case .ready:state(true,nil);case .failed(let error):state(false,error.localizedDescription);case .cancelled:state(false,nil);default:break }
}
listener.newConnectionHandler = { [weak self] connection in
guard let self else { connection.cancel();return }
if Date().timeIntervalSince(self.second) >= 1 { self.second = Date();self.requestsThisSecond = 0 };self.requestsThisSecond += 1
guard self.connections.count < 48,self.requestsThisSecond <= 80 else { connection.cancel();return }
let id = UUID();self.connections[id] = connection;connection.start(queue:self.queue)
self.queue.asyncAfter(deadline:.now()+8) { [weak self] in if let connection = self?.connections.removeValue(forKey:id) { connection.cancel() } }
self.receive(connection,id:id,data:Data())
}
listener.start(queue:queue)
}
private func receive(_ connection: NWConnection,id: UUID,data: Data) {
connection.receive(minimumIncompleteLength:1,maximumLength:4096) { [weak self] bytes,_,complete,error in
guard let self,self.connections[id] != nil else { return }
var data = data;if let bytes { data.append(bytes) }
do {
if let request = try SingerHTTPParser.parse(data) {
Task {
let response = await self.handler(request)
self.queue.async { guard self.connections[id] != nil else { return };connection.send(content:response.wire,completion:.contentProcessed { _ in self.queue.async { self.connections.removeValue(forKey:id);connection.cancel() } }) }
}
} else if complete || error != nil { self.connections.removeValue(forKey:id);connection.cancel() }
else { self.receive(connection,id:id,data:data) }
} catch {
connection.send(content:SingerHTTPResponse.json(["error":"Invalid or oversized request"],status:400).wire,completion:.contentProcessed { _ in self.queue.async { self.connections.removeValue(forKey:id);connection.cancel() } })
}
}
}
func stop() { queue.async { self.listener?.cancel();self.listener = nil;for connection in self.connections.values { connection.cancel() };self.connections.removeAll() } }
deinit { listener?.cancel() }
}
@MainActor final class SingerWebServer: ObservableObject {
@Published private(set) var running = false
@Published private(set) var starting = false
@Published private(set) var error: String?
@Published private(set) var port: UInt16 = 8080
private var transport: SingerTransport?
private var generation = UUID()
func start(port: UInt16,loopback: Bool = false,handler: @escaping @Sendable (SingerHTTPRequest) async -> SingerHTTPResponse) {
guard transport == nil,port >= 1024 else { return }
let token = UUID();generation = token;starting = true;error = nil;self.port = port
let transport = SingerTransport(handler:handler);self.transport = transport
do { try transport.start(port:port,loopback:loopback) { [weak self] running,error in Task { @MainActor in
guard let self,self.generation == token else { return };self.running = running;self.starting = false;self.error = error
if error != nil { self.transport?.stop();self.transport = nil }
} } } catch { self.error = error.localizedDescription;self.transport = nil;starting = false }
}
func stop() { generation = UUID();transport?.stop();transport = nil;running = false;starting = false }
var localURL: String {
var address = "localhost",head: UnsafeMutablePointer<ifaddrs>?
if getifaddrs(&head) == 0 {
defer { freeifaddrs(head) };var cursor = head
while let entry = cursor {
defer { cursor = entry.pointee.ifa_next }
guard let socket = entry.pointee.ifa_addr,socket.pointee.sa_family == UInt8(AF_INET),entry.pointee.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue }
var host = [CChar](repeating:0,count:Int(NI_MAXHOST))
if getnameinfo(socket,socklen_t(socket.pointee.sa_len),&host,socklen_t(host.count),nil,0,NI_NUMERICHOST) == 0 { address = String(cString:host);break }
}
}
return "http://\(address):\(port)"
}
deinit { transport?.stop() }
}
@MainActor extension AppModel {
func startSingerServer(port: UInt16? = nil,loopback: Bool = false) {
guard currentVenue != nil else { error = "Choose a venue first";return }
singerServer.start(port:port ?? UInt16(studio.serverPort),loopback:loopback) { [weak self] request in
guard let self else { return .json(["error":"Host unavailable"],status:503) }
return await self.singerRequest(request)
}
}
func singerRequest(_ request: SingerHTTPRequest) -> SingerHTTPResponse {
guard let components = URLComponents(string:"http://localhost"+request.target) else { return .json(["error":"Invalid URL"],status:400) }
if request.method == "GET",components.path == "/" { return .init(type:"text/html; charset=utf-8",body:Data(Self.singerHTML.utf8)) }
guard components.path.hasPrefix("/api/"),["GET","POST"].contains(request.method) else { return .json(["error":"Not found"],status:404) }
if let origin = request.headers["origin"],origin != "http://"+(request.headers["host"] ?? "") { return .json(["error":"Origin is not allowed"],status:403) }
guard let library,let currentVenue,let auth = request.headers["authorization"],auth.hasPrefix("Bearer "),let singer = try? library.singerForToken(String(auth.dropFirst(7)),venue:currentVenue) else { return .json(["error":"Your singer invitation is missing, expired, or for another venue"],status:401) }
func songJSON(_ song: Song) -> [String:Any] { let preference = (try? library.preference(song:song.id,singer:singer)) ?? .init();return ["id":song.id,"title":song.title,"artist":song.artist,"album":song.album,"duration":song.duration,"pitch":preference.pitch,"tempo":preference.tempo,"rating":preference.rating,"favourite":favourites[singer]?.contains(song.id) == true] }
do {
if request.method == "GET",components.path == "/api/catalog" {
let q = components.queryItems?.first{$0.name == "q"}?.value ?? ""
guard q.count <= 300 else { throw LibraryError.message("Search is too long") }
let offset = max(0,min(100000,Int(components.queryItems?.first{$0.name == "offset"}?.value ?? "0") ?? 0))
let matches = songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:q)}
return .json(["songs":Array(matches.dropFirst(offset).prefix(50)).map(songJSON),"total":matches.count,"offset":offset])
}
if request.method == "GET",components.path == "/api/me" {
let tonight = tonightSongs(for:singer),repertoire = self.songs(for:singer)
return .json(["singer":singers.first{$0.id == singer}?.name ?? "Singer","venue":currentVenueName,"tonight":tonight.map(songJSON),"repertoire":repertoire.map(songJSON),"history":try library.history(venue:currentVenue,singer:singer).prefix(100).map{["title":$0.song,"date":$0.date]}])
}
guard request.method == "POST",request.headers["content-type"]?.lowercased().hasPrefix("application/json") == true,let body = try JSONSerialization.jsonObject(with:request.body) as? [String:Any],let number = body["song"] as? NSNumber else { throw LibraryError.message("Send a song and JSON action") }
let id = number.int64Value
guard number.doubleValue == Double(id),let song = songs.first(where:{$0.id == id}),!song.audio.hasPrefix("showscreen:") else { throw LibraryError.message("Song unavailable") }
if components.path == "/api/tonight" {
switch body["action"] as? String {
case "add":try library.addTonight(song:id,singer:singer,venue:currentVenue)
case "remove":try library.removeTonight(song:id,singer:singer,venue:currentVenue)
case "move":try library.placeTonight(song:id,singer:singer,venue:currentVenue,before:(body["before"] as? NSNumber)?.int64Value,addIfNeeded:false)
default:throw LibraryError.message("Unknown list action")
}
} else if components.path == "/api/preferences" {
guard let pitch = body["pitch"] as? Double,let tempo = body["tempo"] as? Double,let rating = body["rating"] as? Int else { throw LibraryError.message("Pitch, tempo and rating are required") }
try library.savePreference(song:id,singer:singer,value:.init(pitch:pitch,tempo:tempo,rating:rating))
} else if components.path == "/api/favourite" {
guard let included = body["included"] as? Bool else { throw LibraryError.message("Favourite value required") };try library.setFavourite(song:id,singer:singer,included:included)
} else { return .json(["error":"Not found"],status:404) }
try refresh();return .json(["ok":true])
} catch { return .json(["error":error.localizedDescription],status:400) }
}
static let singerHTML = #"""
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>KJMix Pro · Singer</title>
<style>:root{color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#111318;color:#f5f5f7}body{max-width:850px;margin:auto;padding:22px}h1{font-size:25px}h2{font-size:19px}input,select,button{font:inherit;border:1px solid #393d48;border-radius:9px;padding:10px;background:#242831;color:inherit}button{cursor:pointer}button.primary{background:#087af5}input[type=search]{width:100%;box-sizing:border-box}.row{padding:16px 0;border-bottom:1px solid #30333b;display:flex;gap:12px;align-items:center}.details{flex:1;min-width:0}.sub{color:#a9afbd;font-size:14px;margin-top:6px}.actions{display:flex;gap:6px;flex-wrap:wrap}nav{display:flex;gap:8px;flex-wrap:wrap;margin:20px 0}.error{color:#ff9c9c}#login{padding:20px;background:#20232b;border-radius:12px}dialog{border:1px solid #555;border-radius:15px;background:#222630;max-width:85vw}label{display:block;margin:15px 0}button:focus-visible,input:focus-visible{outline:3px solid #479eff}@media(max-width:550px){.row{align-items:flex-start;flex-direction:column}.actions{width:100%}}</style>
<h1>KJMix Pro</h1><p id="who" class="sub">Your songs. Your next performance.</p><div id="login"><label>Singer invitation code <input id="code" type="password" autocomplete="off"></label><button class="primary" id="connect">Connect</button></div>
<div id="app" hidden><nav><button data-tab="tonight">Tonight</button><button data-tab="catalog">Find Songs</button><button data-tab="favourites">Favourites</button><button data-tab="repertoire">My Songs</button><button data-tab="history">History</button><button id="logout">Sign Out</button></nav><input id="search" type="search" placeholder="Search title, artist or album"><h2 id="heading">Tonight</h2><div id="rows"></div><button id="more" hidden>More Results</button></div><p role="status" id="status"></p>
<dialog id="settings"><h2>My Song Settings</h2><p id="settingsTitle"></p><label>Key <input id="pitch" type="number" min="-12" max="12" step="1"></label><label>Tempo % <input id="tempo" type="number" min="50" max="150" step="5"></label><label>Rating <select id="rating"><option value="0">Unrated</option><option value="1">★</option><option value="2">★★</option><option value="3">★★★</option><option value="4">★★★★</option><option value="5">★★★★★</option></select></label><button id="saveSettings" class="primary">Save</button> <button id="closeSettings">Cancel</button></dialog>
<script>
let token=location.hash.slice(1)||sessionStorage.getItem('kjmixToken')||'',tab='tonight',me,offset=0,editSong,searchTimer;
history.replaceState(null,'',location.pathname);const $=id=>document.getElementById(id);function status(text,error=false){$('status').textContent=text;$('status').className=error?'error':''}
async function api(path,data){const r=await fetch('/api/'+path,{method:data?'POST':'GET',headers:{Authorization:'Bearer '+token,...(data?{'Content-Type':'application/json'}:{})},body:data?JSON.stringify(data):undefined});const v=await r.json();if(!r.ok)throw Error(v.error||'Request failed');return v}
function el(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}function button(text,fn){let e=el('button',text);e.onclick=()=>run(fn);return e}async function run(fn){try{status('');await fn()}catch(e){status(e.message,true)}}
async function connect(){me=await api('me');sessionStorage.setItem('kjmixToken',token);$('login').hidden=true;$('app').hidden=false;$('who').textContent=me.singer+' · '+me.venue;await render()}
async function update(){me=await api('me');await render()}
async function render(append=false){$('heading').textContent={tonight:'Tonight',catalog:'Find Songs',favourites:'Favourites',repertoire:'My Songs',history:'History'}[tab];$('search').hidden=tab==='history';if(!append)$('rows').replaceChildren();let list,total=0;if(tab==='catalog'){let v=await api('catalog?q='+encodeURIComponent($('search').value)+'&offset='+offset);list=v.songs;total=v.total}else if(tab==='history'){list=me.history}else{list=tab==='tonight'?me.tonight:me.repertoire;if(tab==='favourites')list=list.filter(x=>x.favourite);let q=$('search').value.toLowerCase();list=list.filter(x=>(x.title+' '+x.artist+' '+x.album).toLowerCase().includes(q))}
$('more').hidden=tab!=='catalog'||offset+list.length>=total;for(let song of list){let row=el('div',undefined,'row'),details=el('div',undefined,'details');details.append(el('div',song.title),el('div',tab==='history'?song.date:song.artist+' · '+song.album,'sub'));row.append(details);if(tab!=='history'){let actions=el('div',undefined,'actions');actions.append(button(tab==='tonight'?'Remove':'+ Tonight',async()=>{await api('tonight',{song:song.id,action:tab==='tonight'?'remove':'add'});await update()}),button(song.favourite?'★':'☆',async()=>{await api('favourite',{song:song.id,included:!song.favourite});await update()}),button('Key / Tempo',()=>{editSong=song;$('settingsTitle').textContent=song.title;$('pitch').value=song.pitch;$('tempo').value=Math.round(song.tempo*100);$('rating').value=song.rating;$('settings').showModal()}));if(tab==='tonight'){let i=me.tonight.findIndex(x=>x.id===song.id);if(i>0)actions.append(button('↑',async()=>{await api('tonight',{song:song.id,action:'move',before:me.tonight[i-1].id});await update()}));if(i<me.tonight.length-1)actions.append(button('↓',async()=>{await api('tonight',{song:song.id,action:'move',before:me.tonight[i+2]?.id??null});await update()}))}row.append(actions)}$('rows').append(row)}if(!list.length&&!append)$('rows').append(el('p','No songs yet. Use Find Songs to build your list.','sub'))}
$('connect').onclick=()=>{token=$('code').value.trim();run(connect)};document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{tab=b.dataset.tab;offset=0;$('search').value='';run(render)});$('search').oninput=()=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{offset=0;run(render)},350)};$('more').onclick=()=>{offset+=50;run(()=>render(true))};$('logout').onclick=()=>{sessionStorage.removeItem('kjmixToken');token='';location.reload()};$('closeSettings').onclick=()=>$('settings').close();$('saveSettings').onclick=()=>run(async()=>{await api('preferences',{song:editSong.id,pitch:Number($('pitch').value),tempo:Number($('tempo').value)/100,rating:Number($('rating').value)});$('settings').close();await update()});if(token)run(connect);
</script></html>
"""#
}
struct SingerServerPane: View {
@ObservedObject var model: AppModel
@ObservedObject var server: SingerWebServer
@State private var port = "8080"
@State private var address = ""
@State private var invitation: String?
var body: some View {
VStack(alignment:.leading,spacing:18) {
HStack { Circle().fill(server.running ? .green : .secondary).frame(width:8,height:8);Text(server.running ? "Singer Web is running" : server.starting ? "Starting…" : "Singer Web is off").font(.headline);Spacer()
TextField("Port",text:$port).frame(width:80).disabled(server.running || server.starting)
Button(server.running || server.starting ? "Stop Server" : "Start Server") {
if server.running || server.starting { server.stop() } else if let port = UInt16(port),port >= 1024 { model.startSingerServer(port:port) } else { model.error = "Use a port from 1024 to 65535" }
}.disabled(model.currentVenue == nil)
}
Text("Singers on the same network can search your library, manage their own Tonight and favourites, and save key, tempo and ratings. Each invitation is restricted to one singer at this venue. The server stays off until you start it.").foregroundStyle(.secondary)
HStack { Text("Address");TextField("http://your-mac.local:8080",text:$address);Button("Open") { if let url = URL(string:address),["http","https"].contains(url.scheme ?? "") { NSWorkspace.shared.open(url) } }.disabled(!server.running) }
Text("Use this Mac’s local hostname or Wi-Fi IP address, including the port. This server uses HTTP on your local network; invitations grant access to that singer’s lists. Do not publish them.").font(.caption).foregroundStyle(.secondary)
if let error = server.error { Text(error).foregroundStyle(.red) }
List(model.venueSingers) { singer in
HStack { Text(singer.name);Spacer()
Button("Copy Invitation") { model.perform { guard let library = model.library,let venue = model.currentVenue,let url = URL(string:address),url.scheme == "http",url.host != nil else { throw LibraryError.message("Enter this Mac’s local server address first") };let token = try library.singerToken(singer:singer.id,venue:venue);let value = address.trimmingCharacters(in:CharacterSet(charactersIn:"/"))+"/#"+token;NSPasteboard.general.clearContents();NSPasteboard.general.setString(value,forType:.string);invitation = "Invitation copied for "+singer.name } }.disabled(!server.running)
Button("Revoke") { model.perform { guard let venue = model.currentVenue else { return };try model.library?.revokeSinger(singer:singer.id,venue:venue);invitation = "Invitation revoked for "+singer.name } }
}.padding(.vertical,4)
}
if let invitation { Text(invitation).font(.callout).foregroundStyle(.secondary) }
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onChange(of:server.running) { running in if running { address = server.localURL } }
}
}