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() 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 tagsBusy = false @Published var tagsStatus = "" let singerServer = SingerWebServer() var playingIntermission = false @Published var error: String? @Published var creating: EntityKind? @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 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 = WaveformBatch.run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled},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 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") player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")) perform { try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) library = try Library(url:root.appendingPathComponent("Library.sqlite")); 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() } 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 updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) { 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) 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) 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 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 = root.appendingPathComponent("Media"),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() } } func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } } private struct LibraryCommands { var importMusic: () -> Void var songInfo: () -> Void var canEdit: Bool var play: () -> Void var next: () -> Void var video: () -> Void var settings: () -> Void } private struct LibraryCommandsKey: FocusedValueKey { typealias Value = LibraryCommands } private extension FocusedValues { var libraryCommands: LibraryCommands? { get { self[LibraryCommandsKey.self] } set { self[LibraryCommandsKey.self] = newValue } } } @main struct KJMixProApp: App { @StateObject private var model = AppModel() @FocusedValue(\.libraryCommands) private var actions var body: some Scene { Window("KJMix Pro", id: "main") { LibraryView(model: model).frame(minWidth: 1060, minHeight: 760).preferredColorScheme(.dark) }.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…") { actions?.settings() }.keyboardShortcut(",").disabled(actions == nil) } CommandMenu("Library") { Button("Song Info…") { actions?.songInfo() }.keyboardShortcut("i").disabled(actions?.canEdit != true) Button("Add Drive or Folder…") { model.chooseMediaLocation() } } 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: 600, height: 400) } } 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(Color(white:0.66)).frame(width:18) configuration.title.foregroundStyle(Color(white:0.88)) } } } struct LyricsView: View { @ObservedObject var player: Player var body: some View { ZStack { Color.black if let image = player.image { Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(contentMode: .fit).opacity(player.outputOpacity) } } } } /// 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(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) 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 private var selected: Int64? @State private var tableSelection = Set() @State private var waveformDetails = false @State private var batchEditing = false @State private var metadataLookup: Song? @State private var confirmWriteTags = false @State private var search = "" @State private var name = "" @State private var importing = false 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 tableSort = [KeyPathComparator(\Song.title)] @State private var librarySort = "Song Name" @State private var sortAscending = true @Environment(\.openWindow) private var openWindow private var visibleSongs: [Song] { libraryRows } private func updateLibraryRows() { 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) } private var selectedSong: Song? { model.songs.first { $0.id == selected } } private var workspace: some View { HSplitView { sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260) VStack(spacing:0) { TransportView(player:model.player,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) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary) } Spacer() if source == "Library" { Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered) } }.padding(.horizontal,22).padding(.vertical,18) sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity) if let progress = model.waveformJob { HStack(spacing:10) { Image(systemName:"waveform").foregroundStyle(Color.accentColor) VStack(alignment:.leading,spacing:2) { Text(progress.current).lineLimit(1);Text("\(progress.processed)/\(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").foregroundStyle(.secondary) }.font(.caption) Spacer();if model.generatingWaveforms { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).frame(width:100);Button("Cancel") { model.cancelWaveformGeneration() }.controlSize(.small) } if progress.issues > 0 { Button("Details") { waveformDetails = true }.controlSize(.small) } if !model.generatingWaveforms { Button { model.dismissWaveformProgress() } label: { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") } }.padding(.horizontal,18).padding(.vertical,8) } Divider() if ["Library","Singer Songs","Playlists"].contains(source) { detailPanel.frame(height:135) } Divider() BackgroundDeckStrip(model:model,deck:model.backgroundDeck).padding(.horizontal,16).padding(.vertical,8) Divider() footer.padding(.horizontal,18).padding(.vertical,8) }.frame(minWidth:780) } .background(StudioTheme.frame) .tint(.accentColor) .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"})) .navigationTitle("KJMix Pro") .searchable(text:$search,placement:.toolbar,prompt:"Search Music") .toolbar { ToolbarItemGroup { Button { importing = true } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import music or video") 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") } Button { source = "Settings" } label: { Label("Settings",systemImage:"gearshape") } } } } var body: some View { workspace .onAppear { updateLibraryRows() } .onChange(of:model.songs) { _ in updateLibraryRows() } .onChange(of:model.tuningCache) { _ in updateLibraryRows() } .onChange(of:search) { _ in updateLibraryRows() } .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:$waveformDetails) { VStack(alignment:.leading,spacing:14) { Text("Waveform Analysis").font(.title2.bold()) ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300) HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) } }.padding(24).frame(width:560).preferredColorScheme(.dark) } .sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) } .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) } } .onChange(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) } .onChange(of:selected) { song in if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song } } .onChange(of:model.selectedSinger) { singer in updateLibraryRows() if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) } } .fileImporter(isPresented: $importing, allowedContentTypes: [.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" { songTable } 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) ScrollView { LazyVStack(alignment:.leading,spacing:3) { 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("Singer Web",symbol:"network") navigationRow("Music Stores",symbol:"bag") navigationRow("Settings",symbol:"gearshape") }.font(.system(size:13)).padding(.horizontal,10).padding(.bottom,12) } 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(.white.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(.white.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 songTable: some View { Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows()})) { 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("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.preference(song.id).pitch)).monospacedDigit().frame(width:24) Stepper("Key",value:Binding(get:{model.preference(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini) }.help("Saved key for this singer or song") }.width(65) TableColumn("Tempo") { song in Picker("Tempo",selection:Binding(get:{model.preference(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) { ForEach(10..<31) { Text("\($0*5)%").tag(Double($0)/20) } }.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("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil) Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil) 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) } } 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 Info…") { editingSong = song } Button("Generate Waveform") { model.generateWaveforms([song.id]) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:")) Button("Regenerate Waveform") { model.generateWaveforms([song.id],force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:")) 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(.white).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 { Button("Generate Waveform for Loaded Track") { if let id = model.player.currentSongID { model.generateWaveforms([id]) } }.disabled(model.player.currentSongID == nil) Button("Generate Waveform for Selected Track") { if let selected { model.generateWaveforms([selected]) } }.disabled(tableSelection.count > 1 || selected == nil) Button("Generate Waveforms for \(tableSelection.count) Selected Tracks") { model.generateWaveforms(tableSelection) }.disabled(tableSelection.isEmpty) Button("Generate Waveforms for All Library Tracks") { model.generateWaveforms() }.disabled(model.songs.isEmpty) Divider() Button("Regenerate Selected Waveforms") { model.generateWaveforms(tableSelection,force:true) }.disabled(tableSelection.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("\(visibleSongs.count) songs").font(.caption).foregroundStyle(.secondary) if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil) 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("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 playSelected() { guard let song = selectedSong else { return } model.perform { try model.play(song) } } private func playNext() { guard !visibleSongs.isEmpty else { return } let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 selected = visibleSongs[(index + 1) % visibleSongs.count].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. private 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) } } private 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 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("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).preferredColorScheme(.dark) .onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating } } private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);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.white.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.white.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).preferredColorScheme(.dark) .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 } } } 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 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 };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } }.padding(24).frame(width:520).background(StudioTheme.frame).preferredColorScheme(.dark) .fileImporter(isPresented:$importing,allowedContentTypes:[.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).preferredColorScheme(.dark) .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 TransportView: View { @ObservedObject var player: Player @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:18) { 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) }.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 hoverX: CGFloat? @State private var scrubStart: Double? @State private var resumeAfterScrub = false @State private var scrubSong: Int64? private var span: Double { max(0.001,player.duration/zoom) } private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) } private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 } private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) } private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) } private func bandColor(_ index: Int) -> Color { guard player.waveformBands.indices.contains(index) else { return .cyan } let band = player.waveformBands[index] let largest = max(0.000001,max(band.low,max(band.mid,band.high))) let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest) return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high)) } 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) GeometryReader { geometry in Canvas { context,size in let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2 var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid)) context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5) if player.duration > 0 { let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600 let first = floor(windowStart/(step/4))*(step/4) for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) { let x = size.width*(t-windowStart)/span if x < 0 { continue } let major = abs(t/step-(t/step).rounded()) < 0.001 var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height)) context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5) if major && x < size.width-25 { context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading) } } } if !player.waveform.isEmpty { let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count) for bar in 0.. 0,geometry.size.width > 0 else { return } if scrubStart == nil { scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing if player.playing { player.toggle() } } guard scrubSong == player.currentSongID else { return } hoverX = value.location.x let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span if abs(target-player.time) > 0.02 { player.seek(target) } }.onEnded { value in if scrubStart == nil { if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) } return } let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span if scrubSong == player.currentSongID { player.seek(target) if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() } } scrubStart = nil;resumeAfterScrub = false;scrubSong = nil }).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.") .accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") .accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } } }.frame(height:60) 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 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,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:174) .background(Color.black.opacity(0.22),in:RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).strokeBorder(.white.opacity(0.07),lineWidth:1)) } } 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 pitch: Binding { Binding(get:{target.map{model.preference($0).pitch} ?? 0},set:{value in if let target { model.updateTuning(target,pitch:value) }}) } private var tempo: Binding { Binding(get:{target.map{model.preference($0).tempo} ?? 1},set:{value in 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("Rating");GoldRating(rating:rating).frame(width:88) Text(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) } } 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(.white.opacity(0.65)) } } 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") } }