Files

2125 lines
149 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Combine
import UniformTypeIdentifiers
import AVFoundation
import AppKit
enum EntityKind: String, Identifiable {
case singer, venue
var id: String { rawValue }
var title: String { self == .singer ? "Singer" : "Venue" }
var table: String { self == .singer ? "singers" : "venues" }
}
/// Only an internal, same-process song reference is exported, never media or file URLs.
struct SongDragReference: Codable, Sendable {
static let type = UTType.plainText
var song: Int64
var library: String
var singer: Int64?
var venue: Int64?
var fromTonight: Bool
var transportText: String { "KJMix.song.v1:"+((try? JSONEncoder().encode(self)) ?? Data()).base64EncodedString() }
func provider() -> NSItemProvider {
let provider = NSItemProvider(object:transportText as NSString)
provider.suggestedName = "KJMix song"
return provider
}
static func load(_ provider: NSItemProvider) async throws -> Self {
let text: String = try await withCheckedThrowingContinuation { continuation in
_ = provider.loadObject(ofClass:NSString.self) { object,error in
if let string = object as? String { continuation.resume(returning:string) }
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read dragged song")) }
}
}
guard text.hasPrefix("KJMix.song.v1:"),let decoded = Data(base64Encoded:String(text.dropFirst("KJMix.song.v1:".count))) else { throw LibraryError.message("Drag a song from the KJMix Pro library") }
return try JSONDecoder().decode(Self.self,from:decoded)
}
}
@MainActor final class AppModel: ObservableObject {
@Published var songs = [Song]()
@Published var singers = [PersonPlace]()
@Published var singerSongIDs = [Int64:[Int64]]()
@Published var favourites = [Int64:Set<Int64>]()
@Published var musicStores = [MusicStore]()
@Published var readingDurations = false
@Published var mediaLocations = [MediaLocation]()
@Published var scanProgress: SourceScanProgress?
@Published private(set) var scanningSource: Int64?
private var scanTask: Task<Void,Never>?
private var scanToken: UUID?
var newSingerSongs = Set<Int64>()
private var songIndex = [Int64:Song]()
private let dragSession = UUID().uuidString
@Published var venues = [PersonPlace]()
@Published var venueSingers = [PersonPlace]()
@Published var playlists = [PersonPlace]()
@Published var queue = [QueueEntry]()
@Published var tonightSongIDs = [Int64:[Int64]]()
@Published var history = [Performance]()
@Published private(set) var currentVenue: Int64?
@Published var selectedSinger: Int64? { didSet { if selectedSinger != oldValue { refreshTuning() } } }
@Published private(set) var tuningCache = [Int64:SongPreference]()
@Published var expandedSingerIDs = Set<Int64>()
@Published var activeSection = "Library"
@Published var studio = StudioSettings() { didSet { applyStudioSettings(save:true,previous:oldValue) } }
let backgroundDeck = BackgroundDeck()
@Published var karaokeOutputUID = ""
let dj: DJSystem
var backgroundAutomationReady = false
var backgroundPlaybackObserver: AnyCancellable?
var backgroundHandoff: Task<Void,Never>?
let rotationSession = UUID().uuidString
var rotationDeckSongID: Int64?
var rotationDeckVenue: Int64?
@Published var rotationPlaybackActive = false
@Published var autoAdvance = true
@Published var playbackSinger: Int64?
@Published var hostSettings = HostSettings()
@Published var rotationSingers = [RotationSinger]()
@Published var showScreens = [ShowScreen]()
@Published var namesBusy = false
@Published private(set) var canUndoNames = false
@Published var tagsBusy = false
@Published var tagsStatus = ""
let singerServer = SingerWebServer()
var playingIntermission = false
@Published var error: String?
@Published var creating: EntityKind?
@Published var automaticWaveformProgress: WaveformJobProgress?
let automaticWaveforms = PostImportWaveformQueue()
private func queueImportedWaveforms(_ ids: Set<Int64>) {
automaticWaveforms.enqueue(songs.filter{ids.contains($0.id)},locations:mediaLocations,cache:root.appendingPathComponent("Waveforms"),update:{ [weak self] in self?.automaticWaveformProgress = $0 },completed:{ [weak self] tracks in
guard let self,let id = self.player.currentSongID,tracks.contains(where:{$0.id == id}) else { return };self.player.refreshCachedWaveform()
})
}
@Published private(set) var waveformJob: WaveformJobProgress?
@Published private(set) var generatingWaveforms = false
private var waveformJobTask: Task<Void,Never>?
private var waveformJobToken: UUID?
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
guard !generatingWaveforms,scanningSource == nil,!isImporting,!movingMedia,!namesBusy else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
let tracks = songs.filter{ids == nil || ids!.contains($0.id)}
guard !tracks.isEmpty else { error = "Select a library track first.";return }
let token = UUID(),locations = mediaLocations,cache = root.appendingPathComponent("Waveforms")
waveformJobToken = token;generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformJobTask = Task.detached(priority:.utility) { [weak self] in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force,progress:{ update in
Task { @MainActor [weak self] in guard let self,self.waveformJobToken == token else { return };self.waveformJob = update }
})
await MainActor.run { [weak self] in
guard let self,self.waveformJobToken == token else { return }
self.waveformJob = result;self.waveformJobToken = nil;self.generatingWaveforms = false;self.waveformJobTask = nil
if let id = self.player.currentSongID,tracks.contains(where:{$0.id == id}) { self.player.refreshCachedWaveform() }
}
}
}
func cancelWaveformGeneration() { waveformJobTask?.cancel();waveformJob?.current = "Cancelling…" }
func dismissWaveformProgress() { if !generatingWaveforms { waveformJob = nil } }
let player: Player
private(set) var library: Library?
let root: URL
@Published private(set) var managedMediaDirectory: URL
@Published private(set) var movingMedia = false
@Published private(set) var storageProgress: ImportProgress?
private var storageTask: Task<Void,Never>?
private var storageToken: UUID?
private var mediaAccessURLs = [URL]()
var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" }
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone")
dj = DJSystem(storageURL:root.appendingPathComponent("DJ"))
managedMediaDirectory = root.appendingPathComponent("Media")
player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));loadMediaStorage();try refresh()
if try library?.setting("store.profiles.v1") == nil { installStoreProfiles();try library?.setSetting("store.profiles.v1","1") }
}
if let data = try? Data(contentsOf:root.appendingPathComponent("StudioSettings.json")),let saved = try? JSONDecoder().decode(StudioSettings.self,from:data) { studio = saved.sanitized }
applyStudioSettings(save:false)
backgroundPlaybackObserver = player.$playing.dropFirst().sink { [weak self] _ in
Task { @MainActor [weak self] in self?.scheduleBackgroundHandoff() }
}
player.onWillStartPlayback = { [weak self] in self?.backgroundDeck.prepareForSinger() ?? 0 }
player.onPlaybackReady = { [weak self] in self?.backgroundDeck.pauseForSinger() }
player.onGlobalMixChanged = { [weak self] mix in self?.backgroundDeck.apply(mix:mix) }
player.onFinished = { [weak self] in
guard let self else { return }
guard self.autoAdvance, !self.rotationPlan.isEmpty else { self.rotationPlaybackActive = false;return }
self.advanceShow()
}
backgroundAutomationReady = true
}
func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } }
func refresh() throws {
guard let library else { throw LibraryError.message("Library unavailable") }
songs = try library.songs();songIndex = Dictionary(uniqueKeysWithValues:songs.map { ($0.id,$0) }); singers = try library.entities("singers"); venues = try library.entities("venues")
singerSongIDs = try library.singerSongIDs();favourites = try library.favouriteSongIDs();musicStores = try library.stores();mediaLocations = try library.mediaLocations()
playlists = try library.entities("playlists"); currentVenue = try library.currentVenue()
venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers
if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil }
tonightSongIDs = try library.tonightSongIDs(venue:currentVenue)
queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue)
showScreens = try library.screens()
if let currentVenue {
rotationSingers = try library.rotation(currentVenue)
hostSettings = try library.setting("host.\(currentVenue)").flatMap { $0.data(using:.utf8) }.flatMap { try? JSONDecoder().decode(HostSettings.self,from:$0) } ?? .init()
} else { rotationSingers = [];hostSettings = .init() }
player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade;refreshTuning();canUndoNames = try library.hasNameUndo()
}
func chooseVenue(_ id: Int64?) {
perform {
guard let library else { throw LibraryError.message("Library unavailable") }
try library.selectVenue(id); selectedSinger = nil;playingIntermission = false;try refresh()
}
}
func membership(singer: Int64, included: Bool) {
perform {
guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") }
try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh()
}
}
@discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 {
guard let library else { throw LibraryError.message("Library unavailable") }
let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines)
try library.addEntity(kind.table,name:trimmed)
guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") }
if kind == .singer {
if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) }
selectedSinger = entity.id
} else { try library.selectVenue(entity.id); selectedSinger = nil }
try refresh(); return entity.id
}
func beginSinger(song: Int64? = nil) { newSingerSongs = Set(song.map { [$0] } ?? []); creating = .singer }
func songs(for singer: Int64) -> [Song] {
(singerSongIDs[singer] ?? []).compactMap { songIndex[$0] }
}
func assign(_ song: Int64, to singer: Int64, included: Bool = true) {
perform {
guard let library else { throw LibraryError.message("Library unavailable") }
if included { try library.assignSong(song,singer:singer) } else { try library.removeSingerSong(song,singer:singer) }
try refresh()
}
}
func createSinger(name: String, venue: Int64, songs: Set<Int64>) throws -> Int64 {
guard let library else { throw LibraryError.message("Library unavailable") }
guard venues.contains(where: { $0.id == venue }) else { throw LibraryError.message("Choose a venue first") }
let name = name.trimmingCharacters(in:.whitespacesAndNewlines)
try library.execute("BEGIN IMMEDIATE")
let id: Int64
do {
try library.addEntity("singers",name:name)
guard let singer = try library.entities("singers").first(where: { $0.name == name }) else { throw LibraryError.message("Could not load singer") }
id = singer.id
try library.setMembership(singer:id,venue:venue,included:true)
try library.markHere(singer:id,venue:venue,here:true)
for song in songs.sorted() { try library.addTonight(song:song,singer:id,venue:venue,transaction:false) }
try library.selectVenue(venue);try library.execute("COMMIT")
} catch { try? library.execute("ROLLBACK");throw error }
try refresh();selectedSinger = id;expandedSingerIDs.insert(id);return id
}
func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } }
func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } }
func refreshTuning() { if let library { tuningCache = (try? library.tuningSnapshot(singer:selectedSinger)) ?? [:] } }
func preference(_ song: Int64) -> SongPreference { tuningCache[song] ?? .init() }
func savePreference(song: Int64, value: SongPreference) {
perform {
guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") }
try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh()
}
}
func play(_ song: Song, singer: Int64? = nil) throws {
rotationPlaybackActive = false;rotationDeckSongID = nil;rotationDeckVenue = nil
guard let library else { throw LibraryError.message("Library unavailable") }
let singer = singer ?? selectedSinger
if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") }
let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? library.defaultPreference(song:song.id)
if let screen = showScreens.first(where:{$0.id == song.id}) { try playScreen(screen);return }
try player.load(song,preference:preference);playbackSinger = singer
do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer
if let singer,let currentVenue { hostSettings.cursor = singer;try library.setSetting("host.\(currentVenue)",String(decoding:JSONEncoder().encode(hostSettings),as:UTF8.self)) }
try refresh() }
catch { player.stop(); throw error }
}
func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } }
func tonightSongs(for singer: Int64) -> [Song] { (tonightSongIDs[singer] ?? []).compactMap { songIndex[$0] } }
func addToTonight(_ song: Int64,singer: Int64? = nil) {
perform {
guard let library,let currentVenue,let singer = singer ?? selectedSinger else { throw LibraryError.message("Choose a current venue and singer, then add the song to Tonight") }
try library.addTonight(song:song,singer:singer,venue:currentVenue);try library.markHere(singer:singer,venue:currentVenue,here:true);try refresh()
}
}
func removeFromTonight(_ song: Int64,singer: Int64) {
perform { guard let library,let currentVenue else { return };try library.removeTonight(song:song,singer:singer,venue:currentVenue);try refresh() }
}
func playTonight(_ song: Song,singer: Int64) {
perform {
try play(song,singer:singer)
if let entry = queue.first(where:{ $0.songID == song.id && $0.singerID == singer }) { try library?.removeQueue(entry.id) }
try refresh()
}
}
func dragReference(_ song: Int64,singer: Int64? = nil,tonight: Bool = false) -> SongDragReference {
.init(song:song,library:dragSession,singer:singer,venue:currentVenue,fromTonight:tonight)
}
func placeDroppedSong(_ reference: SongDragReference,singer: Int64,venue: Int64,before: Int64?) throws {
guard let library else { throw LibraryError.message("Library unavailable") }
guard reference.library == dragSession, songIndex[reference.song] != nil else { throw LibraryError.message("Drop a song from this library") }
guard currentVenue == venue else { throw LibraryError.message("The current venue changed. Please drop again.") }
let reorder = reference.fromTonight && reference.singer == singer && reference.venue == venue && tonightSongIDs[singer]?.contains(reference.song) == true
try library.placeTonight(song:reference.song,singer:singer,venue:venue,before:before,addIfNeeded:!reorder)
if !reorder { try library.markHere(singer:singer,venue:venue,here:true) }
try refresh()
}
func acceptSongDrop(_ providers: [NSItemProvider],singer: Int64,venue: Int64?,before: Int64?) -> Bool {
guard let venue else { return false }
let providers = providers.filter { $0.hasItemConformingToTypeIdentifier(SongDragReference.type.identifier) }
guard !providers.isEmpty else { return false }
Task { @MainActor [weak self] in
guard let self else { return }
do { for provider in providers { let reference = try await SongDragReference.load(provider);try self.placeDroppedSong(reference,singer:singer,venue:venue,before:before) } }
catch { self.error = error.localizedDescription }
}
return true
}
func moveTonight(_ song: Int64,singer: Int64,by offset: Int) {
perform {
guard let library,let currentVenue,let ids = tonightSongIDs[singer],let index = ids.firstIndex(of:song),ids.indices.contains(index+offset) else { return }
let destination = index+offset
let before = offset < 0 ? ids[destination] : destination+1 < ids.count ? ids[destination+1] : nil
try library.placeTonight(song:song,singer:singer,venue:currentVenue,before:before,addIfNeeded:false);try refresh()
}
}
func playQueueNext() {
perform {
guard let row = rotationPlan.first, let song = songs.first(where: { $0.id == row.songID }) else { return }
// Explicitly clear selection for unassigned queue entries.
playingIntermission = false;selectedSinger = row.singerID
try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh();rotationPlaybackActive = true;rotationDeckSongID = song.id;rotationDeckVenue = currentVenue
}
}
func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } }
func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } }
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } }
func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } }
func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } }
func tuningForControls(_ song:Int64) -> SongPreference {
var value = preference(song)
if player.currentSongID == song,player.duration > 0 { value.pitch = player.pitch;value.tempo = player.tempo }
return value
}
var liveSingerName: String { playbackSinger.flatMap { id in singers.first{$0.id == id}?.name } ?? "No singer · Temporary" }
func updateLiveTuning(pitch:Double? = nil,tempo:Double? = nil) {
perform {
guard let song = player.currentSongID,player.duration > 0,let library else { throw LibraryError.message("Load a song before adjusting live key or tempo") }
let nextPitch = pitch ?? player.pitch,nextTempo = tempo ?? player.tempo
guard nextPitch.isFinite,nextTempo.isFinite,(-12...12).contains(nextPitch),(0.5...1.5).contains(nextTempo) else { throw LibraryError.message("Use a key from −12 to +12 and tempo from 50% to 150%") }
if let singer = playbackSinger {
var saved = try library.preference(song:song,singer:singer)
saved.pitch = nextPitch;saved.tempo = nextTempo
try library.savePreference(song:song,singer:singer,value:saved)
if selectedSinger == singer { tuningCache[song] = saved }
}
player.pitch = nextPitch;player.tempo = nextTempo;objectWillChange.send()
}
}
func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) {
if player.currentSongID == song,player.duration > 0 { updateLiveTuning(pitch:pitch,tempo:tempo);return }
perform {
guard let library else { throw LibraryError.message("Library unavailable") }
var value = preference(song)
if let pitch { value.pitch = pitch };if let tempo { value.tempo = tempo }
if let selectedSinger { try library.savePreference(song:song,singer:selectedSinger,value:value) }
else { try library.saveDefaultPreference(song:song,value:value) }
if player.currentSongID == song && playbackSinger == selectedSinger { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } }
tuningCache[song] = value
}
}
func saveStore(_ store: MusicStore) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.saveStore(store);try refresh() }
func deleteStore(_ id: Int64) { perform { try library?.deleteStore(id);try refresh() } }
func scanDurations() {
guard !readingDurations else { return };readingDurations = true
let songs = self.songs.filter { $0.duration == 0 }
Task.detached(priority:.utility) { [weak self] in
var durations = [(Int64,Double)]()
for song in songs {
if let file = try? AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) { durations.append((song.id,Double(file.length)/file.processingFormat.sampleRate)) }
}
let completedDurations = durations
await MainActor.run { [weak self] in
guard let self else { return }
self.perform { for (id,value) in completedDurations { try self.library?.updateDuration(id,seconds:value) };try self.refresh() }
self.readingDurations = false
}
}
}
func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int,trackNumber: String? = nil,rendition: String? = nil) throws {
guard let library else { throw LibraryError.message("Library unavailable") }
try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating,trackNumber:trackNumber,rendition:rendition)
if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) }
try refresh()
}
func chooseMediaLocation() {
let panel = NSOpenPanel();panel.title = "Add Library Location";panel.message = "Choose a folder or mounted external drive. KJMix Pro will index it and prepare its waveforms."
panel.canChooseDirectories = true;panel.canChooseFiles = false;panel.allowsMultipleSelection = false;panel.canCreateDirectories = false;panel.prompt = "Add & Prepare"
panel.directoryURL = URL(fileURLWithPath:"/Volumes")
panel.begin { [weak self] response in
guard response == .OK,let url = panel.url else { return }
Task { @MainActor in
guard let self else { return }
self.perform { let id = try self.addMediaLocation(url);self.startSourceScan(id) }
}
}
}
@discardableResult func addMediaLocation(_ url: URL) throws -> Int64 {
guard let library else { throw LibraryError.message("Library unavailable") }
let access = url.startAccessingSecurityScopedResource();defer { if access { url.stopAccessingSecurityScopedResource() } }
let bookmark = (try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? url.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil))
let external = (try? url.resourceValues(forKeys:[.volumeIsInternalKey]).volumeIsInternal) == false || url.path.hasPrefix("/Volumes/")
let id = try library.saveMediaLocation(name:url.lastPathComponent.isEmpty ? "Library location" : url.lastPathComponent,url:url,bookmark:bookmark,external:external)
try refresh();return id
}
func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) {
guard scanningSource == nil,!generatingWaveforms,!isImporting,!movingMedia,!namesBusy else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard let source = mediaLocations.first(where:{ $0.id == id }) else { return }
let token = UUID();scanToken = token;scanningSource = id;scanProgress = SourceScanProgress(source:id)
let database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory,cache = root.appendingPathComponent("Waveforms")
scanTask = Task.detached(priority:.utility) { [weak self] in
let result: Result<SourceScanProgress,Error>
do {
result = .success(try LibraryScanner.scan(source,database:database,media:media,cache:cache,forceWaveforms:forceWaveforms,cancelled:{ Task.isCancelled },progress:{ update in
Task { @MainActor [weak self] in guard let self,self.scanToken == token else { return };self.scanProgress = update }
}))
} catch { result = .failure(error) }
await MainActor.run { [weak self] in
guard let self,self.scanToken == token else { return }
switch result {
case .success(let progress):self.scanProgress = progress
case .failure(let error):
var progress = SourceScanProgress(source:id);progress.finished = true;progress.issues = 1;progress.current = error.localizedDescription;progress.messages = [error.localizedDescription];self.scanProgress = progress
try? self.library?.finishSourceScan(id,path:source.path,message:error.localizedDescription)
}
self.scanToken = nil;self.scanningSource = nil;self.scanTask = nil;self.perform { try self.refresh() }
}
}
}
func cancelSourceScan() { scanTask?.cancel();scanProgress?.current = "Cancelling after the current read/extraction…" }
func removeMediaLocation(_ id: Int64) {
guard scanningSource == nil else { error = "Finish or cancel the scan before removing a location";return }
perform { try library?.removeMediaLocation(id);try refresh() }
}
deinit { for url in mediaAccessURLs { url.stopAccessingSecurityScopedResource() } }
private func mediaStorageLocation() -> MediaStorageLocation? {
guard let json = try? library?.setting(MediaStorageLocation.key),let data = json.data(using:.utf8) else { return nil }
return try? JSONDecoder().decode(MediaStorageLocation.self,from:data)
}
private func loadMediaStorage() {
guard let location = mediaStorageLocation() else { return }
var url = URL(fileURLWithPath:location.path)
if let bookmark = location.bookmark {
var stale = false
if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { url = resolved }
}
managedMediaDirectory = url
if !mediaAccessURLs.contains(url),url.startAccessingSecurityScopedResource() { mediaAccessURLs.append(url) }
}
private var storageBusy: Bool { isImporting || movingMedia || namesBusy || scanningSource != nil || generatingWaveforms }
func chooseMediaStorage(moveExisting:Bool = false) {
guard !storageBusy else { error = "Finish or cancel the current import, scan or move first.";return }
let panel = NSOpenPanel();panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true;panel.allowsMultipleSelection = false
panel.title = moveExisting ? "Move Imported Media" : "Choose Import Folder"
panel.prompt = moveExisting ? "Move Media Here" : "Use Folder"
panel.message = moveExisting ? "Existing imported audio and CDG files will be copied, verified and moved here. Library links will update automatically." : "New imports will be stored in this folder. Use Move Existing Media to relocate previous imports too."
panel.directoryURL = managedMediaDirectory
panel.begin { [weak self] response in
guard response == .OK,let url = panel.url else { return }
Task { @MainActor in
guard let self else { return }
if moveExisting { self.moveMedia(to:url) } else { self.perform { try self.setMediaDirectory(url) } }
}
}
}
func setMediaDirectory(_ url:URL) throws {
guard !storageBusy,let library else { throw LibraryError.message("Finish the current library job before changing storage") }
let scoped = url.startAccessingSecurityScopedResource();defer { if scoped { url.stopAccessingSecurityScopedResource() } }
try ManagedMediaStorage.prepare(url)
let roots = (mediaStorageLocation()?.roots ?? [])+[root.appendingPathComponent("Media").path,managedMediaDirectory.path]
try ManagedMediaStorage.save(ManagedMediaStorage.location(url,roots:roots),to:library)
loadMediaStorage()
}
func moveMedia(to url:URL) {
guard !storageBusy else { error = "Finish or cancel the current library job first.";return }
guard !player.playing,!backgroundDeck.playing else { error = "Stop playback before moving imported media.";return }
let roots = Array(Set((mediaStorageLocation()?.roots ?? [])+[root.appendingPathComponent("Media").path,managedMediaDirectory.path])).map{URL(fileURLWithPath:$0)}
let token = UUID(),database = root.appendingPathComponent("Library.sqlite")
storageToken = token;movingMedia = true;storageProgress = ImportProgress()
storageTask = Task.detached(priority:.utility) { [weak self] in
let scoped = url.startAccessingSecurityScopedResource();defer { if scoped { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
do { latest = try ManagedMediaStorage.move(database:database,roots:roots,destination:url,cancelled:{Task.isCancelled},progress:{ update in
latest = update
Task { @MainActor [weak self] in guard let self,self.storageToken == token else { return };self.storageProgress = update }
}) } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; original media kept" }
catch { latest.failure = error.localizedDescription;latest.current = "Move stopped; original media kept" }
latest.finished = true;let result = latest
await MainActor.run { [weak self] in
guard let self,self.storageToken == token else { return }
self.storageToken = nil;self.storageTask = nil;self.movingMedia = false;self.loadMediaStorage()
if let data = try? Data(contentsOf:self.root.appendingPathComponent("StudioSettings.json")),let saved = try? JSONDecoder().decode(StudioSettings.self,from:data),saved.backgroundPath != self.studio.backgroundPath {
var next = self.studio;next.backgroundPath = saved.backgroundPath;next.backgroundBookmark = saved.backgroundBookmark;self.studio = next
}
self.perform { try self.refresh() };self.storageProgress = result
}
}
}
func cancelMediaMove() { storageTask?.cancel();storageProgress?.current = "Cancelling after the current copy…" }
func dismissMediaMove() { if !movingMedia { storageProgress = nil } }
@Published private(set) var importProgress: ImportProgress?
@Published private(set) var isImporting = false
private var importTask: Task<Void,Never>?
private var importToken: UUID?
func importFiles(_ urls: [URL]) {
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia,!namesBusy else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !urls.isEmpty else { return }
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory,beforeIDs = Set(songs.map(\.id))
importToken = token;isImporting = true;importProgress = ImportProgress()
importTask = Task.detached(priority:.utility) { [weak self] in
let accessed = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in accessed { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress(),importedIDs = [Int64]()
do {
let database = try Library(url:database)
importedIDs = try Importer(media:media).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
latest = update
Task { @MainActor [weak self] in
guard let self,self.importToken == token else { return };self.importProgress = update
}
})
} catch is CancellationError { latest.cancelled = true;latest.current = "Import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "Import stopped" }
latest.finished = true
let result = latest,ids = Set(importedIDs)
await MainActor.run { [weak self] in
guard let self,self.importToken == token else { return }
self.importToken = nil;self.importTask = nil;self.isImporting = false
self.perform { try self.refresh() }
self.importProgress = result
if let failure = result.failure { self.error = failure }
if !result.cancelled { self.queueImportedWaveforms(ids.union(self.songs.filter{!beforeIDs.contains($0.id)}.map(\.id))) }
}
}
}
func cancelImport() { importTask?.cancel();importProgress?.current = "Cancelling after the current file…" }
func dismissImport() { if !isImporting { importProgress = nil } }
}
private struct LibraryCommands {
var importMusic: () -> Void
var songInfo: () -> Void
var canEdit: Bool
var play: () -> Void
var next: () -> Void
var video: () -> Void
}
private struct StudioSettingsAction { var open: () -> Void }
private struct StudioSettingsActionKey: FocusedValueKey { typealias Value = StudioSettingsAction }
private struct LibraryCommandsKey: FocusedValueKey { typealias Value = LibraryCommands }
private extension FocusedValues {
var studioSettings: StudioSettingsAction? {
get { self[StudioSettingsActionKey.self] }
set { self[StudioSettingsActionKey.self] = newValue }
}
var libraryCommands: LibraryCommands? {
get { self[LibraryCommandsKey.self] }
set { self[LibraryCommandsKey.self] = newValue }
}
}
@main struct KJMixProApp: App {
@StateObject private var model = AppModel(storageURL:ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil ? nil : FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-TestHost-"+String(ProcessInfo.processInfo.processIdentifier)))
@AppStorage("studioAppearance") private var appearance = "system"
@FocusedValue(\.libraryCommands) private var actions
@FocusedValue(\.studioSettings) private var settingsAction
@FocusedValue(\.nameReinterpretation) private var nameActions
var body: some Scene {
Window("KJMix Pro", id: "main") {
LibraryView(model: model).frame(minWidth: 1060, minHeight: 760)
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
}.defaultSize(width: 1320, height: 900).windowToolbarStyle(.unified)
.commands {
CommandGroup(replacing: .newItem) {
Button("Import Music…") { actions?.importMusic() }.keyboardShortcut("o").disabled(actions == nil)
Divider()
Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift])
Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option])
}
CommandGroup(replacing:.appSettings) {
Button("Settings…") { settingsAction?.open() }.keyboardShortcut(",").disabled(settingsAction == nil)
}
CommandMenu("Library") {
Button("Song Info…") { actions?.songInfo() }.keyboardShortcut("i").disabled(actions?.canEdit != true)
Button("Add Drive or Folder…") { model.chooseMediaLocation() }
}
CommandMenu("Advanced") {
Button("Reinterpret Names…") { nameActions?.perform() }.disabled(nameActions?.enabled != true || model.namesBusy)
Button("Undo Last Name Reinterpretation") { Task { do { try await model.undoNameChanges() } catch { model.error = error.localizedDescription } } }.disabled(!model.canUndoNames || model.namesBusy)
}
CommandMenu("Playback") {
Button("Play / Pause") { if model.player.duration > 0 { model.player.toggle() } else { actions?.play() } }.keyboardShortcut(.space,modifiers:[])
Button("Stop Deck (Keep Track Loaded)") { model.player.stopDeck() }.keyboardShortcut(".",modifiers:[.command,.shift])
Button("Return to Cue") { model.player.returnToCue() }
Button("Set Cue Here") { model.player.setCue() }
Menu("Hot Cues") { ForEach(0..<4) { index in Button("Hot Cue \(index+1)") { model.player.hotCue(index) } } }
Button("Loop In") { model.player.markLoopIn() }
Button("Loop Out") { model.player.markLoopOut() }
Button("Clear Loop") { model.player.clearLoop() }
Divider()
Button("Fade Out & Stop") { model.player.fadeOutAndStop() }.keyboardShortcut(".")
Divider()
Button("Back 10 Seconds") { model.player.seek(model.player.time-10) }.keyboardShortcut(.leftArrow)
Button("Forward 10 Seconds") { model.player.seek(model.player.time+10) }.keyboardShortcut(.rightArrow)
Button("Next Song") { actions?.next() }.disabled(actions == nil)
Divider()
Button("Show Video Output") { actions?.video() }.keyboardShortcut("v",modifiers:[.command,.shift]).disabled(actions == nil)
}
}
Window("Video", id: "lyrics") {
LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark)
}.defaultSize(width: 1280, height: 720)
}
}
private struct ImportProgressDialog: View {
@ObservedObject var model: AppModel
var storageMode = false
var body: some View {
ZStack {
Color.black.opacity(0.16).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { }
if let progress = storageMode ? model.storageProgress : model.importProgress {
if !storageMode {
ImportProgressPanel(progress:progress,studio:"Karaoke",cancel:{ model.cancelImport() },close:{ model.dismissImport() })
} else {
VStack(alignment:.leading,spacing:16) {
HStack(spacing:12) {
Image(systemName:progress.finished ? (progress.failure == nil ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") : "square.and.arrow.down")
.font(.system(size:28)).foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:3) {
Text(storageMode ? (progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Media Moved" : "Move Needs Attention") : "Moving Imported Media") : (progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Stopped") : "Importing Music")).font(.headline)
Text(progress.total > 0 ? "\(progress.completed) of \(progress.total) \(storageMode ? "files verified" : "tracks checked")" : "Preparing files…").font(.subheadline).foregroundStyle(.secondary)
}
}
if progress.total > 0 { ProgressView(value:Double(progress.completed),total:Double(progress.total)) }
else if !progress.finished { ProgressView().controlSize(.small) }
if progress.completed > 0 && !storageMode { Text("\(progress.completed-progress.skipped) imported · \(progress.skipped) already in library").font(.callout.weight(.medium)) }
Text(progress.failure ?? progress.current).font(.callout).foregroundStyle(.secondary).lineLimit(3).frame(maxWidth:.infinity,alignment:.leading)
if progress.finished,!storageMode { ImportLogView(progress:progress).frame(height:240) }
if progress.cancelled || progress.failure != nil { Text(storageMode ? "Original files are kept until their copies and library links are verified." : "Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) }
HStack { Spacer();if progress.finished { Button("Done") { if storageMode { model.dismissMediaMove() } else { model.dismissImport() } }.keyboardShortcut(.defaultAction) } else { Button(storageMode ? "Cancel Move" : "Cancel Import") { if storageMode { model.cancelMediaMove() } else { model.cancelImport() } }.keyboardShortcut(.cancelAction) } }
}.padding(24).frame(width:storageMode || !progress.finished ? 380 : 660).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14))
.overlay(RoundedRectangle(cornerRadius:14).strokeBorder(.primary.opacity(0.08)))
.shadow(color:.black.opacity(0.2),radius:24,y:8)
.accessibilityElement(children:.contain).accessibilityLabel(storageMode ? "Media move progress" : "Music import progress")
}
}
}
}
}
struct LibraryActionButtonStyle: ButtonStyle {
let tint: Color
var prominent = false
@Environment(\.isEnabled) private var enabled
func makeBody(configuration: Configuration) -> some View {
configuration.label.font(.system(size:12,weight:.semibold)).fixedSize()
.padding(.horizontal,12).padding(.vertical,8)
.foregroundStyle(prominent && enabled ? Color.black : enabled ? tint : Color.secondary)
.background(RoundedRectangle(cornerRadius:8).fill(tint.opacity(enabled ? (prominent ? (configuration.isPressed ? 0.65 : 0.95) : configuration.isPressed ? 0.25 : 0.12) : 0.04)))
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(tint.opacity(enabled ? 0.28 : 0.08)))
}
}
struct LibrarySelectionBadge: View {
let count: Int
var body: some View {
Text(count == 0 ? "No selection" : "\(count.formatted()) selected").font(.system(size:11,weight:.medium)).monospacedDigit()
.foregroundStyle(count > 0 ? Color.cyan : Color.secondary).padding(.horizontal,10).padding(.vertical,6)
.background((count > 0 ? Color.cyan : Color.primary).opacity(0.08),in:Capsule())
}
}
struct StudioSearchField: View {
let placeholder: String
@Binding var text: String
@FocusState private var focused: Bool
var body: some View {
HStack(spacing:7) {
Image(systemName:"magnifyingglass").foregroundStyle(.secondary)
TextField(placeholder,text:$text).textFieldStyle(.plain).focused($focused)
if !text.isEmpty {
Button { text = "";focused = true } label: { Image(systemName:"xmark.circle.fill").foregroundStyle(.secondary) }.buttonStyle(.plain).help("Clear search").accessibilityLabel("Clear search")
}
}.font(.system(size:12)).padding(.horizontal,9).padding(.vertical,7)
.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:7))
.overlay(RoundedRectangle(cornerRadius:7).strokeBorder(.primary.opacity(0.12)))
}
}
struct BackgroundWaveformStatus: View {
let progress: WaveformJobProgress
let cancel: () -> Void
let dismiss: () -> Void
var body: some View {
HStack(spacing:10) {
Image(systemName:progress.finished ? "checkmark.circle.fill" : "waveform").foregroundStyle(progress.cancelled || progress.issues > 0 ? Color.orange : progress.finished ? .green : .cyan)
VStack(alignment:.leading,spacing:2) {
Text(progress.finished ? progress.cancelled ? "Background waveforms cancelled" : "Background waveforms complete" : "Preparing waveforms in the background").font(.caption.weight(.semibold))
Text("\(progress.processed) / \(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if progress.finished { Button(action:dismiss) { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
else { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).tint(.cyan).frame(width:120);Button("Cancel",action:cancel).controlSize(.small) }
}.padding(10).background(Color.cyan.opacity(0.05),in:RoundedRectangle(cornerRadius:8))
}
}
struct WaveformProgressCard: View {
let progress: WaveformJobProgress
let running: Bool
var studio = "Karaoke"
let cancel: () -> Void
let details: () -> Void
let dismiss: () -> Void
private var fraction: Double { min(1,max(0,Double(progress.processed)/Double(max(1,progress.total)))) }
private var colour: Color { progress.cancelled || progress.issues > 0 ? .orange : running ? .cyan : .green }
var body: some View {
HStack(spacing:16) {
ZStack {
Circle().stroke(colour.opacity(0.15),lineWidth:5)
Circle().trim(from:0,to:fraction).stroke(colour,style:StrokeStyle(lineWidth:5,lineCap:.round)).rotationEffect(.degrees(-90))
Text("\(Int(fraction*100))%").font(.system(size:11,weight:.semibold)).monospacedDigit()
}.frame(width:46,height:46).accessibilityLabel("Waveform progress").accessibilityValue("\(Int(fraction*100)) percent")
VStack(alignment:.leading,spacing:7) {
HStack { Text(running ? "Preparing "+studio+" Waveforms" : "Waveform Results").font(.callout.weight(.semibold));Spacer();Text("\(progress.processed.formatted()) / \(progress.total.formatted())").font(.caption).monospacedDigit().foregroundStyle(.secondary) }
GeometryReader { proxy in
ZStack(alignment:.leading) {
Capsule().fill(colour.opacity(0.12))
Capsule().fill(LinearGradient(colors:[colour.opacity(0.6),colour],startPoint:.leading,endPoint:.trailing)).frame(width:proxy.size.width*fraction)
}
}.frame(height:7)
Text(progress.current).font(.caption).lineLimit(1).foregroundStyle(.secondary)
Text("\(progress.generated) generated · \(progress.cached) reused · \(progress.skipped) skipped · \(progress.issues) issues").font(.caption2).foregroundStyle(.secondary)
}
if running { Button("Cancel",action:cancel).controlSize(.small) }
if progress.issues > 0 { Button("Details",action:details).controlSize(.small) }
if !running { Button(action:dismiss) { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(16).background(colour.opacity(0.05),in:RoundedRectangle(cornerRadius:12))
.overlay(RoundedRectangle(cornerRadius:12).strokeBorder(colour.opacity(0.18)))
}
}
struct ImportProgressPanel: View {
let progress: ImportProgress
let studio: String
let cancel: () -> Void
let close: () -> Void
var background: (() -> Void)? = nil
var isMove = false
var activity: String? = nil
var waveformStatus: BackgroundWaveformStatus? = nil
private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 }
private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan }
private var title: String { if let activity { return progress.finished ? (progress.cancelled ? "Cancelled" : progress.failure == nil ? "Library Updated" : "Library Needs Attention") : activity };if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
var body: some View {
VStack(alignment:.leading,spacing:20) {
HStack(spacing:14) {
Image(systemName:progress.finished ? (progress.failure != nil || progress.cancelled ? "exclamationmark.circle.fill" : "checkmark.circle.fill") : "square.and.arrow.down.fill")
.font(.system(size:26,weight:.medium)).foregroundStyle(accent).frame(width:52,height:52)
.background(accent.opacity(0.12),in:RoundedRectangle(cornerRadius:12))
VStack(alignment:.leading,spacing:4) {
Text(studio.uppercased()+(isMove ? "" : " LIBRARY")).font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary)
Text(title).font(.system(size:22,weight:.semibold))
}
Spacer()
if !progress.finished { ProgressView().controlSize(.small).tint(accent) }
}
VStack(alignment:.leading,spacing:9) {
HStack {
Text(progress.total > 0 ? "\(progress.completed.formatted()) of \(progress.total.formatted()) \(isMove ? "files verified" : "tracks checked")" : "Scanning files and folders…").font(.callout)
Spacer()
Text(progress.total > 0 ? "\(Int(fraction*100))%" : "Preparing").font(.callout.weight(.semibold)).monospacedDigit().foregroundStyle(accent)
}
GeometryReader { proxy in
ZStack(alignment:.leading) {
Capsule().fill(.primary.opacity(0.08))
Capsule().fill(LinearGradient(colors:[accent.opacity(0.65),accent],startPoint:.leading,endPoint:.trailing))
.frame(width:progress.total > 0 ? proxy.size.width*fraction : proxy.size.width*0.08)
}
}.frame(height:9).accessibilityLabel("Import progress").accessibilityValue("\(Int(fraction*100)) percent")
Text(progress.failure ?? progress.current).font(.system(size:11)).foregroundStyle(.secondary).lineLimit(2).frame(height:30,alignment:.topLeading).frame(maxWidth:.infinity,alignment:.leading)
}
HStack(spacing:12) {
count(activity != nil ? "Processed" : isMove ? "Copied" : "Imported",value:max(0,progress.completed-progress.skipped),color:.green)
count("Skipped",value:progress.skipped,color:.orange)
count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan)
}
if let waveformStatus { waveformStatus }
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:waveformStatus == nil ? 260 : 200)
Divider()
HStack {
Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
Spacer()
if progress.finished {
Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction)
} else {
if let background { Button("Run in Background",action:background) }
Button(progress.current.hasPrefix("Cancelling") ? "Cancelling…" : activity != nil ? "Cancel" : isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction).disabled(progress.current.hasPrefix("Cancelling"))
}
}
}.padding(26).frame(width:660)
.background(Color(nsColor:.windowBackgroundColor),in:RoundedRectangle(cornerRadius:16))
.overlay(RoundedRectangle(cornerRadius:16).strokeBorder(.primary.opacity(0.1)))
.shadow(color:.black.opacity(0.2),radius:24,y:8)
.accessibilityElement(children:.contain).accessibilityLabel(studio+" import progress")
}
private func count(_ title: String,value: Int,color: Color) -> some View {
VStack(alignment:.leading,spacing:5) {
Text(value >= 0 ? value.formatted() : "—").font(.system(size:22,weight:.semibold,design:.rounded)).monospacedDigit().foregroundStyle(color)
Text(title).font(.caption).foregroundStyle(.secondary)
}.frame(maxWidth:.infinity,alignment:.leading).padding(12).background(color.opacity(0.07),in:RoundedRectangle(cornerRadius:8))
}
}
struct ImportLogView: View {
let progress: ImportProgress
var title = "Import Log"
@State private var follow = true
var body: some View {
let allLines = progress.reportLines
let lines = Array(allLines.suffix(300))
VStack(alignment:.leading,spacing:8) {
HStack {
Text(title).font(.headline)
Spacer()
if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) }
Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) }
}
if allLines.count > lines.count { Text("Showing the latest \(lines.count.formatted()) of \(allLines.count.formatted()) entries. Copy Log includes the full report.").font(.caption).foregroundStyle(.secondary) }
ScrollViewReader { reader in
ScrollView {
LazyVStack(alignment:.leading,spacing:7) {
ForEach(lines.indices,id:\.self) { index in
let line = lines[index]
HStack(alignment:.top,spacing:8) {
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : (line.hasPrefix("[SKIPPED]") || line.hasPrefix("[WARNING]")) ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)
}.id(index)
}
if lines.isEmpty { Text(progress.finished ? "No tracks processed." : "Waiting for the first track…").font(.callout).foregroundStyle(.secondary) }
}.padding(12)
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06)))
.onStudioChange(of:allLines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
}
}
}
}
private enum StudioTheme {
static let frame = Color(nsColor:.windowBackgroundColor)
static let navy = Color(nsColor:.textBackgroundColor)
static let blue = Color.accentColor.opacity(0.15)
static let header = Color(nsColor:.controlBackgroundColor)
static let line = Color.primary.opacity(0.08)
}
private struct StudioSidebarLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing:9) {
configuration.icon.foregroundStyle(.secondary).frame(width:18)
configuration.title.foregroundStyle(.primary)
}
}
}
struct LyricsView: View {
@ObservedObject var player: Player
var body: some View {
ZStack {
Color.black
if let image = player.image {
NativeVideoSurface(image:image,opacity:player.outputOpacity)
}
}
}
}
private struct NativeVideoSurface: NSViewRepresentable {
let image: CGImage
let opacity: Double
func makeNSView(context: Context) -> NativeVideoView { NativeVideoView() }
func updateNSView(_ view: NativeVideoView,context: Context) {
if view.image !== image { view.image = image;view.picture.contents = image }
view.picture.opacity = Float(opacity)
}
}
private final class NativeVideoView: NSView {
let picture = CALayer()
var image: CGImage?
override init(frame: NSRect) {
super.init(frame:frame);wantsLayer = true;layer?.backgroundColor = NSColor.black.cgColor
picture.contentsGravity = .resizeAspect;picture.magnificationFilter = .linear;picture.minificationFilter = .trilinear
picture.actions = ["contents":NSNull(),"bounds":NSNull(),"position":NSNull(),"opacity":NSNull()]
layer?.addSublayer(picture)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layout() { super.layout();picture.frame = bounds }
override func viewDidMoveToWindow() { super.viewDidMoveToWindow();updateScale() }
override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties();updateScale() }
private func updateScale() { let scale = window?.backingScaleFactor ?? 1;layer?.contentsScale = scale;picture.contentsScale = scale;picture.frame = bounds }
}
/// This is another view of the same published CGImage used by the Video window.
/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical.
private struct SidebarVideoMonitor: View {
@ObservedObject var player: Player
var body: some View {
VStack(alignment: .leading, spacing: 7) {
Label("Video Monitor", systemImage: "tv")
.font(.caption.weight(.medium)).foregroundStyle(.secondary)
LyricsView(player: player)
.frame(height:140)
.clipped()
.clipShape(RoundedRectangle(cornerRadius:8)).overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.white.opacity(0.1),lineWidth:1))
.accessibilityLabel("Video monitor, synchronized with the Video window")
}.frame(maxWidth: .infinity)
}
}
private struct StudioButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label.font(.system(size:12,weight:.medium)).foregroundStyle(.primary)
.padding(.horizontal,9).padding(.vertical,5)
.background(.primary.opacity(configuration.isPressed ? 0.12 : 0.05),in:RoundedRectangle(cornerRadius:6))
}
}
private struct TransportButton: View {
let symbol: String
let label: String
var size: CGFloat = 34
var active = false
var enabled = true
let action: () -> Void
var body: some View {
Button(action:action) {
Image(systemName:symbol).font(.system(size:size > 40 ? 19 : 14,weight:.semibold))
.frame(width:size,height:size)
.foregroundStyle(size > 40 ? Color.white : Color.primary)
.background(size > 40 ? Color.accentColor : Color.primary.opacity(0.055),in:Circle())
}.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.3)
.help(label).accessibilityLabel(label)
}
}
struct LibraryView: View {
@ObservedObject var model: AppModel
@State var workspaceTab = "Karaoke"
@State private var djSongSettings = false
@State private var studioSettingsModal = false
@State private var selected: Int64?
@State private var tableSelection = Set<Int64>()
@State private var waveformDetails = false
@State private var batchEditing = false
@State private var reinterpreting = false
@State private var reinterpretIDs = Set<Int64>()
@State private var metadataLookup: Song?
@State private var confirmWriteTags = false
@State private var search = ""
@State private var name = ""
@State private var importing = false
@State private var importingServer = false
@State private var serverImportURLs = [URL]()
private var source: String { get { model.activeSection } nonmutating set { model.activeSection = newValue } }
@State private var singersExpanded = true
private var expandedSingers: Set<Int64> { get { model.expandedSingerIDs } nonmutating set { model.expandedSingerIDs = newValue } }
@State private var expandedSongLists = Set<Int64>()
@State private var songPickerSinger: PersonPlace?
@State private var songPickerTonight = false
@State private var singerSections = [Int64:String]()
@State private var singerSelections = [Int64:Int64]()
@State private var presentedSinger: Int64?
@State private var singerSection = "All Songs"
@State private var storesExpanded = true
@State private var focusedStore: Int64?
@State private var focusedLocation: Int64?
@State private var editingSong: Song?
@State private var libraryRows = [Song]()
@State private var page = 0
@State private var tableSort = [KeyPathComparator(\Song.title)]
@State private var librarySort = "Song Name"
@State private var sortAscending = true
@Environment(\.openWindow) private var openWindow
private var pagination: LibraryPage { LibraryPage(total:libraryRows.count,requested:page) }
private var visibleSongs: [Song] { Array(libraryRows[pagination.range]) }
private func updateLibraryRows(resetPage:Bool = false) {
let filtered = model.songs.map { source -> Song in var song = source;if model.selectedSinger != nil { song.rating = model.displayedRating(source) };return song }.filter { SongSearch.matches($0,query:search) }
libraryRows = filtered.sorted(using:tableSort)
page = LibraryPage(total:libraryRows.count,requested:resetPage ? 0 : page).number
if source == "Library" {
let ids = Set(visibleSongs.map(\.id));tableSelection.formIntersection(ids)
if let selected,!ids.contains(selected) { self.selected = nil }
}
}
private var selectedSong: Song? { model.songs.first { $0.id == selected } }
private var libraryWorkspace: some View {
HSplitView {
sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260)
VStack(spacing:0) {
BackgroundDeckStrip(model:model,deck:model.backgroundDeck).padding(.horizontal,16).padding(.vertical,6)
Divider()
TransportView(player:model.player,model:model,search:$search,hasSelection:selectedSong != nil,
play:playSelected,next:playNext,display:{ openWindow(id:"lyrics") })
.padding(20)
Divider()
HStack {
VStack(alignment:.leading,spacing:4) {
Text(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer")+" · "+singerSection : source).font(.title2.weight(.semibold))
Text(source == "Library" ? "\(model.songs.count.formatted()) tracks in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
}
Spacer(minLength:20)
LibrarySelectionBadge(count:tableSelection.count)
StudioSearchField(placeholder:"Search music",text:$search).frame(minWidth:180,idealWidth:280,maxWidth:320)
if source == "Library" {
Button { importing = true } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan,prominent:true))
}
}.padding(.horizontal,20).padding(.vertical,14).background(Color.primary.opacity(0.025))
.sheet(isPresented:$djSongSettings) {
VStack(spacing:0) {
HStack { VStack(alignment:.leading,spacing:4) { Text("Song Settings").font(.title2.bold());Text(selectedSong?.title ?? "Select a karaoke track to adjust its settings").font(.callout).foregroundStyle(.secondary) };Spacer() }.padding(24)
Divider()
detailPanel.frame(height:150).padding(16)
Divider()
HStack { Text("Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { djSongSettings = false }.keyboardShortcut(.defaultAction) }.padding(20)
}.frame(width:920).background(Color(nsColor:.windowBackgroundColor))
}
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.automaticWaveformProgress {
BackgroundWaveformStatus(progress:progress,cancel:{model.automaticWaveforms.cancel()},dismiss:{model.automaticWaveformProgress = nil}).padding(.horizontal,18).padding(.vertical,8)
}
Divider()
footer.padding(.horizontal,18).padding(.vertical,8)
}.frame(minWidth:780)
}
}
private var workspaceLayout: some View {
TabView(selection:$workspaceTab) {
libraryWorkspace.tabItem { Label("Karaoke Studio",systemImage:"music.mic") }.tag("Karaoke")
DJConsole(system:model.dj)
.tabItem { Label("DJ Studio",systemImage:"opticaldisc") }.tag("DJ Studio")
}.accessibilityIdentifier("workspace.tabs")
}
private var workspace: some View {
workspaceLayout
.background(StudioTheme.frame)
.tint(.accentColor)
.focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:workspaceTab == "Karaoke" && ["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty)))
.focusedSceneValue(\.studioSettings,StudioSettingsAction(open:{studioSettingsModal = true}))
.focusedSceneValue(\.libraryCommands,workspaceTab == "Karaoke" ? LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")}) : nil)
.sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model,scope:workspaceTab == "DJ Studio" ? "DJ" : "Karaoke") }
.navigationTitle(workspaceTab == "DJ Studio" ? "DJ Studio" : source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source)
.navigationSubtitle(model.currentVenueName)
.toolbar {
ToolbarItemGroup {
if workspaceTab == "DJ Studio" { Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") } }
if workspaceTab == "Karaoke" {
Menu {
Button("Import Files or Folder…") { importing = true }
Button("Import from SMB/NAS…") { importingServer = true }
} label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import from files, folders or a server drive")
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.disabled(model.generatingWaveforms || model.scanningSource != nil).help("Prepare waveforms for a track, selected tracks or the whole library")
Menu {
Button("New Singer…") { model.beginSinger() }
Button("New Venue…") { model.creating = .venue }
Button("Add Drive or Folder…") { model.chooseMediaLocation() }
} label: { Label("Add",systemImage:"plus") }
Button { editingSong = selectedSong } label: { Label("Song Info",systemImage:"info.circle") }.disabled(selectedSong == nil)
Button { openWindow(id:"lyrics") } label: { Label("Video Output",systemImage:"rectangle.on.rectangle") }
}
}
}
}
var body: some View {
workspace
.disabled(model.storageProgress != nil)
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } }
.onAppear { updateLibraryRows() }
.onStudioChange(of:model.songs) { _ in updateLibraryRows() }
.onStudioChange(of:model.tuningCache) { _ in updateLibraryRows() }
.onStudioChange(of:search) { _ in updateLibraryRows(resetPage:true) }
.sheet(item: $model.creating) { kind in
AddEntitySheet(model: model, kind: kind) { id in
if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) }
}
}
.sheet(isPresented:Binding(get:{model.waveformJob != nil},set:{if !$0 { model.dismissWaveformProgress() }})) {
if let progress = model.waveformJob {
VStack(alignment:.leading,spacing:20) {
Label("Karaoke Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(.cyan)
Text(model.generatingWaveforms ? "Preparing saved waveforms in the background. Playback remains available." : progress.cancelled ? "Generation cancelled. Completed waveforms have been kept." : progress.issues > 0 ? "Some tracks could not be analysed. See the log below." : "Waveforms are saved and ready to use.").font(.callout).foregroundStyle(.secondary)
WaveformProgressCard(progress:progress,running:model.generatingWaveforms,studio:"Karaoke Studio",cancel:{model.cancelWaveformGeneration()},details:{waveformDetails.toggle()},dismiss:{model.dismissWaveformProgress()})
if waveformDetails || !progress.messages.isEmpty {
Text("Analysis Log").font(.headline)
ScrollView { Text(progress.messages.isEmpty ? "No issues reported." : progress.messages.joined(separator:"\n\n")).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:180)
}
HStack {
if model.generatingWaveforms { ProgressView().controlSize(.small).tint(.cyan);Text("Cancel keeps completed waveforms.").font(.caption).foregroundStyle(.secondary) }
Spacer()
if !model.generatingWaveforms { Button("Done") { model.dismissWaveformProgress() }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) }
}
}.padding(26).frame(width:660).background(Color(nsColor:.windowBackgroundColor))
.interactiveDismissDisabled(model.generatingWaveforms)
}
}
.sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) }
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) }
.sheet(item:$metadataLookup) { song in MetadataLookupSheet(model:model,song:song) }
.confirmationDialog("Write file tags for \(tableSelection.count) songs? Original files will be backed up in TagBackups before replacement. Unsupported files are reported and left unchanged.",isPresented:$confirmWriteTags) {
Button("Write File Tags") { model.writeFileTags(tableSelection) }
}
.onStudioChange(of:tableSelection) { ids in selected = ids.sorted().first }
.sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) }
.sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer,tonight:songPickerTonight) }
.onStudioChange(of:selected) { song in
if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song }
}
.onStudioChange(of:model.selectedSinger) { singer in
updateLibraryRows()
if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) }
}
.sheet(isPresented:$importingServer,onDismiss:{
let urls = serverImportURLs;serverImportURLs = []
model.importFiles(urls)
}) { ServerImportSheet { serverImportURLs = $0 } }
.fileImporter(isPresented: $importing, allowedContentTypes: [.folder, .audio, .movie, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): model.importFiles(urls)
case .failure(let error): model.error = error.localizedDescription
}
}
.alert("KJMix Pro", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) {
Button("OK") { model.error = nil }
} message: { Text(model.error ?? "") }
}
@ViewBuilder private var sourceContent: some View {
if source == "Library" { VStack(spacing:0) { songTable;Divider();libraryPagination } }
else if source == "Queue" { queuePane }
else if source == "Settings" { StudioSettingsPane(model:model) }
else if source == "Rotation" { RotationPane(model:model) }
else if source == "ShowScreens" { ShowScreensPane(model:model) }
else if source == "Singer Web" { SingerServerPane(model:model,server:model.singerServer) }
else if source == "History" { historyPane }
else if source == "Playlists" { playlistPane }
else if source == "Singer Songs" { singerSongPane }
else if source == "Music Stores" { MusicStoresPane(model:model,selected:$focusedStore) }
else if source == "Library Locations" { LibraryLocationsPane(model:model,selected:$focusedLocation) }
else { directory }
}
private func showSinger(_ id: Int64?,section: String? = nil) {
if source == "Singer Songs",let previous = presentedSinger {
singerSections[previous] = singerSection
if let selected { singerSelections[previous] = selected }
}
presentedSinger = id;model.selectedSinger = id
singerSection = section ?? id.flatMap { singerSections[$0] } ?? "Tonight"
if let id { singerSections[id] = singerSection;selected = singerSelections[id] }
source = "Singer Songs"
}
private func sourceCount(_ source: String) -> Int {
switch source {
case "Library": return model.songs.count
case "Singers": return model.venueSingers.count
case "Venues": return model.venues.count
case "Queue": return model.queue.count
case "History": return model.history.count
default: return model.playlists.count
}
}
private var sidebar: some View {
VStack(spacing:0) {
HStack(spacing:10) {
Image("BrandMark").resizable().scaledToFit().frame(width:32,height:32)
VStack(alignment:.leading,spacing:2) { Text("KJMix Pro").font(.headline);Text("Karaoke Studio").font(.caption).foregroundStyle(.secondary) };Spacer()
}.padding(16)
List {
Group {
sidebarHeading("Workspace")
navigationRow("Library",symbol:"music.note.list",count:model.songs.count)
navigationRow("Rotation",symbol:"person.3.sequence",count:model.rotationPlan.count)
navigationRow("Queue",symbol:"list.number",count:model.queue.count)
navigationRow("History",symbol:"clock",count:model.history.count)
navigationRow("Playlists",symbol:"music.note",count:model.playlists.count)
sidebarHeading("Singers · \(model.currentVenueName)")
ForEach(model.venueSingers) { singer in
VStack(spacing:2) {
HStack(spacing:0) {
Button { toggleSinger(singer.id) } label: {
Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right")
.font(.system(size:10,weight:.semibold)).frame(width:28,height:34).contentShape(Rectangle())
}.buttonStyle(.plain).accessibilityLabel("\(expandedSingers.contains(singer.id) ? "Collapse" : "Expand") \(singer.name)").accessibilityValue(expandedSingers.contains(singer.id) ? "Expanded" : "Collapsed").accessibilityIdentifier("singer.expand.\(singer.id)")
Button { expandedSingers.insert(singer.id);showSinger(singer.id) } label: {
HStack(spacing:7) { Image(systemName:"person").foregroundStyle(.secondary);Text(singer.name).lineLimit(1);Spacer() }.frame(height:34).contentShape(Rectangle())
}.buttonStyle(.plain).modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true))
Button { expandedSingers.insert(singer.id);showSinger(singer.id,section:"Tonight");songPickerTonight = true;songPickerSinger = singer } label: { Image(systemName:"plus").frame(width:25,height:30) }.buttonStyle(.plain).help("Add songs for \(singer.name)")
}.background(source == "Singer Songs" && model.selectedSinger == singer.id ? Color.accentColor.opacity(0.13) : Color.clear,in:RoundedRectangle(cornerRadius:7))
if expandedSingers.contains(singer.id) {
singerDestination("Tonight",symbol:"moon.stars",singer:singer)
.modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true))
singerDestination("Favourites",symbol:"star",singer:singer)
singerDestination("History",symbol:"clock",singer:singer)
singerDestination("All Songs",symbol:"music.note.list",singer:singer)
}
}
}
Button { model.beginSinger() } label: { Label("Add Singer…",systemImage:"plus").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain).foregroundStyle(.secondary)
sidebarHeading("Manage")
navigationRow("Venues",symbol:"building.2",count:model.venues.count)
navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count)
navigationRow("ShowScreens",symbol:"rectangle.on.rectangle")
navigationRow("Music Stores",symbol:"bag")
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
}.listStyle(.sidebar)
Divider().padding(.horizontal,14)
SidebarVideoMonitor(player:model.player).padding(14)
}.background(.ultraThinMaterial)
}
private func sidebarHeading(_ value: String) -> some View { Text(value).font(.system(size:11,weight:.semibold)).foregroundStyle(.secondary).lineLimit(1).padding(.top,14).padding(.bottom,5).padding(.horizontal,9) }
private func navigationRow(_ value: String,symbol: String,count: Int? = nil) -> some View {
Button { source = value } label: {
HStack(spacing:9) { Image(systemName:symbol).frame(width:18).foregroundStyle(source == value ? Color.accentColor : Color.secondary);Text(value);Spacer();if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) } }.padding(.horizontal,9).frame(height:34).contentShape(Rectangle())
}.buttonStyle(.plain).accessibilityIdentifier("nav."+value).background(source == value ? Color.accentColor.opacity(0.15) : .clear,in:RoundedRectangle(cornerRadius:7))
}
private func singerDestination(_ value: String,symbol: String,singer: PersonPlace) -> some View {
Button { showSinger(singer.id,section:value) } label: {
HStack(spacing:8) { Image(systemName:symbol).frame(width:16).foregroundStyle(.secondary);Text(value);Spacer() }.padding(.leading,35).padding(.trailing,9).frame(height:29).contentShape(Rectangle())
}.buttonStyle(.plain).background(source == "Singer Songs" && model.selectedSinger == singer.id && singerSection == value ? Color.accentColor.opacity(0.15) : .clear,in:RoundedRectangle(cornerRadius:6))
}
private func toggleSinger(_ id: Int64) { if expandedSingers.contains(id) { expandedSingers.remove(id) } else { expandedSingers.insert(id) } }
private var singerSongPane: some View {
VStack(alignment:.leading,spacing:12) {
if let singer = model.singers.first(where:{ $0.id == model.selectedSinger }) {
HStack { Text("\(singer.name) · \(singerSection)").font(.headline);Spacer();Button(singerSection == "Tonight" ? "Add to Tonight…" : "Add Songs…") { songPickerTonight = singerSection == "Tonight";songPickerSinger = singer } }
Text("\(model.currentVenueName) · Singer song lists follow them across venues.").font(.caption).foregroundStyle(.secondary)
if singerSection == "History" {
ScrollView { LazyVStack(alignment:.leading,spacing:12) { ForEach(model.history.filter { $0.singerID == singer.id }) { row in HStack { Text(row.date).font(.caption.monospaced());Text(row.song);Spacer() } } } }
} else if singerSection == "Tonight" {
ScrollView {
VStack(spacing:10) {
ForEach(Array(model.tonightSongs(for:singer.id).enumerated()),id:\.element.id) { index,song in
HStack {
Image(systemName:"line.3.horizontal").foregroundStyle(.secondary).help("Drag to reorder Tonight")
Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:18)
Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite")
Text(song.title).lineLimit(1);Spacer()
Text(model.queue.contains(where:{ $0.songID == song.id && $0.singerID == singer.id }) ? "Queued" : "Ready").font(.caption).foregroundStyle(.secondary)
Button("Play") { model.playTonight(song,singer:singer.id) }
Button("Queue Again") { model.selectedSinger = singer.id;model.enqueue(song.id) }
Button { model.moveTonight(song.id,singer:singer.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0).help("Move earlier")
Button { model.moveTonight(song.id,singer:singer.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.tonightSongs(for:singer.id).count-1).help("Move later")
Button("Remove") { model.removeFromTonight(song.id,singer:singer.id) }
}.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle())
.onDrag { model.dragReference(song.id,singer:singer.id,tonight:true).provider() }
.modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:song.id,enabled:true))
}
Text(model.tonightSongs(for:singer.id).isEmpty ? "Drop a library song here to add it to \(singer.name)’s Tonight." : "Drop here to append · Drop on a row to place before it")
.font(.callout).foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(18)
.overlay(RoundedRectangle(cornerRadius:4).strokeBorder(.white.opacity(0.16),style:StrokeStyle(lineWidth:1,dash:[5,4])))
.modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true))
}
}
} else {
ScrollView {
VStack(spacing:10) {
ForEach(model.songs(for:singer.id).filter { singerSection != "Favourites" || model.favourites[singer.id]?.contains($0.id) == true }) { song in
HStack {
Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Favourite for this singer")
Text(song.title).lineLimit(1);Spacer()
Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } }
Button("Add to Tonight") { model.addToTonight(song.id,singer:singer.id) }.disabled(model.currentVenue == nil)
Button("Remove") { model.assign(song.id,to:singer.id,included:false) }
}.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id }
.onDrag { model.dragReference(song.id).provider() }
}
if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) }
}
}
}
} else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() }
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
}
private var libraryPagination: some View {
HStack(spacing:12) {
Text(pagination.total == 0 ? "No songs" : "Showing \(pagination.range.lowerBound+1)–\(pagination.range.upperBound) of \(pagination.total) songs").foregroundStyle(.secondary)
Spacer()
Button { changePage(0) } label: { Image(systemName:"backward.end") }.disabled(pagination.number == 0).help("First page").accessibilityLabel("First library page")
Button { changePage(pagination.number-1) } label: { Image(systemName:"chevron.left") }.disabled(pagination.number == 0).help("Previous page").accessibilityLabel("Previous library page")
Text("Page \(pagination.number+1) of \(pagination.count)").monospacedDigit().frame(minWidth:95)
Button { changePage(pagination.number+1) } label: { Image(systemName:"chevron.right") }.disabled(pagination.number+1 >= pagination.count).help("Next page").accessibilityLabel("Next library page").accessibilityIdentifier("library.page.next")
Button { changePage(pagination.count-1) } label: { Image(systemName:"forward.end") }.disabled(pagination.number+1 >= pagination.count).help("Last page").accessibilityLabel("Last library page")
}.font(.caption).buttonStyle(LibraryActionButtonStyle(tint:.cyan)).padding(.horizontal,16).padding(.vertical,10)
}
private func changePage(_ requested:Int) { page = LibraryPage(total:libraryRows.count,requested:requested).number;tableSelection = [];selected = nil }
private var songTable: some View {
Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows(resetPage:true)})) {
TableColumn("ID",value:\Song.id) { song in
HStack(spacing:5) { NativeSongDragHandle(reference:model.dragReference(song.id),title:song.title).frame(width:14,height:24);Text("\(song.id)").foregroundStyle(.tertiary).monospacedDigit() }
}.width(min:48,ideal:55,max:75)
TableColumn("Title",value:\Song.title) { song in
HStack(spacing:8) {
if model.player.currentSongID == song.id { Image(systemName:"waveform").foregroundStyle(Color.accentColor).font(.caption) }
Text(song.title).fontWeight(.medium).lineLimit(1)
}.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() }
}.width(min:180,ideal:220,max:600)
TableColumn("Artist",value:\Song.artist) { song in Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary) }.width(min:90,ideal:110,max:240)
TableColumn("Album",value:\Song.album) { song in Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary) }.width(min:85,ideal:95,max:220)
TableColumn("Track",value:\Song.trackOrder) { song in Text(song.trackNumber.isEmpty ? "—" : song.trackNumber).monospacedDigit().foregroundStyle(.secondary) }.width(45)
TableColumn("Rendition",value:\Song.rendition) { song in Text(song.rendition.isEmpty ? "—" : song.rendition).foregroundStyle(.secondary) }.width(min:70,ideal:100,max:220)
TableColumn("Time",value:\Song.duration) { song in Text(durationLabel(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(50)
TableColumn("Key") { song in
HStack(spacing:2) {
Text(String(format:"%+.0f",model.tuningForControls(song.id).pitch)).monospacedDigit().frame(width:24)
Stepper("Key",value:Binding(get:{model.tuningForControls(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini)
}.help(model.player.currentSongID == song.id ? "Live key · \(model.liveSingerName)" : "Saved key for the selected singer or song")
}.width(65)
TableColumn("Tempo") { song in
Picker("Tempo",selection:Binding(get:{model.tuningForControls(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) {
ForEach(50...150,id:\.self) { Text("\($0)%").tag(Double($0)/100) }
}.labelsHidden().controlSize(.small)
}.width(78)
TableColumn("Rating",value:\Song.rating) { song in
GoldRating(rating:Binding(get:{model.displayedRating(song)},set:{model.rateSong(song,$0)}))
}.width(94)
}.tableStyle(.inset(alternatesRowBackgrounds:true))
.contextMenu(forSelectionType:Int64.self) { ids in
if ids.count > 1 {
Button("Reinterpret Names…") { beginReinterpretation(ids) }
Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true }
}
else if let id = ids.first,let song = model.songs.first(where:{$0.id == id}) { songActions(song) }
Divider()
Menu("Waveforms") { waveformActions(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
} primaryAction: { ids in if let id = ids.first { selected = id;tableSelection = [id];playSelected() } }
.overlay {
if visibleSongs.isEmpty {
VStack(spacing:12) {
Image(systemName:search.isEmpty ? "music.note.list" : "magnifyingglass").font(.system(size:34,weight:.light)).foregroundStyle(.tertiary)
Text(search.isEmpty ? "Your stage starts here" : "No matching songs").font(.headline)
Text(search.isEmpty ? "Import music or add a drive to build your library." : "Try a different title, artist or album.").foregroundStyle(.secondary)
if search.isEmpty { Button("Import Music…") { importing = true }.buttonStyle(.borderedProminent) }
}.padding(24)
}
}
}
@ViewBuilder private func songActions(_ song: Song) -> some View {
Button("Play") { selected = song.id;playSelected() }
Button("Song Settings…") { selected = song.id;tableSelection = [song.id];djSongSettings = true }
Button("Song Info…") { editingSong = song }
Button("Reinterpret Name…") { beginReinterpretation([song.id]) }
Button("Find Metadata Online…") { metadataLookup = song }
Button("Batch Edit Selected…") { if tableSelection.isEmpty { tableSelection = [song.id] };batchEditing = true }
Button("Write File Tags…") { tableSelection = [song.id];confirmWriteTags = true }.disabled(model.tagsBusy || song.audio.hasPrefix("showscreen:"))
Divider()
Menu("Add to Tonight") { ForEach(model.venueSingers) { singer in Button(singer.name) { model.addToTonight(song.id,singer:singer.id) } } }.disabled(model.currentVenue == nil)
Menu("Add to Singer") {
ForEach(model.venueSingers) { singer in Button(singer.name) { model.assign(song.id,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } }
Divider();Button("New Singer with This Song…") { model.beginSinger(song:song.id) }
}
Button("Add to Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil)
Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } }
}
private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" }
@State private var managingRoster = false
@State private var rosterNewName = ""
@State private var editingEntity: PersonPlace?
@State private var renameText = ""
@State private var pendingDelete: PersonPlace?
private var directory: some View {
VStack(alignment:.leading,spacing:0) {
HStack {
Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline)
Spacer()
if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) }
Button(source == "Singers" ? "New Singer…" : "New Venue…") { if source == "Singers" { model.beginSinger() } else { model.creating = .venue } }
}.padding(12).background(StudioTheme.header)
ScrollView {
VStack(alignment:.leading,spacing:0) {
if source == "Singers" && model.venueSingers.isEmpty {
Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding()
}
ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in
HStack {
Button {
if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) }
} label: {
HStack {
Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill")
Text(entity.name); Spacer()
if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) {
Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan)
Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan)
}
}.contentShape(Rectangle())
}.buttonStyle(.plain)
Button("Rename") { renameText = entity.name; editingEntity = entity }
Button("Delete",role:.destructive) { pendingDelete = entity }
}.padding(12)
}
}
}
HStack {
TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity)
Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty)
}.padding(12)
}.foregroundStyle(.primary).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
.sheet(isPresented:$managingRoster) { rosterSheet }
.sheet(item:$editingEntity) { entity in
VStack(alignment:.leading,spacing:16) {
Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline)
TextField("Name",text:$renameText).textFieldStyle(.roundedBorder)
HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(24).frame(width:360)
}
.confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) {
Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil }
}
}
private var rosterSheet: some View {
VStack(alignment:.leading,spacing:16) {
Text("Singers at \(model.currentVenueName)").font(.title2.bold())
Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary)
ScrollView {
VStack(alignment:.leading,spacing:12) {
ForEach(model.singers) { singer in
Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) }))
}
}.frame(maxWidth:.infinity,alignment:.leading)
}.frame(minHeight:120,maxHeight:280)
HStack {
TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder)
Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty)
Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction)
}
}.padding(24).frame(width:460)
}
private var detailPanel: some View {
VStack(alignment:.leading,spacing:9) {
HStack(spacing:10) {
Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) {
Text("Select venue…").tag(nil as Int64?)
ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) }
}
Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue")
Picker("Singer",selection:$model.selectedSinger) {
Text("None").tag(nil as Int64?)
ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) }
}
Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer")
Menu("Add to Singer") {
ForEach(model.venueSingers) { singer in Button(singer.name) { if let selected { model.assign(selected,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } }
Divider();Button("New Singer + Song…") { model.beginSinger(song:selected) }
}.disabled(selected == nil)
Button("Add to Tonight") { if let selected { model.addToTonight(selected) } }.disabled(selected == nil || model.currentVenue == nil || model.selectedSinger == nil)
}
PlaybackSettings(player:model.player,model:model,selectedSong:selected)
SeekBar(player:model.player)
}.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity)
.background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
}
@State private var playlistName = ""
@State private var selectedPlaylist: Int64?
private var queuePane: some View {
VStack(alignment:.leading,spacing:12) {
HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.studio.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) }
ScrollView {
VStack(spacing:0) {
ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in
HStack {
Text("\(index+1)").foregroundStyle(.secondary).frame(width:24)
Text(row.singer).frame(width:140,alignment:.leading)
Text(row.title).lineLimit(1); Spacer()
Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0)
Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1)
Picker("Singer",selection:Binding(get:{row.singerID},set:{model.assignQueue(row.id,singer:$0)})) { Text("No singer / Filler").tag(nil as Int64?);ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:130)
Button("Remove") { model.removeQueue(row.id) }
}.padding(.vertical,8)
}
if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) }
}
}
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
}
@State private var historyAllVenues = false
private var displayedHistory: [Performance] {
if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] }
return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name }
}
private var historyPane: some View {
VStack(alignment:.leading,spacing:12) {
HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) }
ScrollView {
LazyVStack(alignment:.leading,spacing:12) {
ForEach(displayedHistory) { row in
HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) }
}
}
}
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
}
private var playlistPane: some View { PlaylistWorkspace(model:model,query:$search) }
@ViewBuilder private var waveformActions: some View { waveformActions(tableSelection) }
@ViewBuilder private func waveformActions(_ ids: Set<Int64>) -> some View {
Button("Generate Waveform for Loaded Track") { if let id = model.player.currentSongID { model.generateWaveforms([id]) } }.disabled(model.player.currentSongID == nil)
if ids.count == 1 {
Button("Generate Waveform for Selected Track") { model.generateWaveforms(ids) }
} else {
Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(ids.isEmpty)
}
Button("Generate Waveforms for All Library Tracks") { model.generateWaveforms() }.disabled(model.songs.isEmpty)
Divider()
Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(ids.isEmpty)
Button("Regenerate All Library Waveforms") { model.generateWaveforms(force:true) }.disabled(model.songs.isEmpty)
}
private var footer: some View {
HStack(spacing:8) {
Circle().fill(Color.green.opacity(0.85)).frame(width:5,height:5)
Text("\(libraryRows.count) songs").font(.caption).foregroundStyle(.secondary)
if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
if tableSelection.count > 1 { Button("Edit \(tableSelection.count) Songs…") { batchEditing = true }.controlSize(.small) }
Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary)
RepeatButton(player:model.player).buttonStyle(.borderless)
Menu {
Button("Reinterpret Selected Names…") { beginReinterpretation() }.disabled(tableSelection.isEmpty && selectedSong == nil)
Button("Batch Edit Selected…") { batchEditing = true }.disabled(tableSelection.isEmpty)
Button("Find Metadata Online…") { metadataLookup = selectedSong }.disabled(selectedSong == nil)
Button("Write File Tags…") { confirmWriteTags = true }.disabled(tableSelection.isEmpty || model.tagsBusy)
Divider()
Button("Read Missing Durations") { model.scanDurations() }.disabled(model.readingDurations)
Button("Song Info…") { editingSong = selectedSong }.disabled(selectedSong == nil)
} label: { Image(systemName:"ellipsis.circle") }.menuStyle(.borderlessButton).frame(width:20).help("Library actions")
}
}
private func beginReinterpretation(_ ids:Set<Int64>? = nil) {
let chosen = ids ?? (source == "Library" && !tableSelection.isEmpty ? tableSelection : Set(selected.map{[$0]} ?? []))
guard !chosen.isEmpty else { return };reinterpretIDs = chosen;reinterpreting = true
}
private func playSelected() {
guard let song = selectedSong else { return }
model.perform { try model.play(song) }
}
private func playNext() {
guard !libraryRows.isEmpty else { return }
let index = libraryRows.firstIndex { $0.id == selected } ?? -1
let next = (index+1)%libraryRows.count
page = next/LibraryPage.size;selected = libraryRows[next].id;tableSelection = [libraryRows[next].id];playSelected()
}
private func addEntity() {
model.perform {
let kind: EntityKind = source == "Singers" ? .singer : .venue
let id = try model.createEntity(kind, name: name)
if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) }
name = ""
}
}
}
/// AppKit starts this drag directly, so row click/double-click recognisers cannot swallow it.
private struct NativeSongDragHandle: NSViewRepresentable {
let reference: SongDragReference
let title: String
func makeNSView(context: Context) -> NativeSongDragView { let view = NativeSongDragView();view.reference = reference;view.title = title;return view }
func updateNSView(_ view: NativeSongDragView,context: Context) { view.reference = reference;view.title = title }
}
private final class NativeSongDragView: NSView,NSDraggingSource {
var reference: SongDragReference?
var title = "Song"
private var start: NSPoint?
private var dragging = false
override func draw(_ dirtyRect: NSRect) {
NSColor.secondaryLabelColor.setStroke()
let path = NSBezierPath();path.lineWidth = 1
for y in [7.0,10.0,13.0] { path.move(to:NSPoint(x:3,y:y));path.line(to:NSPoint(x:13,y:y)) };path.stroke()
}
override func resetCursorRects() { addCursorRect(bounds,cursor:.openHand) }
override func mouseDown(with event: NSEvent) { start = event.locationInWindow;dragging = false }
override func mouseDragged(with event: NSEvent) {
guard !dragging,let reference else { return }
if let start,hypot(event.locationInWindow.x-start.x,event.locationInWindow.y-start.y) < 3 { return }
dragging = true
let item = NSDraggingItem(pasteboardWriter:reference.transportText as NSString)
let text = title
let image = NSImage(size:NSSize(width:240,height:28),flipped:false) { rect in
NSColor.windowBackgroundColor.setFill();NSBezierPath(roundedRect:rect,xRadius:5,yRadius:5).fill()
let paragraph = NSMutableParagraphStyle();paragraph.lineBreakMode = .byTruncatingTail
(text as NSString).draw(in:NSRect(x:10,y:6,width:220,height:18),withAttributes:[.font:NSFont.systemFont(ofSize:12),.foregroundColor:NSColor.labelColor,.paragraphStyle:paragraph]);return true
}
item.setDraggingFrame(NSRect(origin:convert(event.locationInWindow,from:nil),size:NSSize(width:240,height:28)),contents:image)
beginDraggingSession(with:[item],event:event,source:self).animatesToStartingPositionsOnCancelOrFail = true
}
override func mouseUp(with event: NSEvent) { start = nil;dragging = false }
func draggingSession(_ session: NSDraggingSession,sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { [.copy,.move] }
func draggingSession(_ session: NSDraggingSession,endedAt screenPoint: NSPoint,operation: NSDragOperation) { start = nil;dragging = false }
}
private struct TonightSongDropTarget: ViewModifier {
@ObservedObject var model: AppModel
let singer: Int64
let venue: Int64?
let before: Int64?
let enabled: Bool
@State private var targeted = false
func body(content: Content) -> some View {
content.contentShape(Rectangle())
.background(targeted && enabled ? Color.cyan.opacity(0.14) : .clear)
.overlay(alignment:.top) { if targeted && enabled { Rectangle().fill(.cyan).frame(height:2) } }
.onDrop(of:enabled && venue != nil ? [SongDragReference.type] : [],isTargeted:$targeted) { providers in
guard enabled else { return false }
return model.acceptSongDrop(providers,singer:singer,venue:venue,before:before)
}
}
}
/// Drawn with literal colours, so native menu/button tint cannot turn favourite stars white.
struct GoldStar: View {
var filled: Bool
var size: CGFloat = 14
var body: some View {
Canvas { context,bounds in
let centre = CGPoint(x:bounds.width/2,y:bounds.height/2),outer = min(bounds.width,bounds.height)*0.46
var star = Path()
for point in 0..<10 {
let angle = Double(point)*Double.pi/5-Double.pi/2,radius = point % 2 == 0 ? outer : outer*0.43
let p = CGPoint(x:centre.x+CGFloat(cos(angle))*radius,y:centre.y+CGFloat(sin(angle))*radius)
if point == 0 { star.move(to:p) } else { star.addLine(to:p) }
}
star.closeSubpath()
let gold = Color(red:1,green:0.73,blue:0.08)
if filled { context.fill(star,with:.color(gold)) }
else { context.stroke(star,with:.color(gold.opacity(0.7)),lineWidth:1) }
}.frame(width:size,height:size).accessibilityHidden(true)
}
}
struct GoldRating: View {
@Binding var rating: Int
var body: some View {
HStack(spacing:2) { ForEach(1..<6) { value in Button { rating = rating == value ? 0 : value } label: { GoldStar(filled:value <= rating) }.buttonStyle(.plain).help("\(value) stars; click the selected rating to clear").accessibilityLabel("Set \(value) stars") } }
}
}
private struct SongMetadataSheet: View {
@ObservedObject var model: AppModel
let song: Song
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var artist = ""
@State private var album = ""
@State private var label = ""
@State private var track = ""
@State private var rendition = ""
@State private var rating = 0
@State private var error: String?
var body: some View {
VStack(alignment:.leading,spacing:14) {
Text("Edit Song Metadata").font(.title2.bold())
Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:10) {
GridRow { Text("Title");TextField("Title",text:$title) }
GridRow { Text("Artist");TextField("Artist",text:$artist) }
GridRow { Text("Album");TextField("Album",text:$album) }
GridRow { Text("Label");TextField("Label / publisher",text:$label) }
GridRow { Text("Track");TextField("Track number",text:$track) }
GridRow { Text("Rendition");TextField("Version / vocals",text:$rendition) }
GridRow { Text("Rating");GoldRating(rating:$rating) }
}.textFieldStyle(.roundedBorder)
Text("Saved to your library. Embedded audio-file tags are not changed, and re-importing will preserve your library edits.").font(.caption).foregroundStyle(.secondary)
Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio)
if let error { Text(error).foregroundStyle(.red).font(.callout) }
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(24).frame(width:500).background(StudioTheme.frame)
.onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating;track = song.trackNumber;rendition = song.rendition }
}
private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating,trackNumber:track,rendition:rendition);dismiss() } catch { self.error = error.localizedDescription } }
}
private struct LibraryLocationsPane: View {
@ObservedObject var model: AppModel
@Binding var selected: Int64?
@State private var removing: Int64?
var body: some View {
VStack(alignment:.leading,spacing:14) {
HStack { Text("Library Locations").font(.headline);Spacer();Button("Add Drive / Folder…") { model.chooseMediaLocation() }.disabled(model.scanningSource != nil) }
Text("Index a mounted drive or folder in place and prepare its waveforms. Reconnect external drives to play their referenced audio. ZIP contents are copied into managed media for playback.").font(.callout).foregroundStyle(.secondary)
ScrollView {
VStack(alignment:.leading,spacing:12) {
ForEach(model.mediaLocations) { location in
VStack(alignment:.leading,spacing:8) {
HStack {
Image(systemName:location.external ? "externaldrive.fill" : "folder.fill").foregroundStyle(.cyan)
Text(location.name).font(.headline)
Text(location.online ? "Online" : "Offline").font(.caption).foregroundStyle(location.online ? Color.green : Color.orange)
Spacer();Text("\(location.songCount) songs").font(.caption).foregroundStyle(.secondary)
}
Text(location.path).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
HStack {
Button("Scan / Prepare") { model.startSourceScan(location.id) }.disabled(model.scanningSource != nil)
Button("Rebuild Waveforms") { model.startSourceScan(location.id,forceWaveforms:true) }.disabled(model.scanningSource != nil)
Spacer();Button("Remove Location",role:.destructive) { removing = location.id }.disabled(model.scanningSource != nil)
}
if !location.lastScan.isEmpty { Text("Last scan: \(location.lastScan)").font(.caption).foregroundStyle(.secondary) }
if !location.lastError.isEmpty { Text(location.lastError).font(.caption).foregroundStyle(.orange) }
}.padding(12).background(selected == location.id ? Color.blue.opacity(0.13) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = location.id }
}
if model.mediaLocations.isEmpty { Text("No locations added. Choose Add Drive / Folder… to index an external drive or music folder.").foregroundStyle(.secondary).padding(.vertical,25) }
}
}
if let progress = model.scanProgress {
Divider()
HStack { Text(progress.current).font(.callout).lineLimit(1);Spacer();if model.scanningSource != nil { ProgressView().controlSize(.small);Button("Cancel") { model.cancelSourceScan() } } }
Text("\(progress.files) files scanned · \(progress.indexed) songs indexed · \(progress.generated) waveforms built · \(progress.cached) cached · \(progress.issues) issues").font(.caption).foregroundStyle(.secondary)
if !progress.messages.isEmpty { DisclosureGroup("Scan details") { ScrollView { Text(progress.messages.joined(separator:"\n")).font(.caption.monospaced()).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(maxHeight:100) } }
}
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
.confirmationDialog("Remove this location? Its files and library songs will be kept.",isPresented:Binding(get:{ removing != nil },set:{ if !$0 { removing = nil } })) {
Button("Remove Location",role:.destructive) { if let removing { model.removeMediaLocation(removing) };removing = nil }
}
}
}
private struct MusicStoresPane: View {
@ObservedObject var model: AppModel
@Binding var selected: Int64?
@Environment(\.openURL) private var openURL
@State private var editor = false
@State private var editing: MusicStore?
@State private var catalogue: MusicStore?
@State private var search = ""
@State private var deleting: Int64?
var body: some View {
VStack(alignment:.leading,spacing:14) {
HStack { Text("Music Stores").font(.headline);Spacer();Button("Add All Provider Profiles") { model.installStoreProfiles() };Button("Add Store…") { editing = nil;editor = true } }
Text("Define websites and optional catalogue-search URLs. Purchases and subscriptions are handled by the store in your browser.").font(.callout).foregroundStyle(.secondary)
HStack { TextField("Search store catalogue",text:$search).textFieldStyle(.roundedBorder);Text("{query} in a store’s search URL is replaced with this text.").font(.caption).foregroundStyle(.secondary) }
ScrollView {
VStack(alignment:.leading,spacing:12) {
ForEach(model.musicStores) { store in
VStack(alignment:.leading,spacing:8) {
HStack { Image(systemName:"bag.fill").foregroundStyle(.green);Text(store.name).font(.headline);Spacer()
Button("Catalogue…") { catalogue = store }
Button("Open Store") { open(store,query:"") }
Button("Search") { open(store,query:search) }.disabled(search.isEmpty || store.searchTemplate.isEmpty)
Button("Edit") { editing = store;editor = true }
Button("Delete",role:.destructive) { deleting = store.id }
}
Text(store.url).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
}.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id }
}
if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) }
}
}
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
.sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) }
.sheet(item:$catalogue) { store in StoreCatalogueSheet(model:model,store:store) }
.confirmationDialog("Delete this store definition?",isPresented:Binding(get:{ deleting != nil },set:{ if !$0 { deleting = nil } })) {
Button("Delete",role:.destructive) { if let deleting { model.deleteStore(deleting) };deleting = nil }
}
}
private func open(_ store: MusicStore,query: String) {
do { openURL(try store.destination(query:query)) } catch { model.error = error.localizedDescription }
}
}
private struct MusicStoreEditor: View {
@ObservedObject var model: AppModel
let store: MusicStore?
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var url = ""
@State private var search = ""
@State private var error: String?
var body: some View {
VStack(alignment:.leading,spacing:14) {
Text(store == nil ? "Add Music Store" : "Edit Music Store").font(.title2.bold())
TextField("Store name",text:$name).textFieldStyle(.roundedBorder)
TextField("Website URL — https://example.com",text:$url).textFieldStyle(.roundedBorder)
TextField("Optional search URL — https://example.com/search?q={query}",text:$search).textFieldStyle(.roundedBorder)
Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary)
if let error { Text(error).foregroundStyle(.red).font(.callout) }
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) }
}.padding(24).frame(width:500).background(StudioTheme.frame)
.onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" }
}
private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } }
}
/// Finder owns server authentication and stores credentials through macOS.
/// Only the user-selected mounted folder is passed to the ordinary copy importer.
enum SMBImportAddress {
static func url(_ address:String) throws -> URL {
let text = address.trimmingCharacters(in:.whitespacesAndNewlines)
guard let url = URL(string:text),url.scheme?.lowercased() == "smb",
let host = url.host,!host.isEmpty,url.user == nil,url.password == nil,
url.query == nil,url.fragment == nil else {
throw LibraryError.message("Enter an SMB address such as smb://nas/Music. Leave usernames and passwords out; macOS will ask you to sign in.")
}
return url
}
}
private struct ServerImportSheet: View {
let selected: ([URL]) -> Void
@Environment(\.dismiss) private var dismiss
@State private var address = "smb://"
@State private var choosingFolder = false
@State private var error: String?
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text("Import from SMB/NAS").font(.title2.bold())
Text("Connect to your server, then choose a folder on its mounted drive. Supported files in that folder and its subfolders will be copied into your media library.").fixedSize(horizontal:false,vertical:true)
TextField("smb://nas/Music",text:$address).textFieldStyle(.roundedBorder)
Button("Connect in Finder…") {
do {
let url = try SMBImportAddress.url(address)
guard NSWorkspace.shared.open(url) else { throw LibraryError.message("Could not open the server connection in Finder.") }
error = nil
} catch { self.error = error.localizedDescription }
}
Text("Finish signing in through macOS. Then use Choose Server Folder to select the connected drive under Locations. You can also choose a drive that is already connected.").font(.callout).foregroundStyle(.secondary).fixedSize(horizontal:false,vertical:true)
if let error { Text(error).foregroundStyle(.red).fixedSize(horizontal:false,vertical:true) }
HStack {
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
Spacer()
Button("Choose Server Folder…") {
choosingFolder = true
}.keyboardShortcut(.defaultAction)
}
}.padding(24).frame(width:500)
.fileImporter(isPresented:$choosingFolder,allowedContentTypes:[.folder],allowsMultipleSelection:false) { result in
switch result {
case .success(let urls):
guard !urls.isEmpty else { return }
selected(urls)
dismiss()
case .failure(let error): self.error = error.localizedDescription
}
}
}
}
private struct SingerSongsSheet: View {
@ObservedObject var model: AppModel
let singer: PersonPlace
var tonight = false
@Environment(\.dismiss) private var dismiss
@State private var search = ""
@State private var importing = false
@State private var importingServer = false
@State private var serverImportURLs = [URL]()
var body: some View {
VStack(alignment:.leading,spacing:14) {
Text(tonight ? "Tonight for \(singer.name)" : "Songs for \(singer.name)").font(.title2.bold())
Text(tonight ? "Tick songs for this venue’s Tonight list. They stay here after playback and when switching singers." : "Tick songs to add them to this singer’s repertoire.").font(.callout).foregroundStyle(.secondary)
TextField("Search library",text:$search).textFieldStyle(.roundedBorder)
ScrollView {
LazyVStack(alignment:.leading,spacing:12) {
ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in
HStack {
Toggle(song.title,isOn:Binding(get:{ tonight ? model.tonightSongIDs[singer.id]?.contains(song.id) == true : model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ included in
if tonight { if included { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) } }
else { model.assign(song.id,to:singer.id,included:included) }
})).toggleStyle(.checkbox)
Spacer()
Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite")
}
}
if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) }
}.frame(maxWidth:.infinity,alignment:.leading)
}.frame(height:270)
HStack { Button("Import Songs…") { importing = true };Button("SMB/NAS…") { importingServer = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:520).background(StudioTheme.frame)
.disabled(model.storageProgress != nil)
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } }
.sheet(isPresented:$importingServer,onDismiss:{
let urls = serverImportURLs;serverImportURLs = []
model.importFiles(urls)
}) { ServerImportSheet { serverImportURLs = $0 } }
.fileImporter(isPresented:$importing,allowedContentTypes:[.folder,.audio,.movie,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in
switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription }
}
}
}
private struct AddEntitySheet: View {
@ObservedObject var model: AppModel
let kind: EntityKind
let saved: (Int64) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var error: String?
@State private var venue: Int64?
@State private var songs = Set<Int64>()
@State private var search = ""
@FocusState private var focused: Bool
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text(kind == .singer ? "New Singer + Songs" : "New Venue").font(.title2.bold())
TextField("\(kind.title) name",text:$name).textFieldStyle(.roundedBorder).focused($focused).onSubmit(save)
if kind == .singer {
Picker("Venue",selection:$venue) { Text("Choose a venue…").tag(nil as Int64?);ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } }
Text(venue == nil ? "Create a venue first, then add a singer to it." : "Singer will be marked Here. Selected songs join Tonight and the rotation.").font(.caption).foregroundStyle(.secondary)
Text("Add songs to Tonight (optional)").font(.headline)
TextField("Search library",text:$search).textFieldStyle(.roundedBorder)
ScrollView {
LazyVStack(alignment:.leading,spacing:10) {
ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in
Toggle(song.title,isOn:Binding(get:{ songs.contains(song.id) },set:{ if $0 { songs.insert(song.id) } else { songs.remove(song.id) } })).toggleStyle(.checkbox)
}
if model.songs.isEmpty { Text("No library songs yet. You can add songs from the singer’s + button after importing.").font(.callout).foregroundStyle(.secondary) }
}.frame(maxWidth:.infinity,alignment:.leading)
}.frame(height:190)
}
if let error { Text(error).foregroundStyle(.red).font(.callout) }
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil))
}
}.padding(24).frame(width:kind == .singer ? 500 : 360).background(StudioTheme.frame)
.onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs }
}
private func save() {
do {
let id: Int64
if kind == .singer { guard let venue else { throw LibraryError.message("Choose a venue") };id = try model.createSinger(name:name,venue:venue,songs:songs) }
else { id = try model.createEntity(kind,name:name) }
saved(id);dismiss()
} catch { self.error = error.localizedDescription }
}
}
private struct LiveTuningControls: View {
@ObservedObject var model: AppModel
@ObservedObject var player: Player
var body: some View {
VStack(alignment:.leading,spacing:5) {
Text(model.liveSingerName).font(.caption2).foregroundStyle(.secondary).lineLimit(1).help("Live changes save to the performing singer and this song")
HStack {
Text("Key").frame(width:40,alignment:.leading)
Stepper(value:Binding(get:{player.pitch},set:{model.updateLiveTuning(pitch:$0)}),in:-12...12,step:1) { Text(String(format:"%+.0f",player.pitch)).monospacedDigit().frame(width:30) }.accessibilityLabel("Live key for performing singer")
}
HStack {
Text("Tempo").frame(width:40,alignment:.leading)
Stepper(value:Binding(get:{player.tempo},set:{model.updateLiveTuning(tempo:$0)}),in:0.5...1.5,step:0.01) { Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) }.accessibilityLabel("Live tempo for performing singer")
Button { model.updateLiveTuning(pitch:0,tempo:1) } label: { Image(systemName:"arrow.counterclockwise") }.buttonStyle(.borderless).help("Reset this singer’s key and tempo")
}
}.font(.caption).frame(width:190).disabled(player.currentSongID == nil || player.duration <= 0)
}
}
private struct TransportView: View {
@ObservedObject var player: Player
@ObservedObject var model: AppModel
@Binding var search: String
let hasSelection: Bool
let play: () -> Void
let next: () -> Void
let display: () -> Void
var body: some View {
HStack(spacing:24) {
VStack(spacing:10) {
HStack(spacing:8) {
TransportButton(symbol:"gobackward.10",label:"Back 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time-10) }
TransportButton(symbol:player.playing ? "pause.fill" : "play.fill",label:player.playing ? "Pause" : "Play",size:48,enabled:hasSelection || player.duration > 0) {
if player.duration > 0 { player.toggle() } else { play() }
}
TransportButton(symbol:"stop.fill",label:"Stop",size:28,enabled:player.duration > 0) { player.stopDeck() }
TransportButton(symbol:"goforward.10",label:"Forward 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time+10) }
TransportButton(symbol:"forward.end.fill",label:"Next song",size:26,enabled:hasSelection,action:next)
}
HStack(spacing:8) {
Image(systemName:"speaker.fill").font(.caption2).foregroundStyle(.secondary)
Slider(value:$player.volume,in:0...1).controlSize(.small).accessibilityLabel("Global master volume")
Image(systemName:"speaker.wave.3.fill").font(.caption2).foregroundStyle(.secondary)
}.frame(width:172)
LiveTuningControls(model:model,player:player)
}.frame(width:200)
NowPlayingWaveform(player:player)
}.frame(height:174)
}
}
private struct NowPlayingWaveform: View {
@ObservedObject var player: Player
@State private var mixer = false
@State private var zoom: Double = 1
@State private var bpm: Double?
private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) }
var body: some View {
VStack(alignment:.leading,spacing:5) {
Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94))
.lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title)
DJWaveform(player:player,color:.cyan,bpm:bpm).frame(height:14)
DJScrollingSpectrum(player:player,bpm:bpm,seconds:16/zoom)
.frame(height:46).background(.black)
.background(WaveformJogWheel(player:player))
.accessibilityAdjustableAction { direction in player.seek(player.time+(direction == .increment ? 5 : -5)) }
HStack(spacing:5) {
Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4)
Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1)
if let bpm { Text(String(format:"· %.1f BPM~",bpm*player.tempo)).monospacedDigit() }
if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) }
Spacer(minLength:0)
Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out")
Button { zoom = min(32,zoom*2) } label: { Image(systemName:"plus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom >= 32).help("Zoom in for precise scrubbing")
Menu("\(Int(zoom))×") { ForEach([1.0,2,4,8,16,32],id:\.self) { value in Button("\(Int(value))× zoom") { zoom = value } } }.menuStyle(.borderlessButton).frame(width:34).help("Waveform zoom around the playhead")
Menu { Button("Re-analyse Waveform") { player.reanalyseWaveform() }.disabled(player.duration == 0)
Text(player.waveformLoading ? "Analysing…" : player.waveformFromCache ? "Loaded saved waveform" : player.waveformSaved ? "Analysis saved" : "No saved analysis")
} label: { Image(systemName:"waveform") }.menuStyle(.borderlessButton).frame(width:20).help("Waveform cache and re-analysis")
Button("Mixer") { mixer.toggle() }.buttonStyle(.plain).foregroundStyle(.white.opacity(0.85))
.padding(.horizontal,6).padding(.vertical,2).background(.white.opacity(0.08),in:RoundedRectangle(cornerRadius:3))
.popover(isPresented:$mixer) { ToneMixerView(player:player) }
Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5)
}.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6))
DJDeckControls(player:player)
}.padding(.horizontal,14).padding(.vertical,10).frame(maxWidth:.infinity).frame(height:174)
.background(Color(.sRGB,red:0.055,green:0.065,blue:0.08,opacity:1),in:RoundedRectangle(cornerRadius:10))
.overlay(RoundedRectangle(cornerRadius:10).strokeBorder(.white.opacity(0.1),lineWidth:1))
.environment(\.colorScheme,.dark)
.task(id:player.currentSongID) {
bpm = nil
guard let source = player.sourceAudioURL,!player.isShowScreen else { return }
let analysis = Task.detached(priority:.utility) { try? DJBPM.analyse(source,cancelled:{Task.isCancelled}) }
let result = await withTaskCancellationHandler(operation:{ await analysis.value },onCancel:{ analysis.cancel() })
if !Task.isCancelled { bpm = result }
}
}
}
private struct ToneKnob: View {
let title: String
@Binding var value: Double
let range: ClosedRange<Double>
var filter = false
@State private var initial: Double?
private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) }
private var display: String {
if filter { return abs(value) < 0.01 ? "OFF" : value < 0 ? "LOW PASS" : "HIGH PASS" }
return String(format:"%+.1f dB",value)
}
var body: some View {
VStack(spacing:4) {
ZStack {
Circle().trim(from:0,to:0.75).stroke(Color(white:0.21),style:StrokeStyle(lineWidth:4,lineCap:.round)).rotationEffect(.degrees(135))
Circle().trim(from:0,to:0.75*fraction).stroke(Color(white:0.48),style:StrokeStyle(lineWidth:3,lineCap:.round)).rotationEffect(.degrees(135))
Circle().fill(LinearGradient(colors:[Color(white:0.19),Color(white:0.09)],startPoint:.top,endPoint:.bottom)).padding(6).overlay(Circle().stroke(.black.opacity(0.65),lineWidth:1.5).padding(6))
Capsule().fill(.white).frame(width:2,height:12).offset(y:-13).rotationEffect(.degrees(-135+270*fraction))
}.frame(width:50,height:50).contentShape(Circle())
.gesture(DragGesture(minimumDistance:0).onChanged { gesture in
if initial == nil { initial = value }
value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(gesture.translation.height)/130*(range.upperBound-range.lowerBound)))
}.onEnded { _ in initial = nil })
.simultaneousGesture(TapGesture(count:2).onEnded { value = 0 })
.accessibilityElement().accessibilityLabel(title).accessibilityValue(display)
.accessibilityAdjustableAction { direction in
let change = (range.upperBound-range.lowerBound)/24
value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? change : -change)))
}.help("Drag up/down to adjust. Double-click to reset.")
Text(title).font(.system(size:10,weight:.bold)).foregroundStyle(Color(white:0.6))
Text(display).font(.system(size:8,design:.monospaced)).foregroundStyle(Color(white:0.45))
}
}
}
private struct StereoMeter: View {
@ObservedObject var player: Player
private func normal(_ level: Float) -> Double { max(0,min(1,(20*log10(Double(max(level,0.000001)))+60)/60)) }
var body: some View {
HStack(spacing:3) {
ForEach(0..<2) { channel in
VStack(spacing:2) {
ForEach((0..<24).reversed(),id:\.self) { segment in
let on = Double(segment)/24 < normal(channel == 0 ? player.leftLevel : player.rightLevel)
Rectangle().fill(on ? (segment > 21 ? Color.red : segment > 17 ? Color.yellow : Color.green) : Color.black.opacity(0.5))
}
}
}
}.frame(width:23,height:125).accessibilityLabel("Stereo output levels")
}
}
private struct MixerFader: View {
@Binding var value: Double
var body: some View {
GeometryReader { geometry in
let travel = geometry.size.height-22
ZStack(alignment:.top) {
VStack { ForEach(0..<7) { _ in Rectangle().fill(.white.opacity(0.09)).frame(height:1);Spacer(minLength:0) } }.padding(.vertical,11)
Capsule().fill(.black.opacity(0.8)).frame(width:5).padding(.vertical,8)
RoundedRectangle(cornerRadius:5).fill(LinearGradient(colors:[Color(white:0.45),Color(white:0.23)],startPoint:.top,endPoint:.bottom))
.frame(width:45,height:20).overlay(Rectangle().fill(.white.opacity(0.15)).frame(width:18,height:1))
.offset(y:(1-value)*travel)
}.frame(maxWidth:.infinity).contentShape(Rectangle())
.gesture(DragGesture(minimumDistance:0).onChanged { value = min(1,max(0,1-Double(($0.location.y-11)/travel))) })
}.frame(width:56,height:135).accessibilityLabel("Master volume").accessibilityValue("\(Int(value*100)) percent")
.accessibilityAdjustableAction { direction in value = min(1,max(0,value+(direction == .increment ? 0.05 : -0.05))) }
}
}
private struct ToneMixerView: View {
@ObservedObject var player: Player
var body: some View {
VStack(spacing:14) {
Text("Global settings · Saved automatically").font(.caption).foregroundStyle(.secondary)
HStack { Text("GLOBAL TONE / MASTER").font(.system(size:12,weight:.bold));Spacer();Toggle("EQ",isOn:$player.toneEnabled).toggleStyle(.switch).controlSize(.mini) }
HStack(alignment:.top,spacing:28) {
VStack(spacing:8) {
ToneKnob(title:"HIGH",value:$player.highGain,range:-12...12)
ToneKnob(title:"MID",value:$player.midGain,range:-12...12)
ToneKnob(title:"LOW",value:$player.lowGain,range:-12...12)
}
VStack(spacing:15) {
ToneKnob(title:"FILTER",value:$player.filter,range:-1...1,filter:true)
HStack(spacing:20) { StereoMeter(player:player);MixerFader(value:$player.volume) }
Text(player.volume <= 0 ? "−∞ dB" : String(format:"%+.1f dB",20*log10(player.volume))).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary)
}
}.padding(.horizontal,15)
HStack { Text("Drag knobs · Double-click to reset").font(.system(size:9)).foregroundStyle(.secondary);Spacer();Button("Reset EQ") { player.resetTone() }.controlSize(.small) }
}.padding(18).frame(width:270).background(Color(white:0.105)).preferredColorScheme(.dark)
}
}
private struct PlaybackSettings: View {
@ObservedObject var player: Player
@ObservedObject var model: AppModel
let selectedSong: Int64?
private var target: Int64? { selectedSong ?? player.currentSongID }
private var live: Bool { player.currentSongID != nil && player.duration > 0 }
private var pitch: Binding<Double> { Binding(get:{live ? player.pitch : target.map{model.preference($0).pitch} ?? 0},set:{value in if live { model.updateLiveTuning(pitch:value) } else if let target { model.updateTuning(target,pitch:value) }}) }
private var tempo: Binding<Double> { Binding(get:{live ? player.tempo : target.map{model.preference($0).tempo} ?? 1},set:{value in if live { model.updateLiveTuning(tempo:value) } else if let target { model.updateTuning(target,tempo:value) }}) }
private var rating: Binding<Int> { Binding(get:{target.flatMap { id in model.songs.first{$0.id == id} }.map{model.displayedRating($0)} ?? 0},set:{value in if let song = model.songs.first(where:{$0.id == target}) { model.rateSong(song,value) }}) }
var body: some View {
HStack(spacing:10) {
Text("Key")
Picker("Key",selection:pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70)
Text("Tempo");Slider(value:tempo,in:0.5...1.5,step:0.05).frame(width:85)
Text(String(format:"%.0f%%",tempo.wrappedValue*100)).monospacedDigit().frame(width:40)
Text("Selected rating");GoldRating(rating:rating).frame(width:88)
Text(live ? "Live: \(model.liveSingerName)" : model.selectedSinger == nil ? "Song defaults · Saved" : "Singer settings · Saved").font(.caption).foregroundStyle(.secondary)
Menu {
Button("Reset key / tempo") { pitch.wrappedValue = 0;tempo.wrappedValue = 1 }
ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } }
} label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22)
}.font(.system(size:11)).disabled(target == nil && !live)
}
}
private struct SeekBar: View {
@ObservedObject var player: Player
private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) }
var body: some View {
HStack(spacing: 10) {
Text(clock(player.time))
Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration))
.controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position")
Text(clock(player.duration))
}.font(.system(size: 10, design: .monospaced)).foregroundStyle(.secondary)
}
}
private struct RepeatButton: View {
@ObservedObject var player: Player
var body: some View {
Button { player.repeating.toggle() } label: {
Image(systemName: "repeat").font(.system(size: 13, weight: .medium))
.foregroundStyle(player.repeating ? Color.accentColor : Color.secondary).frame(width: 32, height: 24)
}.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off")
}
}
// Keep one change-observer API across the supported macOS versions.
extension View {
@ViewBuilder func onStudioChange<Value: Equatable>(of value: Value,perform action: @escaping (Value) -> Void) -> some View {
if #available(macOS 14.0, *) { onChange(of:value) { _,newValue in action(newValue) } }
else { legacyStudioChange(of:value,perform:action) }
}
@available(macOS,introduced:10.15,deprecated:14.0)
private func legacyStudioChange<Value: Equatable>(of value: Value,perform action: @escaping (Value) -> Void) -> some View {
onChange(of:value,perform:action)
}
}