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]() @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? private var scanToken: UUID? var newSingerSongs = Set() 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() @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? 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) { 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? private var waveformJobToken: UUID? func generateWaveforms(_ ids: Set? = 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? 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) 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 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? 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() @State private var waveformDetails = false @State private var batchEditing = false @State private var reinterpreting = false @State private var reinterpretIDs = Set() @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 { get { model.expandedSingerIDs } nonmutating set { model.expandedSingerIDs = newValue } } @State private var expandedSongLists = Set() @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) -> 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? = 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() @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 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 { 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 { 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 { 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(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(of value: Value,perform action: @escaping (Value) -> Void) -> some View { onChange(of:value,perform:action) } }