From 6d4a68d219f682f5d9f23404533977c61abc3a00 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 3 Nov 2026 14:33:26 +0000 Subject: [PATCH] updated --- .../KJamsClone/Importer.swift | 64 - .../KJamsClone/KJamsCloneApp.swift | 940 ------------ .../KJamsClone/Library.swift | 219 --- .../KJamsClone/Player.swift | 228 --- .../KJamsCloneTests/KJamsCloneTests.swift | 364 ----- .../KJamsClone.xcodeproj/project.pbxproj | 311 ---- .../xcschemes/KJamsClone.xcscheme | 7 - .../KJamsClone/CDG.swift | 68 - .../KJamsClone/Importer.swift | 68 - .../KJamsClone/KJamsCloneApp.swift | 1336 ----------------- .../KJamsClone/Library.swift | 348 ----- .../KJamsClone/Player.swift | 277 ---- .../scripts/build.sh | 4 - .../scripts/generate-test-media.py | 100 -- .../scripts/release.sh | 6 - .../scripts/test.sh | 4 - .../KJamsClone/KJamsCloneApp.swift | 700 --------- .../KJamsClone/Library.swift | 196 --- .../KJamsClone/Player.swift | 150 -- .../KJamsCloneTests/KJamsCloneTests.swift | 266 ---- .../KJamsClone/KJamsCloneApp.swift | 619 -------- .../KJamsClone/KJamsCloneApp.swift | 1251 --------------- .../KJamsClone/Library.swift | 317 ---- .../KJamsCloneTests/KJamsCloneTests.swift | 504 ------- .../KJamsClone/KJamsCloneApp.swift | 1162 -------------- .../KJamsClone/Library.swift | 286 ---- .../KJamsCloneTests/KJamsCloneTests.swift | 455 ------ .../KJamsClone.xcodeproj/project.pbxproj | 311 ---- .../KJamsClone/Importer.swift | 59 - .../KJamsClone/KJamsCloneApp.swift | 379 ----- .../KJamsClone/Library.swift | 196 --- .../KJamsClone/Player.swift | 92 -- .../KJamsCloneTests/KJamsCloneTests.swift | 100 -- .../before-venues-20261102-231352/README.md | 71 - .../verification/ui-preview.png | Bin 227409 -> 0 bytes .../verification/xcode-test.log | 335 ----- .../KJamsClone/KJamsCloneApp.swift | 636 -------- .../KJamsClone/Player.swift | 91 -- .../KJamsCloneTests/KJamsCloneTests.swift | 229 --- .../verification}/extracted-project.txt | 0 .../verification}/showscreen-output.png | Bin .../verification}/singer-browser-check.txt | 0 .../verification}/test-cdg-ffmpeg.png | Bin .../verification}/ui-0.6-library.png | Bin .../verification}/ui-0.6-rotation-small.png | Bin .../verification}/ui-0.6-rotation.png | Bin .../verification}/ui-0.6-settings.png | Bin .../verification}/ui-0.6.1-add-songs.png | Bin .../verification}/ui-0.6.1-reordered.png | Bin .../ui-0.6.4-waveforms-button.png | Bin .../verification}/ui-batch-metadata.png | Bin .../ui-kjmix-pro-global-mixer.png | Bin .../verification}/ui-kjmix-pro-locations.png | Bin .../verification}/ui-kjmix-pro-minimum.png | Bin .../verification}/ui-kjmix-pro-mp4.png | Bin .../verification}/ui-kjmix-pro.png | Bin .../verification}/ui-metadata.png | Bin .../verification}/ui-minimum-size.png | Bin .../verification}/ui-monitor-minimum-size.png | Bin .../verification}/ui-monitor-video-window.png | Bin .../verification}/ui-monitor.png | Bin .../verification}/ui-new-singer.png | Bin .../verification}/ui-preview.png | Bin .../verification}/ui-rotation.png | Bin .../verification}/ui-showscreens.png | Bin .../verification}/ui-singer-browser.png | Bin .../verification}/ui-singer-server.png | Bin .../verification}/ui-store-editor.png | Bin .../verification}/ui-studio-minimum-size.png | Bin .../verification}/ui-studio.png | Bin .../verification}/ui-tone-mixer.png | Bin .../ui-tonight-drag-minimum-size.png | Bin .../verification}/ui-tonight-drag.png | Bin .../verification}/ui-tonight-minimum-size.png | Bin .../verification}/ui-tonight.png | Bin .../ui-waveform-minimum-size.png | Bin .../verification}/ui-waveform.png | Bin verification/xcode-release.log | 297 ---- verification/xcode-test.log | 556 ------- 79 files changed, 13602 deletions(-) delete mode 100644 backups/before-classic-library-20261103-085620/KJamsClone/Importer.swift delete mode 100644 backups/before-classic-library-20261103-085620/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-classic-library-20261103-085620/KJamsClone/Library.swift delete mode 100644 backups/before-classic-library-20261103-085620/KJamsClone/Player.swift delete mode 100644 backups/before-classic-library-20261103-085620/KJamsCloneTests/KJamsCloneTests.swift delete mode 100644 backups/before-kjmix-20261103-100337/KJamsClone.xcodeproj/project.pbxproj delete mode 100644 backups/before-kjmix-20261103-100337/KJamsClone.xcodeproj/xcshareddata/xcschemes/KJamsClone.xcscheme delete mode 100644 backups/before-kjmix-20261103-100337/KJamsClone/CDG.swift delete mode 100644 backups/before-kjmix-20261103-100337/KJamsClone/Importer.swift delete mode 100644 backups/before-kjmix-20261103-100337/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-kjmix-20261103-100337/KJamsClone/Library.swift delete mode 100644 backups/before-kjmix-20261103-100337/KJamsClone/Player.swift delete mode 100755 backups/before-kjmix-20261103-100337/scripts/build.sh delete mode 100644 backups/before-kjmix-20261103-100337/scripts/generate-test-media.py delete mode 100755 backups/before-kjmix-20261103-100337/scripts/release.sh delete mode 100755 backups/before-kjmix-20261103-100337/scripts/test.sh delete mode 100644 backups/before-mixer-repertoire-20261103-084301/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-mixer-repertoire-20261103-084301/KJamsClone/Library.swift delete mode 100644 backups/before-mixer-repertoire-20261103-084301/KJamsClone/Player.swift delete mode 100644 backups/before-mixer-repertoire-20261103-084301/KJamsCloneTests/KJamsCloneTests.swift delete mode 100644 backups/before-sidebar-monitor-20261103-081330/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-tonight-drag-20261103-094630/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-tonight-drag-20261103-094630/KJamsClone/Library.swift delete mode 100644 backups/before-tonight-drag-20261103-094630/KJamsCloneTests/KJamsCloneTests.swift delete mode 100644 backups/before-tonight-fix-20261103-092811/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-tonight-fix-20261103-092811/KJamsClone/Library.swift delete mode 100644 backups/before-tonight-fix-20261103-092811/KJamsCloneTests/KJamsCloneTests.swift delete mode 100644 backups/before-venues-20261102-231352/KJamsClone.xcodeproj/project.pbxproj delete mode 100644 backups/before-venues-20261102-231352/KJamsClone/Importer.swift delete mode 100644 backups/before-venues-20261102-231352/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-venues-20261102-231352/KJamsClone/Library.swift delete mode 100644 backups/before-venues-20261102-231352/KJamsClone/Player.swift delete mode 100644 backups/before-venues-20261102-231352/KJamsCloneTests/KJamsCloneTests.swift delete mode 100644 backups/before-venues-20261102-231352/README.md delete mode 100644 backups/before-venues-20261102-231352/verification/ui-preview.png delete mode 100644 backups/before-venues-20261102-231352/verification/xcode-test.log delete mode 100644 backups/before-waveform-20261103-082733/KJamsClone/KJamsCloneApp.swift delete mode 100644 backups/before-waveform-20261103-082733/KJamsClone/Player.swift delete mode 100644 backups/before-waveform-20261103-082733/KJamsCloneTests/KJamsCloneTests.swift rename {verification => docs/verification}/extracted-project.txt (100%) rename {verification => docs/verification}/showscreen-output.png (100%) rename {verification => docs/verification}/singer-browser-check.txt (100%) rename {verification => docs/verification}/test-cdg-ffmpeg.png (100%) rename {verification => docs/verification}/ui-0.6-library.png (100%) rename {verification => docs/verification}/ui-0.6-rotation-small.png (100%) rename {verification => docs/verification}/ui-0.6-rotation.png (100%) rename {verification => docs/verification}/ui-0.6-settings.png (100%) rename {verification => docs/verification}/ui-0.6.1-add-songs.png (100%) rename {verification => docs/verification}/ui-0.6.1-reordered.png (100%) rename {verification => docs/verification}/ui-0.6.4-waveforms-button.png (100%) rename {verification => docs/verification}/ui-batch-metadata.png (100%) rename {verification => docs/verification}/ui-kjmix-pro-global-mixer.png (100%) rename {verification => docs/verification}/ui-kjmix-pro-locations.png (100%) rename {verification => docs/verification}/ui-kjmix-pro-minimum.png (100%) rename {verification => docs/verification}/ui-kjmix-pro-mp4.png (100%) rename {verification => docs/verification}/ui-kjmix-pro.png (100%) rename {verification => docs/verification}/ui-metadata.png (100%) rename {verification => docs/verification}/ui-minimum-size.png (100%) rename {verification => docs/verification}/ui-monitor-minimum-size.png (100%) rename {verification => docs/verification}/ui-monitor-video-window.png (100%) rename {verification => docs/verification}/ui-monitor.png (100%) rename {verification => docs/verification}/ui-new-singer.png (100%) rename {verification => docs/verification}/ui-preview.png (100%) rename {verification => docs/verification}/ui-rotation.png (100%) rename {verification => docs/verification}/ui-showscreens.png (100%) rename {verification => docs/verification}/ui-singer-browser.png (100%) rename {verification => docs/verification}/ui-singer-server.png (100%) rename {verification => docs/verification}/ui-store-editor.png (100%) rename {verification => docs/verification}/ui-studio-minimum-size.png (100%) rename {verification => docs/verification}/ui-studio.png (100%) rename {verification => docs/verification}/ui-tone-mixer.png (100%) rename {verification => docs/verification}/ui-tonight-drag-minimum-size.png (100%) rename {verification => docs/verification}/ui-tonight-drag.png (100%) rename {verification => docs/verification}/ui-tonight-minimum-size.png (100%) rename {verification => docs/verification}/ui-tonight.png (100%) rename {verification => docs/verification}/ui-waveform-minimum-size.png (100%) rename {verification => docs/verification}/ui-waveform.png (100%) delete mode 100644 verification/xcode-release.log delete mode 100644 verification/xcode-test.log diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/Importer.swift b/backups/before-classic-library-20261103-085620/KJamsClone/Importer.swift deleted file mode 100644 index 0b023d1..0000000 --- a/backups/before-classic-library-20261103-085620/KJamsClone/Importer.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import CryptoKit -import AVFoundation - -struct Importer { - let media: URL - func run(_ urls: [URL], library: Library) throws { - try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) - var files = urls - var temporary = [URL]() - defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } - for zip in urls where zip.pathExtension.lowercased() == "zip" { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - temporary.append(folder) - // Validate names before extraction; reject path traversal and symlinks afterwards. - let details = try process(["-Z", "-l", zip.path]) - guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") } - let list = try process(["-Z1", zip.path]) - let names = list.split(separator: "\n").map(String.init) - guard !names.isEmpty, names.allSatisfy({ !$0.hasPrefix("/") && !$0.split(separator: "/").contains("..") && !$0.contains("\\") }) else { throw LibraryError.message("Unsafe or empty ZIP") } - _ = try process(["-q", zip.path, "-d", folder.path]) - let extracted = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isSymbolicLinkKey,.isRegularFileKey])! - for case let url as URL in extracted { - let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey]) - guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") } - if values.isRegularFile == true { files.append(url) } - } - } - for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { - let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil) - if let audio = siblings.first(where: { ["mp3","wav","m4a"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) } - } - var imported = 0 - for audio in Set(files) where ["mp3","wav","m4a"].contains(audio.pathExtension.lowercased()) { - let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil) - let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame } - let hash = SHA256.hash(data: try Data(contentsOf: audio)).map { String(format: "%02x", $0) }.joined() - let dest = media.appendingPathComponent(hash + "." + audio.pathExtension.lowercased()) - if !FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.copyItem(at: audio, to: dest) } - var graphics: String? - if let cdg { - let target = media.appendingPathComponent(hash + ".cdg") - if !FileManager.default.fileExists(atPath: target.path) { try FileManager.default.copyItem(at: cdg, to: target) } - graphics = target.path - } - imported += 1 - let metadata = AVURLAsset(url:audio).commonMetadata - func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } - let taggedTitle = tag(.commonKeyTitle) - try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher)) - } - guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } - } - private func process(_ arguments: [String]) throws -> String { - let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments - let out = Pipe(); task.standardOutput = out - let err = FileHandle.nullDevice; task.standardError = err - try task.run() - let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit() - guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") } - return String(decoding: bytes, as: UTF8.self) - } -} diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/KJamsCloneApp.swift b/backups/before-classic-library-20261103-085620/KJamsClone/KJamsCloneApp.swift deleted file mode 100644 index 4678872..0000000 --- a/backups/before-classic-library-20261103-085620/KJamsClone/KJamsCloneApp.swift +++ /dev/null @@ -1,940 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -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" } -} - -@MainActor final class AppModel: ObservableObject { - @Published var songs = [Song]() - @Published var singers = [PersonPlace]() - @Published var singerSongIDs = [Int64:[Int64]]() - var newSingerSongs = Set() - @Published var venues = [PersonPlace]() - @Published var venueSingers = [PersonPlace]() - @Published var playlists = [PersonPlace]() - @Published var queue = [QueueEntry]() - @Published var history = [Performance]() - @Published private(set) var currentVenue: Int64? - @Published var selectedSinger: Int64? - @Published var autoAdvance = true - @Published var error: String? - @Published var creating: EntityKind? - 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") - perform { - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() - } - player.onFinished = { [weak self] in - guard let self, self.autoAdvance, !self.queue.isEmpty else { return } - self.playQueueNext() - } - } - 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(); singers = try library.entities("singers"); venues = try library.entities("venues") - singerSongIDs = try library.singerSongIDs() - 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 } - queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) - } - func chooseVenue(_ id: Int64?) { - perform { - guard let library else { throw LibraryError.message("Library unavailable") } - try library.selectVenue(id); selectedSinger = nil; 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 { id in songs.first { $0.id == id } } - } - 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) - for song in songs { try library.assignSong(song,singer:id) } - try library.selectVenue(venue);try library.execute("COMMIT") - } catch { try? library.execute("ROLLBACK");throw error } - try refresh();selectedSinger = 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 preference(_ song: Int64) -> SongPreference { - guard let library, let selectedSinger else { return .init() } - return (try? library.preference(song:song,singer:selectedSinger)) ?? .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 { - 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) } ?? SongPreference() - try player.load(song,preference:preference) - do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } - catch { player.stop(); throw error } - } - func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } - func playQueueNext() { - perform { - guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } - // Explicitly clear selection for unassigned queue entries. - selectedSinger = row.singerID - try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() - } - } - 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 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -/// 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 { - let 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) - .aspectRatio(1.5, contentMode: .fit) - .clipped() - .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) - .accessibilityLabel("Video monitor, synchronized with the Video window") - }.frame(maxWidth: .infinity) - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @State private var singersExpanded = true - @State private var expandedSingers = Set() - @State private var songPickerSinger: PersonPlace? - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } - else if source == "Queue" { queuePane } - else if source == "History" { historyPane } - else if source == "Playlists" { playlistPane } - else if source == "Singer Songs" { singerSongPane } - else { directory } - detailPanel.frame(height: 135) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .sheet(item: $model.creating) { kind in - AddEntitySheet(model: model, kind: kind) { id in - if kind == .singer { model.selectedSinger = id;expandedSingers.insert(id);singersExpanded = true;source = "Singer Songs" } else { model.chooseVenue(id) } - } - } - .sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer) } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - 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(alignment:.leading,spacing:0) { - Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) - .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) - ScrollView { - VStack(spacing:0) { - sourceRow("Library",icon:"music.note.house.fill",color:.yellow) - HStack(spacing:5) { - Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") - Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) - Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") - }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) - if singersExpanded { - Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) - ForEach(model.venueSingers) { singer in sidebarSinger(singer) } - if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } - } - sourceRow("Venues",icon:"building.2.fill",color:.cyan) - sourceRow("Queue",icon:"list.number",color:.orange) - sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) - sourceRow("Playlists",icon:"music.note.list",color:.mint) - } - } - SidebarVideoMonitor(player:model.player).padding(10) - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) - } - private func sourceRow(_ title: String, icon: String, color: Color) -> some View { - Button { source = title } label: { - HStack(spacing:10) { - Image(systemName:icon).foregroundStyle(color).frame(width:22) - Text(title).font(.system(size:16,weight:.medium));Spacer() - Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) - .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) - }.buttonStyle(.plain) - } - private func sidebarSinger(_ singer: PersonPlace) -> some View { - VStack(alignment:.leading,spacing:0) { - HStack(spacing:5) { - Button { - if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } - } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") - Button { model.selectedSinger = singer.id;source = "Singer Songs" } label: { - HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } - }.buttonStyle(.plain) - Button { model.selectedSinger = singer.id;songPickerSinger = singer;expandedSingers.insert(singer.id) } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.buttonStyle(.plain).help("Add songs to \(singer.name)") - }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) - .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) - if expandedSingers.contains(singer.id) { - ForEach(model.songs(for:singer.id)) { song in - Button { model.selectedSinger = singer.id;selected = song.id;source = "Singer Songs" } label: { - Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) - }.buttonStyle(.plain) - .contextMenu { - Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } - Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } - } - } - if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } - } - } - } - 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)’s Songs").font(.headline);Spacer();Button("Add Songs…") { songPickerSinger = singer } } - Text("At \(model.currentVenueName). Song assignments follow this singer across venues.").font(.caption).foregroundStyle(.secondary) - ScrollView { - VStack(spacing:10) { - ForEach(model.songs(for:singer.id)) { song in - HStack { - Text(song.title).lineLimit(1);Spacer() - Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } - Button("Queue") { model.selectedSinger = singer.id;model.enqueue(song.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 } - } - if model.songs(for:singer.id).isEmpty { Text("Add library songs with 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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 790)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { - [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] - } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in - Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) - .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) - .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } - } - }.foregroundStyle(.white).background(Classic.header) - .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } - } - private func cellText(_ song: Song, column: Int) -> String { - switch column { - case 2: return song.artist.isEmpty ? "—" : song.artist - case 3: return song.album.isEmpty ? "—" : song.album - case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label - case 5: return String(format:"%+.0f",model.preference(song.id).pitch) - default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) - } - } - private func songRow(_ song: Song, width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") - .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) - Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) - ForEach(2..<7) { index in - Text(cellText(song, column: index)) - .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) - } - Menu { - ForEach(0..<6) { value in - Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { - model.perform { try model.library?.rate(song.id, value); try model.refresh() } - } - } - } label: { - Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) - .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) - }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") - }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) - .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) - .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } - .onTapGesture { selected = song.id } - .contextMenu { - Button("Play") { selected = song.id; playSelected() } - 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 + This Song…") { model.beginSinger(song:song.id) } - } - Button("Add to Current 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) } } } - } - } - @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(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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("Queue") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) - } - PlaybackSettings(player:model.player,model:model,selectedSong:selected) - SeekBar(player:model.player) - }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) - .background(Classic.navy).overlay(Rectangle().stroke(Classic.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.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) - 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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var playlistPane: some View { - VStack(alignment:.leading,spacing:12) { - HStack { - Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } - TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) - Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - if let selectedPlaylist { - Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } - } - ScrollView { - if let selectedPlaylist { - let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] - VStack(alignment:.leading,spacing:10) { - ForEach(ids,id:\.self) { id in - if let song = model.songs.first(where: { $0.id == id }) { - HStack { - Text(song.title); Spacer() - Button("Play") { model.perform { try model.play(song) } } - Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } - } - } - } - } - } - Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) - } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - 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 = "" - } - } -} -private struct SingerSongsSheet: View { - @ObservedObject var model: AppModel - let singer: PersonPlace - @Environment(\.dismiss) private var dismiss - @State private var search = "" - @State private var importing = false - var body: some View { - VStack(alignment:.leading,spacing:14) { - Text("Songs for \(singer.name)").font(.title2.bold()) - Text("Tick songs to add them to this singer. Unticking removes only the assignment.").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 - Toggle(song.title,isOn:Binding(get:{ model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ model.assign(song.id,to:singer.id,included:$0) })).toggleStyle(.checkbox) - } - 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) - .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.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." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) - Text("Add songs now (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) - .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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - NowPlayingWaveform(player: player).padding(.top, 4) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 114) - } -} -private struct NowPlayingWaveform: View { - @ObservedObject var player: Player - @State private var mixer = false - private var progress: Double { player.duration > 0 ? min(1,max(0,player.time/player.duration)) : 0 } - private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%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,low+mid*0.18+high*0.65),green:min(1,low*0.18+mid+high*0.2),blue:min(1,low*0.22+mid*0.28+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 = [1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= player.duration/5 } ?? 3600 - for t in stride(from:0.0,through:player.duration,by:step/4) { - let x = size.width*t/player.duration - 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(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 } - player.seek(min(1,max(0,value.location.x/geometry.size.width))*player.duration) - }).help("Frequency colours: bass red, mids green, treble blue. Click to seek.") - .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:44) - 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("EQ / TONE") { 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)) - }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:104) - .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),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) { - HStack { Text("TONE / MIXER").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? - @State private var personalRating = 0 - var body: some View { - HStack(spacing:10) { - Text("Key") - Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) - Text("Tempo") - Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) - Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) - Text("Rating") - Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") - Button("Save for Singer") { - if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } - }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) - Menu { - Button("Reset key / tempo") { player.pitch = 0; player.tempo = 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).help("Playback settings") - }.font(.system(size:11)) - .onChange(of:model.selectedSinger) { _ in loadRating() } - .onChange(of:player.currentSongID) { _ in loadRating() } - .onChange(of:selectedSong) { _ in loadRating() } - } - private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/Library.swift b/backups/before-classic-library-20261103-085620/KJamsClone/Library.swift deleted file mode 100644 index de9da0c..0000000 --- a/backups/before-classic-library-20261103-085620/KJamsClone/Library.swift +++ /dev/null @@ -1,219 +0,0 @@ -import Foundation -import SQLite3 - -struct Song: Identifiable, Hashable { - var id: Int64 - var title: String - var audio: String - var cdg: String? - var rating: Int - var plays: Int - var artist: String = "" - var album: String = "" - var label: String = "" -} -struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } -struct SongPreference: Equatable { - var pitch: Double = 0 - var tempo: Double = 1 - var rating: Int = 0 -} -struct Performance: Identifiable { - var id: Int64 - var song: String - var singer: String - var venue: String - var date: String -} -struct QueueEntry: Identifiable { - var id: Int64 - var songID: Int64 - var singerID: Int64? - var title: String - var singer: String -} -enum LibraryError: LocalizedError { - case message(String) - var errorDescription: String? { if case .message(let s) = self { return s }; return nil } -} -final class Library { - private var db: OpaquePointer? - init(url: URL) throws { - guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } - try execute("PRAGMA foreign_keys=ON") - try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") - try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") - // Additive migration: preserve older libraries and infer existing memberships from history. - for column in ["artist", "album", "label"] { - if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } - } - try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") - if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } - try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") - try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") - try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") - try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") - if try schemaVersion() < 4 { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") - } - try execute("PRAGMA user_version=4") - } - deinit { sqlite3_close(db) } - private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { - var s: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } - for (i,v) in values.enumerated() { - let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) - if code != SQLITE_OK { sqlite3_finalize(s); throw error() } - } - return s - } - private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } - func execute(_ sql: String, _ values: [String?] = []) throws { - let s = try statement(sql, values); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_DONE else { throw error() } - } - func songs() throws -> [Song] { - let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } - var result = [Song]() - while true { - let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } - result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8))) - } - return result - } - func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "") throws { - try execute("INSERT INTO songs(title,audio,cdg,artist,album,label) VALUES(?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN excluded.label!='' THEN excluded.label ELSE songs.label END", [title,audio,cdg,artist,album,label]) - } - func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } - func entities(_ table: String) throws -> [PersonPlace] { - precondition(["singers","venues","playlists"].contains(table)) - let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } - var rows = [PersonPlace]() - while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } - return rows - } - func addEntity(_ table: String, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) - } - func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { - try execute("BEGIN IMMEDIATE") - do { - if let singer { try assignSong(song,singer:singer) } - try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) - try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - - private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } - private func schemaVersion() throws -> Int { - let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 - } - private func columns(_ table: String) throws -> Set { - let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } - var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result - } - func members(of venue: Int64) throws -> [PersonPlace] { - let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } - var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result - } - func setMembership(singer: Int64, venue: Int64, included: Bool) throws { - try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) - if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } - } - func currentVenue() throws -> Int64? { - let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil - } - func selectVenue(_ id: Int64?) throws { - if let id { - guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } - } - try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) - } - func rename(_ table: String, id: Int64, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) - } - func deleteEntity(_ table: String, id: Int64) throws { - precondition(["singers","venues","playlists"].contains(table)) - try execute("BEGIN IMMEDIATE") - do { - if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } - if table == "venues" { - try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) - try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) - } - try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - func preference(song: Int64, singer: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return .init() } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) - } - func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { - try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) - } - func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { - let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } - var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4))) }; return result - } - func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { - guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } - if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } - try execute("BEGIN IMMEDIATE") - do { - if let singer { try assignSong(song,singer:singer) } - try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } - func queue(venue: Int64?) throws -> [QueueEntry] { - guard let venue else { return [] } - let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } - var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result - } - func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } - func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { - var rows = try queue(venue:venue) - guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } - rows.swapAt(index,index+offset) - try execute("BEGIN IMMEDIATE") - do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } - catch { try? execute("ROLLBACK"); throw error } - } - func addToPlaylist(_ playlist: Int64, song: Int64) throws { - try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) - } - func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { - let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } - var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids - } - func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } - func assignSong(_ song: Int64, singer: Int64) throws { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) - } - func removeSingerSong(_ song: Int64, singer: Int64) throws { - try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) - } - func singerSongIDs() throws -> [Int64:[Int64]] { - let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } - var result = [Int64:[Int64]]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } - return result - } -} diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/Player.swift b/backups/before-classic-library-20261103-085620/KJamsClone/Player.swift deleted file mode 100644 index 1cff467..0000000 --- a/backups/before-classic-library-20261103-085620/KJamsClone/Player.swift +++ /dev/null @@ -1,228 +0,0 @@ -import SwiftUI -import AVFoundation - -/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. -@MainActor final class Player: ObservableObject { - @Published var image: CGImage? - @Published private(set) var waveform = [Float]() - @Published private(set) var waveformBands = [WaveformBands]() - @Published var lowGain: Double = 0 { didSet { updateTone() } } - @Published var midGain: Double = 0 { didSet { updateTone() } } - @Published var highGain: Double = 0 { didSet { updateTone() } } - @Published var filter: Double = 0 { didSet { updateTone() } } - @Published var toneEnabled = true { didSet { updateTone() } } - @Published private(set) var leftLevel: Float = 0 - @Published private(set) var rightLevel: Float = 0 - private let tone = AVAudioUnitEQ(numberOfBands:4) - private let meter = AudioLevelMeter() - private var metering = false - @Published private(set) var waveformLoading = false - @Published private(set) var waveformUnavailable = false - private var waveformTask: Task? - private var waveformGeneration = UUID() - @Published var playing = false - @Published var time: Double = 0 - @Published var duration: Double = 0 - @Published var title = "Select a song" - @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } - @Published var repeating = false - @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } - @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } - @Published var graphicsOffset: Double = 0 - private(set) var currentSongID: Int64? - var onFinished: (() -> Void)? - private let engine = AVAudioEngine() - private let node = AVAudioPlayerNode() - private let effect = AVAudioUnitTimePitch() - private var file: AVAudioFile? - private var anchor: Double = 0 - private var generation = UUID() - private var cdg = Data() - private var decoder = CDGDecoder() - private var timer: Timer? - init() { engine.attach(node); engine.attach(effect); engine.attach(tone); updateTone() } - func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true } - private func updateTone() { - AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) - } - func load(_ song: Song, preference: SongPreference = .init()) throws { - stop() - do { - let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) - let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() - guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } - engine.connect(node,to:effect,format:loaded.processingFormat) - engine.connect(effect,to:tone,format:loaded.processingFormat) - engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat) - file = loaded; cdg = graphics; decoder = CDGDecoder() - duration = Double(loaded.length)/loaded.processingFormat.sampleRate - title = song.title; currentSongID = song.id - pitch = preference.pitch; tempo = preference.tempo - updateTone() - let meter = self.meter - engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) } - metering = true - engine.mainMixerNode.outputVolume = Float(volume) - engine.prepare(); try engine.start() - schedule(at:0,play:true); startTimer(); tick() - analyseWaveform(URL(fileURLWithPath:song.audio)) - } catch { stop(); throw error } - } - private func analyseWaveform(_ url: URL) { - let token = UUID(); waveformGeneration = token; waveformLoading = true - waveformTask = Task.detached(priority: .utility) { [weak self] in - let result: Result - do { result = .success(try AudioWaveform.analyse(url: url, cancelled: { Task.isCancelled })) } - catch { result = .failure(error) } - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - guard let self, self.waveformGeneration == token else { return } - self.waveformLoading = false - switch result { - case .success(let analysis): self.waveform = analysis.levels; self.waveformBands = analysis.bands - case .failure: self.waveformUnavailable = true - } - } - } - } - private func schedule(at seconds: Double, play: Bool) { - guard let file else { return } - generation = UUID(); let token = generation - node.stop() - let rate = file.processingFormat.sampleRate - let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) - anchor = Double(start)/rate; time = anchor - guard start < file.length else { playing = false; render(); return } - node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in - Task { @MainActor in - guard let self, self.generation == token else { return } - if self.repeating { self.schedule(at:0,play:true) } - else { - self.playing = false; self.time = self.duration; self.render() - self.timer?.invalidate(); self.timer = nil; self.onFinished?() - } - } - } - if play { node.play() }; playing = play; render() - } - private func startTimer() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } - } - func toggle() { - guard file != nil else { return } - if playing { tick(); node.pause(); playing = false } - else if time >= duration { schedule(at:0,play:true); startTimer() } - else { node.play(); playing = true; startTimer() } - } - func stop() { - waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() - waveform = []; waveformBands = []; waveformLoading = false; waveformUnavailable = false - generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() - if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } - meter.reset(); leftLevel = 0; rightLevel = 0 - engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone) - file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" - } - func seek(_ value: Double) { - guard file != nil, value.isFinite else { return } - schedule(at:min(max(value,0),duration),play:playing); startTimer() - } - private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } - private func tick() { - let levels = meter.snapshot() - leftLevel = playing ? levels.0 : 0; rightLevel = playing ? levels.1 : 0 - if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { - time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) - } - render() - } -} - -/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. -/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. -enum AudioWaveform { - struct Analysis { var levels: [Float]; var bands: [WaveformBands] } - static func read(url: URL, bins: Int = 1024, cancelled: () -> Bool = { false }) throws -> [Float] { - try analyse(url:url,bins:bins,cancelled:cancelled).levels - } - static func analyse(url: URL, bins: Int = 1024, cancelled: () -> Bool = { false }) throws -> Analysis { - guard bins > 0 else { return .init(levels:[],bands:[]) } - let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) - guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) } - var energy = [Double](repeating: 0, count: bins) - var counts = [Int](repeating: 0, count: bins) - var position: Int64 = 0 - let channels = Int(file.processingFormat.channelCount) - let rate = file.processingFormat.sampleRate - let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate) - var lowState = [Double](repeating:0,count:channels), highState = lowState - var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows - while position < file.length { - if cancelled() { throw CancellationError() } - try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) - let frames = Int(buffer.frameLength) - guard frames > 0, let data = buffer.floatChannelData else { break } - for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } - let peak = rms.max() ?? 0 - let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins) - let bands = (0.. (Float,Float) { lock.lock();defer { lock.unlock() };return levels } - func reset() { lock.lock();levels = (0,0);lock.unlock() } -} diff --git a/backups/before-classic-library-20261103-085620/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-classic-library-20261103-085620/KJamsCloneTests/KJamsCloneTests.swift deleted file mode 100644 index 3348584..0000000 --- a/backups/before-classic-library-20261103-085620/KJamsCloneTests/KJamsCloneTests.swift +++ /dev/null @@ -1,364 +0,0 @@ -import XCTest -import AVFoundation -@testable import KJamsClone - -final class KJamsCloneTests: XCTestCase { - func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } - func testPresetTileXORAndInvalidCoordinates() { - var d = CDGDecoder() - d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) - d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],5) - d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],0) - let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) - } - func testSeekAndTruncatedPacket() { - var d = CDGDecoder() - let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) - d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) - d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) - d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) - } - func testPaletteBorderScrollAndImage() { - var d = CDGDecoder() - d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) - d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) - d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) - d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) - XCTAssertEqual(d.image()?.width,288) - } - func testSQLitePersistenceDedupRatingsAndHistory() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("test.sqlite") - do { - let db = try Library(url:url) - try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) - try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") - let song = try XCTUnwrap(db.songs().first) - try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) - try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") - try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) - } - let reopened = try Library(url:url); let songs = try reopened.songs() - XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") - XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") - } - func testPairedImportAndContentDedup() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") - try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) - let db = try Library(url:root.appendingPathComponent("test.sqlite")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([audio],library:db); try importer.run([audio],library:db) - XCTAssertEqual(try db.songs().count,1) - XCTAssertNotNil(try db.songs().first?.cdg) - } - - func testZippedMP3GImportAndUnsafeArchiveRejection() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let db = try Library(url:folder.appendingPathComponent("db.sqlite")) - let importer = Importer(media:folder.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) - XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) - XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) - } - @MainActor func testMP3PlaybackPauseAndSeek() throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player() - defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) - XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) - player.toggle(); XCTAssertFalse(player.playing) - player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) - player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) - } - @MainActor func testCreateSingerAndVenueThroughAppModel() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: root) } - let model = AppModel(storageURL: root) - XCTAssertNil(model.error) - let singer = try model.createEntity(.singer, name: " Alex ") - let venue = try model.createEntity(.venue, name: "Town Hall") - XCTAssertEqual(model.singers.first?.name, "Alex") - XCTAssertEqual(model.venues.first?.name, "Town Hall") - XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) - XCTAssertEqual(model.singers.count, 1) - XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) - let reopened = AppModel(storageURL: root) - XCTAssertEqual(reopened.singers.first?.id, singer) - XCTAssertEqual(reopened.venues.first?.id, venue) - } - - @MainActor func testVenuesShareSingersAndRememberSelection() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - let alex = try model.createEntity(.singer,name:"Alex") - let club = try model.createEntity(.venue,name:"Club") - XCTAssertTrue(model.venueSingers.isEmpty) - model.membership(singer:alex,included:true) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(club); model.membership(singer:alex,included:false) - XCTAssertTrue(model.venueSingers.isEmpty) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) - reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) - XCTAssertEqual(reopened.singers.count,1) - } - func testMigrationAndMembershipRemovalDoesNotReappear() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("db.sqlite") - var singer: Int64 = 0, venue: Int64 = 0 - do { - let db = try Library(url:url) - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") - singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) - try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) - try db.execute("PRAGMA user_version=1") - } - do { - let migrated = try Library(url:url) - XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) - try migrated.setMembership(singer:singer,venue:venue,included:false) - } - let reopened = try Library(url:url) - XCTAssertTrue(try reopened.members(of:venue).isEmpty) - XCTAssertEqual(try reopened.history(venue:venue).count,1) - } - func testQueuePlaylistsPreferencesAndDeletion() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.add(title:"First",audio:"/1.mp3",cdg:nil) - try db.add(title:"Second",audio:"/2.mp3",cdg:nil) - let songs = try db.songs() - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") - let singer = try XCTUnwrap(db.entities("singers").first?.id) - let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) - let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) - XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) - try db.setMembership(singer:singer,venue:hall,included:true) - try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) - XCTAssertTrue(try db.queue(venue:club).isEmpty) - let second = try XCTUnwrap(db.queue(venue:hall).last) - try db.moveQueue(second.id,venue:hall,by:-1) - XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) - let preference = SongPreference(pitch:3,tempo:0.85,rating:4) - try db.savePreference(song:songs[0].id,singer:singer,value:preference) - XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) - XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) - try db.addEntity("playlists",name:"Party") - let playlist = try XCTUnwrap(db.entities("playlists").first?.id) - try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) - XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) - try db.record(songs[0].id,singer:singer,venue:hall) - try db.rename("singers",id:singer,name:"Alex Jones") - XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") - try db.deleteEntity("singers",id:singer) - XCTAssertTrue(try db.queue(venue:hall).isEmpty) - XCTAssertEqual(try db.history(venue:hall).count,1) - try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) - XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) - } - @MainActor func testTempoSourceClockPauseAndResume() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) - try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) - XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) - try await Task.sleep(nanoseconds:500_000_000) - XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) - player.toggle(); let paused = player.time - try await Task.sleep(nanoseconds:100_000_000) - XCTAssertEqual(player.time,paused,accuracy:0.025) - player.seek(1.5); player.toggle() - try await Task.sleep(nanoseconds:200_000_000) - XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) - } - func testNewTestMediaMetadataAndCDGPairImport() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"MP3 Plus G Test") - XCTAssertEqual(song.artist,"KJamsClone Test Generator") - XCTAssertNotNil(song.cdg) - let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) - XCTAssertEqual(bytes.count,20*300*24) - var decoder = CDGDecoder(); decoder.render(bytes,at:2) - XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) - } - @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root); defer { model.player.stop() } - _ = try model.createEntity(.venue,name:"Test Hall") - _ = try model.createEntity(.singer,name:"Alex") - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) - let song = try XCTUnwrap(model.songs.first) - model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() - XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) - model.player.seek(model.player.duration-0.1) - for _ in 0..<25 { - if model.queue.isEmpty { break } - try await Task.sleep(nanoseconds:100_000_000) - } - XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) - XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") - } - func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("signal.wav") - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) - do { - let file = try AVAudioFile(forWriting:url,settings:format.settings) - let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 - let samples = try XCTUnwrap(buffer.floatChannelData?[0]) - for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } - try file.write(from:buffer) - } - let envelope = try AudioWaveform.read(url:url,bins:32) - XCTAssertEqual(envelope.count,32) - XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) - XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) - XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) - } - @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) - player.toggle() - for _ in 0..<50 { - if !player.waveformLoading { break } - try await Task.sleep(nanoseconds:20_000_000) - } - XCTAssertEqual(player.waveform.count,1024) - XCTAssertFalse(player.waveformUnavailable) - player.stop() - XCTAssertTrue(player.waveform.isEmpty) - XCTAssertFalse(player.waveformLoading) - try await Task.sleep(nanoseconds:50_000_000) - XCTAssertTrue(player.waveform.isEmpty) - } - - @MainActor func testConnectedSingerCreationAndSharedRepertoire() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh() - let song = try XCTUnwrap(model.songs.first?.id) - let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song]) - XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) - XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999])) - XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" }) - let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true) - XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) - model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song]) - reopened.assign(song,to:alex,included:false) - XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty) - } - func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall") - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id) - try db.setMembership(singer:singer,venue:venue,included:true) - try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue) - XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2) - try db.removeSingerSong(song,singer:singer) - XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1) - } - func testFrequencyBandsDistinguishBassAndTreble() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("bands.wav") - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1)) - do { - let file = try AVAudioFile(forWriting:url,settings:format.settings) - let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192 - for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) } - try file.write(from:buffer) - } - let analysis = try AudioWaveform.analyse(url:url,bins:32) - XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3) - XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3) - } - private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double { - let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4) - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) - engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format) - AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled) - try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024) - let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768 - for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) } - node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() } - let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024)) - var energy: Double = 0,count = 0 - for block in 0..<12 { - let status = try engine.renderOffline(1024,to:output) - XCTAssertEqual(status,.success) - if block >= 2 { for i in 0.. - - - - - - \ No newline at end of file diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/CDG.swift b/backups/before-kjmix-20261103-100337/KJamsClone/CDG.swift deleted file mode 100644 index 6b4625f..0000000 --- a/backups/before-kjmix-20261103-100337/KJamsClone/CDG.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import CoreGraphics - -/// CD+G packet decoder. Packet parity is ignored; malformed coordinates are rejected. -struct CDGDecoder { - private(set) var pixels = [UInt8](repeating: 0, count: 300 * 216) - private(set) var palette = [UInt32](repeating: 0x000000FF, count: 16) - private(set) var packetIndex = 0 - private(set) var horizontalOffset = 0 - private(set) var verticalOffset = 0 - private var transparent: UInt8? - mutating func render(_ data: Data, at seconds: Double) { - let target = min(data.count / 24, max(0, Int(max(0, seconds) * 300))) - if target < packetIndex { self = CDGDecoder() } - while packetIndex < target { - apply(Array(data[(packetIndex * 24)..<(packetIndex * 24 + 24)])) - packetIndex += 1 - } - } - mutating func apply(_ packet: [UInt8]) { - guard packet.count == 24, packet[0] & 63 == 9 else { return } - let d = packet[4..<20].map { Int($0 & 63) } - switch packet[1] & 63 { - case 1: - if d[1] == 0 { pixels = .init(repeating: UInt8(d[0] & 15), count: pixels.count) } - case 2: - for y in 0..<216 { for x in 0..<300 where x < 6 || x >= 294 || y < 12 || y >= 204 { pixels[y * 300 + x] = UInt8(d[0] & 15) } } - case 6, 38: - let row = d[2] & 31, col = d[3] & 63 - guard row < 18, col < 50 else { return } - for y in 0..<12 { for x in 0..<6 { - let color = UInt8(d[(y + 4)] & (1 << (5 - x)) == 0 ? d[0] & 15 : d[1] & 15) - let i = (row * 12 + y) * 300 + col * 6 + x - if packet[1] & 63 == 38 { pixels[i] ^= color } else { pixels[i] = color } - } } - case 20, 24: - horizontalOffset = min(d[1] & 7, 5); verticalOffset = min(d[2] & 15, 11) - let hx = (d[1] >> 4) & 3, vy = (d[2] >> 4) & 3 - let dx = hx == 1 ? 6 : hx == 2 ? -6 : 0 - let dy = vy == 1 ? 12 : vy == 2 ? -12 : 0 - let old = pixels - for y in 0..<216 { for x in 0..<300 { - let sx = x - dx, sy = y - dy - if packet[1] & 63 == 24 || (sx >= 0 && sx < 300 && sy >= 0 && sy < 216) { - pixels[y * 300 + x] = old[((sy + 216) % 216) * 300 + (sx + 300) % 300] - } else { pixels[y * 300 + x] = UInt8(d[0] & 15) } - } } - case 28: transparent = UInt8(d[0] & 15) - case 30, 31: - let start = packet[1] & 63 == 30 ? 0 : 8 - for i in 0..<8 { - let v = (d[i * 2] << 6) | d[i * 2 + 1] - palette[start + i] = UInt32(((v >> 8) & 15) * 17) << 24 | UInt32(((v >> 4) & 15) * 17) << 16 | UInt32((v & 15) * 17) << 8 | 255 - } - default: break - } - } - func image() -> CGImage? { - var rgba = [UInt8](); rgba.reserveCapacity(288 * 192 * 4) - for y in 12..<204 { for x in 6..<294 { - let index = pixels[(y + verticalOffset) * 300 + x + horizontalOffset] - let c = palette[Int(index)] - rgba += [UInt8((c >> 24) & 255), UInt8((c >> 16) & 255), UInt8((c >> 8) & 255), index == transparent ? 0 : 255] - } } - guard let provider = CGDataProvider(data: Data(rgba) as CFData) else { return nil } - return CGImage(width: 288, height: 192, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 288 * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent) - } -} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/Importer.swift b/backups/before-kjmix-20261103-100337/KJamsClone/Importer.swift deleted file mode 100644 index 69e8fd7..0000000 --- a/backups/before-kjmix-20261103-100337/KJamsClone/Importer.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import CryptoKit -import AVFoundation - -struct Importer { - let media: URL - func run(_ urls: [URL], library: Library) throws { - try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) - var files = urls - var temporary = [URL]() - defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } - for zip in urls where zip.pathExtension.lowercased() == "zip" { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - temporary.append(folder) - // Validate names before extraction; reject path traversal and symlinks afterwards. - let details = try process(["-Z", "-l", zip.path]) - guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") } - let list = try process(["-Z1", zip.path]) - let names = list.split(separator: "\n").map(String.init) - guard !names.isEmpty, names.allSatisfy({ !$0.hasPrefix("/") && !$0.split(separator: "/").contains("..") && !$0.contains("\\") }) else { throw LibraryError.message("Unsafe or empty ZIP") } - _ = try process(["-q", zip.path, "-d", folder.path]) - let extracted = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isSymbolicLinkKey,.isRegularFileKey])! - for case let url as URL in extracted { - let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey]) - guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") } - if values.isRegularFile == true { files.append(url) } - } - } - for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { - let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil) - if let audio = siblings.first(where: { ["mp3","wav","m4a"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) } - } - var imported = 0 - for audio in Set(files) where ["mp3","wav","m4a"].contains(audio.pathExtension.lowercased()) { - let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil) - let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame } - let hash = SHA256.hash(data: try Data(contentsOf: audio)).map { String(format: "%02x", $0) }.joined() - let dest = media.appendingPathComponent(hash + "." + audio.pathExtension.lowercased()) - if !FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.copyItem(at: audio, to: dest) } - var graphics: String? - if let cdg { - let target = media.appendingPathComponent(hash + ".cdg") - if !FileManager.default.fileExists(atPath: target.path) { try FileManager.default.copyItem(at: cdg, to: target) } - graphics = target.path - } - imported += 1 - let metadata = AVURLAsset(url:audio).commonMetadata - func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } - let taggedTitle = tag(.commonKeyTitle) - try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio)) - } - guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } - } - private func audioDuration(_ url: URL) -> Double { - guard let file = try? AVAudioFile(forReading:url) else { return 0 } - return Double(file.length)/file.processingFormat.sampleRate - } - private func process(_ arguments: [String]) throws -> String { - let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments - let out = Pipe(); task.standardOutput = out - let err = FileHandle.nullDevice; task.standardError = err - try task.run() - let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit() - guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") } - return String(decoding: bytes, as: UTF8.self) - } -} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/KJamsCloneApp.swift b/backups/before-kjmix-20261103-100337/KJamsClone/KJamsCloneApp.swift deleted file mode 100644 index ca835bb..0000000 --- a/backups/before-kjmix-20261103-100337/KJamsClone/KJamsCloneApp.swift +++ /dev/null @@ -1,1336 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers -import AVFoundation - -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(exportedAs:"com.kjamsclone.internal-song") - var song: Int64 - var library: String - var singer: Int64? - var venue: Int64? - var fromTonight: Bool - func provider() -> NSItemProvider { - let provider = NSItemProvider() - let data = (try? JSONEncoder().encode(self)) ?? Data() - provider.registerDataRepresentation(forTypeIdentifier:Self.type.identifier,visibility:.ownProcess) { completion in completion(data,nil);return nil } - return provider - } - static func load(_ provider: NSItemProvider) async throws -> Self { - let data: Data = try await withCheckedThrowingContinuation { continuation in - provider.loadDataRepresentation(forTypeIdentifier:Self.type.identifier) { data,error in - if let data { continuation.resume(returning:data) } - else { continuation.resume(throwing:error ?? LibraryError.message("Could not read dragged song")) } - } - } - return try JSONDecoder().decode(Self.self,from:data) - } -} -@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 - var newSingerSongs = Set() - private var songIndex = [Int64:Song]() - @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? - @Published var autoAdvance = true - @Published var error: String? - @Published var creating: EntityKind? - 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() - } - player.onFinished = { [weak self] in - guard let self, self.autoAdvance, !self.queue.isEmpty else { return } - self.playQueueNext() - } - } - 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() - 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) - } - func chooseVenue(_ id: Int64?) { - perform { - guard let library else { throw LibraryError.message("Library unavailable") } - try library.selectVenue(id); selectedSinger = nil; 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) - for song in songs { try library.assignSong(song,singer:id) } - try library.selectVenue(venue);try library.execute("COMMIT") - } catch { try? library.execute("ROLLBACK");throw error } - try refresh();selectedSinger = 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 preference(_ song: Int64) -> SongPreference { - guard let library else { return .init() } - if let selectedSinger { return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() } - return (try? library.defaultPreference(song: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 { - 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) - try player.load(song,preference:preference) - do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; 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 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:root.standardizedFileURL.path,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 == root.standardizedFileURL.path, 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) - 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 = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } - // Explicitly clear selection for unassigned queue entries. - selectedSinger = row.singerID - try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() - } - } - 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 { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } } - try refresh() - } - } - 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 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -/// 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 { - let 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) - .aspectRatio(1.5, contentMode: .fit) - .clipped() - .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) - .accessibilityLabel("Video monitor, synchronized with the Video window") - }.frame(maxWidth: .infinity) - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @State private var singersExpanded = true - @State private var expandedSingers = Set() - @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 editingSong: Song? - @State private var librarySort = "Song Name" - @State private var sortAscending = true - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { - let filtered = model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } - return filtered.sorted { a,b in - if a.id == b.id { return false } - if librarySort == "Duration" { return a.duration == b.duration ? a.id < b.id : sortAscending ? a.duration < b.duration : a.duration > b.duration } - if librarySort == "Rating" { return a.rating == b.rating ? a.id < b.id : sortAscending ? a.rating < b.rating : a.rating > b.rating } - let first = librarySort == "Artist" ? a.artist : librarySort == "Album" ? a.album : a.title - let second = librarySort == "Artist" ? b.artist : librarySort == "Album" ? b.album : b.title - let order = first.localizedStandardCompare(second) - return order == .orderedSame ? a.id < b.id : sortAscending ? order == .orderedAscending : order == .orderedDescending - } - } - - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } - else if source == "Queue" { queuePane } - 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 { directory } - detailPanel.frame(height: 135) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .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(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 - if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) } - } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - 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(alignment:.leading,spacing:0) { - Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) - .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) - ScrollView { - VStack(spacing:0) { - sourceRow("Library",icon:"music.note.house.fill",color:.yellow) - HStack(spacing:5) { - Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") - Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) - Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") - }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) - if singersExpanded { - Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) - ForEach(model.venueSingers) { singer in sidebarSinger(singer) } - if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } - } - HStack { - Button { storesExpanded.toggle() } label: { Image(systemName:storesExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)) }.buttonStyle(.plain) - Button { source = "Music Stores" } label: { Label("Music Stores",systemImage:"bag.fill").foregroundStyle(.green);Spacer() }.buttonStyle(.plain) - }.padding(.horizontal,12).frame(height:34) - if storesExpanded { - ForEach(model.musicStores) { store in - Button { focusedStore = store.id;source = "Music Stores" } label: { Label(store.name,systemImage:"globe").font(.system(size:12)).lineLimit(1).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:26) }.buttonStyle(.plain) - } - Button("Configure Stores…") { source = "Music Stores" }.buttonStyle(.plain).font(.system(size:11)).foregroundStyle(.secondary).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) - } - sourceRow("Venues",icon:"building.2.fill",color:.cyan) - sourceRow("Queue",icon:"list.number",color:.orange) - sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) - sourceRow("Playlists",icon:"music.note.list",color:.mint) - } - } - SidebarVideoMonitor(player:model.player).padding(10) - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) - } - private func sourceRow(_ title: String, icon: String, color: Color) -> some View { - Button { source = title } label: { - HStack(spacing:10) { - Image(systemName:icon).foregroundStyle(color).frame(width:22) - Text(title).font(.system(size:16,weight:.medium));Spacer() - Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) - .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) - }.buttonStyle(.plain) - } - private func sidebarSinger(_ singer: PersonPlace) -> some View { - VStack(alignment:.leading,spacing:0) { - HStack(spacing:5) { - Button { - if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } - } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") - Button { showSinger(singer.id) } label: { - HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } - }.buttonStyle(.plain) - Menu { - Button("Add to Tonight…") { showSinger(singer.id,section:"Tonight");songPickerTonight = true;songPickerSinger = singer } - Button("Manage All Songs…") { showSinger(singer.id,section:"All Songs");songPickerTonight = false;songPickerSinger = singer } - } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.menuStyle(.borderlessButton).frame(width:16).help("Add songs to \(singer.name)’s Tonight or repertoire") - }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) - .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) - .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true)) - if expandedSingers.contains(singer.id) { - singerFolder("Tonight",icon:"music.note.list",singer:singer) - singerFolder("Favourites",icon:"star.fill",singer:singer) - singerFolder("History",icon:"clock.arrow.circlepath",singer:singer) - HStack(spacing:3) { - Button { if expandedSongLists.contains(singer.id) { expandedSongLists.remove(singer.id) } else { expandedSongLists.insert(singer.id) } } label: { Image(systemName:expandedSongLists.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)) }.buttonStyle(.plain) - singerFolder("All Songs",icon:"folder",singer:singer) - }.padding(.leading,28) - if expandedSongLists.contains(singer.id) { - ForEach(model.songs(for:singer.id)) { song in - Button { showSinger(singer.id,section:"All Songs");selected = song.id;singerSelections[singer.id] = song.id } label: { - Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) - }.buttonStyle(.plain) - .onDrag { model.dragReference(song.id).provider() } - .contextMenu { - Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } - Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button(model.favourites[singer.id]?.contains(song.id) == true ? "Remove Favourite" : "Add to Favourites") { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } - Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } - } - } - if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } - } - } - } - } - private func singerFolder(_ title: String, icon: String, singer: PersonPlace) -> some View { - Button { showSinger(singer.id,section:title) } label: { - Label { Text(title + (title == "Tonight" ? " (\(model.tonightSongIDs[singer.id]?.count ?? 0))" : "")) } icon: { if title == "Favourites" { GoldStar(filled:true,size:12) } else { Image(systemName:icon) } }.font(.system(size:11)).foregroundStyle(title == "Favourites" ? Color(red:1,green:0.75,blue:0.16) : Color.white.opacity(0.75)).padding(.leading,title == "All Songs" ? 0 : 43).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) - }.buttonStyle(.plain) - .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:title == "Tonight")) - } - 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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 960)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 960)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 960), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { [44,max(220,total-600),110,100,65,82,90,109] } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing:0) { - ForEach(Array(["ID","Song Name","Artist","Album","Duration","Pitch","Tempo","Rating"].enumerated()),id:\.offset) { index,title in - Button { - guard ["Song Name","Artist","Album","Duration","Rating"].contains(title) else { return } - if librarySort == title { sortAscending.toggle() } else { librarySort = title;sortAscending = true } - } label: { HStack(spacing:3) { Text(title);if librarySort == title { Image(systemName:sortAscending ? "chevron.up" : "chevron.down").font(.system(size:8)) } }.font(.system(size:12,weight:.medium)).lineLimit(1).padding(.leading,7).frame(width:sizes[index],height:30,alignment:.leading) } - .buttonStyle(.plain).overlay(alignment:.trailing) { Rectangle().fill(Classic.line).frame(width:1).padding(.vertical,4) } - } - }.foregroundStyle(.white).background(Classic.header).overlay(alignment:.bottom) { Rectangle().fill(Color(white:0.65)).frame(height:1) } - } - private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" } - private func songRow(_ song: Song,width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing:0) { - Text("\(song.id)").foregroundStyle(.secondary).frame(width:sizes[0]) - Text(song.title).lineLimit(1).padding(.leading,7).frame(width:sizes[1],alignment:.leading) - Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[2],alignment:.leading) - Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[3],alignment:.leading) - Text(durationLabel(song.duration)).font(.system(size:11,design:.monospaced)).frame(width:sizes[4]) - HStack(spacing:2) { - Text(String(format:"%+.0f",model.preference(song.id).pitch)).font(.system(size:11,design:.monospaced)).frame(width:30) - Stepper("Pitch",value:Binding(get:{ model.preference(song.id).pitch },set:{ model.updateTuning(song.id,pitch:$0) }),in:-12...12,step:1).labelsHidden().controlSize(.mini).fixedSize() - }.frame(width:sizes[5]).help("Saved pitch; applies to the selected singer, or song default") - 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(.mini).frame(width:sizes[6]-8).padding(.horizontal,4).help("Saved tempo; applies live if this song is playing") - GoldRating(rating:Binding(get:{ song.rating },set:{ value in model.perform { try model.library?.rate(song.id,value);try model.refresh() } })).frame(width:sizes[7]) - }.font(.system(size:12)).foregroundStyle(.white).frame(height:30) - .background(selected == song.id ? Color.blue.opacity(0.5) : song.id % 2 == 0 ? Color.white.opacity(0.025) : .clear) - .contentShape(Rectangle()).onTapGesture(count:2) { selected = song.id;playSelected() }.onTapGesture { selected = song.id } - .onDrag { model.dragReference(song.id).provider() } - .contextMenu { - Button("Play") { selected = song.id; playSelected() } - Button("Edit Metadata…") { editingSong = song } - 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 + This Song…") { model.beginSinger(song:song.id) } - } - Menu("Add to Tonight") { ForEach(model.venueSingers) { singer in Button(singer.name) { model.addToTonight(song.id,singer:singer.id) } } }.disabled(model.currentVenue == nil) - Button("Add to Current 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) } } } - } - } - @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(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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.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) - 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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var playlistPane: some View { - VStack(alignment:.leading,spacing:12) { - HStack { - Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } - TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) - Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - if let selectedPlaylist { - Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } - } - ScrollView { - if let selectedPlaylist { - let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] - VStack(alignment:.leading,spacing:10) { - ForEach(ids,id:\.self) { id in - if let song = model.songs.first(where: { $0.id == id }) { - HStack { - Text(song.title); Spacer() - Button("Play") { model.perform { try model.play(song) } } - Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } - } - } - } - } - } - Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) - } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Button(model.readingDurations ? "Reading…" : "Read Durations") { model.scanDurations() }.controlSize(.small).disabled(model.readingDurations).help("Read durations for songs imported by older versions") - Button("Edit Metadata…") { editingSong = selectedSong }.controlSize(.small).disabled(selectedSong == nil) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - 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 = "" - } - } -} -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 - .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(Classic.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 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 search = "" - @State private var deleting: Int64? - var body: some View { - VStack(alignment:.leading,spacing:14) { - HStack { Text("Music Stores").font(.headline);Spacer();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("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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - .sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) } - .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(Classic.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(Classic.frame).preferredColorScheme(.dark) - .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.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." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) - Text("Add songs now (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(Classic.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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - NowPlayingWaveform(player: player).padding(.top, 4) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 130) - } -} -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("EQ / TONE") { 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)) - }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:120) - .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),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) { - HStack { Text("TONE / MIXER").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? - @State private var personalRating = 0 - var body: some View { - HStack(spacing:10) { - Text("Key") - Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) - Text("Tempo") - Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) - Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) - Text("Rating") - GoldRating(rating:$personalRating).frame(width:88).help("Singer-specific song rating") - Button("Save for Singer") { - if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } - }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) - Menu { - Button("Reset key / tempo") { player.pitch = 0; player.tempo = 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).help("Playback settings") - }.font(.system(size:11)) - .onChange(of:model.selectedSinger) { _ in loadRating() } - .onChange(of:player.currentSongID) { _ in loadRating() } - .onChange(of:selectedSong) { _ in loadRating() } - } - private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/Library.swift b/backups/before-kjmix-20261103-100337/KJamsClone/Library.swift deleted file mode 100644 index ce3d2e4..0000000 --- a/backups/before-kjmix-20261103-100337/KJamsClone/Library.swift +++ /dev/null @@ -1,348 +0,0 @@ -import Foundation -import SQLite3 - -struct Song: Identifiable, Hashable { - var id: Int64 - var title: String - var audio: String - var cdg: String? - var rating: Int - var plays: Int - var artist: String = "" - var album: String = "" - var label: String = "" - var duration: Double = 0 -} -struct MusicStore: Identifiable, Hashable { - var id: Int64 - var name: String - var url: String - var searchTemplate: String - func destination(query: String = "") throws -> URL { - let query = query.trimmingCharacters(in:.whitespacesAndNewlines) - let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? "" - let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded) - guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") } - return result - } -} -struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } -struct SongPreference: Equatable { - var pitch: Double = 0 - var tempo: Double = 1 - var rating: Int = 0 -} -struct Performance: Identifiable { - var id: Int64 - var song: String - var singer: String - var venue: String - var date: String - var singerID: Int64? - var songID: Int64? -} -struct QueueEntry: Identifiable { - var id: Int64 - var songID: Int64 - var singerID: Int64? - var title: String - var singer: String -} -enum LibraryError: LocalizedError { - case message(String) - var errorDescription: String? { if case .message(let s) = self { return s }; return nil } -} -final class Library { - private var db: OpaquePointer? - init(url: URL) throws { - guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } - try execute("PRAGMA foreign_keys=ON") - try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") - try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") - // Additive migration: preserve older libraries and infer existing memberships from history. - for column in ["artist", "album", "label"] { - if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } - } - try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") - if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } - try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") - try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") - try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") - try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") - if try schemaVersion() < 4 { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") - } - if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") } - if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") } - if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") } - try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))") - try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')") - if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") } - try execute("CREATE TABLE IF NOT EXISTS singer_tonight(venue INTEGER NOT NULL,singer INTEGER NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(venue,singer,song),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") - if try schemaVersion() < 7 { - try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) SELECT queue.venue,queue.singer,queue.song,MIN(queue.position) FROM queue JOIN venue_singers ON venue_singers.venue=queue.venue AND venue_singers.singer=queue.singer WHERE queue.singer IS NOT NULL GROUP BY queue.venue,queue.singer,queue.song") - } - try execute("PRAGMA user_version=7") - } - deinit { sqlite3_close(db) } - private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { - var s: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } - for (i,v) in values.enumerated() { - let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) - if code != SQLITE_OK { sqlite3_finalize(s); throw error() } - } - return s - } - private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } - func execute(_ sql: String, _ values: [String?] = []) throws { - let s = try statement(sql, values); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_DONE else { throw error() } - } - func songs() throws -> [Song] { - let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } - var result = [Song]() - while true { - let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } - result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9))) - } - return result - } - func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "", duration: Double = 0) throws { - try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration) VALUES(?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END", [title,audio,cdg,artist,album,label,String(duration)]) - } - func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } - func entities(_ table: String) throws -> [PersonPlace] { - precondition(["singers","venues","playlists"].contains(table)) - let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } - var rows = [PersonPlace]() - while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } - return rows - } - func addEntity(_ table: String, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) - } - func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { - try execute("BEGIN IMMEDIATE") - do { - if let singer { try assignSong(song,singer:singer) } - try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) - try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - - private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } - private func schemaVersion() throws -> Int { - let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 - } - private func columns(_ table: String) throws -> Set { - let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } - var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result - } - func members(of venue: Int64) throws -> [PersonPlace] { - let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } - var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result - } - func setMembership(singer: Int64, venue: Int64, included: Bool) throws { - try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) - if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } - } - func currentVenue() throws -> Int64? { - let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil - } - func selectVenue(_ id: Int64?) throws { - if let id { - guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } - } - try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) - } - func rename(_ table: String, id: Int64, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) - } - func deleteEntity(_ table: String, id: Int64) throws { - precondition(["singers","venues","playlists"].contains(table)) - try execute("BEGIN IMMEDIATE") - do { - if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } - if table == "venues" { - try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) - try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) - } - try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - func preference(song: Int64, singer: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) - } - func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { - try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) - } - func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { - let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } - var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result - } - func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { - guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } - if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } - try execute("BEGIN IMMEDIATE") - do { - if let singer { - try assignSong(song,singer:singer) - try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) - } - try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } - func queue(venue: Int64?) throws -> [QueueEntry] { - guard let venue else { return [] } - let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } - var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result - } - func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } - func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { - var rows = try queue(venue:venue) - guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } - rows.swapAt(index,index+offset) - try execute("BEGIN IMMEDIATE") - do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } - catch { try? execute("ROLLBACK"); throw error } - } - func addToPlaylist(_ playlist: Int64, song: Int64) throws { - try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) - } - func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { - let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } - var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids - } - func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } - func assignSong(_ song: Int64, singer: Int64) throws { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) - } - func removeSingerSong(_ song: Int64, singer: Int64) throws { - try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) - } - func singerSongIDs() throws -> [Int64:[Int64]] { - let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } - var result = [Int64:[Int64]]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } - return result - } - func defaultPreference(song: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return .init() } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0) - } - func saveDefaultPreference(song: Int64, value: SongPreference) throws { - try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)]) - } - func updateDuration(_ song: Int64, seconds: Double) throws { - guard seconds.isFinite && seconds >= 0 else { return } - try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)]) - } - func setFavourite(song: Int64, singer: Int64, included: Bool) throws { - if included { try assignSong(song,singer:singer) } - try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)]) - } - func favouriteSongIDs() throws -> [Int64:Set] { - let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) } - var result = [Int64:Set]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result - } - func stores() throws -> [MusicStore] { - let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) } - var result = [MusicStore]() - while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result - } - func saveStore(_ store: MusicStore) throws { - let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a store name") } - let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search) - _ = try cleaned.destination() - if !search.isEmpty { - guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") } - _ = try cleaned.destination(query:"test") - } - if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) } - else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) } - } - func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) } - func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws { - let title = title.trimmingCharacters(in:.whitespacesAndNewlines) - guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") } - try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)]) - guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") } - } - func tonightSongIDs(venue: Int64?) throws -> [Int64:[Int64]] { - guard let venue else { return [:] } - let s = try statement("SELECT singer,song FROM singer_tonight WHERE venue=? ORDER BY position,song",[String(venue)]);defer { sqlite3_finalize(s) } - var result = [Int64:[Int64]]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) };return result - } - func addTonight(song: Int64,singer: Int64,venue: Int64) throws { - guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not a member of the current venue") } - try execute("BEGIN IMMEDIATE") - do { - try assignSong(song,singer:singer) - try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) - try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } - func removeTonight(song: Int64,singer: Int64,venue: Int64) throws { - try execute("BEGIN IMMEDIATE") - do { - try execute("DELETE FROM singer_tonight WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) - try execute("DELETE FROM queue WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } - /// Atomically adds/copies or reorders a request, retaining other singers' queue slots. - func placeTonight(song: Int64,singer: Int64,venue: Int64,before: Int64?,addIfNeeded: Bool) throws { - guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not in the target venue") } - try execute("BEGIN IMMEDIATE") - do { - var ids = try tonightSongIDs(venue:venue)[singer] ?? [] - if let before, !ids.contains(before) { throw LibraryError.message("The drop target changed. Please drop again.") } - if !ids.contains(song) && !addIfNeeded { throw LibraryError.message("This Tonight request no longer exists") } - if addIfNeeded { - try assignSong(song,singer:singer) - try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,0)",[String(venue),String(singer),String(song)]) - try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) - } - if before != song { - ids.removeAll { $0 == song } - if let before,let index = ids.firstIndex(of:before) { ids.insert(song,at:index) } else { ids.append(song) } - } - for (index,id) in ids.enumerated() { try execute("UPDATE singer_tonight SET position=? WHERE venue=? AND singer=? AND song=?",[String(index),String(venue),String(singer),String(id)]) } - let all = try queue(venue:venue) - let slots = all.indices.filter { all[$0].singerID == singer } - let rank = Dictionary(uniqueKeysWithValues:ids.enumerated().map { ($0.element,$0.offset) }) - let requests = slots.enumerated().map { (original:$0.offset,row:all[$0.element]) }.sorted { - let a = rank[$0.row.songID] ?? Int.max,b = rank[$1.row.songID] ?? Int.max - return a == b ? $0.original < $1.original : a < b - } - var ordered = all - for (slot,request) in zip(slots,requests) { ordered[slot] = request.row } - for (index,row) in ordered.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(index),String(row.id)]) } - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } -} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/Player.swift b/backups/before-kjmix-20261103-100337/KJamsClone/Player.swift deleted file mode 100644 index b289215..0000000 --- a/backups/before-kjmix-20261103-100337/KJamsClone/Player.swift +++ /dev/null @@ -1,277 +0,0 @@ -import SwiftUI -import AVFoundation -import CryptoKit - -/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. -@MainActor final class Player: ObservableObject { - @Published var image: CGImage? - @Published private(set) var waveform = [Float]() - @Published private(set) var waveformBands = [WaveformBands]() - @Published var lowGain: Double = 0 { didSet { updateTone() } } - @Published var midGain: Double = 0 { didSet { updateTone() } } - @Published var highGain: Double = 0 { didSet { updateTone() } } - @Published var filter: Double = 0 { didSet { updateTone() } } - @Published var toneEnabled = true { didSet { updateTone() } } - @Published private(set) var leftLevel: Float = 0 - @Published private(set) var rightLevel: Float = 0 - private let tone = AVAudioUnitEQ(numberOfBands:4) - private let meter = AudioLevelMeter() - private var metering = false - @Published private(set) var waveformLoading = false - @Published private(set) var waveformUnavailable = false - @Published private(set) var waveformFromCache = false - @Published private(set) var waveformSaved = false - private let waveformCacheDirectory: URL - private var audioURL: URL? - private var waveformTask: Task? - private var waveformGeneration = UUID() - @Published var playing = false - @Published var time: Double = 0 - @Published var duration: Double = 0 - @Published var title = "Select a song" - @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } - @Published var repeating = false - @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } - @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } - @Published var graphicsOffset: Double = 0 - private(set) var currentSongID: Int64? - var onFinished: (() -> Void)? - private let engine = AVAudioEngine() - private let node = AVAudioPlayerNode() - private let effect = AVAudioUnitTimePitch() - private var file: AVAudioFile? - private var anchor: Double = 0 - private var generation = UUID() - private var cdg = Data() - private var decoder = CDGDecoder() - private var timer: Timer? - init(waveformCacheDirectory: URL? = nil) { - self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms") - engine.attach(node);engine.attach(effect);engine.attach(tone);updateTone() - } - func reanalyseWaveform() { if let audioURL { analyseWaveform(audioURL,force:true) } } - func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true } - private func updateTone() { - AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) - } - func load(_ song: Song, preference: SongPreference = .init()) throws { - stop() - do { - let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) - let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() - guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } - engine.connect(node,to:effect,format:loaded.processingFormat) - engine.connect(effect,to:tone,format:loaded.processingFormat) - engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat) - file = loaded; cdg = graphics; decoder = CDGDecoder() - duration = Double(loaded.length)/loaded.processingFormat.sampleRate - title = song.title; currentSongID = song.id - pitch = preference.pitch; tempo = preference.tempo - updateTone() - let meter = self.meter - engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) } - metering = true - engine.mainMixerNode.outputVolume = Float(volume) - engine.prepare(); try engine.start() - schedule(at:0,play:true); startTimer(); tick() - audioURL = URL(fileURLWithPath:song.audio) - analyseWaveform(audioURL!) - } catch { stop(); throw error } - } - private func analyseWaveform(_ url: URL, force: Bool = false) { - waveformTask?.cancel() - let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false - let directory = waveformCacheDirectory - waveformTask = Task.detached(priority:.utility) { [weak self] in - let result: Result - var cached = false,saved = false - do { - if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) { - result = .success(analysis);cached = true;saved = true - } else { - let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled }) - if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil } - result = .success(analysis) - } - } catch { result = .failure(error) } - guard !Task.isCancelled else { return } - let loadedFromCache = cached,analysisSaved = saved - await MainActor.run { [weak self] in - guard let self,self.waveformGeneration == token else { return } - self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved - switch result { - case .success(let analysis):self.waveform = analysis.levels;self.waveformBands = analysis.bands - case .failure:self.waveformUnavailable = true - } - } - } - } - private func schedule(at seconds: Double, play: Bool) { - guard let file else { return } - generation = UUID(); let token = generation - node.stop() - let rate = file.processingFormat.sampleRate - let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) - anchor = Double(start)/rate; time = anchor - guard start < file.length else { playing = false; render(); return } - node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in - Task { @MainActor in - guard let self, self.generation == token else { return } - if self.repeating { self.schedule(at:0,play:true) } - else { - self.playing = false; self.time = self.duration; self.render() - self.timer?.invalidate(); self.timer = nil; self.onFinished?() - } - } - } - if play { node.play() }; playing = play; render() - } - private func startTimer() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } - } - func toggle() { - guard file != nil else { return } - if playing { tick(); node.pause(); playing = false } - else if time >= duration { schedule(at:0,play:true); startTimer() } - else { node.play(); playing = true; startTimer() } - } - func stop() { - waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() - waveform = [];waveformBands = [];waveformLoading = false;waveformUnavailable = false;waveformFromCache = false;waveformSaved = false;audioURL = nil - generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() - if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } - meter.reset(); leftLevel = 0; rightLevel = 0 - engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone) - file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" - } - func seek(_ value: Double) { - guard file != nil, value.isFinite else { return } - schedule(at:min(max(value,0),duration),play:playing); startTimer() - } - private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } - private func tick() { - let levels = meter.snapshot() - leftLevel = playing ? levels.0 : 0; rightLevel = playing ? levels.1 : 0 - if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { - time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) - } - render() - } -} - -/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. -/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. -enum AudioWaveform { - struct Analysis: Codable, Sendable { var levels: [Float]; var bands: [WaveformBands] } - static func read(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> [Float] { - try analyse(url:url,bins:bins,cancelled:cancelled).levels - } - static func analyse(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> Analysis { - guard bins > 0 else { return .init(levels:[],bands:[]) } - let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) - guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) } - var energy = [Double](repeating: 0, count: bins) - var counts = [Int](repeating: 0, count: bins) - var position: Int64 = 0 - let channels = Int(file.processingFormat.channelCount) - let rate = file.processingFormat.sampleRate - let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate) - var lowState = [Double](repeating:0,count:channels), highState = lowState - var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows - while position < file.length { - if cancelled() { throw CancellationError() } - try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) - let frames = Int(buffer.frameLength) - guard frames > 0, let data = buffer.floatChannelData else { break } - for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } - let peak = rms.max() ?? 0 - let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins) - let bands = (0.. (Float,Float) { lock.lock();defer { lock.unlock() };return levels } - func reset() { lock.lock();levels = (0,0);lock.unlock() } -} - -/// Persistent analysis cache: file path, size, modification time and analyser version form its key. -/// Invalid or obsolete entries are treated as misses, never as playback errors. -enum WaveformCache { - static func location(audio: URL,directory: URL) throws -> URL { - let values = try FileManager.default.attributesOfItem(atPath:audio.path) - let stamp = (values[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let size = (values[.size] as? NSNumber)?.int64Value ?? 0 - let signature = "rgb-rms-v2-4096|\(audio.standardizedFileURL.path)|\(size)|\(stamp)" - let key = SHA256.hash(data:Data(signature.utf8)).map { String(format:"%02x",$0) }.joined() - return directory.appendingPathComponent(key+".waveform") - } - static func load(audio: URL,directory: URL) throws -> AudioWaveform.Analysis { - let url = try location(audio:audio,directory:directory) - let data = try Data(contentsOf:url) - guard data.count < 4_000_000 else { throw LibraryError.message("Invalid waveform cache") } - let analysis = try PropertyListDecoder().decode(AudioWaveform.Analysis.self,from:data) - guard analysis.levels.count == 4096,analysis.bands.count == 4096, - analysis.levels.allSatisfy({ $0.isFinite && $0 >= 0 && $0 <= 1 }), - analysis.bands.allSatisfy({ $0.low.isFinite && $0.mid.isFinite && $0.high.isFinite && $0.low >= 0 && $0.mid >= 0 && $0.high >= 0 }) else { throw LibraryError.message("Invalid waveform cache") } - return analysis - } - static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws { - try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true) - let encoder = PropertyListEncoder();encoder.outputFormat = .binary - try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic) - } -} diff --git a/backups/before-kjmix-20261103-100337/scripts/build.sh b/backups/before-kjmix-20261103-100337/scripts/build.sh deleted file mode 100755 index 434cd7e..0000000 --- a/backups/before-kjmix-20261103-100337/scripts/build.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -euo pipefail -cd "$(dirname "$0")/.." -xcodebuild -project KJamsClone.xcodeproj -scheme KJamsClone -configuration Debug -destination 'platform=macOS' -derivedDataPath "${DERIVED_DATA:-$PWD/build}" CODE_SIGNING_ALLOWED=NO build "$@" diff --git a/backups/before-kjmix-20261103-100337/scripts/generate-test-media.py b/backups/before-kjmix-20261103-100337/scripts/generate-test-media.py deleted file mode 100644 index f6b74ee..0000000 --- a/backups/before-kjmix-20261103-100337/scripts/generate-test-media.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -"""Original synthetic MP3+G test media. Regeneration requires ffmpeg; playback does not.""" -from pathlib import Path -import wave,struct,math,subprocess,zipfile,shutil -root=Path(__file__).resolve().parents[1]/'Fixtures';root.mkdir(exist_ok=True) -font={ -'A':['01110','10001','10001','11111','10001','10001','10001'], -'C':['01111','10000','10000','10000','10000','10000','01111'], -'D':['11110','10001','10001','10001','10001','10001','11110'], -'E':['11111','10000','10000','11110','10000','10000','11111'], -'F':['11111','10000','10000','11110','10000','10000','10000'], -'G':['01111','10000','10000','10111','10001','10001','01111'], -'H':['10001','10001','10001','11111','10001','10001','10001'], -'I':['11111','00100','00100','00100','00100','00100','11111'], -'J':['00111','00010','00010','00010','10010','10010','01100'], -'K':['10001','10010','10100','11000','10100','10010','10001'], -'L':['10000','10000','10000','10000','10000','10000','11111'], -'M':['10001','11011','10101','10101','10001','10001','10001'], -'N':['10001','11001','10101','10011','10001','10001','10001'], -'O':['01110','10001','10001','10001','10001','10001','01110'], -'P':['11110','10001','10001','11110','10000','10000','10000'], -'R':['11110','10001','10001','11110','10100','10010','10001'], -'S':['01111','10000','10000','01110','00001','00001','11110'], -'T':['11111','00100','00100','00100','00100','00100','00100'], -'U':['10001','10001','10001','10001','10001','10001','01110'], -'V':['10001','10001','10001','10001','10001','01010','00100'], -'W':['10001','10001','10001','10101','10101','11011','10001'], -'Y':['10001','10001','01010','00100','00100','00100','00100'], -'3':['11110','00001','00001','01110','00001','00001','11110'], -' ':['00000']*7, -} -def packet(inst,data):return bytes([9,inst,0,0]+(data+[0]*16)[:16]+[0]*4) -packets=[bytes(24) for _ in range(6000)] -def put(index,seq): - for p in seq: - assert packets[index]==bytes(24),'overlapping instructions' - packets[index]=p;index+=1 - return index -colors=[0x013,0xfff,0xfd0,0x0cf]+[0]*12 -def palette(offset): - data=[] - for c in colors[offset:offset+8]:data.extend([(c>>6)&63,c&63]) - return packet(30 if offset==0 else 31,data) -put(0,[palette(0),palette(8),packet(1,[0,0]),packet(2,[0])]) -def frame(lines): - pixels=[[0]*300 for _ in range(216)] - for line,(text,y) in enumerate(lines): - x=(300-len(text)*12)//2 - for char in text: - for gy,row in enumerate(font[char]): - for gx,bit in enumerate(row): - if bit=='1': - for dy in range(2): - for dx in range(2):pixels[y+gy*2+dy][x+gx*2+dx]=1 - x+=12 - return pixels -def tiles(pixels,xor=False): - result=[] - for row in range(1,17): - for col in range(1,49): - bits=[] - for y in range(12): - v=0 - for x in range(6):v=(v<<1)|pixels[row*12+y][col*6+x] - bits.append(v) - if any(bits):result.append(packet(38 if xor else 6,[0,3 if xor else 1,row,col]+bits)) - return result -scenes=[[("KJAMS CLONE",66),("MP3 PLUS G TEST",114)],[("ONE TWO THREE FOUR",66),("SING ALONG",114)],[("WITH THE TEST TONE",66),("PLAY PAUSE SEEK",114)],[("MP3 PLUS G TEST",66),("TEST COMPLETE",114)]] -for index,lines in enumerate(scenes): - start=index*1500+(5 if index==0 else 0) - pixels=frame(lines) - put(start,([packet(1,[0,0])] if index else [])+tiles(pixels)) - # Highlight each word after the initial white text has been drawn. - for line,(text,y) in enumerate(lines): - left=(300-len(text)*12)//2 - words=text.split();cursor=0 - for wi,word in enumerate(words): - mask=[[0]*300 for _ in range(216)] - for py in range(y,y+14): - for px in range(left+cursor*12,left+(cursor+len(word))*12):mask[py][px]=pixels[py][px] - put(index*1500+240+line*600+wi*100,tiles(mask,True)) - cursor+=len(word)+1 -(root/'TestTrack.cdg').write_bytes(b''.join(packets)) -rate=44100;duration=20;notes=[261.63,329.63,392.0,523.25,392.0,329.63,293.66,261.63] -with wave.open(str(root/'TestTrack.wav'),'w') as out: - out.setparams((1,2,rate,0,'NONE','not compressed')) - for beat in range(duration*2): - freq=notes[beat%len(notes)] - data=bytearray() - for i in range(rate//2): - t=i/rate;fade=min(1,t/0.015,(0.5-t)/0.04) - data.extend(struct.pack(' 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(); singers = try library.entities("singers"); venues = try library.entities("venues") - 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 } - queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) - } - func chooseVenue(_ id: Int64?) { - perform { - guard let library else { throw LibraryError.message("Library unavailable") } - try library.selectVenue(id); selectedSinger = nil; 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 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 preference(_ song: Int64) -> SongPreference { - guard let library, let selectedSinger else { return .init() } - return (try? library.preference(song:song,singer:selectedSinger)) ?? .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 { - 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) } ?? SongPreference() - try player.load(song,preference:preference) - do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } - catch { player.stop(); throw error } - } - func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } - func playQueueNext() { - perform { - guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } - // Explicitly clear selection for unassigned queue entries. - selectedSinger = row.singerID - try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() - } - } - 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 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -/// 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 { - let 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) - .aspectRatio(1.5, contentMode: .fit) - .clipped() - .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) - .accessibilityLabel("Video monitor, synchronized with the Video window") - }.frame(maxWidth: .infinity) - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } - else if source == "Queue" { queuePane } - else if source == "History" { historyPane } - else if source == "Playlists" { playlistPane } - else { directory } - detailPanel.frame(height: 135) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .sheet(item: $model.creating) { kind in - AddEntitySheet(model: model, kind: kind) { id in - if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } - } - } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - 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(alignment: .leading, spacing: 0) { - Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) - .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) - ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan), ("Queue", "list.number", Color.orange), ("History", "clock.arrow.circlepath", Color.purple), ("Playlists", "music.note.list", Color.mint)], id: \.0) { item in - Button { source = item.0 } label: { - HStack(spacing: 10) { - Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) - Text(item.0).font(.system(size: 16, weight: .medium)) - Spacer() - Text("\(sourceCount(item.0))") - .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) - .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) - }.buttonStyle(.plain) - } - Spacer(minLength: 12) - SidebarVideoMonitor(player: model.player).padding(10) - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 790)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { - [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] - } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in - Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) - .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) - .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } - } - }.foregroundStyle(.white).background(Classic.header) - .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } - } - private func cellText(_ song: Song, column: Int) -> String { - switch column { - case 2: return song.artist.isEmpty ? "—" : song.artist - case 3: return song.album.isEmpty ? "—" : song.album - case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label - case 5: return String(format:"%+.0f",model.preference(song.id).pitch) - default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) - } - } - private func songRow(_ song: Song, width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") - .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) - Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) - ForEach(2..<7) { index in - Text(cellText(song, column: index)) - .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) - } - Menu { - ForEach(0..<6) { value in - Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { - model.perform { try model.library?.rate(song.id, value); try model.refresh() } - } - } - } label: { - Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) - .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) - }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") - }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) - .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) - .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } - .onTapGesture { selected = song.id } - .contextMenu { - Button("Play") { selected = song.id; playSelected() } - Button("Add to Current 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) } } } - } - } - @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…") { model.creating = source == "Singers" ? .singer : .venue } - }.padding(12).background(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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.creating = .singer } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") - Button("Queue Song") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) - } - PlaybackSettings(player:model.player,model:model,selectedSong:selected) - SeekBar(player:model.player) - }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) - .background(Classic.navy).overlay(Rectangle().stroke(Classic.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.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) - 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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var playlistPane: some View { - VStack(alignment:.leading,spacing:12) { - HStack { - Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } - TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) - Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - if let selectedPlaylist { - Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } - } - ScrollView { - if let selectedPlaylist { - let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] - VStack(alignment:.leading,spacing:10) { - ForEach(ids,id:\.self) { id in - if let song = model.songs.first(where: { $0.id == id }) { - HStack { - Text(song.title); Spacer() - Button("Play") { model.perform { try model.play(song) } } - Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } - } - } - } - } - } - Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) - } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - 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 = "" - } - } -} -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? - @FocusState private var focused: Bool - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("New \(kind.title)").font(.title2.bold()) - TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) - .focused($focused).onSubmit(save) - if let error { Text(error).foregroundStyle(.red).font(.callout) } - HStack { - Spacer() - Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) - Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - }.padding(24).frame(width: 360).onAppear { focused = true } - } - private func save() { - do { let 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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - NowPlayingWaveform(player: player).padding(.top, 4) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 96) - } -} -private struct NowPlayingWaveform: View { - @ObservedObject var player: Player - private let teal = Color(red: 0.18, green: 0.83, blue: 0.75) - private let cyan = Color(red: 0.22, green: 0.74, blue: 0.97) - private var progress: Double { player.duration > 0 ? min(1, max(0, player.time / player.duration)) : 0 } - private func clock(_ value: Double) -> String { let n = max(0, Int(value)); return String(format: "%d:%02d", n/60, n%60) } - var body: some View { - VStack(alignment: .leading, spacing: 5) { - Text(player.duration > 0 ? player.title : "Ready to play") - .font(.system(size: 13, weight: .semibold)).foregroundStyle(Color(white: 0.94)) - .lineLimit(1).truncationMode(.middle).frame(maxWidth: .infinity, alignment: .leading).help(player.title) - GeometryReader { geometry in - Canvas { context, size in - let mid = size.height/2 - var grid = Path() - for fraction in [0.25, 0.5, 0.75] { - let y = size.height*fraction; grid.move(to:CGPoint(x:0,y:y)); grid.addLine(to:CGPoint(x:size.width,y:y)) - } - context.stroke(grid,with:.color(.white.opacity(0.045)),lineWidth:1) - if !player.waveform.isEmpty { - let count = max(1, Int(size.width/3)) - let step = size.width/CGFloat(count) - for bar in 0.. 0, geometry.size.width > 0 else { return } - player.seek(min(1,max(0,value.location.x/geometry.size.width))*player.duration) - }) - .help("Click the waveform to seek") - .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:28) - HStack(spacing:5) { - Circle().fill(player.playing ? teal : Color(red:0.9,green:0.68,blue:0.3)).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) - Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit() - }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(red:0.57,green:0.67,blue:0.79)) - }.padding(.horizontal,12).padding(.vertical,9) - .frame(maxWidth:.infinity).frame(height:86) - .background(LinearGradient(colors:[Color(red:0.035,green:0.065,blue:0.105),Color(red:0.05,green:0.09,blue:0.15)],startPoint:.top,endPoint:.bottom)) - .overlay(Rectangle().strokeBorder(Color(red:0.21,green:0.29,blue:0.38),lineWidth:1)) - } -} -private struct PlaybackSettings: View { - @ObservedObject var player: Player - @ObservedObject var model: AppModel - let selectedSong: Int64? - @State private var personalRating = 0 - var body: some View { - HStack(spacing:10) { - Text("Key") - Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) - Text("Tempo") - Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) - Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) - Text("Rating") - Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") - Button("Save for Singer") { - if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } - }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) - Menu { - Button("Reset key / tempo") { player.pitch = 0; player.tempo = 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).help("Playback settings") - }.font(.system(size:11)) - .onChange(of:model.selectedSinger) { _ in loadRating() } - .onChange(of:player.currentSongID) { _ in loadRating() } - .onChange(of:selectedSong) { _ in loadRating() } - } - private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Library.swift b/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Library.swift deleted file mode 100644 index b98286f..0000000 --- a/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Library.swift +++ /dev/null @@ -1,196 +0,0 @@ -import Foundation -import SQLite3 - -struct Song: Identifiable, Hashable { - var id: Int64 - var title: String - var audio: String - var cdg: String? - var rating: Int - var plays: Int - var artist: String = "" - var album: String = "" - var label: String = "" -} -struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } -struct SongPreference: Equatable { - var pitch: Double = 0 - var tempo: Double = 1 - var rating: Int = 0 -} -struct Performance: Identifiable { - var id: Int64 - var song: String - var singer: String - var venue: String - var date: String -} -struct QueueEntry: Identifiable { - var id: Int64 - var songID: Int64 - var singerID: Int64? - var title: String - var singer: String -} -enum LibraryError: LocalizedError { - case message(String) - var errorDescription: String? { if case .message(let s) = self { return s }; return nil } -} -final class Library { - private var db: OpaquePointer? - init(url: URL) throws { - guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } - try execute("PRAGMA foreign_keys=ON") - try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") - try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") - // Additive migration: preserve older libraries and infer existing memberships from history. - for column in ["artist", "album", "label"] { - if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } - } - try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") - if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } - try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") - try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") - try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") - try execute("PRAGMA user_version=3") - } - deinit { sqlite3_close(db) } - private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { - var s: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } - for (i,v) in values.enumerated() { - let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) - if code != SQLITE_OK { sqlite3_finalize(s); throw error() } - } - return s - } - private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } - func execute(_ sql: String, _ values: [String?] = []) throws { - let s = try statement(sql, values); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_DONE else { throw error() } - } - func songs() throws -> [Song] { - let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } - var result = [Song]() - while true { - let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } - result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8))) - } - return result - } - func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "") throws { - try execute("INSERT INTO songs(title,audio,cdg,artist,album,label) VALUES(?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN excluded.label!='' THEN excluded.label ELSE songs.label END", [title,audio,cdg,artist,album,label]) - } - func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } - func entities(_ table: String) throws -> [PersonPlace] { - precondition(["singers","venues","playlists"].contains(table)) - let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } - var rows = [PersonPlace]() - while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } - return rows - } - func addEntity(_ table: String, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) - } - func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { - try execute("BEGIN IMMEDIATE") - do { - try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) - try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - - private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } - private func schemaVersion() throws -> Int { - let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 - } - private func columns(_ table: String) throws -> Set { - let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } - var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result - } - func members(of venue: Int64) throws -> [PersonPlace] { - let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } - var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result - } - func setMembership(singer: Int64, venue: Int64, included: Bool) throws { - try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) - if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } - } - func currentVenue() throws -> Int64? { - let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil - } - func selectVenue(_ id: Int64?) throws { - if let id { - guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } - } - try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) - } - func rename(_ table: String, id: Int64, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) - } - func deleteEntity(_ table: String, id: Int64) throws { - precondition(["singers","venues","playlists"].contains(table)) - try execute("BEGIN IMMEDIATE") - do { - if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } - if table == "venues" { - try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) - try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) - } - try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - func preference(song: Int64, singer: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return .init() } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) - } - func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { - try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) - } - func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { - let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } - var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4))) }; return result - } - func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { - guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } - if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } - try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) - } - func queue(venue: Int64?) throws -> [QueueEntry] { - guard let venue else { return [] } - let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } - var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result - } - func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } - func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { - var rows = try queue(venue:venue) - guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } - rows.swapAt(index,index+offset) - try execute("BEGIN IMMEDIATE") - do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } - catch { try? execute("ROLLBACK"); throw error } - } - func addToPlaylist(_ playlist: Int64, song: Int64) throws { - try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) - } - func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { - let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } - var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids - } - func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } -} diff --git a/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Player.swift b/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Player.swift deleted file mode 100644 index 5c2440b..0000000 --- a/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Player.swift +++ /dev/null @@ -1,150 +0,0 @@ -import SwiftUI -import AVFoundation - -/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. -@MainActor final class Player: ObservableObject { - @Published var image: CGImage? - @Published private(set) var waveform = [Float]() - @Published private(set) var waveformLoading = false - @Published private(set) var waveformUnavailable = false - private var waveformTask: Task? - private var waveformGeneration = UUID() - @Published var playing = false - @Published var time: Double = 0 - @Published var duration: Double = 0 - @Published var title = "Select a song" - @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } - @Published var repeating = false - @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } - @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } - @Published var graphicsOffset: Double = 0 - private(set) var currentSongID: Int64? - var onFinished: (() -> Void)? - private let engine = AVAudioEngine() - private let node = AVAudioPlayerNode() - private let effect = AVAudioUnitTimePitch() - private var file: AVAudioFile? - private var anchor: Double = 0 - private var generation = UUID() - private var cdg = Data() - private var decoder = CDGDecoder() - private var timer: Timer? - init() { engine.attach(node); engine.attach(effect) } - func load(_ song: Song, preference: SongPreference = .init()) throws { - stop() - do { - let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) - let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() - guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } - engine.connect(node,to:effect,format:loaded.processingFormat) - engine.connect(effect,to:engine.mainMixerNode,format:loaded.processingFormat) - file = loaded; cdg = graphics; decoder = CDGDecoder() - duration = Double(loaded.length)/loaded.processingFormat.sampleRate - title = song.title; currentSongID = song.id - pitch = preference.pitch; tempo = preference.tempo - engine.mainMixerNode.outputVolume = Float(volume) - engine.prepare(); try engine.start() - schedule(at:0,play:true); startTimer(); tick() - analyseWaveform(URL(fileURLWithPath:song.audio)) - } catch { stop(); throw error } - } - private func analyseWaveform(_ url: URL) { - let token = UUID(); waveformGeneration = token; waveformLoading = true - waveformTask = Task.detached(priority: .utility) { [weak self] in - let result: Result<[Float], Error> - do { result = .success(try AudioWaveform.read(url: url, cancelled: { Task.isCancelled })) } - catch { result = .failure(error) } - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - guard let self, self.waveformGeneration == token else { return } - self.waveformLoading = false - switch result { - case .success(let samples): self.waveform = samples - case .failure: self.waveformUnavailable = true - } - } - } - } - private func schedule(at seconds: Double, play: Bool) { - guard let file else { return } - generation = UUID(); let token = generation - node.stop() - let rate = file.processingFormat.sampleRate - let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) - anchor = Double(start)/rate; time = anchor - guard start < file.length else { playing = false; render(); return } - node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in - Task { @MainActor in - guard let self, self.generation == token else { return } - if self.repeating { self.schedule(at:0,play:true) } - else { - self.playing = false; self.time = self.duration; self.render() - self.timer?.invalidate(); self.timer = nil; self.onFinished?() - } - } - } - if play { node.play() }; playing = play; render() - } - private func startTimer() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } - } - func toggle() { - guard file != nil else { return } - if playing { tick(); node.pause(); playing = false } - else if time >= duration { schedule(at:0,play:true); startTimer() } - else { node.play(); playing = true; startTimer() } - } - func stop() { - waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() - waveform = []; waveformLoading = false; waveformUnavailable = false - generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() - engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect) - file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" - } - func seek(_ value: Double) { - guard file != nil, value.isFinite else { return } - schedule(at:min(max(value,0),duration),play:playing); startTimer() - } - private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } - private func tick() { - if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { - time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) - } - render() - } -} - -/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. -/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. -enum AudioWaveform { - static func read(url: URL, bins: Int = 1024, cancelled: () -> Bool = { false }) throws -> [Float] { - guard bins > 0 else { return [] } - let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) - guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return [] } - var energy = [Double](repeating: 0, count: bins) - var counts = [Int](repeating: 0, count: bins) - var position: Int64 = 0 - let channels = Int(file.processingFormat.channelCount) - while position < file.length { - if cancelled() { throw CancellationError() } - try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) - let frames = Int(buffer.frameLength) - guard frames > 0, let data = buffer.floatChannelData else { break } - for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } - let peak = rms.max() ?? 0 - guard peak > 0 else { return [Float](repeating:0,count:bins) } - return rms.map { Float(pow(min(1,$0/peak),0.7)) } - } -} diff --git a/backups/before-mixer-repertoire-20261103-084301/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-mixer-repertoire-20261103-084301/KJamsCloneTests/KJamsCloneTests.swift deleted file mode 100644 index 61af9e3..0000000 --- a/backups/before-mixer-repertoire-20261103-084301/KJamsCloneTests/KJamsCloneTests.swift +++ /dev/null @@ -1,266 +0,0 @@ -import XCTest -import AVFoundation -@testable import KJamsClone - -final class KJamsCloneTests: XCTestCase { - func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } - func testPresetTileXORAndInvalidCoordinates() { - var d = CDGDecoder() - d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) - d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],5) - d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],0) - let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) - } - func testSeekAndTruncatedPacket() { - var d = CDGDecoder() - let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) - d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) - d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) - d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) - } - func testPaletteBorderScrollAndImage() { - var d = CDGDecoder() - d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) - d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) - d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) - d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) - XCTAssertEqual(d.image()?.width,288) - } - func testSQLitePersistenceDedupRatingsAndHistory() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("test.sqlite") - do { - let db = try Library(url:url) - try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) - try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") - let song = try XCTUnwrap(db.songs().first) - try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) - try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") - try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) - } - let reopened = try Library(url:url); let songs = try reopened.songs() - XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") - XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") - } - func testPairedImportAndContentDedup() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") - try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) - let db = try Library(url:root.appendingPathComponent("test.sqlite")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([audio],library:db); try importer.run([audio],library:db) - XCTAssertEqual(try db.songs().count,1) - XCTAssertNotNil(try db.songs().first?.cdg) - } - - func testZippedMP3GImportAndUnsafeArchiveRejection() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let db = try Library(url:folder.appendingPathComponent("db.sqlite")) - let importer = Importer(media:folder.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) - XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) - XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) - } - @MainActor func testMP3PlaybackPauseAndSeek() throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player() - defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) - XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) - player.toggle(); XCTAssertFalse(player.playing) - player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) - player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) - } - @MainActor func testCreateSingerAndVenueThroughAppModel() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: root) } - let model = AppModel(storageURL: root) - XCTAssertNil(model.error) - let singer = try model.createEntity(.singer, name: " Alex ") - let venue = try model.createEntity(.venue, name: "Town Hall") - XCTAssertEqual(model.singers.first?.name, "Alex") - XCTAssertEqual(model.venues.first?.name, "Town Hall") - XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) - XCTAssertEqual(model.singers.count, 1) - XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) - let reopened = AppModel(storageURL: root) - XCTAssertEqual(reopened.singers.first?.id, singer) - XCTAssertEqual(reopened.venues.first?.id, venue) - } - - @MainActor func testVenuesShareSingersAndRememberSelection() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - let alex = try model.createEntity(.singer,name:"Alex") - let club = try model.createEntity(.venue,name:"Club") - XCTAssertTrue(model.venueSingers.isEmpty) - model.membership(singer:alex,included:true) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(club); model.membership(singer:alex,included:false) - XCTAssertTrue(model.venueSingers.isEmpty) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) - reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) - XCTAssertEqual(reopened.singers.count,1) - } - func testMigrationAndMembershipRemovalDoesNotReappear() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("db.sqlite") - var singer: Int64 = 0, venue: Int64 = 0 - do { - let db = try Library(url:url) - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") - singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) - try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) - try db.execute("PRAGMA user_version=1") - } - do { - let migrated = try Library(url:url) - XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) - try migrated.setMembership(singer:singer,venue:venue,included:false) - } - let reopened = try Library(url:url) - XCTAssertTrue(try reopened.members(of:venue).isEmpty) - XCTAssertEqual(try reopened.history(venue:venue).count,1) - } - func testQueuePlaylistsPreferencesAndDeletion() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.add(title:"First",audio:"/1.mp3",cdg:nil) - try db.add(title:"Second",audio:"/2.mp3",cdg:nil) - let songs = try db.songs() - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") - let singer = try XCTUnwrap(db.entities("singers").first?.id) - let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) - let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) - XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) - try db.setMembership(singer:singer,venue:hall,included:true) - try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) - XCTAssertTrue(try db.queue(venue:club).isEmpty) - let second = try XCTUnwrap(db.queue(venue:hall).last) - try db.moveQueue(second.id,venue:hall,by:-1) - XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) - let preference = SongPreference(pitch:3,tempo:0.85,rating:4) - try db.savePreference(song:songs[0].id,singer:singer,value:preference) - XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) - XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) - try db.addEntity("playlists",name:"Party") - let playlist = try XCTUnwrap(db.entities("playlists").first?.id) - try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) - XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) - try db.record(songs[0].id,singer:singer,venue:hall) - try db.rename("singers",id:singer,name:"Alex Jones") - XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") - try db.deleteEntity("singers",id:singer) - XCTAssertTrue(try db.queue(venue:hall).isEmpty) - XCTAssertEqual(try db.history(venue:hall).count,1) - try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) - XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) - } - @MainActor func testTempoSourceClockPauseAndResume() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) - try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) - XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) - try await Task.sleep(nanoseconds:500_000_000) - XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) - player.toggle(); let paused = player.time - try await Task.sleep(nanoseconds:100_000_000) - XCTAssertEqual(player.time,paused,accuracy:0.025) - player.seek(1.5); player.toggle() - try await Task.sleep(nanoseconds:200_000_000) - XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) - } - func testNewTestMediaMetadataAndCDGPairImport() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"MP3 Plus G Test") - XCTAssertEqual(song.artist,"KJamsClone Test Generator") - XCTAssertNotNil(song.cdg) - let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) - XCTAssertEqual(bytes.count,20*300*24) - var decoder = CDGDecoder(); decoder.render(bytes,at:2) - XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) - } - @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root); defer { model.player.stop() } - _ = try model.createEntity(.venue,name:"Test Hall") - _ = try model.createEntity(.singer,name:"Alex") - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) - let song = try XCTUnwrap(model.songs.first) - model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() - XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) - model.player.seek(model.player.duration-0.1) - for _ in 0..<25 { - if model.queue.isEmpty { break } - try await Task.sleep(nanoseconds:100_000_000) - } - XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) - XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") - } - func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("signal.wav") - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) - do { - let file = try AVAudioFile(forWriting:url,settings:format.settings) - let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 - let samples = try XCTUnwrap(buffer.floatChannelData?[0]) - for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } - try file.write(from:buffer) - } - let envelope = try AudioWaveform.read(url:url,bins:32) - XCTAssertEqual(envelope.count,32) - XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) - XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) - XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) - } - @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) - player.toggle() - for _ in 0..<50 { - if !player.waveformLoading { break } - try await Task.sleep(nanoseconds:20_000_000) - } - XCTAssertEqual(player.waveform.count,1024) - XCTAssertFalse(player.waveformUnavailable) - player.stop() - XCTAssertTrue(player.waveform.isEmpty) - XCTAssertFalse(player.waveformLoading) - try await Task.sleep(nanoseconds:50_000_000) - XCTAssertTrue(player.waveform.isEmpty) - } -} diff --git a/backups/before-sidebar-monitor-20261103-081330/KJamsClone/KJamsCloneApp.swift b/backups/before-sidebar-monitor-20261103-081330/KJamsClone/KJamsCloneApp.swift deleted file mode 100644 index 89c3daa..0000000 --- a/backups/before-sidebar-monitor-20261103-081330/KJamsClone/KJamsCloneApp.swift +++ /dev/null @@ -1,619 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -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" } -} - -@MainActor final class AppModel: ObservableObject { - @Published var songs = [Song]() - @Published var singers = [PersonPlace]() - @Published var venues = [PersonPlace]() - @Published var venueSingers = [PersonPlace]() - @Published var playlists = [PersonPlace]() - @Published var queue = [QueueEntry]() - @Published var history = [Performance]() - @Published private(set) var currentVenue: Int64? - @Published var selectedSinger: Int64? - @Published var autoAdvance = true - @Published var error: String? - @Published var creating: EntityKind? - 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") - perform { - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() - } - player.onFinished = { [weak self] in - guard let self, self.autoAdvance, !self.queue.isEmpty else { return } - self.playQueueNext() - } - } - 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(); singers = try library.entities("singers"); venues = try library.entities("venues") - 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 } - queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) - } - func chooseVenue(_ id: Int64?) { - perform { - guard let library else { throw LibraryError.message("Library unavailable") } - try library.selectVenue(id); selectedSinger = nil; 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 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 preference(_ song: Int64) -> SongPreference { - guard let library, let selectedSinger else { return .init() } - return (try? library.preference(song:song,singer:selectedSinger)) ?? .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 { - 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) } ?? SongPreference() - try player.load(song,preference:preference) - do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } - catch { player.stop(); throw error } - } - func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } - func playQueueNext() { - perform { - guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } - // Explicitly clear selection for unassigned queue entries. - selectedSinger = row.singerID - try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() - } - } - 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 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } - else if source == "Queue" { queuePane } - else if source == "History" { historyPane } - else if source == "Playlists" { playlistPane } - else { directory } - detailPanel.frame(height: 135) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .sheet(item: $model.creating) { kind in - AddEntitySheet(model: model, kind: kind) { id in - if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } - } - } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - 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(alignment: .leading, spacing: 0) { - Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) - .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) - ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan), ("Queue", "list.number", Color.orange), ("History", "clock.arrow.circlepath", Color.purple), ("Playlists", "music.note.list", Color.mint)], id: \.0) { item in - Button { source = item.0 } label: { - HStack(spacing: 10) { - Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) - Text(item.0).font(.system(size: 16, weight: .medium)) - Spacer() - Text("\(sourceCount(item.0))") - .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) - .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) - }.buttonStyle(.plain) - } - Spacer() - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 790)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { - [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] - } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in - Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) - .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) - .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } - } - }.foregroundStyle(.white).background(Classic.header) - .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } - } - private func cellText(_ song: Song, column: Int) -> String { - switch column { - case 2: return song.artist.isEmpty ? "—" : song.artist - case 3: return song.album.isEmpty ? "—" : song.album - case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label - case 5: return String(format:"%+.0f",model.preference(song.id).pitch) - default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) - } - } - private func songRow(_ song: Song, width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") - .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) - Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) - ForEach(2..<7) { index in - Text(cellText(song, column: index)) - .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) - } - Menu { - ForEach(0..<6) { value in - Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { - model.perform { try model.library?.rate(song.id, value); try model.refresh() } - } - } - } label: { - Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) - .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) - }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") - }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) - .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) - .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } - .onTapGesture { selected = song.id } - .contextMenu { - Button("Play") { selected = song.id; playSelected() } - Button("Add to Current 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) } } } - } - } - @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…") { model.creating = source == "Singers" ? .singer : .venue } - }.padding(12).background(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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.creating = .singer } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") - Button("Queue Song") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) - } - PlaybackSettings(player:model.player,model:model,selectedSong:selected) - SeekBar(player:model.player) - }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) - .background(Classic.navy).overlay(Rectangle().stroke(Classic.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.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) - 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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var playlistPane: some View { - VStack(alignment:.leading,spacing:12) { - HStack { - Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } - TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) - Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - if let selectedPlaylist { - Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } - } - ScrollView { - if let selectedPlaylist { - let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] - VStack(alignment:.leading,spacing:10) { - ForEach(ids,id:\.self) { id in - if let song = model.songs.first(where: { $0.id == id }) { - HStack { - Text(song.title); Spacer() - Button("Play") { model.perform { try model.play(song) } } - Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } - } - } - } - } - } - Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) - } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - 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 = "" - } - } -} -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? - @FocusState private var focused: Bool - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("New \(kind.title)").font(.title2.bold()) - TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) - .focused($focused).onSubmit(save) - if let error { Text(error).foregroundStyle(.red).font(.callout) } - HStack { - Spacer() - Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) - Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - }.padding(24).frame(width: 360).onAppear { focused = true } - } - private func save() { - do { let 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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - VStack(spacing: 0) { - Text(player.duration == 0 ? "Ready!" : player.title).font(.system(size: 15, weight: .medium)).lineLimit(1).padding(.horizontal, 20) - if player.duration > 0 { Text(player.playing ? "Playing" : "Paused").font(.system(size: 10)).foregroundStyle(.white.opacity(0.65)).padding(.top, 4) } - }.frame(maxWidth: .infinity).frame(height: 64) - .background(LinearGradient(colors: [Color(red: 0, green: 0.10, blue: 0.30), Classic.blue], startPoint: .top, endPoint: .bottom), in: Capsule()) - .overlay(Capsule().strokeBorder(LinearGradient(colors: [.black, .white], startPoint: .top, endPoint: .bottom), lineWidth: 1.5)) - .padding(.top, 14) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 96) - } -} -private struct PlaybackSettings: View { - @ObservedObject var player: Player - @ObservedObject var model: AppModel - let selectedSong: Int64? - @State private var personalRating = 0 - var body: some View { - HStack(spacing:10) { - Text("Key") - Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) - Text("Tempo") - Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) - Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) - Text("Rating") - Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") - Button("Save for Singer") { - if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } - }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) - Menu { - Button("Reset key / tempo") { player.pitch = 0; player.tempo = 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).help("Playback settings") - }.font(.system(size:11)) - .onChange(of:model.selectedSinger) { _ in loadRating() } - .onChange(of:player.currentSongID) { _ in loadRating() } - .onChange(of:selectedSong) { _ in loadRating() } - } - private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-tonight-drag-20261103-094630/KJamsClone/KJamsCloneApp.swift b/backups/before-tonight-drag-20261103-094630/KJamsClone/KJamsCloneApp.swift deleted file mode 100644 index 67647b3..0000000 --- a/backups/before-tonight-drag-20261103-094630/KJamsClone/KJamsCloneApp.swift +++ /dev/null @@ -1,1251 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers -import AVFoundation - -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" } -} - -@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 - var newSingerSongs = Set() - private var songIndex = [Int64:Song]() - @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? - @Published var autoAdvance = true - @Published var error: String? - @Published var creating: EntityKind? - 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() - } - player.onFinished = { [weak self] in - guard let self, self.autoAdvance, !self.queue.isEmpty else { return } - self.playQueueNext() - } - } - 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() - 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) - } - func chooseVenue(_ id: Int64?) { - perform { - guard let library else { throw LibraryError.message("Library unavailable") } - try library.selectVenue(id); selectedSinger = nil; 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) - for song in songs { try library.assignSong(song,singer:id) } - try library.selectVenue(venue);try library.execute("COMMIT") - } catch { try? library.execute("ROLLBACK");throw error } - try refresh();selectedSinger = 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 preference(_ song: Int64) -> SongPreference { - guard let library else { return .init() } - if let selectedSinger { return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() } - return (try? library.defaultPreference(song: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 { - 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) - try player.load(song,preference:preference) - do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; 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 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 playQueueNext() { - perform { - guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } - // Explicitly clear selection for unassigned queue entries. - selectedSinger = row.singerID - try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() - } - } - 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 { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } } - try refresh() - } - } - 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 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -/// 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 { - let 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) - .aspectRatio(1.5, contentMode: .fit) - .clipped() - .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) - .accessibilityLabel("Video monitor, synchronized with the Video window") - }.frame(maxWidth: .infinity) - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @State private var singersExpanded = true - @State private var expandedSingers = Set() - @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 editingSong: Song? - @State private var librarySort = "Song Name" - @State private var sortAscending = true - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { - let filtered = model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } - return filtered.sorted { a,b in - if a.id == b.id { return false } - if librarySort == "Duration" { return a.duration == b.duration ? a.id < b.id : sortAscending ? a.duration < b.duration : a.duration > b.duration } - if librarySort == "Rating" { return a.rating == b.rating ? a.id < b.id : sortAscending ? a.rating < b.rating : a.rating > b.rating } - let first = librarySort == "Artist" ? a.artist : librarySort == "Album" ? a.album : a.title - let second = librarySort == "Artist" ? b.artist : librarySort == "Album" ? b.album : b.title - let order = first.localizedStandardCompare(second) - return order == .orderedSame ? a.id < b.id : sortAscending ? order == .orderedAscending : order == .orderedDescending - } - } - - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } - else if source == "Queue" { queuePane } - 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 { directory } - detailPanel.frame(height: 135) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .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(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 - if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) } - } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - 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(alignment:.leading,spacing:0) { - Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) - .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) - ScrollView { - VStack(spacing:0) { - sourceRow("Library",icon:"music.note.house.fill",color:.yellow) - HStack(spacing:5) { - Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") - Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) - Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") - }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) - if singersExpanded { - Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) - ForEach(model.venueSingers) { singer in sidebarSinger(singer) } - if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } - } - HStack { - Button { storesExpanded.toggle() } label: { Image(systemName:storesExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)) }.buttonStyle(.plain) - Button { source = "Music Stores" } label: { Label("Music Stores",systemImage:"bag.fill").foregroundStyle(.green);Spacer() }.buttonStyle(.plain) - }.padding(.horizontal,12).frame(height:34) - if storesExpanded { - ForEach(model.musicStores) { store in - Button { focusedStore = store.id;source = "Music Stores" } label: { Label(store.name,systemImage:"globe").font(.system(size:12)).lineLimit(1).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:26) }.buttonStyle(.plain) - } - Button("Configure Stores…") { source = "Music Stores" }.buttonStyle(.plain).font(.system(size:11)).foregroundStyle(.secondary).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) - } - sourceRow("Venues",icon:"building.2.fill",color:.cyan) - sourceRow("Queue",icon:"list.number",color:.orange) - sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) - sourceRow("Playlists",icon:"music.note.list",color:.mint) - } - } - SidebarVideoMonitor(player:model.player).padding(10) - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) - } - private func sourceRow(_ title: String, icon: String, color: Color) -> some View { - Button { source = title } label: { - HStack(spacing:10) { - Image(systemName:icon).foregroundStyle(color).frame(width:22) - Text(title).font(.system(size:16,weight:.medium));Spacer() - Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) - .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) - }.buttonStyle(.plain) - } - private func sidebarSinger(_ singer: PersonPlace) -> some View { - VStack(alignment:.leading,spacing:0) { - HStack(spacing:5) { - Button { - if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } - } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") - Button { showSinger(singer.id) } label: { - HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } - }.buttonStyle(.plain) - Menu { - Button("Add to Tonight…") { showSinger(singer.id,section:"Tonight");songPickerTonight = true;songPickerSinger = singer } - Button("Manage All Songs…") { showSinger(singer.id,section:"All Songs");songPickerTonight = false;songPickerSinger = singer } - } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.menuStyle(.borderlessButton).frame(width:16).help("Add songs to \(singer.name)’s Tonight or repertoire") - }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) - .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) - if expandedSingers.contains(singer.id) { - singerFolder("Tonight",icon:"music.note.list",singer:singer) - singerFolder("Favourites",icon:"star.fill",singer:singer) - singerFolder("History",icon:"clock.arrow.circlepath",singer:singer) - HStack(spacing:3) { - Button { if expandedSongLists.contains(singer.id) { expandedSongLists.remove(singer.id) } else { expandedSongLists.insert(singer.id) } } label: { Image(systemName:expandedSongLists.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)) }.buttonStyle(.plain) - singerFolder("All Songs",icon:"folder",singer:singer) - }.padding(.leading,28) - if expandedSongLists.contains(singer.id) { - ForEach(model.songs(for:singer.id)) { song in - Button { showSinger(singer.id,section:"All Songs");selected = song.id;singerSelections[singer.id] = song.id } label: { - Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) - }.buttonStyle(.plain) - .contextMenu { - Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } - Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button(model.favourites[singer.id]?.contains(song.id) == true ? "Remove Favourite" : "Add to Favourites") { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } - Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } - } - } - if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } - } - } - } - } - private func singerFolder(_ title: String, icon: String, singer: PersonPlace) -> some View { - Button { showSinger(singer.id,section:title) } label: { - Label { Text(title + (title == "Tonight" ? " (\(model.tonightSongIDs[singer.id]?.count ?? 0))" : "")) } icon: { if title == "Favourites" { GoldStar(filled:true,size:12) } else { Image(systemName:icon) } }.font(.system(size:11)).foregroundStyle(title == "Favourites" ? Color(red:1,green:0.75,blue:0.16) : Color.white.opacity(0.75)).padding(.leading,title == "All Songs" ? 0 : 43).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) - }.buttonStyle(.plain) - } - 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(model.tonightSongs(for:singer.id)) { 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("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("Remove") { model.removeFromTonight(song.id,singer:singer.id) } - }.padding(8).background(.white.opacity(0.035)) - } - if model.tonightSongs(for:singer.id).isEmpty { Text("No songs in \(singer.name)’s Tonight list. Use Add to Tonight… above.").foregroundStyle(.secondary).padding(.top,20) } - } - } - } 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 } - } - 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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 960)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 960)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 960), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { [44,max(220,total-600),110,100,65,82,90,109] } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing:0) { - ForEach(Array(["ID","Song Name","Artist","Album","Duration","Pitch","Tempo","Rating"].enumerated()),id:\.offset) { index,title in - Button { - guard ["Song Name","Artist","Album","Duration","Rating"].contains(title) else { return } - if librarySort == title { sortAscending.toggle() } else { librarySort = title;sortAscending = true } - } label: { HStack(spacing:3) { Text(title);if librarySort == title { Image(systemName:sortAscending ? "chevron.up" : "chevron.down").font(.system(size:8)) } }.font(.system(size:12,weight:.medium)).lineLimit(1).padding(.leading,7).frame(width:sizes[index],height:30,alignment:.leading) } - .buttonStyle(.plain).overlay(alignment:.trailing) { Rectangle().fill(Classic.line).frame(width:1).padding(.vertical,4) } - } - }.foregroundStyle(.white).background(Classic.header).overlay(alignment:.bottom) { Rectangle().fill(Color(white:0.65)).frame(height:1) } - } - private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" } - private func songRow(_ song: Song,width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing:0) { - Text("\(song.id)").foregroundStyle(.secondary).frame(width:sizes[0]) - Text(song.title).lineLimit(1).padding(.leading,7).frame(width:sizes[1],alignment:.leading) - Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[2],alignment:.leading) - Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[3],alignment:.leading) - Text(durationLabel(song.duration)).font(.system(size:11,design:.monospaced)).frame(width:sizes[4]) - HStack(spacing:2) { - Text(String(format:"%+.0f",model.preference(song.id).pitch)).font(.system(size:11,design:.monospaced)).frame(width:30) - Stepper("Pitch",value:Binding(get:{ model.preference(song.id).pitch },set:{ model.updateTuning(song.id,pitch:$0) }),in:-12...12,step:1).labelsHidden().controlSize(.mini).fixedSize() - }.frame(width:sizes[5]).help("Saved pitch; applies to the selected singer, or song default") - 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(.mini).frame(width:sizes[6]-8).padding(.horizontal,4).help("Saved tempo; applies live if this song is playing") - GoldRating(rating:Binding(get:{ song.rating },set:{ value in model.perform { try model.library?.rate(song.id,value);try model.refresh() } })).frame(width:sizes[7]) - }.font(.system(size:12)).foregroundStyle(.white).frame(height:30) - .background(selected == song.id ? Color.blue.opacity(0.5) : song.id % 2 == 0 ? Color.white.opacity(0.025) : .clear) - .contentShape(Rectangle()).onTapGesture(count:2) { selected = song.id;playSelected() }.onTapGesture { selected = song.id } - .contextMenu { - Button("Play") { selected = song.id; playSelected() } - Button("Edit Metadata…") { editingSong = song } - 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 + This Song…") { model.beginSinger(song:song.id) } - } - Menu("Add to Tonight") { ForEach(model.venueSingers) { singer in Button(singer.name) { model.addToTonight(song.id,singer:singer.id) } } }.disabled(model.currentVenue == nil) - Button("Add to Current 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) } } } - } - } - @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(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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.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) - 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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var playlistPane: some View { - VStack(alignment:.leading,spacing:12) { - HStack { - Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } - TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) - Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - if let selectedPlaylist { - Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } - } - ScrollView { - if let selectedPlaylist { - let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] - VStack(alignment:.leading,spacing:10) { - ForEach(ids,id:\.self) { id in - if let song = model.songs.first(where: { $0.id == id }) { - HStack { - Text(song.title); Spacer() - Button("Play") { model.perform { try model.play(song) } } - Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } - } - } - } - } - } - Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) - } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Button(model.readingDurations ? "Reading…" : "Read Durations") { model.scanDurations() }.controlSize(.small).disabled(model.readingDurations).help("Read durations for songs imported by older versions") - Button("Edit Metadata…") { editingSong = selectedSong }.controlSize(.small).disabled(selectedSong == nil) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - 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 = "" - } - } -} -/// 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(Classic.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 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 search = "" - @State private var deleting: Int64? - var body: some View { - VStack(alignment:.leading,spacing:14) { - HStack { Text("Music Stores").font(.headline);Spacer();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("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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - .sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) } - .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(Classic.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(Classic.frame).preferredColorScheme(.dark) - .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.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." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) - Text("Add songs now (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(Classic.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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - NowPlayingWaveform(player: player).padding(.top, 4) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 130) - } -} -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("EQ / TONE") { 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)) - }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:120) - .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),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) { - HStack { Text("TONE / MIXER").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? - @State private var personalRating = 0 - var body: some View { - HStack(spacing:10) { - Text("Key") - Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) - Text("Tempo") - Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) - Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) - Text("Rating") - GoldRating(rating:$personalRating).frame(width:88).help("Singer-specific song rating") - Button("Save for Singer") { - if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } - }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) - Menu { - Button("Reset key / tempo") { player.pitch = 0; player.tempo = 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).help("Playback settings") - }.font(.system(size:11)) - .onChange(of:model.selectedSinger) { _ in loadRating() } - .onChange(of:player.currentSongID) { _ in loadRating() } - .onChange(of:selectedSong) { _ in loadRating() } - } - private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-tonight-drag-20261103-094630/KJamsClone/Library.swift b/backups/before-tonight-drag-20261103-094630/KJamsClone/Library.swift deleted file mode 100644 index 5c7ccae..0000000 --- a/backups/before-tonight-drag-20261103-094630/KJamsClone/Library.swift +++ /dev/null @@ -1,317 +0,0 @@ -import Foundation -import SQLite3 - -struct Song: Identifiable, Hashable { - var id: Int64 - var title: String - var audio: String - var cdg: String? - var rating: Int - var plays: Int - var artist: String = "" - var album: String = "" - var label: String = "" - var duration: Double = 0 -} -struct MusicStore: Identifiable, Hashable { - var id: Int64 - var name: String - var url: String - var searchTemplate: String - func destination(query: String = "") throws -> URL { - let query = query.trimmingCharacters(in:.whitespacesAndNewlines) - let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? "" - let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded) - guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") } - return result - } -} -struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } -struct SongPreference: Equatable { - var pitch: Double = 0 - var tempo: Double = 1 - var rating: Int = 0 -} -struct Performance: Identifiable { - var id: Int64 - var song: String - var singer: String - var venue: String - var date: String - var singerID: Int64? - var songID: Int64? -} -struct QueueEntry: Identifiable { - var id: Int64 - var songID: Int64 - var singerID: Int64? - var title: String - var singer: String -} -enum LibraryError: LocalizedError { - case message(String) - var errorDescription: String? { if case .message(let s) = self { return s }; return nil } -} -final class Library { - private var db: OpaquePointer? - init(url: URL) throws { - guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } - try execute("PRAGMA foreign_keys=ON") - try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") - try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") - // Additive migration: preserve older libraries and infer existing memberships from history. - for column in ["artist", "album", "label"] { - if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } - } - try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") - if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } - try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") - try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") - try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") - try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") - if try schemaVersion() < 4 { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") - } - if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") } - if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") } - if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") } - try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))") - try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')") - if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") } - try execute("CREATE TABLE IF NOT EXISTS singer_tonight(venue INTEGER NOT NULL,singer INTEGER NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(venue,singer,song),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") - if try schemaVersion() < 7 { - try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) SELECT queue.venue,queue.singer,queue.song,MIN(queue.position) FROM queue JOIN venue_singers ON venue_singers.venue=queue.venue AND venue_singers.singer=queue.singer WHERE queue.singer IS NOT NULL GROUP BY queue.venue,queue.singer,queue.song") - } - try execute("PRAGMA user_version=7") - } - deinit { sqlite3_close(db) } - private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { - var s: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } - for (i,v) in values.enumerated() { - let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) - if code != SQLITE_OK { sqlite3_finalize(s); throw error() } - } - return s - } - private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } - func execute(_ sql: String, _ values: [String?] = []) throws { - let s = try statement(sql, values); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_DONE else { throw error() } - } - func songs() throws -> [Song] { - let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } - var result = [Song]() - while true { - let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } - result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9))) - } - return result - } - func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "", duration: Double = 0) throws { - try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration) VALUES(?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END", [title,audio,cdg,artist,album,label,String(duration)]) - } - func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } - func entities(_ table: String) throws -> [PersonPlace] { - precondition(["singers","venues","playlists"].contains(table)) - let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } - var rows = [PersonPlace]() - while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } - return rows - } - func addEntity(_ table: String, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) - } - func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { - try execute("BEGIN IMMEDIATE") - do { - if let singer { try assignSong(song,singer:singer) } - try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) - try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - - private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } - private func schemaVersion() throws -> Int { - let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 - } - private func columns(_ table: String) throws -> Set { - let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } - var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result - } - func members(of venue: Int64) throws -> [PersonPlace] { - let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } - var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result - } - func setMembership(singer: Int64, venue: Int64, included: Bool) throws { - try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) - if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } - } - func currentVenue() throws -> Int64? { - let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil - } - func selectVenue(_ id: Int64?) throws { - if let id { - guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } - } - try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) - } - func rename(_ table: String, id: Int64, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) - } - func deleteEntity(_ table: String, id: Int64) throws { - precondition(["singers","venues","playlists"].contains(table)) - try execute("BEGIN IMMEDIATE") - do { - if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } - if table == "venues" { - try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) - try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) - } - try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - func preference(song: Int64, singer: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) - } - func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { - try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) - } - func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { - let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } - var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result - } - func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { - guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } - if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } - try execute("BEGIN IMMEDIATE") - do { - if let singer { - try assignSong(song,singer:singer) - try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) - } - try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } - func queue(venue: Int64?) throws -> [QueueEntry] { - guard let venue else { return [] } - let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } - var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result - } - func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } - func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { - var rows = try queue(venue:venue) - guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } - rows.swapAt(index,index+offset) - try execute("BEGIN IMMEDIATE") - do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } - catch { try? execute("ROLLBACK"); throw error } - } - func addToPlaylist(_ playlist: Int64, song: Int64) throws { - try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) - } - func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { - let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } - var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids - } - func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } - func assignSong(_ song: Int64, singer: Int64) throws { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) - } - func removeSingerSong(_ song: Int64, singer: Int64) throws { - try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) - } - func singerSongIDs() throws -> [Int64:[Int64]] { - let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } - var result = [Int64:[Int64]]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } - return result - } - func defaultPreference(song: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return .init() } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0) - } - func saveDefaultPreference(song: Int64, value: SongPreference) throws { - try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)]) - } - func updateDuration(_ song: Int64, seconds: Double) throws { - guard seconds.isFinite && seconds >= 0 else { return } - try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)]) - } - func setFavourite(song: Int64, singer: Int64, included: Bool) throws { - if included { try assignSong(song,singer:singer) } - try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)]) - } - func favouriteSongIDs() throws -> [Int64:Set] { - let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) } - var result = [Int64:Set]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result - } - func stores() throws -> [MusicStore] { - let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) } - var result = [MusicStore]() - while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result - } - func saveStore(_ store: MusicStore) throws { - let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a store name") } - let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search) - _ = try cleaned.destination() - if !search.isEmpty { - guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") } - _ = try cleaned.destination(query:"test") - } - if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) } - else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) } - } - func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) } - func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws { - let title = title.trimmingCharacters(in:.whitespacesAndNewlines) - guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") } - try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)]) - guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") } - } - func tonightSongIDs(venue: Int64?) throws -> [Int64:[Int64]] { - guard let venue else { return [:] } - let s = try statement("SELECT singer,song FROM singer_tonight WHERE venue=? ORDER BY position,song",[String(venue)]);defer { sqlite3_finalize(s) } - var result = [Int64:[Int64]]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) };return result - } - func addTonight(song: Int64,singer: Int64,venue: Int64) throws { - guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not a member of the current venue") } - try execute("BEGIN IMMEDIATE") - do { - try assignSong(song,singer:singer) - try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) - try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } - func removeTonight(song: Int64,singer: Int64,venue: Int64) throws { - try execute("BEGIN IMMEDIATE") - do { - try execute("DELETE FROM singer_tonight WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) - try execute("DELETE FROM queue WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } -} diff --git a/backups/before-tonight-drag-20261103-094630/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-tonight-drag-20261103-094630/KJamsCloneTests/KJamsCloneTests.swift deleted file mode 100644 index 67ecbe3..0000000 --- a/backups/before-tonight-drag-20261103-094630/KJamsCloneTests/KJamsCloneTests.swift +++ /dev/null @@ -1,504 +0,0 @@ -import XCTest -import AVFoundation -@testable import KJamsClone - -final class KJamsCloneTests: XCTestCase { - func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } - func testPresetTileXORAndInvalidCoordinates() { - var d = CDGDecoder() - d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) - d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],5) - d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],0) - let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) - } - func testSeekAndTruncatedPacket() { - var d = CDGDecoder() - let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) - d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) - d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) - d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) - } - func testPaletteBorderScrollAndImage() { - var d = CDGDecoder() - d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) - d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) - d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) - d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) - XCTAssertEqual(d.image()?.width,288) - } - func testSQLitePersistenceDedupRatingsAndHistory() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("test.sqlite") - do { - let db = try Library(url:url) - try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) - try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") - let song = try XCTUnwrap(db.songs().first) - try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) - try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") - try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) - } - let reopened = try Library(url:url); let songs = try reopened.songs() - XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") - XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") - } - func testPairedImportAndContentDedup() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") - try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) - let db = try Library(url:root.appendingPathComponent("test.sqlite")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([audio],library:db); try importer.run([audio],library:db) - XCTAssertEqual(try db.songs().count,1) - XCTAssertNotNil(try db.songs().first?.cdg) - } - - func testZippedMP3GImportAndUnsafeArchiveRejection() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let db = try Library(url:folder.appendingPathComponent("db.sqlite")) - let importer = Importer(media:folder.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) - XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) - XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) - } - @MainActor func testMP3PlaybackPauseAndSeek() throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player() - defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) - XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) - player.toggle(); XCTAssertFalse(player.playing) - player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) - player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) - } - @MainActor func testCreateSingerAndVenueThroughAppModel() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: root) } - let model = AppModel(storageURL: root) - XCTAssertNil(model.error) - let singer = try model.createEntity(.singer, name: " Alex ") - let venue = try model.createEntity(.venue, name: "Town Hall") - XCTAssertEqual(model.singers.first?.name, "Alex") - XCTAssertEqual(model.venues.first?.name, "Town Hall") - XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) - XCTAssertEqual(model.singers.count, 1) - XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) - let reopened = AppModel(storageURL: root) - XCTAssertEqual(reopened.singers.first?.id, singer) - XCTAssertEqual(reopened.venues.first?.id, venue) - } - - @MainActor func testVenuesShareSingersAndRememberSelection() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - let alex = try model.createEntity(.singer,name:"Alex") - let club = try model.createEntity(.venue,name:"Club") - XCTAssertTrue(model.venueSingers.isEmpty) - model.membership(singer:alex,included:true) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(club); model.membership(singer:alex,included:false) - XCTAssertTrue(model.venueSingers.isEmpty) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) - reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) - XCTAssertEqual(reopened.singers.count,1) - } - func testMigrationAndMembershipRemovalDoesNotReappear() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("db.sqlite") - var singer: Int64 = 0, venue: Int64 = 0 - do { - let db = try Library(url:url) - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") - singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) - try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) - try db.execute("PRAGMA user_version=1") - } - do { - let migrated = try Library(url:url) - XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) - try migrated.setMembership(singer:singer,venue:venue,included:false) - } - let reopened = try Library(url:url) - XCTAssertTrue(try reopened.members(of:venue).isEmpty) - XCTAssertEqual(try reopened.history(venue:venue).count,1) - } - func testQueuePlaylistsPreferencesAndDeletion() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.add(title:"First",audio:"/1.mp3",cdg:nil) - try db.add(title:"Second",audio:"/2.mp3",cdg:nil) - let songs = try db.songs() - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") - let singer = try XCTUnwrap(db.entities("singers").first?.id) - let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) - let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) - XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) - try db.setMembership(singer:singer,venue:hall,included:true) - try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) - XCTAssertTrue(try db.queue(venue:club).isEmpty) - let second = try XCTUnwrap(db.queue(venue:hall).last) - try db.moveQueue(second.id,venue:hall,by:-1) - XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) - let preference = SongPreference(pitch:3,tempo:0.85,rating:4) - try db.savePreference(song:songs[0].id,singer:singer,value:preference) - XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) - XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) - try db.addEntity("playlists",name:"Party") - let playlist = try XCTUnwrap(db.entities("playlists").first?.id) - try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) - XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) - try db.record(songs[0].id,singer:singer,venue:hall) - try db.rename("singers",id:singer,name:"Alex Jones") - XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") - try db.deleteEntity("singers",id:singer) - XCTAssertTrue(try db.queue(venue:hall).isEmpty) - XCTAssertEqual(try db.history(venue:hall).count,1) - try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) - XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) - } - @MainActor func testTempoSourceClockPauseAndResume() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) - try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) - XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) - try await Task.sleep(nanoseconds:500_000_000) - XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) - player.toggle(); let paused = player.time - try await Task.sleep(nanoseconds:100_000_000) - XCTAssertEqual(player.time,paused,accuracy:0.025) - player.seek(1.5); player.toggle() - try await Task.sleep(nanoseconds:200_000_000) - XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) - } - func testNewTestMediaMetadataAndCDGPairImport() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"MP3 Plus G Test") - XCTAssertEqual(song.artist,"KJamsClone Test Generator") - XCTAssertNotNil(song.cdg) - let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) - XCTAssertEqual(bytes.count,20*300*24) - var decoder = CDGDecoder(); decoder.render(bytes,at:2) - XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) - } - @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root); defer { model.player.stop() } - _ = try model.createEntity(.venue,name:"Test Hall") - _ = try model.createEntity(.singer,name:"Alex") - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) - let song = try XCTUnwrap(model.songs.first) - model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() - XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) - model.player.seek(model.player.duration-0.1) - for _ in 0..<25 { - if model.queue.isEmpty { break } - try await Task.sleep(nanoseconds:100_000_000) - } - XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) - XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") - } - func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("signal.wav") - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) - do { - let file = try AVAudioFile(forWriting:url,settings:format.settings) - let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 - let samples = try XCTUnwrap(buffer.floatChannelData?[0]) - for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } - try file.write(from:buffer) - } - let envelope = try AudioWaveform.read(url:url,bins:32) - XCTAssertEqual(envelope.count,32) - XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) - XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) - XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) - } - @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) - player.toggle() - for _ in 0..<50 { - if !player.waveformLoading { break } - try await Task.sleep(nanoseconds:20_000_000) - } - XCTAssertEqual(player.waveform.count,4096) - XCTAssertFalse(player.waveformUnavailable) - player.stop() - XCTAssertTrue(player.waveform.isEmpty) - XCTAssertFalse(player.waveformLoading) - try await Task.sleep(nanoseconds:50_000_000) - XCTAssertTrue(player.waveform.isEmpty) - } - - @MainActor func testConnectedSingerCreationAndSharedRepertoire() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh() - let song = try XCTUnwrap(model.songs.first?.id) - let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song]) - XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) - XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999])) - XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" }) - let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true) - XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) - model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song]) - reopened.assign(song,to:alex,included:false) - XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty) - } - func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall") - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id) - try db.setMembership(singer:singer,venue:venue,included:true) - try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue) - XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2) - try db.removeSingerSong(song,singer:singer) - XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1) - } - func testFrequencyBandsDistinguishBassAndTreble() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("bands.wav") - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1)) - do { - let file = try AVAudioFile(forWriting:url,settings:format.settings) - let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192 - for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) } - try file.write(from:buffer) - } - let analysis = try AudioWaveform.analyse(url:url,bins:32) - XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3) - XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3) - } - private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double { - let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4) - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) - engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format) - AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled) - try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024) - let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768 - for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) } - node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() } - let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024)) - var energy: Double = 0,count = 0 - for block in 0..<12 { - let status = try engine.renderOffline(1024,to:output) - XCTAssertEqual(status,.success) - if block >= 2 { for i in 0..]() - @Published var musicStores = [MusicStore]() - @Published var readingDurations = false - var newSingerSongs = Set() - private var songIndex = [Int64:Song]() - @Published var venues = [PersonPlace]() - @Published var venueSingers = [PersonPlace]() - @Published var playlists = [PersonPlace]() - @Published var queue = [QueueEntry]() - @Published var history = [Performance]() - @Published private(set) var currentVenue: Int64? - @Published var selectedSinger: Int64? - @Published var autoAdvance = true - @Published var error: String? - @Published var creating: EntityKind? - 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() - } - player.onFinished = { [weak self] in - guard let self, self.autoAdvance, !self.queue.isEmpty else { return } - self.playQueueNext() - } - } - 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() - 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 } - queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) - } - func chooseVenue(_ id: Int64?) { - perform { - guard let library else { throw LibraryError.message("Library unavailable") } - try library.selectVenue(id); selectedSinger = nil; 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) - for song in songs { try library.assignSong(song,singer:id) } - try library.selectVenue(venue);try library.execute("COMMIT") - } catch { try? library.execute("ROLLBACK");throw error } - try refresh();selectedSinger = 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 preference(_ song: Int64) -> SongPreference { - guard let library else { return .init() } - if let selectedSinger { return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() } - return (try? library.defaultPreference(song: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 { - 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) - try player.load(song,preference:preference) - do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } - catch { player.stop(); throw error } - } - func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } - func playQueueNext() { - perform { - guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } - // Explicitly clear selection for unassigned queue entries. - selectedSinger = row.singerID - try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() - } - } - 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 { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } } - try refresh() - } - } - 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 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -/// 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 { - let 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) - .aspectRatio(1.5, contentMode: .fit) - .clipped() - .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) - .accessibilityLabel("Video monitor, synchronized with the Video window") - }.frame(maxWidth: .infinity) - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @State private var singersExpanded = true - @State private var expandedSingers = Set() - @State private var expandedSongLists = Set() - @State private var songPickerSinger: PersonPlace? - @State private var singerSection = "All Songs" - @State private var storesExpanded = true - @State private var focusedStore: Int64? - @State private var editingSong: Song? - @State private var librarySort = "Song Name" - @State private var sortAscending = true - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { - let filtered = model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } - return filtered.sorted { a,b in - if a.id == b.id { return false } - if librarySort == "Duration" { return a.duration == b.duration ? a.id < b.id : sortAscending ? a.duration < b.duration : a.duration > b.duration } - if librarySort == "Rating" { return a.rating == b.rating ? a.id < b.id : sortAscending ? a.rating < b.rating : a.rating > b.rating } - let first = librarySort == "Artist" ? a.artist : librarySort == "Album" ? a.album : a.title - let second = librarySort == "Artist" ? b.artist : librarySort == "Album" ? b.album : b.title - let order = first.localizedStandardCompare(second) - return order == .orderedSame ? a.id < b.id : sortAscending ? order == .orderedAscending : order == .orderedDescending - } - } - - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } - else if source == "Queue" { queuePane } - 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 { directory } - detailPanel.frame(height: 135) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .sheet(item: $model.creating) { kind in - AddEntitySheet(model: model, kind: kind) { id in - if kind == .singer { model.selectedSinger = id;expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;singerSection = "All Songs";source = "Singer Songs" } else { model.chooseVenue(id) } - } - } - .sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) } - .sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer) } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - 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(alignment:.leading,spacing:0) { - Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) - .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) - ScrollView { - VStack(spacing:0) { - sourceRow("Library",icon:"music.note.house.fill",color:.yellow) - HStack(spacing:5) { - Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") - Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) - Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") - }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) - if singersExpanded { - Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) - ForEach(model.venueSingers) { singer in sidebarSinger(singer) } - if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } - } - HStack { - Button { storesExpanded.toggle() } label: { Image(systemName:storesExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)) }.buttonStyle(.plain) - Button { source = "Music Stores" } label: { Label("Music Stores",systemImage:"bag.fill").foregroundStyle(.green);Spacer() }.buttonStyle(.plain) - }.padding(.horizontal,12).frame(height:34) - if storesExpanded { - ForEach(model.musicStores) { store in - Button { focusedStore = store.id;source = "Music Stores" } label: { Label(store.name,systemImage:"globe").font(.system(size:12)).lineLimit(1).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:26) }.buttonStyle(.plain) - } - Button("Configure Stores…") { source = "Music Stores" }.buttonStyle(.plain).font(.system(size:11)).foregroundStyle(.secondary).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) - } - sourceRow("Venues",icon:"building.2.fill",color:.cyan) - sourceRow("Queue",icon:"list.number",color:.orange) - sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) - sourceRow("Playlists",icon:"music.note.list",color:.mint) - } - } - SidebarVideoMonitor(player:model.player).padding(10) - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) - } - private func sourceRow(_ title: String, icon: String, color: Color) -> some View { - Button { source = title } label: { - HStack(spacing:10) { - Image(systemName:icon).foregroundStyle(color).frame(width:22) - Text(title).font(.system(size:16,weight:.medium));Spacer() - Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) - .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) - }.buttonStyle(.plain) - } - private func sidebarSinger(_ singer: PersonPlace) -> some View { - VStack(alignment:.leading,spacing:0) { - HStack(spacing:5) { - Button { - if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } - } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") - Button { model.selectedSinger = singer.id;singerSection = "All Songs";source = "Singer Songs" } label: { - HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } - }.buttonStyle(.plain) - Button { model.selectedSinger = singer.id;songPickerSinger = singer;expandedSingers.insert(singer.id) } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.buttonStyle(.plain).help("Add songs to \(singer.name)") - }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) - .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) - if expandedSingers.contains(singer.id) { - singerFolder("Tonight",icon:"music.note.list",singer:singer) - singerFolder("Favourites",icon:"star.fill",singer:singer) - singerFolder("History",icon:"clock.arrow.circlepath",singer:singer) - HStack(spacing:3) { - Button { if expandedSongLists.contains(singer.id) { expandedSongLists.remove(singer.id) } else { expandedSongLists.insert(singer.id) } } label: { Image(systemName:expandedSongLists.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)) }.buttonStyle(.plain) - singerFolder("All Songs",icon:"folder",singer:singer) - }.padding(.leading,28) - if expandedSongLists.contains(singer.id) { - ForEach(model.songs(for:singer.id)) { song in - Button { model.selectedSinger = singer.id;selected = song.id;singerSection = "All Songs";source = "Singer Songs" } label: { - Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) - }.buttonStyle(.plain) - .contextMenu { - Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } - Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button(model.favourites[singer.id]?.contains(song.id) == true ? "Remove Favourite" : "Add to Favourites") { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } - Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } - } - } - if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } - } - } - } - } - private func singerFolder(_ title: String, icon: String, singer: PersonPlace) -> some View { - Button { model.selectedSinger = singer.id;singerSection = title;source = "Singer Songs" } label: { - Label(title,systemImage:icon).font(.system(size:11)).foregroundStyle(title == "Favourites" ? Color(red:1,green:0.75,blue:0.16) : Color.white.opacity(0.75)).padding(.leading,title == "All Songs" ? 0 : 43).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) - }.buttonStyle(.plain) - } - 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("Add Songs…") { 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(model.queue.filter { $0.singerID == singer.id }) { row in HStack { Text(row.title);Spacer();Button("Play") { model.perform { if let song = model.songs.first(where:{ $0.id == row.songID }) { try model.play(song,singer:singer.id);try model.library?.removeQueue(row.id);try model.refresh() } } };Button("Remove") { model.removeQueue(row.id) } } };if model.queue.filter({ $0.singerID == singer.id }).isEmpty { Text("No songs queued for this singer at this venue.").foregroundStyle(.secondary) } } } - } 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: { Image(systemName:model.favourites[singer.id]?.contains(song.id) == true ? "star.fill" : "star").foregroundStyle(Color(red:1,green:0.75,blue:0.16)) }.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("Tonight") { model.selectedSinger = singer.id;model.enqueue(song.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 } - } - 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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 960)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 960)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 960), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { [44,max(220,total-600),110,100,65,82,90,109] } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing:0) { - ForEach(Array(["ID","Song Name","Artist","Album","Duration","Pitch","Tempo","Rating"].enumerated()),id:\.offset) { index,title in - Button { - guard ["Song Name","Artist","Album","Duration","Rating"].contains(title) else { return } - if librarySort == title { sortAscending.toggle() } else { librarySort = title;sortAscending = true } - } label: { HStack(spacing:3) { Text(title);if librarySort == title { Image(systemName:sortAscending ? "chevron.up" : "chevron.down").font(.system(size:8)) } }.font(.system(size:12,weight:.medium)).lineLimit(1).padding(.leading,7).frame(width:sizes[index],height:30,alignment:.leading) } - .buttonStyle(.plain).overlay(alignment:.trailing) { Rectangle().fill(Classic.line).frame(width:1).padding(.vertical,4) } - } - }.foregroundStyle(.white).background(Classic.header).overlay(alignment:.bottom) { Rectangle().fill(Color(white:0.65)).frame(height:1) } - } - private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" } - private func songRow(_ song: Song,width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing:0) { - Text("\(song.id)").foregroundStyle(.secondary).frame(width:sizes[0]) - Text(song.title).lineLimit(1).padding(.leading,7).frame(width:sizes[1],alignment:.leading) - Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[2],alignment:.leading) - Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[3],alignment:.leading) - Text(durationLabel(song.duration)).font(.system(size:11,design:.monospaced)).frame(width:sizes[4]) - HStack(spacing:2) { - Text(String(format:"%+.0f",model.preference(song.id).pitch)).font(.system(size:11,design:.monospaced)).frame(width:30) - Stepper("Pitch",value:Binding(get:{ model.preference(song.id).pitch },set:{ model.updateTuning(song.id,pitch:$0) }),in:-12...12,step:1).labelsHidden().controlSize(.mini).fixedSize() - }.frame(width:sizes[5]).help("Saved pitch; applies to the selected singer, or song default") - 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(.mini).frame(width:sizes[6]-8).padding(.horizontal,4).help("Saved tempo; applies live if this song is playing") - Menu { ForEach(0..<6) { value in Button(value == 0 ? "Unrated" : String(repeating:"★",count:value)) { model.perform { try model.library?.rate(song.id,value);try model.refresh() } } } } label: { - Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating:"★",count:song.rating)+String(repeating:"☆",count:5-song.rating)).font(.system(size:12)).foregroundStyle(song.rating == 0 ? Color.gray : Color(red:1,green:0.75,blue:0.16)) - }.menuStyle(.borderlessButton).frame(width:sizes[7]-14).padding(.horizontal,7) - }.font(.system(size:12)).foregroundStyle(.white).frame(height:30) - .background(selected == song.id ? Color.blue.opacity(0.5) : song.id % 2 == 0 ? Color.white.opacity(0.025) : .clear) - .contentShape(Rectangle()).onTapGesture(count:2) { selected = song.id;playSelected() }.onTapGesture { selected = song.id } - .contextMenu { - Button("Play") { selected = song.id; playSelected() } - Button("Edit Metadata…") { editingSong = song } - 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 + This Song…") { model.beginSinger(song:song.id) } - } - Button("Add to Current 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) } } } - } - } - @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(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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("Queue") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) - } - PlaybackSettings(player:model.player,model:model,selectedSong:selected) - SeekBar(player:model.player) - }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) - .background(Classic.navy).overlay(Rectangle().stroke(Classic.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.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) - 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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var playlistPane: some View { - VStack(alignment:.leading,spacing:12) { - HStack { - Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } - TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) - Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - if let selectedPlaylist { - Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } - } - ScrollView { - if let selectedPlaylist { - let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] - VStack(alignment:.leading,spacing:10) { - ForEach(ids,id:\.self) { id in - if let song = model.songs.first(where: { $0.id == id }) { - HStack { - Text(song.title); Spacer() - Button("Play") { model.perform { try model.play(song) } } - Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } - } - } - } - } - } - Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) - } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Button(model.readingDurations ? "Reading…" : "Read Durations") { model.scanDurations() }.controlSize(.small).disabled(model.readingDurations).help("Read durations for songs imported by older versions") - Button("Edit Metadata…") { editingSong = selectedSong }.controlSize(.small).disabled(selectedSong == nil) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - 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 = "" - } - } -} -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");Picker("Rating",selection:$rating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden() } - }.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(Classic.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 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 search = "" - @State private var deleting: Int64? - var body: some View { - VStack(alignment:.leading,spacing:14) { - HStack { Text("Music Stores").font(.headline);Spacer();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("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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - .sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) } - .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(Classic.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 - @Environment(\.dismiss) private var dismiss - @State private var search = "" - @State private var importing = false - var body: some View { - VStack(alignment:.leading,spacing:14) { - Text("Songs for \(singer.name)").font(.title2.bold()) - Text("Tick songs to add them to this singer. Unticking removes only the assignment.").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 - Toggle(song.title,isOn:Binding(get:{ model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ model.assign(song.id,to:singer.id,included:$0) })).toggleStyle(.checkbox) - } - 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(Classic.frame).preferredColorScheme(.dark) - .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.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." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) - Text("Add songs now (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(Classic.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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - NowPlayingWaveform(player: player).padding(.top, 4) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 130) - } -} -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("EQ / TONE") { 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)) - }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:120) - .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),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) { - HStack { Text("TONE / MIXER").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? - @State private var personalRating = 0 - var body: some View { - HStack(spacing:10) { - Text("Key") - Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) - Text("Tempo") - Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) - Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) - Text("Rating") - Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") - Button("Save for Singer") { - if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } - }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) - Menu { - Button("Reset key / tempo") { player.pitch = 0; player.tempo = 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).help("Playback settings") - }.font(.system(size:11)) - .onChange(of:model.selectedSinger) { _ in loadRating() } - .onChange(of:player.currentSongID) { _ in loadRating() } - .onChange(of:selectedSong) { _ in loadRating() } - } - private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-tonight-fix-20261103-092811/KJamsClone/Library.swift b/backups/before-tonight-fix-20261103-092811/KJamsClone/Library.swift deleted file mode 100644 index ded33f3..0000000 --- a/backups/before-tonight-fix-20261103-092811/KJamsClone/Library.swift +++ /dev/null @@ -1,286 +0,0 @@ -import Foundation -import SQLite3 - -struct Song: Identifiable, Hashable { - var id: Int64 - var title: String - var audio: String - var cdg: String? - var rating: Int - var plays: Int - var artist: String = "" - var album: String = "" - var label: String = "" - var duration: Double = 0 -} -struct MusicStore: Identifiable, Hashable { - var id: Int64 - var name: String - var url: String - var searchTemplate: String - func destination(query: String = "") throws -> URL { - let query = query.trimmingCharacters(in:.whitespacesAndNewlines) - let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? "" - let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded) - guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") } - return result - } -} -struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } -struct SongPreference: Equatable { - var pitch: Double = 0 - var tempo: Double = 1 - var rating: Int = 0 -} -struct Performance: Identifiable { - var id: Int64 - var song: String - var singer: String - var venue: String - var date: String - var singerID: Int64? - var songID: Int64? -} -struct QueueEntry: Identifiable { - var id: Int64 - var songID: Int64 - var singerID: Int64? - var title: String - var singer: String -} -enum LibraryError: LocalizedError { - case message(String) - var errorDescription: String? { if case .message(let s) = self { return s }; return nil } -} -final class Library { - private var db: OpaquePointer? - init(url: URL) throws { - guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } - try execute("PRAGMA foreign_keys=ON") - try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") - try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") - // Additive migration: preserve older libraries and infer existing memberships from history. - for column in ["artist", "album", "label"] { - if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } - } - try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") - if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } - try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") - try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") - try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") - try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") - if try schemaVersion() < 4 { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") - } - if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") } - if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") } - if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") } - try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))") - try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')") - if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") } - try execute("PRAGMA user_version=6") - } - deinit { sqlite3_close(db) } - private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { - var s: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } - for (i,v) in values.enumerated() { - let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) - if code != SQLITE_OK { sqlite3_finalize(s); throw error() } - } - return s - } - private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } - func execute(_ sql: String, _ values: [String?] = []) throws { - let s = try statement(sql, values); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_DONE else { throw error() } - } - func songs() throws -> [Song] { - let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } - var result = [Song]() - while true { - let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } - result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9))) - } - return result - } - func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "", duration: Double = 0) throws { - try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration) VALUES(?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END", [title,audio,cdg,artist,album,label,String(duration)]) - } - func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } - func entities(_ table: String) throws -> [PersonPlace] { - precondition(["singers","venues","playlists"].contains(table)) - let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } - var rows = [PersonPlace]() - while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } - return rows - } - func addEntity(_ table: String, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) - } - func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { - try execute("BEGIN IMMEDIATE") - do { - if let singer { try assignSong(song,singer:singer) } - try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) - try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - - private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } - private func schemaVersion() throws -> Int { - let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 - } - private func columns(_ table: String) throws -> Set { - let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } - var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result - } - func members(of venue: Int64) throws -> [PersonPlace] { - let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } - var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result - } - func setMembership(singer: Int64, venue: Int64, included: Bool) throws { - try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) - if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } - } - func currentVenue() throws -> Int64? { - let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil - } - func selectVenue(_ id: Int64?) throws { - if let id { - guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } - } - try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) - } - func rename(_ table: String, id: Int64, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) - } - func deleteEntity(_ table: String, id: Int64) throws { - precondition(["singers","venues","playlists"].contains(table)) - try execute("BEGIN IMMEDIATE") - do { - if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } - if table == "venues" { - try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) - try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) - } - try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - func preference(song: Int64, singer: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) - } - func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { - try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) - } - func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { - let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } - var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result - } - func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { - guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } - if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } - try execute("BEGIN IMMEDIATE") - do { - if let singer { try assignSong(song,singer:singer) } - try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK");throw error } - } - func queue(venue: Int64?) throws -> [QueueEntry] { - guard let venue else { return [] } - let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } - var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result - } - func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } - func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { - var rows = try queue(venue:venue) - guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } - rows.swapAt(index,index+offset) - try execute("BEGIN IMMEDIATE") - do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } - catch { try? execute("ROLLBACK"); throw error } - } - func addToPlaylist(_ playlist: Int64, song: Int64) throws { - try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) - } - func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { - let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } - var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids - } - func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } - func assignSong(_ song: Int64, singer: Int64) throws { - try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) - } - func removeSingerSong(_ song: Int64, singer: Int64) throws { - try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) - } - func singerSongIDs() throws -> [Int64:[Int64]] { - let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } - var result = [Int64:[Int64]]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } - return result - } - func defaultPreference(song: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return .init() } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0) - } - func saveDefaultPreference(song: Int64, value: SongPreference) throws { - try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)]) - } - func updateDuration(_ song: Int64, seconds: Double) throws { - guard seconds.isFinite && seconds >= 0 else { return } - try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)]) - } - func setFavourite(song: Int64, singer: Int64, included: Bool) throws { - if included { try assignSong(song,singer:singer) } - try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)]) - } - func favouriteSongIDs() throws -> [Int64:Set] { - let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) } - var result = [Int64:Set]() - while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result - } - func stores() throws -> [MusicStore] { - let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) } - var result = [MusicStore]() - while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result - } - func saveStore(_ store: MusicStore) throws { - let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a store name") } - let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search) - _ = try cleaned.destination() - if !search.isEmpty { - guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") } - _ = try cleaned.destination(query:"test") - } - if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) } - else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) } - } - func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) } - func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws { - let title = title.trimmingCharacters(in:.whitespacesAndNewlines) - guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") } - try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)]) - guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") } - } -} diff --git a/backups/before-tonight-fix-20261103-092811/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-tonight-fix-20261103-092811/KJamsCloneTests/KJamsCloneTests.swift deleted file mode 100644 index d39aa54..0000000 --- a/backups/before-tonight-fix-20261103-092811/KJamsCloneTests/KJamsCloneTests.swift +++ /dev/null @@ -1,455 +0,0 @@ -import XCTest -import AVFoundation -@testable import KJamsClone - -final class KJamsCloneTests: XCTestCase { - func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } - func testPresetTileXORAndInvalidCoordinates() { - var d = CDGDecoder() - d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) - d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],5) - d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],0) - let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) - } - func testSeekAndTruncatedPacket() { - var d = CDGDecoder() - let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) - d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) - d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) - d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) - } - func testPaletteBorderScrollAndImage() { - var d = CDGDecoder() - d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) - d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) - d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) - d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) - XCTAssertEqual(d.image()?.width,288) - } - func testSQLitePersistenceDedupRatingsAndHistory() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("test.sqlite") - do { - let db = try Library(url:url) - try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) - try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") - let song = try XCTUnwrap(db.songs().first) - try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) - try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") - try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) - } - let reopened = try Library(url:url); let songs = try reopened.songs() - XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") - XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") - } - func testPairedImportAndContentDedup() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") - try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) - let db = try Library(url:root.appendingPathComponent("test.sqlite")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([audio],library:db); try importer.run([audio],library:db) - XCTAssertEqual(try db.songs().count,1) - XCTAssertNotNil(try db.songs().first?.cdg) - } - - func testZippedMP3GImportAndUnsafeArchiveRejection() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let db = try Library(url:folder.appendingPathComponent("db.sqlite")) - let importer = Importer(media:folder.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) - XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) - XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) - } - @MainActor func testMP3PlaybackPauseAndSeek() throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player() - defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) - XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) - player.toggle(); XCTAssertFalse(player.playing) - player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) - player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) - } - @MainActor func testCreateSingerAndVenueThroughAppModel() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: root) } - let model = AppModel(storageURL: root) - XCTAssertNil(model.error) - let singer = try model.createEntity(.singer, name: " Alex ") - let venue = try model.createEntity(.venue, name: "Town Hall") - XCTAssertEqual(model.singers.first?.name, "Alex") - XCTAssertEqual(model.venues.first?.name, "Town Hall") - XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) - XCTAssertEqual(model.singers.count, 1) - XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) - let reopened = AppModel(storageURL: root) - XCTAssertEqual(reopened.singers.first?.id, singer) - XCTAssertEqual(reopened.venues.first?.id, venue) - } - - @MainActor func testVenuesShareSingersAndRememberSelection() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - let alex = try model.createEntity(.singer,name:"Alex") - let club = try model.createEntity(.venue,name:"Club") - XCTAssertTrue(model.venueSingers.isEmpty) - model.membership(singer:alex,included:true) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(club); model.membership(singer:alex,included:false) - XCTAssertTrue(model.venueSingers.isEmpty) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) - reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) - XCTAssertEqual(reopened.singers.count,1) - } - func testMigrationAndMembershipRemovalDoesNotReappear() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("db.sqlite") - var singer: Int64 = 0, venue: Int64 = 0 - do { - let db = try Library(url:url) - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") - singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) - try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) - try db.execute("PRAGMA user_version=1") - } - do { - let migrated = try Library(url:url) - XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) - try migrated.setMembership(singer:singer,venue:venue,included:false) - } - let reopened = try Library(url:url) - XCTAssertTrue(try reopened.members(of:venue).isEmpty) - XCTAssertEqual(try reopened.history(venue:venue).count,1) - } - func testQueuePlaylistsPreferencesAndDeletion() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.add(title:"First",audio:"/1.mp3",cdg:nil) - try db.add(title:"Second",audio:"/2.mp3",cdg:nil) - let songs = try db.songs() - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") - let singer = try XCTUnwrap(db.entities("singers").first?.id) - let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) - let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) - XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) - try db.setMembership(singer:singer,venue:hall,included:true) - try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) - XCTAssertTrue(try db.queue(venue:club).isEmpty) - let second = try XCTUnwrap(db.queue(venue:hall).last) - try db.moveQueue(second.id,venue:hall,by:-1) - XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) - let preference = SongPreference(pitch:3,tempo:0.85,rating:4) - try db.savePreference(song:songs[0].id,singer:singer,value:preference) - XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) - XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) - try db.addEntity("playlists",name:"Party") - let playlist = try XCTUnwrap(db.entities("playlists").first?.id) - try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) - XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) - try db.record(songs[0].id,singer:singer,venue:hall) - try db.rename("singers",id:singer,name:"Alex Jones") - XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") - try db.deleteEntity("singers",id:singer) - XCTAssertTrue(try db.queue(venue:hall).isEmpty) - XCTAssertEqual(try db.history(venue:hall).count,1) - try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) - XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) - } - @MainActor func testTempoSourceClockPauseAndResume() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) - try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) - XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) - try await Task.sleep(nanoseconds:500_000_000) - XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) - player.toggle(); let paused = player.time - try await Task.sleep(nanoseconds:100_000_000) - XCTAssertEqual(player.time,paused,accuracy:0.025) - player.seek(1.5); player.toggle() - try await Task.sleep(nanoseconds:200_000_000) - XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) - } - func testNewTestMediaMetadataAndCDGPairImport() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"MP3 Plus G Test") - XCTAssertEqual(song.artist,"KJamsClone Test Generator") - XCTAssertNotNil(song.cdg) - let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) - XCTAssertEqual(bytes.count,20*300*24) - var decoder = CDGDecoder(); decoder.render(bytes,at:2) - XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) - } - @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root); defer { model.player.stop() } - _ = try model.createEntity(.venue,name:"Test Hall") - _ = try model.createEntity(.singer,name:"Alex") - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) - let song = try XCTUnwrap(model.songs.first) - model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() - XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) - model.player.seek(model.player.duration-0.1) - for _ in 0..<25 { - if model.queue.isEmpty { break } - try await Task.sleep(nanoseconds:100_000_000) - } - XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) - XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") - } - func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("signal.wav") - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) - do { - let file = try AVAudioFile(forWriting:url,settings:format.settings) - let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 - let samples = try XCTUnwrap(buffer.floatChannelData?[0]) - for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } - try file.write(from:buffer) - } - let envelope = try AudioWaveform.read(url:url,bins:32) - XCTAssertEqual(envelope.count,32) - XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) - XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) - XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) - } - @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) - player.toggle() - for _ in 0..<50 { - if !player.waveformLoading { break } - try await Task.sleep(nanoseconds:20_000_000) - } - XCTAssertEqual(player.waveform.count,4096) - XCTAssertFalse(player.waveformUnavailable) - player.stop() - XCTAssertTrue(player.waveform.isEmpty) - XCTAssertFalse(player.waveformLoading) - try await Task.sleep(nanoseconds:50_000_000) - XCTAssertTrue(player.waveform.isEmpty) - } - - @MainActor func testConnectedSingerCreationAndSharedRepertoire() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh() - let song = try XCTUnwrap(model.songs.first?.id) - let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song]) - XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) - XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999])) - XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" }) - let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true) - XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) - model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song]) - reopened.assign(song,to:alex,included:false) - XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty) - } - func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall") - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id) - try db.setMembership(singer:singer,venue:venue,included:true) - try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue) - XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2) - try db.removeSingerSong(song,singer:singer) - XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1) - } - func testFrequencyBandsDistinguishBassAndTreble() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("bands.wav") - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1)) - do { - let file = try AVAudioFile(forWriting:url,settings:format.settings) - let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192 - for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) } - try file.write(from:buffer) - } - let analysis = try AudioWaveform.analyse(url:url,bins:32) - XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3) - XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3) - } - private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double { - let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4) - let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) - engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format) - AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled) - try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024) - let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768 - for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) } - node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() } - let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024)) - var energy: Double = 0,count = 0 - for block in 0..<12 { - let status = try engine.renderOffline(1024,to:output) - XCTAssertEqual(status,.success) - if block >= 2 { for i in 0.. String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } - let taggedTitle = tag(.commonKeyTitle) - try library.add(title: taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle, - audio: dest.path, cdg: graphics, artist: tag(.commonKeyArtist), album: tag(.commonKeyAlbumName), label: tag(.commonKeyPublisher)) - } - } - private func process(_ arguments: [String]) throws -> String { - let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments - let out = Pipe(); task.standardOutput = out - let err = FileHandle.nullDevice; task.standardError = err - try task.run() - let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit() - guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") } - return String(decoding: bytes, as: UTF8.self) - } -} diff --git a/backups/before-venues-20261102-231352/KJamsClone/KJamsCloneApp.swift b/backups/before-venues-20261102-231352/KJamsClone/KJamsCloneApp.swift deleted file mode 100644 index e448694..0000000 --- a/backups/before-venues-20261102-231352/KJamsClone/KJamsCloneApp.swift +++ /dev/null @@ -1,379 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -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" } -} - -@MainActor final class AppModel: ObservableObject { - @Published var songs = [Song]() - @Published var singers = [PersonPlace]() - @Published var venues = [PersonPlace]() - @Published var error: String? - @Published var creating: EntityKind? - let player = Player() - private(set) var library: Library? - let root: URL - init(storageURL: URL? = nil) { - root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") - perform { - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - library = try Library(url: root.appendingPathComponent("Library.sqlite")); try refresh() - } - } - func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } - func refresh() throws { guard let library else { return }; songs = try library.songs(); singers = try library.entities("singers"); venues = try library.entities("venues") } - @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) - try refresh() - let entities = kind == .singer ? singers : venues - guard let entity = entities.first(where: { $0.name == trimmed }) else { - throw LibraryError.message("The new \(kind.title.lowercased()) could not be loaded") - } - return entity.id - } - 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var singer: Int64? - @State private var venue: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) } } - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } else { directory } - detailPanel.frame(height: 84) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .sheet(item: $model.creating) { kind in - AddEntitySheet(model: model, kind: kind) { id in - if kind == .singer { singer = id } else { venue = id } - } - } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - private var sidebar: some View { - VStack(alignment: .leading, spacing: 0) { - Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) - .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) - ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan)], id: \.0) { item in - Button { source = item.0 } label: { - HStack(spacing: 10) { - Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) - Text(item.0).font(.system(size: 16, weight: .medium)) - Spacer() - Text("\(item.0 == "Library" ? model.songs.count : item.0 == "Singers" ? model.singers.count : model.venues.count)") - .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) - .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) - }.buttonStyle(.plain) - } - Spacer() - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 790)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { - [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] - } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in - Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) - .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) - .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } - } - }.foregroundStyle(.white).background(Classic.header) - .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } - } - private func songRow(_ song: Song, width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") - .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) - Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) - ForEach(2..<7) { index in - Text(index == 4 ? (song.cdg == nil ? "Audio" : "MP3+G") : "—") - .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) - } - Menu { - ForEach(0..<6) { value in - Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { - model.perform { try model.library?.rate(song.id, value); try model.refresh() } - } - } - } label: { - Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) - .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) - }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") - }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) - .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) - .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } - .onTapGesture { selected = song.id } - } - private var directory: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text(source).font(.system(size: 14, weight: .semibold)) - Spacer() - Button(source == "Singers" ? "New Singer…" : "New Venue…") { model.creating = source == "Singers" ? .singer : .venue } - }.padding(.horizontal, 12).frame(height: 36).background(Classic.header) - ScrollView { - VStack(alignment: .leading, spacing: 0) { - ForEach(source == "Singers" ? model.singers : model.venues) { entity in - Button { - if source == "Singers" { singer = entity.id } else { venue = entity.id } - } label: { - HStack { Image(systemName: source == "Singers" ? "person.fill" : "building.2.fill"); Text(entity.name); Spacer() - if entity.id == (source == "Singers" ? singer : venue) { Image(systemName: "checkmark") } - }.padding(12).contentShape(Rectangle()) - }.buttonStyle(.plain) - } - } - } - HStack { - TextField(source == "Singers" ? "Singer name" : "Venue name", text: $name).textFieldStyle(.roundedBorder).onSubmit(addEntity) - Button("Add \(source == "Singers" ? "Singer" : "Venue")", action: addEntity).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - }.padding(12) - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private var detailPanel: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 16) { - Picker("Singer", selection: $singer) { Text("None").tag(nil as Int64?); ForEach(model.singers) { Text($0.name).tag(Optional($0.id)) } } - Button { model.creating = .singer } label: { Image(systemName: "plus.circle.fill") } - .buttonStyle(.plain).help("Add singer").accessibilityLabel("Add singer") - Picker("Venue", selection: $venue) { Text("None").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").accessibilityLabel("Add venue") - Text(selectedSong.map { "\($0.plays) plays" } ?? "No song selected").foregroundStyle(.white.opacity(0.55)).font(.system(size: 11)) - } - SeekBar(player: model.player) - }.padding(12).frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - private func playSelected() { - guard let song = selectedSong else { return } - model.perform { try model.player.load(song); try model.library?.record(song.id, singer: singer, venue: venue); try model.refresh() } - } - 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 { singer = id } else { venue = id } - name = "" - } - } -} -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? - @FocusState private var focused: Bool - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("New \(kind.title)").font(.title2.bold()) - TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) - .focused($focused).onSubmit(save) - if let error { Text(error).foregroundStyle(.red).font(.callout) } - HStack { - Spacer() - Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) - Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - }.padding(24).frame(width: 360).onAppear { focused = true } - } - private func save() { - do { let 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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - VStack(spacing: 0) { - Text(player.duration == 0 ? "Ready!" : player.title).font(.system(size: 15, weight: .medium)).lineLimit(1).padding(.horizontal, 20) - if player.duration > 0 { Text(player.playing ? "Playing" : "Paused").font(.system(size: 10)).foregroundStyle(.white.opacity(0.65)).padding(.top, 4) } - }.frame(maxWidth: .infinity).frame(height: 64) - .background(LinearGradient(colors: [Color(red: 0, green: 0.10, blue: 0.30), Classic.blue], startPoint: .top, endPoint: .bottom), in: Capsule()) - .overlay(Capsule().strokeBorder(LinearGradient(colors: [.black, .white], startPoint: .top, endPoint: .bottom), lineWidth: 1.5)) - .padding(.top, 14) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 96) - } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-venues-20261102-231352/KJamsClone/Library.swift b/backups/before-venues-20261102-231352/KJamsClone/Library.swift deleted file mode 100644 index 5bc07da..0000000 --- a/backups/before-venues-20261102-231352/KJamsClone/Library.swift +++ /dev/null @@ -1,196 +0,0 @@ -import Foundation -import SQLite3 - -struct Song: Identifiable, Hashable { - var id: Int64 - var title: String - var audio: String - var cdg: String? - var rating: Int - var plays: Int - var artist: String = "" - var album: String = "" - var label: String = "" -} -struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } -struct SongPreference: Equatable { - var pitch: Double = 0 - var tempo: Double = 1 - var rating: Int = 0 -} -struct Performance: Identifiable { - var id: Int64 - var song: String - var singer: String - var venue: String - var date: String -} -struct QueueEntry: Identifiable { - var id: Int64 - var songID: Int64 - var singerID: Int64? - var title: String - var singer: String -} -enum LibraryError: LocalizedError { - case message(String) - var errorDescription: String? { if case .message(let s) = self { return s }; return nil } -} -final class Library { - private var db: OpaquePointer? - init(url: URL) throws { - guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } - try execute("PRAGMA foreign_keys=ON") - try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") - try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") - // Additive migration: preserve older libraries and infer existing memberships from history. - for column in ["artist", "album", "label"] { - if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } - } - try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") - if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } - try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") - try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") - try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") - try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") - try execute("PRAGMA user_version=3") - } - deinit { sqlite3_close(db) } - private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { - var s: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } - for (i,v) in values.enumerated() { - let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) - if code != SQLITE_OK { sqlite3_finalize(s); throw error() } - } - return s - } - private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } - func execute(_ sql: String, _ values: [String?] = []) throws { - let s = try statement(sql, values); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_DONE else { throw error() } - } - func songs() throws -> [Song] { - let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } - var result = [Song]() - while true { - let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } - result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8))) - } - return result - } - func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "") throws { - try execute("INSERT INTO songs(title,audio,cdg,artist,album,label) VALUES(?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN excluded.label!='' THEN excluded.label ELSE songs.label END", [title,audio,cdg,artist,album,label]) - } - func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } - func entities(_ table: String) throws -> [PersonPlace] { - precondition(["singers","venues","playlists"].contains(table)) - let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } - var rows = [PersonPlace]() - while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } - return rows - } - func addEntity(_ table: String, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) - } - func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { - try execute("BEGIN IMMEDIATE") - do { - try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) - try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - - private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } - private func schemaVersion() throws -> Int { - let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 - } - private func columns(_ table: String) throws -> Set { - let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } - var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result - } - func members(of venue: Int64) throws -> [PersonPlace] { - let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } - var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result - } - func setMembership(singer: Int64, venue: Int64, included: Bool) throws { - try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) - if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } - } - func currentVenue() throws -> Int64? { - let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } - return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil - } - func selectVenue(_ id: Int64?) throws { - if let id { - guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } - } - try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) - } - func rename(_ table: String, id: Int64, name: String) throws { - precondition(["singers","venues","playlists"].contains(table)) - let name = name.trimmingCharacters(in:.whitespacesAndNewlines) - guard !name.isEmpty else { throw LibraryError.message("Enter a name") } - try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) - } - func deleteEntity(_ table: String, id: Int64) throws { - precondition(["singers","venues","playlists"].contains(table)) - try execute("BEGIN IMMEDIATE") - do { - if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } - if table == "venues" { - try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) - try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) - } - try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) - try execute("COMMIT") - } catch { try? execute("ROLLBACK"); throw error } - } - func preference(song: Int64, singer: Int64) throws -> SongPreference { - let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } - guard sqlite3_step(s) == SQLITE_ROW else { return .init() } - return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) - } - func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { - try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) - } - func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { - let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } - var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4))) }; return result - } - func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { - guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } - if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } - try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) - } - func queue(venue: Int64?) throws -> [QueueEntry] { - guard let venue else { return [] } - let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY position,id",[String(venue)]); defer { sqlite3_finalize(s) } - var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result - } - func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } - func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { - var rows = try queue(venue:venue) - guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } - rows.swapAt(index,index+offset) - try execute("BEGIN IMMEDIATE") - do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } - catch { try? execute("ROLLBACK"); throw error } - } - func addToPlaylist(_ playlist: Int64, song: Int64) throws { - try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) - } - func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { - let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } - var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids - } - func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } -} diff --git a/backups/before-venues-20261102-231352/KJamsClone/Player.swift b/backups/before-venues-20261102-231352/KJamsClone/Player.swift deleted file mode 100644 index 54b56af..0000000 --- a/backups/before-venues-20261102-231352/KJamsClone/Player.swift +++ /dev/null @@ -1,92 +0,0 @@ -import SwiftUI -import AVFoundation - -/// Audio source-frame time drives CDG, including when the time-pitch unit changes tempo. -@MainActor final class Player: ObservableObject { - @Published var image: CGImage? - @Published var playing = false - @Published var time: Double = 0 - @Published var duration: Double = 0 - @Published var title = "Select a song" - @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } - @Published var repeating = false - @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } - @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } - @Published var graphicsOffset: Double = 0 - private(set) var currentSongID: Int64? - var onFinished: (() -> Void)? - private let engine = AVAudioEngine() - private let node = AVAudioPlayerNode() - private let effect = AVAudioUnitTimePitch() - private var file: AVAudioFile? - private var anchor: Double = 0 - private var generation = UUID() - private var cdg = Data() - private var decoder = CDGDecoder() - private var timer: Timer? - init() { engine.attach(node); engine.attach(effect) } - func load(_ song: Song, preference: SongPreference = .init()) throws { - stop() - do { - let loaded = try AVAudioFile(forReading: URL(fileURLWithPath:song.audio)) - let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() - guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } - engine.connect(node,to:effect,format:loaded.processingFormat) - engine.connect(effect,to:engine.mainMixerNode,format:loaded.processingFormat) - file = loaded; cdg = graphics; decoder = CDGDecoder() - duration = Double(loaded.length) / loaded.processingFormat.sampleRate - title = song.title; currentSongID = song.id - pitch = preference.pitch; tempo = preference.tempo - engine.mainMixerNode.outputVolume = Float(volume) - engine.prepare(); try engine.start() - schedule(at:0, play:true); startTimer(); tick() - } catch { stop(); throw error } - } - private func schedule(at seconds: Double, play: Bool) { - guard let file else { return } - generation = UUID(); let token = generation - node.stop() - let rate = file.processingFormat.sampleRate - let start = min(file.length, max(0, AVAudioFramePosition(seconds * rate))) - anchor = Double(start) / rate; time = anchor - guard start < file.length else { playing = false; render(); return } - node.scheduleSegment(file, startingFrame:start, frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))), at:nil, completionCallbackType:.dataPlayedBack) { [weak self] _ in - Task { @MainActor in - guard let self, self.generation == token else { return } - if self.repeating { self.schedule(at:0,play:true) } - else { - self.playing = false; self.time = self.duration; self.render() - self.timer?.invalidate(); self.timer = nil; self.onFinished?() - } - } - } - if play { node.play() }; playing = play; render() - } - private func startTimer() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } - } - func toggle() { - guard file != nil else { return } - if playing { tick(); node.pause(); playing = false } - else if time >= duration { schedule(at:0,play:true); startTimer() } - else { node.play(); playing = true; startTimer() } - } - func stop() { - generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() - engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect) - file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" - } - func seek(_ value: Double) { - guard file != nil, value.isFinite else { return } - schedule(at:min(max(value,0),duration), play:playing) - startTimer() - } - private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } - private func tick() { - if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { - time = min(duration,anchor + Double(playerTime.sampleTime) / file.processingFormat.sampleRate) - } - render() - } -} diff --git a/backups/before-venues-20261102-231352/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-venues-20261102-231352/KJamsCloneTests/KJamsCloneTests.swift deleted file mode 100644 index 0b1e3de..0000000 --- a/backups/before-venues-20261102-231352/KJamsCloneTests/KJamsCloneTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -import XCTest -@testable import KJamsClone - -final class KJamsCloneTests: XCTestCase { - func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } - func testPresetTileXORAndInvalidCoordinates() { - var d = CDGDecoder() - d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) - d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],5) - d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],0) - let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) - } - func testSeekAndTruncatedPacket() { - var d = CDGDecoder() - let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) - d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) - d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) - d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) - } - func testPaletteBorderScrollAndImage() { - var d = CDGDecoder() - d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) - d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) - d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) - d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) - XCTAssertEqual(d.image()?.width,288) - } - func testSQLitePersistenceDedupRatingsAndHistory() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("test.sqlite") - do { - let db = try Library(url:url) - try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) - try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") - let song = try XCTUnwrap(db.songs().first) - try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) - try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") - try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) - } - let reopened = try Library(url:url); let songs = try reopened.songs() - XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") - XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") - } - func testPairedImportAndContentDedup() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") - try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) - let db = try Library(url:root.appendingPathComponent("test.sqlite")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([audio],library:db); try importer.run([audio],library:db) - XCTAssertEqual(try db.songs().count,1) - XCTAssertNotNil(try db.songs().first?.cdg) - } - - func testZippedMP3GImportAndUnsafeArchiveRejection() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let db = try Library(url:folder.appendingPathComponent("db.sqlite")) - let importer = Importer(media:folder.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) - XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) - XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) - } - @MainActor func testMP3PlaybackPauseAndSeek() throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player() - defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) - XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) - player.toggle(); XCTAssertFalse(player.playing) - player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) - player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) - } - @MainActor func testCreateSingerAndVenueThroughAppModel() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: root) } - let model = AppModel(storageURL: root) - XCTAssertNil(model.error) - let singer = try model.createEntity(.singer, name: " Alex ") - let venue = try model.createEntity(.venue, name: "Town Hall") - XCTAssertEqual(model.singers.first?.name, "Alex") - XCTAssertEqual(model.venues.first?.name, "Town Hall") - XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) - XCTAssertEqual(model.singers.count, 1) - XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) - let reopened = AppModel(storageURL: root) - XCTAssertEqual(reopened.singers.first?.id, singer) - XCTAssertEqual(reopened.venues.first?.id, venue) - } -} diff --git a/backups/before-venues-20261102-231352/README.md b/backups/before-venues-20261102-231352/README.md deleted file mode 100644 index 07a0f6c..0000000 --- a/backups/before-venues-20261102-231352/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# KJamsClone — initial macOS implementation, v0.1.0 - -This is a real Xcode application project, not a Swift Package renamed as one. -Open **KJamsClone.xcodeproj**. Its required project file is -**KJamsClone.xcodeproj/project.pbxproj**. No version-control restoration, -project generator, third-party packages, or network downloads are needed. - -## Start - -1. Extract the whole source ZIP to a writable folder. Do not open the project from inside the archive. -2. Open `KJamsClone/KJamsClone.xcodeproj` in Xcode, select the **KJamsClone** scheme and **My Mac**, then Run. -3. Click the **+ import button** and select `Fixtures/Demo.zip` for an original four-second tone and animated tile demonstration. This is a decoder demonstration, not a song with lyrics. -4. Double-click the imported song. Use the **eye button** to open the Video window, then move it onto another monitor or use macOS's window fullscreen control. -5. Double-click a library row to play it; the large Play/Pause button resumes or pauses the current track. The smaller transport buttons seek back/forward ten seconds and play the next song. The volume slider and repeat button control actual playback. Use the + button to import and the eye button to open the Video window. -6. Import your own `.mp3` and matching `.cdg` files, or a ZIP containing those files. Names must match, ignoring case, in the same folder. Select the audio file; its sibling CDG is found automatically. Audio-only MP3/WAV/M4A and WAV+G are also accepted. - -App deployment target: macOS 13+. Verified here with Xcode 27.2 on Apple Silicon macOS 27.2; older systems and Intel execution have not been tested. The build produces arm64 and x86_64 slices. XCTest execution with this Xcode may require macOS 14+. - -## Implemented - -| Feature | Scope | -| --- | --- | -| macOS app | Screenshot-inspired charcoal/silver controls, navy source sidebar and library grid, blue status capsule, library/search, rating menu, separate Video window | -| MP3+G | AVAudioPlayer master clock; CDG packets decoded at 300 packets/sec; play, pause, resume, stop, seek, volume and repeat | -| CDG | Memory/border preset, normal/XOR tiles, low/high palette, scroll preset/copy with fine offsets, transparent color; 288×192 cropped graphics | -| ZIP import | Automatic extraction and pairing, nested folders, traversal/symlink rejection; system `/usr/bin/unzip` | -| Library | SQLite persistence, copied media, SHA-256 audio-content deduplication for the same extension, search by filename title | -| Ratings | Persistent per-song 0–5 stars | -| Singers / venues | Create, list, select; persistent SQLite records | -| Play history | Song/singer/venue/timestamp stored on each new song start; play counts shown | -| Tests | Eight XCTest cases covering decoder behavior, backward seeking, SQLite reopening, ratings, play counts, pair/dedup import, ZIP import/rejection, real MP3 loading/pause/seeking | -| Scripts | Debug build, tests, unsigned Release app ZIP | - -Library and copied media are stored under `~/Library/Application Support/KJamsClone/`. -Imported source files remain untouched. Back up that entire folder together; paths are absolute. -No music catalogue or copyrighted songs are included. Demo tone/graphics are original and may be used freely. - -## Incomplete / unverified - -This is **not a complete kJams replacement or production-qualified release**. -There is no singer rotation queue, playlist management, history browser, singer/venue editing or deletion, per-singer ratings/preferences, pitch/tempo adjustment, crossfade, key detection, metadata tag parsing, physical CD ripping, or standalone CDG-only playback. Titles come from filenames. The rating is global per song. Play counts count playback starts, not completed performances. - -Synthetic fixtures and automated MP3 checks passed; real commercial karaoke collections, long performances, audible output quality, visual lyric accuracy, external monitors, and full UI interactions have not been manually validated. The decoder ignores parity/error correction, unsupported instructions and channel selection; all tile channels are combined. CDG has no adjustable synchronization offset. Backward seeking rebuilds graphics from packet zero on the main thread. Large imports also run on the main thread, without progress/cancel controls or archive expansion limits; use trusted, reasonably sized archives. Importing multiple files is not one database/filesystem transaction. A failed import may leave copied media to clean up. The app is intentionally not sandboxed, signed for distribution, or notarized. It has the default macOS application icon. - -## Build, test, release - -Run from this extracted project folder: - -```sh -./scripts/build.sh -./scripts/test.sh -./scripts/release.sh -``` - -Scripts work from any working directory and stop on failure. Build products go under `build/` and `build-release/`; release output is `dist/KJamsClone-unsigned-macOS.zip`. Release builds disable signing. Developer ID signing and Apple notarization must be added before distributing the app to other users. The source ZIP contains neither personal media nor your library database. - -`../../docs/VERIFICATION.md` records the checks performed on this delivery. `verification/` contains the corresponding build/test excerpts. - -## Implementation reference - -CDG packet layout and commands were implemented using Jim Bumgardner's [CD+G Revealed](https://jbum.com/cdg_revealed.html) as a format reference. The implementation is original Swift code; no third-party player source is bundled. - -## Screenshot-inspired interface - -The interface follows the supplied reference: charcoal frame, silver transport controls, blue status capsule, navy panes, compact column headings, source selection and bottom song count. Library, Singers and Venues are functional sources. Third-party music stores and playlists from the reference are not implemented. Artist, Album, Key Adjust and Tempo columns show dashes because those features are not available; the Label column identifies the imported format. Rating menus persist changes. The separate Video window remains black when no graphics are loaded. - -## Single main window and adding people / places - -The app uses a single main library window; the File menu has no New Window command. The optional Video display remains separate for karaoke output. - -Choose **File → New Singer…** or **File → New Venue…**, enter a name, then click Add. You can also use **New Singer… / New Venue…** at the top of the matching source pane, or the + button beside the singer/venue selector. New entries save to SQLite, appear immediately, and become selected for subsequent playback. Empty names are rejected, and adding an existing name selects its existing record. diff --git a/backups/before-venues-20261102-231352/verification/ui-preview.png b/backups/before-venues-20261102-231352/verification/ui-preview.png deleted file mode 100644 index 743dfe5478deab260e31aee4dc2544e704741a77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 227409 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%0A{r#vx_O`i4< zMe5s$OA|$BOaXp7(JXaQuN+mgM2~uIXJ^BvKi6pqWtFOkAOV4p{LNTAYf3b3z9&%v zI|&Fq2^*8`(vLb2|<6dU(Q3+gQp~@|NM~7Z8 z^7g3Jj7c%C3?!36oU}+tHzJ*>P9;Ac9u&|+>5u2JpXQfU?0V}}ZgJxXo(hvyHBVWS zgA!YZj_~>{O1`2xBZJhxW@I^4`Qya@p}na;&Zj3;E$v$QYm2o`JImMw;^P2mmvx{8 zKEa}lP#KkyQ}A`yHooF~Qbxn-#>Y}cTPOaL-MCxL`3<>l$n1g}9@@jv2h;I;$@Es2 z$y1lvY#IaszlgUs_q}TNih*)%J%6lsp|N7*CQbS)K$CPNOrhjWNhzMt+kyVUC#c>R z_(1CuomI=S!`E}Id7bo0SZx^34ci9WrP2-l+Z9GQ=!AX^^NdPiEw2;Pd$ZF_9(Mt% z+p*-qGEh6X^?Sbp6EuQ_*DT)r07UitH9Nrgg*~|_qj~?q+KbQSP~;NwpDOJDbc(Nh zfZ-YCTfdFByr558U;cPDAG+1Zs#ktojbXTyAE~sY;O6YmY}xJpJCeWp?lWth(Htl6 z?VP-CJ-h2E)I0Mi%IgkCW%1uuem;c4iJ6m zit$EoH3Qz~qjFcSbB~-|{Y|b$eA+x%0VxsxwdTbV?aN_eb|K@)TSDzcD!YFNYBD}V z(&i)n6DcLS6w^;e#hlhXq|>*x=i6o1bsPEj!#AF30V0JElmPiugS zg|oELnlt`?@sgh2&_bqj^%o)|QIdc?H=CR>uiq^O)Ag;L@nBkYBAi}Qb~a_4C)B;t zs(68(aeIWCzyL-Z`qL{;y7-LP?!r^jMk*N1qZhMfENA0 za;UdOrN-vpn&8&IN=~{v_CiF|cUpi?hSay%9tXP^Z0ZVBm8>>P1HL@K+@g%0S7S6t zQR;(#-@7VymtNXV=xbq@

ulcrL-F>x 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 F8BB1C28-BAE8-11D6-9C31-00039315CD46 -Test Case '-[KJamsCloneTests.KJamsCloneTests testMP3PlaybackPauseAndSeek]' passed (0.198 seconds). -Test Case '-[KJamsCloneTests.KJamsCloneTests testPairedImportAndContentDedup]' started. -Test Case '-[KJamsCloneTests.KJamsCloneTests testPairedImportAndContentDedup]' passed (0.005 seconds). -Test Case '-[KJamsCloneTests.KJamsCloneTests testPaletteBorderScrollAndImage]' started. -Test Case '-[KJamsCloneTests.KJamsCloneTests testPaletteBorderScrollAndImage]' passed (0.032 seconds). -Test Case '-[KJamsCloneTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' started. -Test Case '-[KJamsCloneTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' passed (0.001 seconds). -Test Case '-[KJamsCloneTests.KJamsCloneTests testSeekAndTruncatedPacket]' started. -Test Case '-[KJamsCloneTests.KJamsCloneTests testSeekAndTruncatedPacket]' passed (0.001 seconds). -Test Case '-[KJamsCloneTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' started. -Test Case '-[KJamsCloneTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' passed (0.004 seconds). -Test Case '-[KJamsCloneTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' started. -Test Case '-[KJamsCloneTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' passed (0.016 seconds). -Test Suite 'KJamsCloneTests' passed at 2026-11-02 22:56:15.760. - Executed 8 tests, with 0 failures (0 unexpected) in 0.262 (0.263) seconds -Test Suite 'KJamsCloneTests.xctest' passed at 2026-11-02 22:56:15.760. - Executed 8 tests, with 0 failures (0 unexpected) in 0.262 (0.263) seconds -Test Suite 'All tests' passed at 2026-11-02 22:56:15.760. - Executed 8 tests, with 0 failures (0 unexpected) in 0.262 (0.264) seconds -2026-11-02 22:56:16.045 xcodebuild[18779:6291628] [MT] IDETestOperationsObserverDebug: 1.114 elapsed -- Testing started completed. -2026-11-02 22:56:16.045 xcodebuild[18779:6291628] [MT] IDETestOperationsObserverDebug: 0.000 sec, +0.000 sec -- start -2026-11-02 22:56:16.045 xcodebuild[18779:6291628] [MT] IDETestOperationsObserverDebug: 1.114 sec, +1.114 sec -- end - -Test session results, code coverage, and logs: - /Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/build/Logs/Test/Test-KJamsClone-2026.11.02_22-56-11-+0000.xcresult - -** TEST SUCCEEDED ** - -Testing started diff --git a/backups/before-waveform-20261103-082733/KJamsClone/KJamsCloneApp.swift b/backups/before-waveform-20261103-082733/KJamsClone/KJamsCloneApp.swift deleted file mode 100644 index 8fcc73e..0000000 --- a/backups/before-waveform-20261103-082733/KJamsClone/KJamsCloneApp.swift +++ /dev/null @@ -1,636 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -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" } -} - -@MainActor final class AppModel: ObservableObject { - @Published var songs = [Song]() - @Published var singers = [PersonPlace]() - @Published var venues = [PersonPlace]() - @Published var venueSingers = [PersonPlace]() - @Published var playlists = [PersonPlace]() - @Published var queue = [QueueEntry]() - @Published var history = [Performance]() - @Published private(set) var currentVenue: Int64? - @Published var selectedSinger: Int64? - @Published var autoAdvance = true - @Published var error: String? - @Published var creating: EntityKind? - 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") - perform { - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() - } - player.onFinished = { [weak self] in - guard let self, self.autoAdvance, !self.queue.isEmpty else { return } - self.playQueueNext() - } - } - 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(); singers = try library.entities("singers"); venues = try library.entities("venues") - 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 } - queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) - } - func chooseVenue(_ id: Int64?) { - perform { - guard let library else { throw LibraryError.message("Library unavailable") } - try library.selectVenue(id); selectedSinger = nil; 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 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 preference(_ song: Int64) -> SongPreference { - guard let library, let selectedSinger else { return .init() } - return (try? library.preference(song:song,singer:selectedSinger)) ?? .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 { - 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) } ?? SongPreference() - try player.load(song,preference:preference) - do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } - catch { player.stop(); throw error } - } - func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } - func playQueueNext() { - perform { - guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } - // Explicitly clear selection for unassigned queue entries. - selectedSinger = row.singerID - try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() - } - } - 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 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() } } -} -@main struct KJamsCloneApp: App { - @StateObject private var model = AppModel() - var body: some Scene { - Window("KJamsClone", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) - }.defaultSize(width: 1260, height: 840) - .commands { - CommandGroup(replacing: .newItem) { - Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) - } - } - Window("Video", id: "lyrics") { - LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) - }.defaultSize(width: 600, height: 400) - } -} -private enum Classic { - static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) - static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) - static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) - static let header = Color(white: 0.115) - static let line = Color(white: 0.42) -} -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(1.5, contentMode: .fit) - } - } - } -} -/// 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 { - let 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) - .aspectRatio(1.5, contentMode: .fit) - .clipped() - .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) - .accessibilityLabel("Video monitor, synchronized with the Video window") - }.frame(maxWidth: .infinity) - } -} -private struct SilverSquareStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) - .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) - .opacity(configuration.isPressed ? 0.7 : 1) - } -} -private struct SilverButton: View { - let symbol: String - let label: String - var size: CGFloat = 40 - var active = false - var enabled = true - let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) - .foregroundStyle(active ? Color.blue : Color(white: 0.24)) - .frame(width: size, height: size) - .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) - .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) - .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) - .shadow(color: .black.opacity(0.7), radius: 1, y: 1) - }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) - .help(label).accessibilityLabel(label) - } -} -struct LibraryView: View { - @ObservedObject var model: AppModel - @State private var selected: Int64? - @State private var search = "" - @State private var name = "" - @State private var importing = false - @State private var source = "Library" - @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } - private var selectedSong: Song? { model.songs.first { $0.id == selected } } - var body: some View { - VStack(spacing: 0) { - TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, - play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) - .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) - HSplitView { - sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) - VStack(spacing: 12) { - if source == "Library" { songTable } - else if source == "Queue" { queuePane } - else if source == "History" { historyPane } - else if source == "Playlists" { playlistPane } - else { directory } - detailPanel.frame(height: 135) - }.padding(.leading, 12).frame(minWidth: 780) - }.padding(.horizontal, 18) - footer.padding(.horizontal, 18).padding(.vertical, 12) - } - .background(Classic.frame) - .sheet(item: $model.creating) { kind in - AddEntitySheet(model: model, kind: kind) { id in - if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } - } - } - .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .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("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { - Button("OK") { model.error = nil } - } message: { Text(model.error ?? "") } - } - 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(alignment: .leading, spacing: 0) { - Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) - .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) - ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan), ("Queue", "list.number", Color.orange), ("History", "clock.arrow.circlepath", Color.purple), ("Playlists", "music.note.list", Color.mint)], id: \.0) { item in - Button { source = item.0 } label: { - HStack(spacing: 10) { - Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) - Text(item.0).font(.system(size: 16, weight: .medium)) - Spacer() - Text("\(sourceCount(item.0))") - .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) - }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) - .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) - }.buttonStyle(.plain) - } - Spacer(minLength: 12) - SidebarVideoMonitor(player: model.player).padding(10) - }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private var songTable: some View { - GeometryReader { geometry in - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(width: max(geometry.size.width, 790)) - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } - }.frame(maxWidth: .infinity, alignment: .topLeading) - } - }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) - } - }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) - } - private func widths(_ total: CGFloat) -> [CGFloat] { - [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] - } - private func tableHeader(width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in - Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) - .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) - .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } - } - }.foregroundStyle(.white).background(Classic.header) - .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } - } - private func cellText(_ song: Song, column: Int) -> String { - switch column { - case 2: return song.artist.isEmpty ? "—" : song.artist - case 3: return song.album.isEmpty ? "—" : song.album - case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label - case 5: return String(format:"%+.0f",model.preference(song.id).pitch) - default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) - } - } - private func songRow(_ song: Song, width: CGFloat) -> some View { - let sizes = widths(width) - return HStack(spacing: 0) { - Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") - .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) - Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) - ForEach(2..<7) { index in - Text(cellText(song, column: index)) - .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) - } - Menu { - ForEach(0..<6) { value in - Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { - model.perform { try model.library?.rate(song.id, value); try model.refresh() } - } - } - } label: { - Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) - .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) - }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") - }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) - .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) - .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } - .onTapGesture { selected = song.id } - .contextMenu { - Button("Play") { selected = song.id; playSelected() } - Button("Add to Current 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) } } } - } - } - @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…") { model.creating = source == "Singers" ? .singer : .venue } - }.padding(12).background(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.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.creating = .singer } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") - Button("Queue Song") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) - } - PlaybackSettings(player:model.player,model:model,selectedSong:selected) - SeekBar(player:model.player) - }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) - .background(Classic.navy).overlay(Rectangle().stroke(Classic.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.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) - 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(Classic.navy).overlay(Rectangle().stroke(Classic.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(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var playlistPane: some View { - VStack(alignment:.leading,spacing:12) { - HStack { - Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } - TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) - Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - if let selectedPlaylist { - Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) - Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } - } - ScrollView { - if let selectedPlaylist { - let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] - VStack(alignment:.leading,spacing:10) { - ForEach(ids,id:\.self) { id in - if let song = model.songs.first(where: { $0.id == id }) { - HStack { - Text(song.title); Spacer() - Button("Play") { model.perform { try model.play(song) } } - Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) - Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } - } - } - } - } - } - Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) - } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) - } - private var footer: some View { - HStack { - Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } - .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") - RepeatButton(player: model.player).padding(.leading, 12) - Spacer() - Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) - Spacer() - Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } - .help("Open video window").accessibilityLabel("Open video window") - }.buttonStyle(SilverSquareStyle()) - } - 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 = "" - } - } -} -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? - @FocusState private var focused: Bool - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("New \(kind.title)").font(.title2.bold()) - TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) - .focused($focused).onSubmit(save) - if let error { Text(error).foregroundStyle(.red).font(.callout) } - HStack { - Spacer() - Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) - Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - }.padding(24).frame(width: 360).onAppear { focused = true } - } - private func save() { - do { let 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(alignment: .top, spacing: 28) { - VStack(spacing: 15) { - HStack(spacing: 7) { - SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } - SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { - if player.duration > 0 { player.toggle() } else { play() } - } - SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } - SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } - SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) - } - HStack(spacing: 9) { - Image(systemName: "speaker.fill").font(.system(size: 11)) - Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") - Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) - }.frame(width: 180) - }.frame(width: 220) - VStack(spacing: 0) { - Text(player.duration == 0 ? "Ready!" : player.title).font(.system(size: 15, weight: .medium)).lineLimit(1).padding(.horizontal, 20) - if player.duration > 0 { Text(player.playing ? "Playing" : "Paused").font(.system(size: 10)).foregroundStyle(.white.opacity(0.65)).padding(.top, 4) } - }.frame(maxWidth: .infinity).frame(height: 64) - .background(LinearGradient(colors: [Color(red: 0, green: 0.10, blue: 0.30), Classic.blue], startPoint: .top, endPoint: .bottom), in: Capsule()) - .overlay(Capsule().strokeBorder(LinearGradient(colors: [.black, .white], startPoint: .top, endPoint: .bottom), lineWidth: 1.5)) - .padding(.top, 14) - HStack(spacing: 12) { - TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) - .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) - SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) - }.padding(.top, 15) - }.foregroundStyle(.white).frame(height: 96) - } -} -private struct PlaybackSettings: View { - @ObservedObject var player: Player - @ObservedObject var model: AppModel - let selectedSong: Int64? - @State private var personalRating = 0 - var body: some View { - HStack(spacing:10) { - Text("Key") - Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) - Text("Tempo") - Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) - Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) - Text("Rating") - Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") - Button("Save for Singer") { - if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } - }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) - Menu { - Button("Reset key / tempo") { player.pitch = 0; player.tempo = 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).help("Playback settings") - }.font(.system(size:11)) - .onChange(of:model.selectedSinger) { _ in loadRating() } - .onChange(of:player.currentSongID) { _ in loadRating() } - .onChange(of:selectedSong) { _ in loadRating() } - } - private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } -} -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: 18, weight: .semibold)) - .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) - }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") - } -} diff --git a/backups/before-waveform-20261103-082733/KJamsClone/Player.swift b/backups/before-waveform-20261103-082733/KJamsClone/Player.swift deleted file mode 100644 index ab647b3..0000000 --- a/backups/before-waveform-20261103-082733/KJamsClone/Player.swift +++ /dev/null @@ -1,91 +0,0 @@ -import SwiftUI -import AVFoundation - -/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. -@MainActor final class Player: ObservableObject { - @Published var image: CGImage? - @Published var playing = false - @Published var time: Double = 0 - @Published var duration: Double = 0 - @Published var title = "Select a song" - @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } - @Published var repeating = false - @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } - @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } - @Published var graphicsOffset: Double = 0 - private(set) var currentSongID: Int64? - var onFinished: (() -> Void)? - private let engine = AVAudioEngine() - private let node = AVAudioPlayerNode() - private let effect = AVAudioUnitTimePitch() - private var file: AVAudioFile? - private var anchor: Double = 0 - private var generation = UUID() - private var cdg = Data() - private var decoder = CDGDecoder() - private var timer: Timer? - init() { engine.attach(node); engine.attach(effect) } - func load(_ song: Song, preference: SongPreference = .init()) throws { - stop() - do { - let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) - let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() - guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } - engine.connect(node,to:effect,format:loaded.processingFormat) - engine.connect(effect,to:engine.mainMixerNode,format:loaded.processingFormat) - file = loaded; cdg = graphics; decoder = CDGDecoder() - duration = Double(loaded.length)/loaded.processingFormat.sampleRate - title = song.title; currentSongID = song.id - pitch = preference.pitch; tempo = preference.tempo - engine.mainMixerNode.outputVolume = Float(volume) - engine.prepare(); try engine.start() - schedule(at:0,play:true); startTimer(); tick() - } catch { stop(); throw error } - } - private func schedule(at seconds: Double, play: Bool) { - guard let file else { return } - generation = UUID(); let token = generation - node.stop() - let rate = file.processingFormat.sampleRate - let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) - anchor = Double(start)/rate; time = anchor - guard start < file.length else { playing = false; render(); return } - node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in - Task { @MainActor in - guard let self, self.generation == token else { return } - if self.repeating { self.schedule(at:0,play:true) } - else { - self.playing = false; self.time = self.duration; self.render() - self.timer?.invalidate(); self.timer = nil; self.onFinished?() - } - } - } - if play { node.play() }; playing = play; render() - } - private func startTimer() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } - } - func toggle() { - guard file != nil else { return } - if playing { tick(); node.pause(); playing = false } - else if time >= duration { schedule(at:0,play:true); startTimer() } - else { node.play(); playing = true; startTimer() } - } - func stop() { - generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() - engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect) - file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" - } - func seek(_ value: Double) { - guard file != nil, value.isFinite else { return } - schedule(at:min(max(value,0),duration),play:playing); startTimer() - } - private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } - private func tick() { - if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { - time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) - } - render() - } -} diff --git a/backups/before-waveform-20261103-082733/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-waveform-20261103-082733/KJamsCloneTests/KJamsCloneTests.swift deleted file mode 100644 index 69948bc..0000000 --- a/backups/before-waveform-20261103-082733/KJamsCloneTests/KJamsCloneTests.swift +++ /dev/null @@ -1,229 +0,0 @@ -import XCTest -@testable import KJamsClone - -final class KJamsCloneTests: XCTestCase { - func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } - func testPresetTileXORAndInvalidCoordinates() { - var d = CDGDecoder() - d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) - d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],5) - d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) - XCTAssertEqual(d.pixels[12*300+6],0) - let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) - } - func testSeekAndTruncatedPacket() { - var d = CDGDecoder() - let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) - d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) - d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) - d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) - } - func testPaletteBorderScrollAndImage() { - var d = CDGDecoder() - d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) - d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) - d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) - d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) - XCTAssertEqual(d.image()?.width,288) - } - func testSQLitePersistenceDedupRatingsAndHistory() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let url = folder.appendingPathComponent("test.sqlite") - do { - let db = try Library(url:url) - try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) - try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") - let song = try XCTUnwrap(db.songs().first) - try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) - try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") - try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) - } - let reopened = try Library(url:url); let songs = try reopened.songs() - XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") - XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") - } - func testPairedImportAndContentDedup() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") - try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) - let db = try Library(url:root.appendingPathComponent("test.sqlite")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([audio],library:db); try importer.run([audio],library:db) - XCTAssertEqual(try db.songs().count,1) - XCTAssertNotNil(try db.songs().first?.cdg) - } - - func testZippedMP3GImportAndUnsafeArchiveRejection() throws { - let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:folder) } - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let db = try Library(url:folder.appendingPathComponent("db.sqlite")) - let importer = Importer(media:folder.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) - XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) - XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) - } - @MainActor func testMP3PlaybackPauseAndSeek() throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player() - defer { player.stop() } - try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) - XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) - player.toggle(); XCTAssertFalse(player.playing) - player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) - player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) - } - @MainActor func testCreateSingerAndVenueThroughAppModel() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: root) } - let model = AppModel(storageURL: root) - XCTAssertNil(model.error) - let singer = try model.createEntity(.singer, name: " Alex ") - let venue = try model.createEntity(.venue, name: "Town Hall") - XCTAssertEqual(model.singers.first?.name, "Alex") - XCTAssertEqual(model.venues.first?.name, "Town Hall") - XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) - XCTAssertEqual(model.singers.count, 1) - XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) - let reopened = AppModel(storageURL: root) - XCTAssertEqual(reopened.singers.first?.id, singer) - XCTAssertEqual(reopened.venues.first?.id, venue) - } - - @MainActor func testVenuesShareSingersAndRememberSelection() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root) - let hall = try model.createEntity(.venue,name:"Hall") - let alex = try model.createEntity(.singer,name:"Alex") - let club = try model.createEntity(.venue,name:"Club") - XCTAssertTrue(model.venueSingers.isEmpty) - model.membership(singer:alex,included:true) - XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) - model.chooseVenue(club); model.membership(singer:alex,included:false) - XCTAssertTrue(model.venueSingers.isEmpty) - let reopened = AppModel(storageURL:root) - XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) - reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) - XCTAssertEqual(reopened.singers.count,1) - } - func testMigrationAndMembershipRemovalDoesNotReappear() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let url = root.appendingPathComponent("db.sqlite") - var singer: Int64 = 0, venue: Int64 = 0 - do { - let db = try Library(url:url) - try db.add(title:"Song",audio:"/a.mp3",cdg:nil) - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") - singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) - try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) - try db.execute("PRAGMA user_version=1") - } - do { - let migrated = try Library(url:url) - XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) - try migrated.setMembership(singer:singer,venue:venue,included:false) - } - let reopened = try Library(url:url) - XCTAssertTrue(try reopened.members(of:venue).isEmpty) - XCTAssertEqual(try reopened.history(venue:venue).count,1) - } - func testQueuePlaylistsPreferencesAndDeletion() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - try db.add(title:"First",audio:"/1.mp3",cdg:nil) - try db.add(title:"Second",audio:"/2.mp3",cdg:nil) - let songs = try db.songs() - try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") - let singer = try XCTUnwrap(db.entities("singers").first?.id) - let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) - let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) - XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) - try db.setMembership(singer:singer,venue:hall,included:true) - try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) - XCTAssertTrue(try db.queue(venue:club).isEmpty) - let second = try XCTUnwrap(db.queue(venue:hall).last) - try db.moveQueue(second.id,venue:hall,by:-1) - XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) - let preference = SongPreference(pitch:3,tempo:0.85,rating:4) - try db.savePreference(song:songs[0].id,singer:singer,value:preference) - XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) - XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) - try db.addEntity("playlists",name:"Party") - let playlist = try XCTUnwrap(db.entities("playlists").first?.id) - try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) - XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) - try db.record(songs[0].id,singer:singer,venue:hall) - try db.rename("singers",id:singer,name:"Alex Jones") - XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") - try db.deleteEntity("singers",id:singer) - XCTAssertTrue(try db.queue(venue:hall).isEmpty) - XCTAssertEqual(try db.history(venue:hall).count,1) - try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) - XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) - } - @MainActor func testTempoSourceClockPauseAndResume() async throws { - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let player = Player(); defer { player.stop() } - let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) - try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) - XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) - try await Task.sleep(nanoseconds:500_000_000) - XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) - player.toggle(); let paused = player.time - try await Task.sleep(nanoseconds:100_000_000) - XCTAssertEqual(player.time,paused,accuracy:0.025) - player.seek(1.5); player.toggle() - try await Task.sleep(nanoseconds:200_000_000) - XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) - } - func testNewTestMediaMetadataAndCDGPairImport() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) - defer { try? FileManager.default.removeItem(at:root) } - let db = try Library(url:root.appendingPathComponent("db.sqlite")) - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - let importer = Importer(media:root.appendingPathComponent("Media")) - try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) - let song = try XCTUnwrap(db.songs().first) - XCTAssertEqual(song.title,"MP3 Plus G Test") - XCTAssertEqual(song.artist,"KJamsClone Test Generator") - XCTAssertNotNil(song.cdg) - let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) - XCTAssertEqual(bytes.count,20*300*24) - var decoder = CDGDecoder(); decoder.render(bytes,at:2) - XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) - } - @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at:root) } - let model = AppModel(storageURL:root); defer { model.player.stop() } - _ = try model.createEntity(.venue,name:"Test Hall") - _ = try model.createEntity(.singer,name:"Alex") - let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) - model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) - let song = try XCTUnwrap(model.songs.first) - model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() - XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) - model.player.seek(model.player.duration-0.1) - for _ in 0..<25 { - if model.queue.isEmpty { break } - try await Task.sleep(nanoseconds:100_000_000) - } - XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) - XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") - } -} diff --git a/verification/extracted-project.txt b/docs/verification/extracted-project.txt similarity index 100% rename from verification/extracted-project.txt rename to docs/verification/extracted-project.txt diff --git a/verification/showscreen-output.png b/docs/verification/showscreen-output.png similarity index 100% rename from verification/showscreen-output.png rename to docs/verification/showscreen-output.png diff --git a/verification/singer-browser-check.txt b/docs/verification/singer-browser-check.txt similarity index 100% rename from verification/singer-browser-check.txt rename to docs/verification/singer-browser-check.txt diff --git a/verification/test-cdg-ffmpeg.png b/docs/verification/test-cdg-ffmpeg.png similarity index 100% rename from verification/test-cdg-ffmpeg.png rename to docs/verification/test-cdg-ffmpeg.png diff --git a/verification/ui-0.6-library.png b/docs/verification/ui-0.6-library.png similarity index 100% rename from verification/ui-0.6-library.png rename to docs/verification/ui-0.6-library.png diff --git a/verification/ui-0.6-rotation-small.png b/docs/verification/ui-0.6-rotation-small.png similarity index 100% rename from verification/ui-0.6-rotation-small.png rename to docs/verification/ui-0.6-rotation-small.png diff --git a/verification/ui-0.6-rotation.png b/docs/verification/ui-0.6-rotation.png similarity index 100% rename from verification/ui-0.6-rotation.png rename to docs/verification/ui-0.6-rotation.png diff --git a/verification/ui-0.6-settings.png b/docs/verification/ui-0.6-settings.png similarity index 100% rename from verification/ui-0.6-settings.png rename to docs/verification/ui-0.6-settings.png diff --git a/verification/ui-0.6.1-add-songs.png b/docs/verification/ui-0.6.1-add-songs.png similarity index 100% rename from verification/ui-0.6.1-add-songs.png rename to docs/verification/ui-0.6.1-add-songs.png diff --git a/verification/ui-0.6.1-reordered.png b/docs/verification/ui-0.6.1-reordered.png similarity index 100% rename from verification/ui-0.6.1-reordered.png rename to docs/verification/ui-0.6.1-reordered.png diff --git a/verification/ui-0.6.4-waveforms-button.png b/docs/verification/ui-0.6.4-waveforms-button.png similarity index 100% rename from verification/ui-0.6.4-waveforms-button.png rename to docs/verification/ui-0.6.4-waveforms-button.png diff --git a/verification/ui-batch-metadata.png b/docs/verification/ui-batch-metadata.png similarity index 100% rename from verification/ui-batch-metadata.png rename to docs/verification/ui-batch-metadata.png diff --git a/verification/ui-kjmix-pro-global-mixer.png b/docs/verification/ui-kjmix-pro-global-mixer.png similarity index 100% rename from verification/ui-kjmix-pro-global-mixer.png rename to docs/verification/ui-kjmix-pro-global-mixer.png diff --git a/verification/ui-kjmix-pro-locations.png b/docs/verification/ui-kjmix-pro-locations.png similarity index 100% rename from verification/ui-kjmix-pro-locations.png rename to docs/verification/ui-kjmix-pro-locations.png diff --git a/verification/ui-kjmix-pro-minimum.png b/docs/verification/ui-kjmix-pro-minimum.png similarity index 100% rename from verification/ui-kjmix-pro-minimum.png rename to docs/verification/ui-kjmix-pro-minimum.png diff --git a/verification/ui-kjmix-pro-mp4.png b/docs/verification/ui-kjmix-pro-mp4.png similarity index 100% rename from verification/ui-kjmix-pro-mp4.png rename to docs/verification/ui-kjmix-pro-mp4.png diff --git a/verification/ui-kjmix-pro.png b/docs/verification/ui-kjmix-pro.png similarity index 100% rename from verification/ui-kjmix-pro.png rename to docs/verification/ui-kjmix-pro.png diff --git a/verification/ui-metadata.png b/docs/verification/ui-metadata.png similarity index 100% rename from verification/ui-metadata.png rename to docs/verification/ui-metadata.png diff --git a/verification/ui-minimum-size.png b/docs/verification/ui-minimum-size.png similarity index 100% rename from verification/ui-minimum-size.png rename to docs/verification/ui-minimum-size.png diff --git a/verification/ui-monitor-minimum-size.png b/docs/verification/ui-monitor-minimum-size.png similarity index 100% rename from verification/ui-monitor-minimum-size.png rename to docs/verification/ui-monitor-minimum-size.png diff --git a/verification/ui-monitor-video-window.png b/docs/verification/ui-monitor-video-window.png similarity index 100% rename from verification/ui-monitor-video-window.png rename to docs/verification/ui-monitor-video-window.png diff --git a/verification/ui-monitor.png b/docs/verification/ui-monitor.png similarity index 100% rename from verification/ui-monitor.png rename to docs/verification/ui-monitor.png diff --git a/verification/ui-new-singer.png b/docs/verification/ui-new-singer.png similarity index 100% rename from verification/ui-new-singer.png rename to docs/verification/ui-new-singer.png diff --git a/verification/ui-preview.png b/docs/verification/ui-preview.png similarity index 100% rename from verification/ui-preview.png rename to docs/verification/ui-preview.png diff --git a/verification/ui-rotation.png b/docs/verification/ui-rotation.png similarity index 100% rename from verification/ui-rotation.png rename to docs/verification/ui-rotation.png diff --git a/verification/ui-showscreens.png b/docs/verification/ui-showscreens.png similarity index 100% rename from verification/ui-showscreens.png rename to docs/verification/ui-showscreens.png diff --git a/verification/ui-singer-browser.png b/docs/verification/ui-singer-browser.png similarity index 100% rename from verification/ui-singer-browser.png rename to docs/verification/ui-singer-browser.png diff --git a/verification/ui-singer-server.png b/docs/verification/ui-singer-server.png similarity index 100% rename from verification/ui-singer-server.png rename to docs/verification/ui-singer-server.png diff --git a/verification/ui-store-editor.png b/docs/verification/ui-store-editor.png similarity index 100% rename from verification/ui-store-editor.png rename to docs/verification/ui-store-editor.png diff --git a/verification/ui-studio-minimum-size.png b/docs/verification/ui-studio-minimum-size.png similarity index 100% rename from verification/ui-studio-minimum-size.png rename to docs/verification/ui-studio-minimum-size.png diff --git a/verification/ui-studio.png b/docs/verification/ui-studio.png similarity index 100% rename from verification/ui-studio.png rename to docs/verification/ui-studio.png diff --git a/verification/ui-tone-mixer.png b/docs/verification/ui-tone-mixer.png similarity index 100% rename from verification/ui-tone-mixer.png rename to docs/verification/ui-tone-mixer.png diff --git a/verification/ui-tonight-drag-minimum-size.png b/docs/verification/ui-tonight-drag-minimum-size.png similarity index 100% rename from verification/ui-tonight-drag-minimum-size.png rename to docs/verification/ui-tonight-drag-minimum-size.png diff --git a/verification/ui-tonight-drag.png b/docs/verification/ui-tonight-drag.png similarity index 100% rename from verification/ui-tonight-drag.png rename to docs/verification/ui-tonight-drag.png diff --git a/verification/ui-tonight-minimum-size.png b/docs/verification/ui-tonight-minimum-size.png similarity index 100% rename from verification/ui-tonight-minimum-size.png rename to docs/verification/ui-tonight-minimum-size.png diff --git a/verification/ui-tonight.png b/docs/verification/ui-tonight.png similarity index 100% rename from verification/ui-tonight.png rename to docs/verification/ui-tonight.png diff --git a/verification/ui-waveform-minimum-size.png b/docs/verification/ui-waveform-minimum-size.png similarity index 100% rename from verification/ui-waveform-minimum-size.png rename to docs/verification/ui-waveform-minimum-size.png diff --git a/verification/ui-waveform.png b/docs/verification/ui-waveform.png similarity index 100% rename from verification/ui-waveform.png rename to docs/verification/ui-waveform.png diff --git a/verification/xcode-release.log b/verification/xcode-release.log deleted file mode 100644 index ec9af4c..0000000 --- a/verification/xcode-release.log +++ /dev/null @@ -1,297 +0,0 @@ -Command line invocation: - /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project "KJMix Pro.xcodeproj" -scheme "KJMix Pro" -configuration Release -destination platform=macOS -derivedDataPath /Users/markgregory/Developer/Applications/KJamsClone/build-release CODE_SIGNING_ALLOWED=NO build - -Build settings from command line: - CODE_SIGNING_ALLOWED = NO - ---- xcodebuild: WARNING: Using the first of multiple matching destinations: -{ platform:macOS, arch:arm64, id:00006030-000C01641102001C, name:My Mac } -{ platform:macOS, arch:x86_64, id:00006030-000C01641102001C, name:My Mac } -ComputePackagePrebuildTargetDependencyGraph - -Prepare packages - -CreateBuildRequest - -SendProjectDescription - -CreateBuildOperation - -ComputeTargetDependencyGraph -note: Building targets in dependency order -note: Target dependency graph (1 target) - Target 'KJMix Pro' in project 'KJMix Pro' (no dependencies) - -GatherProvisioningInputs - -CreateBuildDescription - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -v -E -dM -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -x c -c /dev/null - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/usr/bin/actool --version --output-format xml1 - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc --version - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld -version_details - -Build description signature: 921fe392a05bfa2d854c4c121a720b55 -Build description path: /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/XCBuildData/921fe392a05bfa2d854c4c121a720b55.xcbuilddata -ClangStatCache /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache - cd /Users/markgregory/Developer/Applications/KJamsClone/KJMix\ Pro.xcodeproj - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -o /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache - -SwiftDriver KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftDriver KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftEmitModule normal arm64 Emitting\ module\ for\ KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - - -SwiftCompile normal arm64 Compiling\ LibraryScanner.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/LibraryScanner.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/LibraryScanner.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile LibraryScanner.swift - -SwiftCompile normal arm64 Compiling\ KJMixProApp.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile KJMixProApp.swift - -SwiftCompile normal x86_64 Compiling\ Player.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') -/Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift:302:35: warning: reference to captured var 'self' in concurrently-executing code; this is an error in the Swift 6 language mode [#SendableClosureCaptures] -300 | guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded -301 | await MainActor.run { -302 | guard let self,self.graphicsGeneration == token else { return } - | `- warning: reference to captured var 'self' in concurrently-executing code; this is an error in the Swift 6 language mode [#SendableClosureCaptures] -303 | self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil -304 | } - -[#SendableClosureCaptures]: - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile Player.swift - -SwiftEmitModule normal x86_64 Emitting\ module\ for\ KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - - -SwiftCompile normal x86_64 Compiling\ LibraryScanner.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/LibraryScanner.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/LibraryScanner.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile LibraryScanner.swift - -SwiftCompile normal x86_64 Compiling\ KJMixProApp.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile KJMixProApp.swift - -SwiftCompile normal arm64 Compiling\ Player.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') -/Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift:302:35: warning: reference to captured var 'self' in concurrently-executing code; this is an error in the Swift 6 language mode [#SendableClosureCaptures] -300 | guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded -301 | await MainActor.run { -302 | guard let self,self.graphicsGeneration == token else { return } - | `- warning: reference to captured var 'self' in concurrently-executing code; this is an error in the Swift 6 language mode [#SendableClosureCaptures] -303 | self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil -304 | } - -[#SendableClosureCaptures]: - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile Player.swift - -SwiftDriverJobDiscovery normal x86_64 Compiling LibraryScanner.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 Compiling\ Metadata.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') -/Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift:97:31: warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] - 95 | exporter.outputURL = temp;exporter.outputFileType = type - 96 | let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher] - 97 | var items = asset.metadata.filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} - | `- warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] - 98 | for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] { - 99 | let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item) - -[#DeprecatedDeclaration]: - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile Metadata.swift - -SwiftCompile normal x86_64 Compiling\ DJWorkspace.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile DJWorkspace.swift - -SwiftCompile normal x86_64 Compiling\ Hosting.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile Hosting.swift - -SwiftCompile normal x86_64 Compiling\ SingerServer.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile SingerServer.swift - -SwiftDriverJobDiscovery normal arm64 Compiling LibraryScanner.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 Compiling\ Hosting.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile Hosting.swift - -SwiftCompile normal arm64 Compiling\ SingerServer.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile SingerServer.swift - -SwiftCompile normal arm64 Compiling\ DJWorkspace.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile DJWorkspace.swift - -SwiftCompile normal arm64 Compiling\ Metadata.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') -/Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift:97:31: warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] - 95 | exporter.outputURL = temp;exporter.outputFileType = type - 96 | let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher] - 97 | var items = asset.metadata.filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} - | `- warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] - 98 | for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] { - 99 | let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item) - -[#DeprecatedDeclaration]: - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile Metadata.swift - -SwiftDriverJobDiscovery normal x86_64 Compiling Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal arm64 Compiling Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal x86_64 Compiling SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal arm64 Compiling Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal x86_64 Compiling Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal arm64 Compiling SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal x86_64 Compiling Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal x86_64 Compiling DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal arm64 Compiling Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal arm64 Compiling DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal x86_64 Compiling KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriver\ Compilation KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftDriverJobDiscovery normal arm64 Emitting module for KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriver\ Compilation\ Requirements KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftDriverJobDiscovery normal x86_64 Emitting module for KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriver\ Compilation\ Requirements KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro normal x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -filelist /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_lto.o -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -lsqlite3 -o /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro - -SwiftDriverJobDiscovery normal arm64 Compiling KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriver\ Compilation KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro normal arm64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -filelist /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_lto.o -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -lsqlite3 -o /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.abi.json (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.abi.json (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.abi.json - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo - -CreateUniversalBinary /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro normal arm64\ x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro -output /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro - -ExtractAppIntentsMetadata (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --module-name KJamsClone --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk --xcode-version 27B52 --platform-family macOS --deployment-target 13.0 --bundle-identifier com.kjamsclone.app --output /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Resources --target-triple arm64-apple-macos13.0 --target-triple x86_64-apple-macos13.0 --binary-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_dependency_info.dat --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_dependency_info.dat --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList --metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro.DependencyMetadataFileList --static-metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro.DependencyStaticMetadataFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftConstValuesFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --no-app-shortcuts-localization -2026-11-03 13:59:20.100 appintentsmetadataprocessor[7033:153799] Starting App Intents metadata extraction -2026-11-03 13:59:20.101 appintentsmetadataprocessor[7033:153799] warning: Metadata extraction skipped, no AppIntents.framework dependency found - -CopySwiftLibs /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Frameworks --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/PlugIns --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Extensions --platform macosx --toolchain /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Frameworks --strip-bitcode --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span -Ignoring --strip-bitcode because --sign was not passed - -ProcessInfoPlistFile /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Info.plist /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/empty-KJMix\ Pro.plist (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-infoPlistUtility /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/empty-KJMix\ Pro.plist -producttype com.apple.product-type.application -genpkginfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/PkgInfo -expandbuildsettings -platform macosx -additionalcontentfile /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/assetcatalog_generated_info.plist -o /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Info.plist - -Validate /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-validationUtility /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app -no-validate-extension -infoplist-subpath Contents/Info.plist - -RegisterWithLaunchServices /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-lsregisterurl --record-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt -- /System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app - -PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/ExplicitPrecompiledModules - -PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules - -PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules - -** BUILD SUCCEEDED ** - diff --git a/verification/xcode-test.log b/verification/xcode-test.log deleted file mode 100644 index 9c32067..0000000 --- a/verification/xcode-test.log +++ /dev/null @@ -1,556 +0,0 @@ -Command line invocation: - /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project "KJMix Pro.xcodeproj" -scheme "KJMix Pro" -configuration Debug -destination platform=macOS -derivedDataPath /Users/markgregory/Developer/Applications/KJamsClone/build CODE_SIGNING_ALLOWED=NO test - -Build settings from command line: - CODE_SIGNING_ALLOWED = NO - ---- xcodebuild: WARNING: Using the first of multiple matching destinations: -{ platform:macOS, arch:arm64, id:00006030-000C01641102001C, name:My Mac } -{ platform:macOS, arch:x86_64, id:00006030-000C01641102001C, name:My Mac } -ComputePackagePrebuildTargetDependencyGraph - -Prepare packages - -CreateBuildRequest - -SendProjectDescription - -CreateBuildOperation - -ComputeTargetDependencyGraph -note: Building targets in dependency order -note: Target dependency graph (2 targets) - Target 'KJMixProTests' in project 'KJMix Pro' - ➜ Explicit dependency on target 'KJMix Pro' in project 'KJMix Pro' - Target 'KJMix Pro' in project 'KJMix Pro' (no dependencies) - -GatherProvisioningInputs - -CreateBuildDescription - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc --version - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -v -E -dM -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -x c -c /dev/null - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/usr/bin/actool --version --output-format xml1 - -ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld -version_details - -Build description signature: d306b18d4ca3c34143e200f5683e27cb -Build description path: /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/XCBuildData/d306b18d4ca3c34143e200f5683e27cb.xcbuilddata -ClangStatCache /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache - cd /Users/markgregory/Developer/Applications/KJamsClone/KJMix\ Pro.xcodeproj - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -o /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache - -SwiftDriver KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftDriver KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -ProcessInfoPlistFile /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Info.plist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/empty-KJMix\ Pro.plist (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-infoPlistUtility /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/empty-KJMix\ Pro.plist -producttype com.apple.product-type.application -genpkginfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PkgInfo -expandbuildsettings -platform macosx -additionalcontentfile /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/assetcatalog_generated_info.plist -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Info.plist - -SwiftEmitModule normal x86_64 Emitting\ module\ for\ KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - - -SwiftCompile normal x86_64 Compiling\ KJMixProApp.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile KJMixProApp.swift - -SwiftCompile normal arm64 Compiling\ KJMixProApp.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile KJMixProApp.swift - -SwiftEmitModule normal arm64 Emitting\ module\ for\ KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftCompile normal arm64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - - -SwiftDriverJobDiscovery normal arm64 Emitting module for KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriver\ Compilation\ Requirements KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo - -SwiftDriverJobDiscovery normal x86_64 Emitting module for KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.abi.json (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.abi.json - -SwiftDriver\ Compilation\ Requirements KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.abi.json (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo - -SwiftDriver KJMixProTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftCompile normal arm64 Compiling\ KJamsCloneTests.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:756:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 754 | let files = try FileManager.default.contentsOfDirectory(at:backup,includingPropertiesForKeys:nil);XCTAssertEqual(files.count,1);XCTAssertEqual(try Data(contentsOf:files[0]),original) - 755 | let metadata = try await AVURLAsset(url:url).load(.commonMetadata) - 756 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Updated ✓") - | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 757 | var second = song;second.title = "Second";try await FileTagWriter.write(second,backupDirectory:backup) - 758 | XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) - -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:824:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 822 | XCTAssertEqual(tracks.count,1) - 823 | let metadata = try await asset.load(.commonMetadata) - 824 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Tagged Video") - | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 825 | } - 826 | - -[#DeprecatedDeclaration]: - -SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile KJamsCloneTests.swift - -SwiftEmitModule normal arm64 Emitting\ module\ for\ KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') - -SwiftCompile normal arm64 (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - - -SwiftDriverJobDiscovery normal arm64 Emitting module for KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') - -SwiftDriver\ Compilation\ Requirements KJMixProTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftDriver KJMixProTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/arm64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.abi.json (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/arm64-apple-macos.abi.json - -SwiftEmitModule normal x86_64 Emitting\ module\ for\ KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') - -SwiftCompile normal x86_64 (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - - -SwiftDriverJobDiscovery normal x86_64 Emitting module for KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') - -SwiftDriver\ Compilation\ Requirements KJMixProTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/x86_64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.abi.json (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/x86_64-apple-macos.abi.json - -SwiftDriverJobDiscovery normal arm64 Compiling KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') - -SwiftDriver\ Compilation KJMixProTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -SwiftDriverJobDiscovery normal arm64 Compiling KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriver\ Compilation KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro.debug.dylib normal arm64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -dynamiclib -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -filelist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.LinkFileList -install_name @rpath/KJMix\ Pro.debug.dylib -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -lsqlite3 -Xlinker -alias -Xlinker _main -Xlinker ___debug_main_executable_dylib_entry_point -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro.debug.dylib - -ConstructStubExecutorLinkFileList /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-ExecutorLinkFileList-normal-arm64.txt (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - construct-stub-executor-link-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro.debug.dylib /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor_no_swift_entry_point.a /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor.a --output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-ExecutorLinkFileList-normal-arm64.txt -note: Using stub executor library with Swift entry point. (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriverJobDiscovery normal x86_64 Compiling KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') - -SwiftDriver\ Compilation KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro.debug.dylib normal x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -dynamiclib -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -filelist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.LinkFileList -install_name @rpath/KJMix\ Pro.debug.dylib -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -lsqlite3 -Xlinker -alias -Xlinker _main -Xlinker ___debug_main_executable_dylib_entry_point -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro.debug.dylib - -CreateUniversalBinary /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib normal arm64\ x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro.debug.dylib /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro.debug.dylib -output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib - -ConstructStubExecutorLinkFileList /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-ExecutorLinkFileList-normal-x86_64.txt (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - construct-stub-executor-link-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro.debug.dylib /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor_no_swift_entry_point.a /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor.a --output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-ExecutorLinkFileList-normal-x86_64.txt -note: Using stub executor library with Swift entry point. (in target 'KJMix Pro' from project 'KJMix Pro') - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro normal x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Xlinker -rpath -Xlinker @executable_path -rdynamic -e ___debug_blank_executor_main -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_dylib -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-DebugDylibPath-normal-x86_64.txt -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_instlnm -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-DebugDylibInstallName-normal-x86_64.txt -Xlinker -filelist -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-ExecutorLinkFileList-normal-x86_64.txt /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro.debug.dylib -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro normal arm64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Xlinker -rpath -Xlinker @executable_path -rdynamic -e ___debug_blank_executor_main -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_dylib -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-DebugDylibPath-normal-arm64.txt -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_instlnm -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-DebugDylibInstallName-normal-arm64.txt -Xlinker -filelist -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro-ExecutorLinkFileList-normal-arm64.txt /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro.debug.dylib -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro - -CreateUniversalBinary /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro normal arm64\ x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro -output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro - -CopySwiftLibs /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Frameworks --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Extensions --platform macosx --toolchain /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Frameworks --strip-bitcode --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span -Ignoring --strip-bitcode because --sign was not passed - -ExtractAppIntentsMetadata (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --module-name KJamsClone --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk --xcode-version 27B52 --platform-family macOS --deployment-target 13.0 --bundle-identifier com.kjamsclone.app --output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Resources --target-triple arm64-apple-macos13.0 --target-triple x86_64-apple-macos13.0 --binary-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_dependency_info.dat --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_dependency_info.dat --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList --metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro.DependencyMetadataFileList --static-metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/KJMix\ Pro.DependencyStaticMetadataFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftConstValuesFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --no-app-shortcuts-localization -2026-11-03 13:58:36.264 appintentsmetadataprocessor[6945:152699] Starting App Intents metadata extraction -2026-11-03 13:58:36.266 appintentsmetadataprocessor[6945:152699] warning: Metadata extraction skipped, no AppIntents.framework dependency found - -ProcessInfoPlistFile /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Info.plist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/empty-KJMixProTests.plist (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-infoPlistUtility /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/empty-KJMixProTests.plist -producttype com.apple.product-type.bundle.unit-test -expandbuildsettings -platform macosx -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Info.plist - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/Binary/KJMixProTests normal arm64 (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -bundle -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -L/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -iframework /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -filelist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -rpath -Xlinker @loader_path/../Frameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -bundle_loader /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -lsqlite3 -Xlinker -needed_framework -Xlinker XCTest -framework XCTest -Xlinker -needed-lXCTestSwiftSupport -lXCTestSwiftSupport /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/Binary/KJMixProTests -/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/XCTest.framework/Versions/A/XCTest' which was built for newer version 14.0 -/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/libXCTestSwiftSupport.dylib' which was built for newer version 14.0 - -SwiftCompile normal x86_64 Compiling\ KJamsCloneTests.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') - -SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-SwiftPerFileCompile KJamsCloneTests.swift - -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:756:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 754 | let files = try FileManager.default.contentsOfDirectory(at:backup,includingPropertiesForKeys:nil);XCTAssertEqual(files.count,1);XCTAssertEqual(try Data(contentsOf:files[0]),original) - 755 | let metadata = try await AVURLAsset(url:url).load(.commonMetadata) - 756 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Updated ✓") - | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 757 | var second = song;second.title = "Second";try await FileTagWriter.write(second,backupDirectory:backup) - 758 | XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) - -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:824:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 822 | XCTAssertEqual(tracks.count,1) - 823 | let metadata = try await asset.load(.commonMetadata) - 824 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Tagged Video") - | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] - 825 | } - 826 | - -[#DeprecatedDeclaration]: -SwiftDriverJobDiscovery normal x86_64 Compiling KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') - -SwiftDriver\ Compilation KJMixProTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo - -Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/Binary/KJMixProTests normal x86_64 (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -bundle -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -L/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -iframework /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -filelist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -rpath -Xlinker @loader_path/../Frameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -bundle_loader /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -lsqlite3 -Xlinker -needed_framework -Xlinker XCTest -framework XCTest -Xlinker -needed-lXCTestSwiftSupport -lXCTestSwiftSupport /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/Binary/KJMixProTests -/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/XCTest.framework/Versions/A/XCTest' which was built for newer version 14.0 -/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/libXCTestSwiftSupport.dylib' which was built for newer version 14.0 - -CreateUniversalBinary /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests normal arm64\ x86_64 (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/Binary/KJMixProTests /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/Binary/KJMixProTests -output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests - -CopySwiftLibs /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Frameworks --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/PlugIns --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Extensions --platform macosx --toolchain /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Frameworks --strip-bitcode --scan-executable /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libXCTestSwiftSupport.dylib --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span -Ignoring --strip-bitcode because --sign was not passed - -ExtractAppIntentsMetadata (in target 'KJMixProTests' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --module-name KJMixProTests --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk --xcode-version 27B52 --platform-family macOS --deployment-target 13.0 --bundle-identifier com.kjamsclone.tests --output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Resources --target-triple arm64-apple-macos13.0 --target-triple x86_64-apple-macos13.0 --binary-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_dependency_info.dat --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_dependency_info.dat --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList --metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests.DependencyMetadataFileList --static-metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests.DependencyStaticMetadataFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftConstValuesFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --no-app-shortcuts-localization -2026-11-03 13:58:37.359 appintentsmetadataprocessor[6952:152737] Starting App Intents metadata extraction -2026-11-03 13:58:37.361 appintentsmetadataprocessor[6952:152737] warning: Metadata extraction skipped, no AppIntents.framework dependency found - -Validate /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-validationUtility /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app -no-validate-extension -infoplist-subpath Contents/Info.plist - -RegisterWithLaunchServices /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') - cd /Users/markgregory/Developer/Applications/KJamsClone - builtin-lsregisterurl --record-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt -- /System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app - -PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules - -PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/ExplicitPrecompiledModules - -PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules - -2026-11-03 13:58:37.964696+0000 KJMix Pro[6954:152857] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -2026-11-03 13:58:37.964872+0000 KJMix Pro[6954:152857] [Application] Error registering app with AppIntents framework: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -2026-11-03 13:58:38.010189+0000 KJMix Pro[6954:152806] [plugin] AddInstanceForFactory: No factory registered for id F8BB1C28-BAE8-11D6-9C31-00039315CD46 -2026-11-03 13:58:38.037807+0000 KJMix Pro[6954:152806] [logging-persist] cannot open file at line 53094 of [8fa8248e30] -2026-11-03 13:58:38.037823+0000 KJMix Pro[6954:152806] [logging-persist] os_unix.c:53094: (2) open(/private/var/db/DetachedSignatures) - No such file or directory -2026-11-03 13:58:38.125525+0000 KJMix Pro[6954:152806] ApplePersistenceIgnoreState: Existing state will not be touched. New state will be written to /var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/com.kjamsclone.app.savedState -Test Suite 'All tests' started at 2026-11-03 13:58:38.681. -Test Suite 'KJMixProTests.xctest' started at 2026-11-03 13:58:38.682. -Test Suite 'KJamsCloneTests' started at 2026-11-03 13:58:38.682. -Test Case '-[KJMixProTests.KJamsCloneTests testActualRotationButtonsAddSongReorderAndStart]' started. -2026-11-03 13:58:38.766641+0000 KJMix Pro[6954:152806] [Window] Warning: -[NSWindow makeKeyWindow] called on windowNumber=40c which returned NO from -[NSWindow canBecomeKeyWindow]. -2026-11-03 13:58:39.018789+0000 KJMix Pro[6954:152919] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -2026-11-03 13:58:39.018905+0000 KJMix Pro[6954:152919] [Connection] Unable to re-register with Process Instance Registry, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -2026-11-03 13:58:40.537542+0000 KJMix Pro[6954:152952] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/1055B5C5-B476-4E83-8758-0FA9D4ADB808/Library.sqlite-wal -2026-11-03 13:58:40.537565+0000 KJMix Pro[6954:152952] [logging] invalidated open fd: 25 (0x11) -2026-11-03 13:58:40.537569+0000 KJMix Pro[6954:152954] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/1055B5C5-B476-4E83-8758-0FA9D4ADB808/Library.sqlite-shm -2026-11-03 13:58:40.537581+0000 KJMix Pro[6954:152954] [logging] invalidated open fd: 26 (0x11) -2026-11-03 13:58:40.537815+0000 KJMix Pro[6954:152954] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/1055B5C5-B476-4E83-8758-0FA9D4ADB808/Library.sqlite -2026-11-03 13:58:40.537822+0000 KJMix Pro[6954:152954] [logging] invalidated open fd: 24 (0x11) -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:0: warning: -[KJMixProTests.KJamsCloneTests testActualRotationButtonsAddSongReorderAndStart] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testActualRotationButtonsAddSongReorderAndStart]' passed (1.894 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testActualSingerDisclosurePressAndNavigationRetainExpansion]' started. -2026-11-03 13:58:40.643163+0000 KJMix Pro[6954:152806] [Window] Warning: -[NSWindow makeKeyWindow] called on windowNumber=40d which returned NO from -[NSWindow canBecomeKeyWindow]. -2026-11-03 13:58:41.150784+0000 KJMix Pro[6954:152951] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -2026-11-03 13:58:41.150817+0000 KJMix Pro[6954:152951] [Connection] Unable to re-register with Process Instance Registry, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -2026-11-03 13:58:41.853748+0000 KJMix Pro[6954:152950] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/2ED75CE8-3644-4657-BD58-B6123D4FC5A7/Library.sqlite-wal -2026-11-03 13:58:41.853786+0000 KJMix Pro[6954:152950] [logging] invalidated open fd: 25 (0x11) -2026-11-03 13:58:41.853798+0000 KJMix Pro[6954:152950] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/2ED75CE8-3644-4657-BD58-B6123D4FC5A7/Library.sqlite-shm -2026-11-03 13:58:41.853804+0000 KJMix Pro[6954:152950] [logging] invalidated open fd: 26 (0x11) -2026-11-03 13:58:41.853935+0000 KJMix Pro[6954:152950] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/2ED75CE8-3644-4657-BD58-B6123D4FC5A7/Library.sqlite -2026-11-03 13:58:41.853943+0000 KJMix Pro[6954:152950] [logging] invalidated open fd: 24 (0x11) -Test Case '-[KJMixProTests.KJamsCloneTests testActualSingerDisclosurePressAndNavigationRetainExpansion]' passed (1.294 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testAllStoreProfilesAndCatalogueValidation]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testAllStoreProfilesAndCatalogueValidation]' passed (0.028 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticBackgroundHandoffAndManualOverride]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:947: warning: -[KJMixProTests.KJamsCloneTests testAutomaticBackgroundHandoffAndManualOverride] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticBackgroundHandoffAndManualOverride]' passed (1.849 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticVenueQueueAdvancesAndRecordsSinger]' started. -2026-11-03 13:58:44.006557+0000 KJMix Pro[6954:152806] [ClientDonation] (+[PPSClientDonation isRegisteredSubsystem:category:]) Permission denied: CoreMedia / CM-ASSETCREATION -:0: warning: -[KJMixProTests.KJamsCloneTests testAutomaticVenueQueueAdvancesAndRecordsSinger] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticVenueQueueAdvancesAndRecordsSinger]' passed (0.762 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger]' started. -2026-11-03 13:58:45.165928+0000 KJMix Pro[6954:152954] [Connection] Will NOT re-try to establish the connection -2026-11-03 13:58:45.166155+0000 KJMix Pro[6954:152954] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -2026-11-03 13:58:45.166281+0000 KJMix Pro[6954:152954] [Connection] Unable to re-register with Process Instance Registry, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1087: warning: -[KJMixProTests.KJamsCloneTests testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger]' passed (1.706 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundSeekRetainsPlaybackStateAndSingerInterlock]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1065: warning: -[KJMixProTests.KJamsCloneTests testBackgroundSeekRetainsPlaybackStateAndSingerInterlock] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundSeekRetainsPlaybackStateAndSingerInterlock]' passed (0.298 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testBatchMetadataIsAtomicAndPlaylistOrderPersists]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testBatchMetadataIsAtomicAndPlaylistOrderPersists]' passed (0.010 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testBulkTuningCacheSwitchesSingersAndUpdatesWithoutLibraryReload]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testBulkTuningCacheSwitchesSingersAndUpdatesWithoutLibraryReload]' passed (0.035 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testConnectedSingerCreationAndSharedRepertoire]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testConnectedSingerCreationAndSharedRepertoire]' passed (0.067 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testCreateSingerAndVenueThroughAppModel]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testCreateSingerAndVenueThroughAppModel]' passed (0.040 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testDJStopCueHotCuesJogAndLoopKeepTrackLoaded]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:831: warning: -[KJMixProTests.KJamsCloneTests testDJStopCueHotCuesJogAndLoopKeepTrackLoaded] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testDJStopCueHotCuesJogAndLoopKeepTrackLoaded]' passed (0.579 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testEditableSongDefaultsAndSingerOverridesAreIndependent]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testEditableSongDefaultsAndSingerOverridesAreIndependent]' passed (0.070 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testFadePreservesGlobalMasterAndCancelsForNextSong]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:739: warning: -[KJMixProTests.KJamsCloneTests testFadePreservesGlobalMasterAndCancelsForNextSong] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testFadePreservesGlobalMasterAndCancelsForNextSong]' passed (0.752 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testFolderScanReferencesAudioAndPreparesReusableWaveform]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testFolderScanReferencesAudioAndPreparesReusableWaveform]' passed (0.183 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testFrequencyBandsDistinguishBassAndTreble]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testFrequencyBandsDistinguishBassAndTreble]' passed (0.004 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testHTTPParserRejectsSmugglingOversizeAndWaitsForBody]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testHTTPParserRejectsSmugglingOversizeAndWaitsForBody]' passed (0.001 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testLowPassAndHighPassFilterActualAudio]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testLowPassAndHighPassFilterActualAudio]' passed (0.031 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testManualBackgroundStopsBeforeSingerAndNeverAutoResumes]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:854: warning: -[KJMixProTests.KJamsCloneTests testManualBackgroundStopsBeforeSingerAndNeverAutoResumes] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testManualBackgroundStopsBeforeSingerAndNeverAutoResumes]' passed (0.269 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMetadataEditsPersistAndSurviveReimport]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testMetadataEditsPersistAndSurviveReimport]' passed (0.007 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMigrationAndMembershipRemovalDoesNotReappear]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testMigrationAndMembershipRemovalDoesNotReappear]' passed (0.012 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMixerIsGlobalAndPersistsWithoutChangingTrackPreferences]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:646: warning: -[KJMixProTests.KJamsCloneTests testMixerIsGlobalAndPersistsWithoutChangingTrackPreferences] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testMixerIsGlobalAndPersistsWithoutChangingTrackPreferences]' passed (0.191 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMP3FileTagWritingBacksUpPreservesAudioAndRejectsBadTags]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testMP3FileTagWritingBacksUpPreservesAudioAndRejectsBadTags]' passed (0.166 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMP3PlaybackPauseAndSeek]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:85: warning: -[KJMixProTests.KJamsCloneTests testMP3PlaybackPauseAndSeek] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testMP3PlaybackPauseAndSeek]' passed (0.132 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMP4ImportWaveformAndPlaybackThroughGlobalMixer]' started. -2026-11-03 13:58:49.160228+0000 KJMix Pro[6954:152952] [player] <<<< FigFilePlayer >>>> signalled err=-12864 at <>:10696 -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:673: warning: -[KJMixProTests.KJamsCloneTests testMP4ImportWaveformAndPlaybackThroughGlobalMixer] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testMP4ImportWaveformAndPlaybackThroughGlobalMixer]' passed (0.259 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMP4TagExportPreservesVideoAndAudio]' started. -2026-11-03 13:58:49.345692+0000 KJMix Pro[6954:153163] [] (Fig) signalled err=-12785 at <>:1576 -2026-11-03 13:58:49.354600+0000 KJMix Pro[6954:153199] [] <<< FigReportingAgentRef >>> signalled err=-12005 at <>:1605 -2026-11-03 13:58:49.354614+0000 KJMix Pro[6954:153199] [] <<< FigReportingAgentRef >>> signalled err=-12005 at <>:1605 -2026-11-03 13:58:49.354623+0000 KJMix Pro[6954:153199] [] <<< FigPlayerModerator >>> signalled err=-17511 at <>:2110 -2026-11-03 13:58:49.354797+0000 KJMix Pro[6954:153199] [player] <<<< FigAirPlay_Route >>>> signalled err=-12860 at <>:696 -2026-11-03 13:58:49.387135+0000 KJMix Pro[6954:153204] [] <<<< FMDW_UserData >>>> signalled err=-12583 at <>:1072 -2026-11-03 13:58:49.387150+0000 KJMix Pro[6954:153204] [] <<<< FMDW_UserData >>>> signalled err=-12583 at <>:1072 -2026-11-03 13:58:49.387155+0000 KJMix Pro[6954:153204] [] <<<< FMDW_UserData >>>> signalled err=-12583 at <>:1072 -2026-11-03 13:58:49.387585+0000 KJMix Pro[6954:153157] [ClientDonation] (+[PPSClientDonation isRegisteredSubsystem:category:]) Permission denied: CoreMedia / CM-EXPORT -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:819: warning: -[KJMixProTests.KJamsCloneTests testMP4TagExportPreservesVideoAndAudio] : [Internal] Thread running at User-initiated quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testMP4TagExportPreservesVideoAndAudio]' passed (0.110 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testMusicStoreDefinitionsValidateSearchAndPersist]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testMusicStoreDefinitionsValidateSearchAndPersist]' passed (0.006 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testNativeSongDragProviderRoundTrip]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testNativeSongDragProviderRoundTrip]' passed (0.001 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:978: warning: -[KJMixProTests.KJamsCloneTests testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty]' passed (1.564 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testNewSingerSongsImmediatelyJoinSmartRotation]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testNewSingerSongsImmediatelyJoinSmartRotation]' passed (0.047 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testNewTestMediaMetadataAndCDGPairImport]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testNewTestMediaMetadataAndCDGPairImport]' passed (0.027 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testOfflineLocationAndDuplicateSourcePreserveLibrary]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testOfflineLocationAndDuplicateSourcePreserveLibrary]' passed (0.074 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testPairedImportAndContentDedup]' started. -2026-11-03 13:58:51.162028+0000 KJMix Pro[6954:153176] [] <<< FFR_Common >>> signalled err=-12873 at <>:4132 -2026-11-03 13:58:51.162160+0000 KJMix Pro[6954:152806] MP3AudioFile.cpp:1565 Problem scanning for packets -2026-11-03 13:58:51.162170+0000 KJMix Pro[6954:152806] MP3AudioFile.cpp:1067 MPEGAudioFile::OpenFromDataSource failed -2026-11-03 13:58:51.162175+0000 KJMix Pro[6954:152806] AudioFileObject.cpp:106 OpenFromDataSource failed -2026-11-03 13:58:51.162178+0000 KJMix Pro[6954:152806] AudioFileObject.cpp:81 Open failed -2026-11-03 13:58:51.162204+0000 KJMix Pro[6954:152806] [avae] AVAEInternal.h:109 [AVAudioFile.mm:305:AVAudioFileImpl: (ExtAudioFileOpenURL((__bridge CFURLRef)fileURL, &_extAudioFile)): error 1685348671 -2026-11-03 13:58:51.162708+0000 KJMix Pro[6954:153176] [] <<< FFR_Common >>> signalled err=-12873 at <>:4132 -2026-11-03 13:58:51.162828+0000 KJMix Pro[6954:152806] MP3AudioFile.cpp:1565 Problem scanning for packets -2026-11-03 13:58:51.162833+0000 KJMix Pro[6954:152806] MP3AudioFile.cpp:1067 MPEGAudioFile::OpenFromDataSource failed -2026-11-03 13:58:51.162836+0000 KJMix Pro[6954:152806] AudioFileObject.cpp:106 OpenFromDataSource failed -2026-11-03 13:58:51.162838+0000 KJMix Pro[6954:152806] AudioFileObject.cpp:81 Open failed -2026-11-03 13:58:51.162861+0000 KJMix Pro[6954:152806] [avae] AVAEInternal.h:109 [AVAudioFile.mm:305:AVAudioFileImpl: (ExtAudioFileOpenURL((__bridge CFURLRef)fileURL, &_extAudioFile)): error 1685348671 -Test Case '-[KJMixProTests.KJamsCloneTests testPairedImportAndContentDedup]' passed (0.008 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testPaletteBorderScrollAndImage]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testPaletteBorderScrollAndImage]' passed (0.032 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testPauseAndUnloadCancelSingerWaitingForBackgroundFade]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1125: warning: -[KJMixProTests.KJamsCloneTests testPauseAndUnloadCancelSingerWaitingForBackgroundFade] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testPauseAndUnloadCancelSingerWaitingForBackgroundFade]' passed (0.984 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testPlayerReusesSavedWaveformAndCanForceReanalysis]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:451: warning: -[KJMixProTests.KJamsCloneTests testPlayerReusesSavedWaveformAndCanForceReanalysis] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testPlayerReusesSavedWaveformAndCanForceReanalysis]' passed (0.354 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' passed (0.001 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testQueuePlaylistsPreferencesAndDeletion]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testQueuePlaylistsPreferencesAndDeletion]' passed (0.006 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testQueueRemembersSingerSongWithoutDuplicatingAssignment]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testQueueRemembersSingerSongWithoutDuplicatingAssignment]' passed (0.005 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testRapidCDGJogCannotPublishAnOldSeekFrame]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:926: warning: -[KJMixProTests.KJamsCloneTests testRapidCDGJogCannotPublishAnOldSeekFrame] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testRapidCDGJogCannotPublishAnOldSeekFrame]' passed (0.504 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testReorderingPlayedTonightDoesNotQueueItAgain]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testReorderingPlayedTonightDoesNotQueueItAgain]' passed (0.040 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testRotationDragTargetsExplicitVenueAndReordersRoster]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testRotationDragTargetsExplicitVenueAndReordersRoster]' passed (0.029 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testRotationSettingsRosterAndPersonalRatingsPersistPerVenue]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testRotationSettingsRosterAndPersonalRatingsPersistPerVenue]' passed (0.048 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testRotationStartPauseResumeStopAndEmptyExplanation]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:999: warning: -[KJMixProTests.KJamsCloneTests testRotationStartPauseResumeStopAndEmptyExplanation] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testRotationStartPauseResumeStopAndEmptyExplanation]' passed (0.114 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSeekAndTruncatedPacket]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testSeekAndTruncatedPacket]' passed (0.001 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSettingsPersistWithoutAutoplayAndPreserveMaster]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:866: warning: -[KJMixProTests.KJamsCloneTests testSettingsPersistWithoutAutoplayAndPreserveMaster] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testSettingsPersistWithoutAutoplayAndPreserveMaster]' passed (0.533 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testShowScreensPersistRenderPauseSeekAndFinish]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testShowScreensPersistRenderPauseSeekAndFinish]' passed (0.242 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSingerAPIIsolationRevocationAndPersonalSettings]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testSingerAPIIsolationRevocationAndPersonalSettings]' passed (0.049 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSingerWaitsForAudibleBackgroundFadeOut]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1108: warning: -[KJMixProTests.KJamsCloneTests testSingerWaitsForAudibleBackgroundFadeOut] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testSingerWaitsForAudibleBackgroundFadeOut]' passed (1.261 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSingerWebServerRealLoopbackHTTP]' started. -2026-11-03 13:58:55.451159+0000 KJMix Pro[6954:152950] [] nw_path_necp_check_for_updates Failed to copy updated result (22) -Test Case '-[KJMixProTests.KJamsCloneTests testSingerWebServerRealLoopbackHTTP]' passed (0.085 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSmartRotationInterleavesPreservesEmptyAndDepartedCursor]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testSmartRotationInterleavesPreservesEmptyAndDepartedCursor]' passed (0.001 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSongDropCopiesToTargetSingerAndRejectsWrongContext]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testSongDropCopiesToTargetSingerAndRejectsWrongContext]' passed (0.034 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSourceZipUsesManagedMediaAndCancellationKeepsCompletedIndex]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testSourceZipUsesManagedMediaAndCancellationKeepsCompletedIndex]' passed (0.317 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' passed (0.006 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testStandardMacPasteboardCarriesKJMixSongAndRejectsOtherText]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testStandardMacPasteboardCarriesKJMixSongAndRejectsOtherText]' passed (0.002 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testTargetedMultiTermQuotedExactAndNumericSearch]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testTargetedMultiTermQuotedExactAndNumericSearch]' passed (0.001 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testTempoSourceClockPauseAndResume]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:193: warning: -[KJMixProTests.KJamsCloneTests testTempoSourceClockPauseAndResume] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testTempoSourceClockPauseAndResume]' passed (0.986 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testToneEQBoostAndBypassProcessActualAudio]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testToneEQBoostAndBypassProcessActualAudio]' passed (0.024 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testTonightDragReorderPersistsAndKeepsOtherSingerSlots]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testTonightDragReorderPersistsAndKeepsOtherSingerSlots]' passed (0.042 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testTonightMigrationAndVenueIsolation]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testTonightMigrationAndVenueIsolation]' passed (0.006 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testTonightPersistsForEachSingerAcrossSwitchPlaybackAndRestart]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:474: warning: -[KJMixProTests.KJamsCloneTests testTonightPersistsForEachSingerAcrossSwitchPlaybackAndRestart] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testTonightPersistsForEachSingerAcrossSwitchPlaybackAndRestart]' passed (0.111 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testUnifiedRosterDropRoutesSingerAndSongProviders]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testUnifiedRosterDropRoutesSingerAndSongProviders]' passed (0.362 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testVenuesShareSingersAndRememberSelection]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testVenuesShareSingersAndRememberSelection]' passed (0.082 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testVersionThreeMigrationBuildsSingerSongListsFromHistory]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testVersionThreeMigrationBuildsSingerSongListsFromHistory]' passed (0.010 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testVisibleSingerReorderUpdatesManualAndSmartPlansAndPersists]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testVisibleSingerReorderUpdatesManualAndSmartPlansAndPersists]' passed (0.060 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformAnalysisAndStopClearsTrackData]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:263: warning: -[KJMixProTests.KJamsCloneTests testWaveformAnalysisAndStopClearsTrackData] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformAnalysisAndStopClearsTrackData]' passed (0.188 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformBatchCancellationKeepsCompletedCaches]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformBatchCancellationKeepsCompletedCaches]' passed (0.088 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformBatchDeduplicatesReportsMissingMediaAndSkipsSlides]' started. -2026-11-03 13:58:57.844328+0000 KJMix Pro[6954:152806] [avae] AVAEInternal.h:109 [AVAudioFile.mm:305:AVAudioFileImpl: (ExtAudioFileOpenURL((__bridge CFURLRef)fileURL, &_extAudioFile)): error 2003334207 -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformBatchDeduplicatesReportsMissingMediaAndSkipsSlides]' passed (0.063 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformBatchGeneratesReusesAndForcesFreshAnalysis]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformBatchGeneratesReusesAndForcesFreshAnalysis]' passed (0.092 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformCacheRejectsChangedFilesAndCorruption]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformCacheRejectsChangedFilesAndCorruption]' passed (0.074 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformRepresentsSilenceAndSignalInTimeOrder]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformRepresentsSilenceAndSignalInTimeOrder]' passed (0.003 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformSelectionAndWholeLibraryGenerateWithoutChangingPlayback]' started. -/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1164: warning: -[KJMixProTests.KJamsCloneTests testWaveformSelectionAndWholeLibraryGenerateWithoutChangingPlayback] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions -Test Case '-[KJMixProTests.KJamsCloneTests testWaveformSelectionAndWholeLibraryGenerateWithoutChangingPlayback]' passed (0.229 seconds). -Test Case '-[KJMixProTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' started. -Test Case '-[KJMixProTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' passed (0.291 seconds). -Test Suite 'KJamsCloneTests' passed at 2026-11-03 13:58:58.535. - Executed 73 tests, with 0 failures (0 unexpected) in 19.834 (19.853) seconds -Test Suite 'KJMixProTests.xctest' passed at 2026-11-03 13:58:58.535. - Executed 73 tests, with 0 failures (0 unexpected) in 19.834 (19.854) seconds -Test Suite 'All tests' passed at 2026-11-03 13:58:58.536. - Executed 73 tests, with 0 failures (0 unexpected) in 19.834 (19.855) seconds -2026-11-03 13:59:02.643 xcodebuild[6913:152399] [MT] IDETestOperationsObserverDebug: 25.227 elapsed -- Testing started completed. -2026-11-03 13:59:02.643 xcodebuild[6913:152399] [MT] IDETestOperationsObserverDebug: 0.000 sec, +0.000 sec -- start -2026-11-03 13:59:02.643 xcodebuild[6913:152399] [MT] IDETestOperationsObserverDebug: 25.227 sec, +25.227 sec -- end - -Test session results, code coverage, and logs: - /Users/markgregory/Developer/Applications/KJamsClone/build/Logs/Test/Test-KJMix Pro-2026.11.03_13-58-27-+0000.xcresult - -** TEST SUCCEEDED ** - -Testing started