diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index 6c63810..42d69d7 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -245,7 +245,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 39R6U3437D; ENABLE_APP_SANDBOX = NO; ENABLE_TESTABILITY = YES; @@ -255,7 +255,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.6.4; + MARKETING_VERSION = 0.6.6; OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; PRODUCT_MODULE_NAME = KJamsClone; @@ -273,7 +273,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 39R6U3437D; ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; @@ -282,7 +282,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.6.4; + MARKETING_VERSION = 0.6.6; OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; PRODUCT_MODULE_NAME = KJamsClone; diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index 4cd25e7..d2a5fc8 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -270,12 +270,24 @@ struct DJDeckControls: View { } struct StudioSettingsPane: View { + @AppStorage("studioAppearance") private var appearance = "system" @ObservedObject var model: AppModel @State private var port = "" var body: some View { ScrollView { VStack(alignment:.leading,spacing:22) { HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() } + GroupBox("Appearance") { + HStack { + Picker("Appearance",selection:$appearance) { + Text("System").tag("system") + Text("Light").tag("light") + Text("Dark").tag("dark") + }.pickerStyle(.segmented).frame(maxWidth:360) + Spacer() + }.padding(10) + Text("System follows your Mac. The waveform and video output remain dark for clear monitoring.").font(.caption).foregroundStyle(.secondary).padding([.horizontal,.bottom],10) + } GroupBox("Playback & Jog") { VStack(alignment:.leading,spacing:12) { Toggle("Automatically play the next queued singer",isOn:$model.studio.autoAdvance) @@ -478,7 +490,7 @@ struct RotationDashboard: View { } }.padding(12).frame(minWidth:360).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10)) } - }.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark) + }.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor)) .onChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() } .onChange(of:model.rotationPlaybackActive) { active in if active { addingSongs = false } } } @@ -531,7 +543,7 @@ private struct RotationTonightSheet: View { } }.frame(height:330) HStack { Text("Adding a song marks this singer Here and puts it in the rotation.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } - }.padding(24).frame(width:570).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark) + }.padding(24).frame(width:570).background(Color(nsColor:.windowBackgroundColor)) } } diff --git a/KJamsClone/Hosting.swift b/KJamsClone/Hosting.swift index f062a18..bd2c409 100644 --- a/KJamsClone/Hosting.swift +++ b/KJamsClone/Hosting.swift @@ -104,7 +104,7 @@ struct ShowScreensPane: View { List(model.showScreens) { screen in HStack { Image(systemName:"rectangle.on.rectangle").foregroundStyle(.tint);VStack(alignment:.leading) { Text(screen.title);Text("\(Int(screen.seconds)) seconds").font(.caption).foregroundStyle(.secondary) };Spacer();Button("Edit") { edit = screen };Button("Show Now") { model.perform { try model.playScreen(screen) } };Button("Queue") { model.enqueueScreen(screen.id) }.disabled(model.currentVenue == nil) } } - }.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(item:$edit) { screen in ShowScreenEditor(model:model,screen:screen) } + }.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(item:$edit) { screen in ShowScreenEditor(model:model,screen:screen) } } } private struct ShowScreenEditor: View { @@ -120,7 +120,7 @@ private struct ShowScreenEditor: View { Text("Placeholders: {venue}, {singer}, {next}, {song}").font(.caption).foregroundStyle(.secondary) HStack { Picker("Colour",selection:$screen.tint) { Text("Indigo").tag("indigo");Text("Teal").tag("teal");Text("Rose").tag("rose") };Stepper("\(Int(screen.seconds)) seconds",value:$screen.seconds,in:3...300,step:1) } HStack { Button("Cancel") { dismiss() };Spacer();Button("Save") { model.saveScreen(screen);if model.error == nil { dismiss() } }.keyboardShortcut(.defaultAction).disabled(screen.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } - }.padding(24).frame(width:530).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder) + }.padding(24).frame(width:530).background(Color(nsColor:.windowBackgroundColor)).textFieldStyle(.roundedBorder) } } @@ -195,7 +195,7 @@ struct PlaylistWorkspace: View { }.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() } }.listStyle(.inset) Text("Use toolbar search for this playlist. Quoted phrases, artist:, album:, title:, id: and exact = searches are supported. Saved order can be changed with the arrow buttons when search is empty.").font(.caption).foregroundStyle(.secondary) - }.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(isPresented:$adding) { addSheet } + }.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(isPresented:$adding) { addSheet } .confirmationDialog("Delete this playlist? Library songs are kept.",isPresented:$delete) { Button("Delete Playlist",role:.destructive) { if let selected { model.delete("playlists",id:selected);self.selected = nil } } } .onChange(of:sort) { _ in saveSort() }.onChange(of:ascending) { _ in saveSort() } .onChange(of:selected) { id in diff --git a/KJamsClone/Importer.swift b/KJamsClone/Importer.swift index 6c4090a..34fd02a 100644 --- a/KJamsClone/Importer.swift +++ b/KJamsClone/Importer.swift @@ -2,19 +2,73 @@ import Foundation import CryptoKit import AVFoundation +struct ImportProgress: Sendable { + var completed = 0 + var skipped = 0 + var total = 0 + var current = "Preparing import…" + var finished = false + var cancelled = false + var failure: String? +} + struct Importer { let media: URL - @discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false }) throws -> [Int64] { + @discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] { try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) + var state = ImportProgress() + var lastReport = Date.distantPast + func report(_ current: String,force: Bool = false) { + state.current = current + if force || Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() } + } + report("Preparing import…",force:true) + // Index each directory once: a large folder must not be searched for every song. + var siblingCache = [URL:[String:[String:URL]]]() + func companions(of url: URL) throws -> [String:URL] { + let directory = url.deletingLastPathComponent() + if siblingCache[directory] == nil { + let entries = try FileManager.default.contentsOfDirectory(at:directory,includingPropertiesForKeys:nil) + var index = [String:[String:URL]]() + for entry in entries { + if cancelled() { throw CancellationError() } + index[entry.deletingPathExtension().lastPathComponent.lowercased(),default:[:]][entry.pathExtension.lowercased()] = entry + } + siblingCache[directory] = index + } + return siblingCache[directory]?[url.deletingPathExtension().lastPathComponent.lowercased()] ?? [:] + } + defer { state.total = max(state.total,state.completed);progress(state) } var files = urls var temporary = [URL]() defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } var songIDs = Set() + var archives = [(url:URL,signature:String,folder:URL)]() + var archiveForFile = [String:URL]() + var archiveSongIDs = [URL:[Int64]]() + func importSignature(_ audio: URL,cdg: URL? = nil) throws -> String { + func signature(_ url: URL) throws -> String { + let attrs = try FileManager.default.attributesOfItem(atPath:url.path) + return "\(url.standardizedFileURL.path)|\(attrs[.size] ?? 0)|\((attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0)|\((attrs[.creationDate] as? Date)?.timeIntervalSince1970 ?? 0)|\(attrs[.systemFileNumber] ?? 0)" + } + return try signature(audio)+"|"+(cdg.map { try signature($0) } ?? "no-cdg")+"|copy=\(copyMedia)" + } + func remember(_ audio:URL,_ signature:String,_ id:Int64) throws { + if let archive = archiveForFile[audio.resolvingSymlinksInPath().path] { archiveSongIDs[archive,default:[]].append(id) } + else { try library.rememberImport(path:audio.standardizedFileURL.path,signature:signature,songs:[id]) } + } for zip in urls where zip.pathExtension.lowercased() == "zip" { if cancelled() { throw CancellationError() } + let signature = try importSignature(zip) + if let existing = try library.importedSongs(path:zip.standardizedFileURL.path,signature:signature) { + songIDs.formUnion(existing);state.completed += existing.count;state.skipped += existing.count + report("Already imported: "+zip.lastPathComponent,force:state.completed == existing.count);continue + } + report("Extracting "+zip.lastPathComponent,force:archives.isEmpty) let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) temporary.append(folder) + archives.append((zip,signature,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") } @@ -26,25 +80,49 @@ struct Importer { 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) } + if values.isRegularFile == true { files.append(url);archiveForFile[url.resolvingSymlinksInPath().path] = folder } } } for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { - let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil) - if let audio = siblings.first(where: { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) } + if cancelled() { throw CancellationError() } + let matching = try companions(of:cdg) + if let audio = ["mp3","wav","m4a","mp4","m4v","mov"].compactMap({ matching[$0] }).first { files.append(audio) } } var imported = 0 - for audio in Set(files) where ["mp3","wav","m4a","mp4","m4v","mov"].contains(audio.pathExtension.lowercased()) { + let audioFiles = Set(files).filter { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) }.sorted { $0.path < $1.path } + state.total = audioFiles.count+state.completed + report("Importing tracks…",force:true) + for audio in audioFiles { if cancelled() { throw CancellationError() } - let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil) - let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame } + report(audio.lastPathComponent) + let cdg = try companions(of:audio)["cdg"] + let archive = archiveForFile[audio.resolvingSymlinksInPath().path] + let copy = copyMedia || archive != nil + let signature = try importSignature(audio,cdg:cdg) + if let existing = try library.importedSongs(path:audio.standardizedFileURL.path,signature:signature) { + songIDs.formUnion(existing) + if let archive { archiveSongIDs[archive,default:[]].append(contentsOf:existing) } + state.completed += 1;state.skipped += 1 + report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue + } let hash = try audioFingerprint(audio,cancelled:cancelled) - let copy = copyMedia || temporary.contains { audio.resolvingSymlinksInPath().path.hasPrefix($0.resolvingSymlinksInPath().path+"/") } + let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) } + let duplicate = try library.songWithFingerprint(hash) + var sameGraphics = cdg == nil + if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) { + sameGraphics = try audioFingerprint(URL(fileURLWithPath:stored),cancelled:cancelled) == graphicsHash + } + if let existing = duplicate,sameGraphics,FileManager.default.fileExists(atPath:existing.audio), + (!copy || URL(fileURLWithPath:existing.audio).deletingLastPathComponent().standardizedFileURL == media.standardizedFileURL) { + try remember(audio,signature,existing.id);songIDs.insert(existing.id) + state.completed += 1;state.skipped += 1 + report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue + } let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL if copy && !FileManager.default.fileExists(atPath:dest.path) { try FileManager.default.copyItem(at:audio,to:dest) } var graphics: String? if let cdg { - let target = copy ? media.appendingPathComponent(hash+".cdg") : cdg.standardizedFileURL + let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL if copy && !FileManager.default.fileExists(atPath:target.path) { try FileManager.default.copyItem(at:cdg,to:target) } graphics = target.path } @@ -54,8 +132,16 @@ struct Importer { let taggedTitle = tag(.commonKeyTitle) let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy) songIDs.insert(id) + try remember(audio,signature,id) + state.completed += 1 + report(audio.lastPathComponent,force:state.completed == 1) } - guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } + for archive in archives { + let ids = archiveSongIDs[archive.folder] ?? [] + try library.rememberImport(path:archive.url.standardizedFileURL.path,signature:archive.signature,songs:ids) + } + guard imported > 0 || state.skipped > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } + state.finished = true;report("Import complete",force:true) return Array(songIDs) } private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String { diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 571d4a8..451ca63 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -88,7 +88,7 @@ struct SongDragReference: Codable, Sendable { private var waveformJobTask: Task? private var waveformJobToken: UUID? func generateWaveforms(_ ids: Set? = nil,force: Bool = false) { - guard !generatingWaveforms,scanningSource == nil else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return } + guard !generatingWaveforms,scanningSource == nil,!isImporting else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return } let tracks = songs.filter{ids == nil || ids!.contains($0.id)} guard !tracks.isEmpty else { error = "Select a library track first.";return } let token = UUID(),locations = mediaLocations,cache = root.appendingPathComponent("Waveforms") @@ -342,7 +342,7 @@ struct SongDragReference: Codable, Sendable { try refresh();return id } func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) { - guard scanningSource == nil,!generatingWaveforms else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return } + guard scanningSource == nil,!generatingWaveforms,!isImporting else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return } guard let source = mediaLocations.first(where:{ $0.id == id }) else { return } let token = UUID();scanToken = token;scanningSource = id;scanProgress = SourceScanProgress(source:id) let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms") @@ -370,7 +370,42 @@ struct SongDragReference: Codable, Sendable { guard scanningSource == nil else { error = "Finish or cancel the scan before removing a location";return } perform { try library?.removeMediaLocation(id);try refresh() } } - func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } + @Published private(set) var importProgress: ImportProgress? + @Published private(set) var isImporting = false + private var importTask: Task? + private var importToken: UUID? + func importFiles(_ urls: [URL]) { + guard !isImporting,scanningSource == nil,!generatingWaveforms else { error = "Wait for the current import, scan or waveform job to finish first.";return } + guard !urls.isEmpty else { return } + let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media") + importToken = token;isImporting = true;importProgress = ImportProgress() + importTask = Task.detached(priority:.utility) { [weak self] in + let accessed = urls.filter { $0.startAccessingSecurityScopedResource() } + defer { for url in accessed { url.stopAccessingSecurityScopedResource() } } + var latest = ImportProgress() + do { + let database = try Library(url:database) + try Importer(media:media).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in + latest = update + Task { @MainActor [weak self] in + guard let self,self.importToken == token else { return };self.importProgress = update + } + }) + } catch is CancellationError { latest.cancelled = true;latest.current = "Import cancelled" } + catch { latest.failure = error.localizedDescription;latest.current = "Import stopped" } + latest.finished = true + let result = latest + await MainActor.run { [weak self] in + guard let self,self.importToken == token else { return } + self.importToken = nil;self.importTask = nil;self.isImporting = false + self.perform { try self.refresh() } + self.importProgress = result + } + } + } + func cancelImport() { importTask?.cancel();importProgress?.current = "Cancelling after the current file…" } + func dismissImport() { if !isImporting { importProgress = nil } } + } private struct LibraryCommands { var importMusic: () -> Void @@ -390,10 +425,12 @@ private extension FocusedValues { } @main struct KJMixProApp: App { @StateObject private var model = AppModel() + @AppStorage("studioAppearance") private var appearance = "system" @FocusedValue(\.libraryCommands) private var actions var body: some Scene { Window("KJMix Pro", id: "main") { - LibraryView(model: model).frame(minWidth: 1060, minHeight: 760).preferredColorScheme(.dark) + LibraryView(model: model).frame(minWidth: 1060, minHeight: 760) + .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) }.defaultSize(width: 1320, height: 900).windowToolbarStyle(.unified) .commands { CommandGroup(replacing: .newItem) { @@ -433,6 +470,35 @@ private extension FocusedValues { }.defaultSize(width: 600, height: 400) } } +private struct ImportProgressDialog: View { + @ObservedObject var model: AppModel + var body: some View { + ZStack { + Color.black.opacity(0.16).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { } + if let progress = model.importProgress { + VStack(alignment:.leading,spacing:16) { + HStack(spacing:12) { + Image(systemName:progress.finished ? (progress.failure == nil ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") : "square.and.arrow.down") + .font(.system(size:28)).foregroundStyle(Color.accentColor) + VStack(alignment:.leading,spacing:3) { + Text(progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Stopped") : "Importing Music").font(.headline) + Text(progress.total > 0 ? "\(progress.completed) of \(progress.total) tracks checked" : "Preparing files and archives…").font(.subheadline).foregroundStyle(.secondary) + } + } + if progress.total > 0 { ProgressView(value:Double(progress.completed),total:Double(progress.total)) } + else if !progress.finished { ProgressView().controlSize(.small) } + if progress.completed > 0 { Text("\(progress.completed-progress.skipped) imported · \(progress.skipped) already in library").font(.callout.weight(.medium)) } + Text(progress.failure ?? progress.current).font(.callout).foregroundStyle(.secondary).lineLimit(3).frame(maxWidth:.infinity,alignment:.leading) + if progress.cancelled || progress.failure != nil { Text("Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) } + HStack { Spacer();if progress.finished { Button("Done") { model.dismissImport() }.keyboardShortcut(.defaultAction) } else { Button("Cancel Import") { model.cancelImport() }.keyboardShortcut(.cancelAction) } } + }.padding(24).frame(width:380).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14)) + .overlay(RoundedRectangle(cornerRadius:14).strokeBorder(.primary.opacity(0.08))) + .shadow(color:.black.opacity(0.2),radius:24,y:8) + .accessibilityElement(children:.contain).accessibilityLabel("Music import progress") + } + } + } +} private enum StudioTheme { static let frame = Color(nsColor:.windowBackgroundColor) static let navy = Color(nsColor:.textBackgroundColor) @@ -443,8 +509,8 @@ private enum StudioTheme { private struct StudioSidebarLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack(spacing:9) { - configuration.icon.foregroundStyle(Color(white:0.66)).frame(width:18) - configuration.title.foregroundStyle(Color(white:0.88)) + configuration.icon.foregroundStyle(.secondary).frame(width:18) + configuration.title.foregroundStyle(.primary) } } } @@ -466,7 +532,7 @@ private struct SidebarVideoMonitor: View { 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)) + .font(.caption.weight(.medium)).foregroundStyle(.secondary) LyricsView(player: player) .frame(height:140) .clipped() @@ -576,7 +642,8 @@ struct LibraryView: View { .background(StudioTheme.frame) .tint(.accentColor) .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"})) - .navigationTitle("KJMix Pro") + .navigationTitle(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source) + .navigationSubtitle(model.currentVenueName) .searchable(text:$search,placement:.toolbar,prompt:"Search Music") .toolbar { ToolbarItemGroup { @@ -589,12 +656,14 @@ struct LibraryView: View { } label: { Label("Add",systemImage:"plus") } Button { editingSong = selectedSong } label: { Label("Song Info",systemImage:"info.circle") }.disabled(selectedSong == nil) Button { openWindow(id:"lyrics") } label: { Label("Video Output",systemImage:"rectangle.on.rectangle") } - Button { source = "Settings" } label: { Label("Settings",systemImage:"gearshape") } + } } } var body: some View { workspace + .disabled(model.importProgress != nil) + .overlay { if model.importProgress != nil { ImportProgressDialog(model:model) } } .onAppear { updateLibraryRows() } .onChange(of:model.songs) { _ in updateLibraryRows() } .onChange(of:model.tuningCache) { _ in updateLibraryRows() } @@ -609,7 +678,7 @@ struct LibraryView: View { Text("Waveform Analysis").font(.title2.bold()) ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300) HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) } - }.padding(24).frame(width:560).preferredColorScheme(.dark) + }.padding(24).frame(width:560) } .sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) } .sheet(item:$metadataLookup) { song in MetadataLookupSheet(model:model,song:song) } @@ -676,8 +745,8 @@ struct LibraryView: View { Image("BrandMark").resizable().scaledToFit().frame(width:32,height:32) VStack(alignment:.leading,spacing:2) { Text("KJMix Pro").font(.headline);Text("Karaoke Studio").font(.caption).foregroundStyle(.secondary) };Spacer() }.padding(16) - ScrollView { - LazyVStack(alignment:.leading,spacing:3) { + List { + Group { sidebarHeading("Workspace") navigationRow("Library",symbol:"music.note.list",count:model.songs.count) navigationRow("Rotation",symbol:"person.3.sequence",count:model.rotationPlan.count) @@ -714,8 +783,8 @@ struct LibraryView: View { navigationRow("Singer Web",symbol:"network") navigationRow("Music Stores",symbol:"bag") navigationRow("Settings",symbol:"gearshape") - }.font(.system(size:13)).padding(.horizontal,10).padding(.bottom,12) - } + }.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8)) + }.listStyle(.sidebar) Divider().padding(.horizontal,14) SidebarVideoMonitor(player:model.player).padding(14) }.background(.ultraThinMaterial) @@ -754,7 +823,7 @@ struct LibraryView: View { 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()) + }.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle()) .onDrag { model.dragReference(song.id,singer:singer.id,tonight:true).provider() } .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:song.id,enabled:true)) } @@ -774,7 +843,7 @@ struct LibraryView: View { 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 } + }.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id } .onDrag { model.dragReference(song.id).provider() } } if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) } @@ -892,7 +961,7 @@ struct LibraryView: View { TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }.padding(12) - }.foregroundStyle(.white).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + }.foregroundStyle(.primary).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) .sheet(isPresented:$managingRoster) { rosterSheet } .sheet(item:$editingEntity) { entity in VStack(alignment:.leading,spacing:16) { @@ -1139,7 +1208,7 @@ private struct SongMetadataSheet: View { Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio) if let error { Text(error).foregroundStyle(.red).font(.callout) } HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } - }.padding(24).frame(width:500).background(StudioTheme.frame).preferredColorScheme(.dark) + }.padding(24).frame(width:500).background(StudioTheme.frame) .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 } } @@ -1170,7 +1239,7 @@ private struct LibraryLocationsPane: View { } if !location.lastScan.isEmpty { Text("Last scan: \(location.lastScan)").font(.caption).foregroundStyle(.secondary) } if !location.lastError.isEmpty { Text(location.lastError).font(.caption).foregroundStyle(.orange) } - }.padding(12).background(selected == location.id ? Color.blue.opacity(0.13) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = location.id } + }.padding(12).background(selected == location.id ? Color.blue.opacity(0.13) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = location.id } } if model.mediaLocations.isEmpty { Text("No locations added. Choose Add Drive / Folder… to index an external drive or music folder.").foregroundStyle(.secondary).padding(.vertical,25) } } @@ -1213,7 +1282,7 @@ private struct MusicStoresPane: View { 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 } + }.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id } } if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) } } @@ -1246,7 +1315,7 @@ private struct MusicStoreEditor: View { Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary) if let error { Text(error).foregroundStyle(.red).font(.callout) } HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) } - }.padding(24).frame(width:500).background(StudioTheme.frame).preferredColorScheme(.dark) + }.padding(24).frame(width:500).background(StudioTheme.frame) .onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" } } private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } } @@ -1279,7 +1348,9 @@ private struct SingerSongsSheet: View { }.frame(maxWidth:.infinity,alignment:.leading) }.frame(height:270) HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } - }.padding(24).frame(width:520).background(StudioTheme.frame).preferredColorScheme(.dark) + }.padding(24).frame(width:520).background(StudioTheme.frame) + .disabled(model.importProgress != nil) + .overlay { if model.importProgress != nil { ImportProgressDialog(model:model) } } .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.movie,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription } } @@ -1319,7 +1390,7 @@ private struct AddEntitySheet: View { Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction) .disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil)) } - }.padding(24).frame(width:kind == .singer ? 500 : 360).background(StudioTheme.frame).preferredColorScheme(.dark) + }.padding(24).frame(width:kind == .singer ? 500 : 360).background(StudioTheme.frame) .onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs } } private func save() { @@ -1416,9 +1487,6 @@ private struct NowPlayingWaveform: View { let path = Path(CGRect(x:x,y:mid-height/2,width:max(0.7,step-0.7),height:height)) context.fill(path,with:.color(bandColor(start).opacity(x <= size.width*progress ? 1 : 0.88))) } - let x = size.width*progress - var head = Path();head.move(to:CGPoint(x:x,y:top));head.addLine(to:CGPoint(x:x,y:size.height)) - context.stroke(head,with:.color(.red),lineWidth:1) if let hoverX { let hx = min(size.width,max(0,hoverX)) var hover = Path();hover.move(to:CGPoint(x:hx,y:top));hover.addLine(to:CGPoint(x:hx,y:size.height)) @@ -1429,6 +1497,19 @@ private struct NowPlayingWaveform: View { } else if player.waveformLoading { context.draw(Text("Analysing waveform…").font(.system(size:9)).foregroundColor(.gray),at:CGPoint(x:size.width/2,y:mid)) } + if player.duration > 0 { + let x = min(size.width-2,max(2,size.width*progress)) + context.fill(Path(CGRect(x:0,y:top,width:x,height:plotHeight)),with:.color(.black.opacity(0.18))) + var head = Path();head.move(to:CGPoint(x:x,y:top));head.addLine(to:CGPoint(x:x,y:size.height)) + context.stroke(head,with:.color(.black.opacity(0.9)),lineWidth:6) + context.stroke(head,with:.color(.cyan.opacity(0.3)),lineWidth:5) + context.stroke(head,with:.color(.white),lineWidth:2) + var pointer = Path();pointer.move(to:CGPoint(x:x-5,y:top));pointer.addLine(to:CGPoint(x:x+5,y:top));pointer.addLine(to:CGPoint(x:x,y:top+7));pointer.closeSubpath() + context.fill(pointer,with:.color(.cyan)) + let labelX = min(size.width-30,max(30,x)) + context.fill(Path(roundedRect:CGRect(x:labelX-29,y:0,width:58,height:13),cornerRadius:3),with:.color(.cyan)) + context.draw(Text(preciseClock(player.time)).font(.system(size:9,weight:.bold,design:.monospaced)).foregroundColor(.black),at:CGPoint(x:labelX,y:6)) + } }.contentShape(Rectangle()).background(WaveformJogWheel(player:player)) .onContinuousHover { phase in switch phase { case .active(let point):hoverX = point.x;case .ended:hoverX = nil } } .gesture(DragGesture(minimumDistance:0).onChanged { value in @@ -1473,8 +1554,10 @@ private struct NowPlayingWaveform: View { Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5) }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6)) DJDeckControls(player:player) - }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:174) - .background(Color.black.opacity(0.22),in:RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).strokeBorder(.white.opacity(0.07),lineWidth:1)) + }.padding(.horizontal,14).padding(.vertical,10).frame(maxWidth:.infinity).frame(height:174) + .background(Color(.sRGB,red:0.055,green:0.065,blue:0.08,opacity:1),in:RoundedRectangle(cornerRadius:10)) + .overlay(RoundedRectangle(cornerRadius:10).strokeBorder(.white.opacity(0.1),lineWidth:1)) + .environment(\.colorScheme,.dark) } } private struct ToneKnob: View { @@ -1598,7 +1681,7 @@ private struct SeekBar: View { 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)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.secondary) } } private struct RepeatButton: View { diff --git a/KJamsClone/Library.swift b/KJamsClone/Library.swift index 8ff9eb7..b19e5a1 100644 --- a/KJamsClone/Library.swift +++ b/KJamsClone/Library.swift @@ -120,7 +120,8 @@ final class Library { try execute("CREATE TABLE IF NOT EXISTS singer_rotation(venue INTEGER NOT NULL,singer INTEGER NOT NULL,position INTEGER NOT NULL,here INTEGER NOT NULL DEFAULT 1,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") try execute("CREATE TABLE IF NOT EXISTS show_screens(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,body TEXT NOT NULL,footer TEXT NOT NULL,tint TEXT NOT NULL)") try execute("CREATE TABLE IF NOT EXISTS singer_access(venue INTEGER NOT NULL,singer INTEGER NOT NULL,token TEXT NOT NULL UNIQUE,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") - try execute("PRAGMA user_version=9") + try execute("CREATE TABLE IF NOT EXISTS import_history(path TEXT PRIMARY KEY,signature TEXT NOT NULL,songs TEXT NOT NULL,imported_at TEXT NOT NULL)") + try execute("PRAGMA user_version=10") } deinit { sqlite3_close(db) } private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { @@ -536,3 +537,24 @@ extension Library { do { for (i,id) in ids.enumerated() { try execute("UPDATE singer_rotation SET position=? WHERE venue=? AND singer=?",[String(i),String(venue),String(id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error } } } + +extension Library { + func importedSongs(path: String,signature: String) throws -> [Int64]? { + let query = try statement("SELECT songs FROM import_history WHERE path=? AND signature=?",[path,signature]);defer { sqlite3_finalize(query) } + guard sqlite3_step(query) == SQLITE_ROW,let data = text(query,0).data(using:.utf8),let ids = try? JSONDecoder().decode([Int64].self,from:data),!ids.isEmpty else { return nil } + for id in ids { + guard let track = try song(id),FileManager.default.fileExists(atPath:track.audio) else { return nil } + if let cdg = track.cdg,!FileManager.default.fileExists(atPath:cdg) { return nil } + } + return ids + } + func rememberImport(path: String,signature: String,songs: [Int64]) throws { + guard !songs.isEmpty else { return } + let json = String(decoding:try JSONEncoder().encode(Array(Set(songs)).sorted()),as:UTF8.self) + try execute("INSERT INTO import_history(path,signature,songs,imported_at) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET signature=excluded.signature,songs=excluded.songs,imported_at=excluded.imported_at",[path,signature,json,ISO8601DateFormatter().string(from:Date())]) + } + func songWithFingerprint(_ fingerprint: String) throws -> Song? { + let query = try statement("SELECT id FROM songs WHERE fingerprint=? ORDER BY id LIMIT 1",[fingerprint]);defer { sqlite3_finalize(query) } + return sqlite3_step(query) == SQLITE_ROW ? try song(sqlite3_column_int64(query,0)) : nil + } +} diff --git a/KJamsClone/Metadata.swift b/KJamsClone/Metadata.swift index 9d60a5d..51432df 100644 --- a/KJamsClone/Metadata.swift +++ b/KJamsClone/Metadata.swift @@ -173,7 +173,7 @@ struct BatchMetadataSheet: View { }.padding(12).frame(maxWidth:.infinity,alignment:.leading).background(Color.primary.opacity(0.05),in:RoundedRectangle(cornerRadius:8)) if let error { Text(error).foregroundStyle(.red) } HStack { Button("Cancel") { dismiss() };Spacer();Button("Apply to \(ids.count) Songs") { do { try model.batchMetadata(ids,patch:effective);dismiss() } catch { self.error = error.localizedDescription } }.keyboardShortcut(.defaultAction).disabled(fields.isEmpty && find.isEmpty) } - }.padding(24).frame(width:590).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder) + }.padding(24).frame(width:590).background(Color(nsColor:.windowBackgroundColor)).textFieldStyle(.roundedBorder) } private func binding(_ key: String) -> Binding { Binding(get:{fields.contains(key)},set:{if $0 { fields.insert(key) } else { fields.remove(key) }}) } private func fieldRow(_ name: String,key: String,text: Binding) -> some View { HStack { Toggle(name,isOn:binding(key)).frame(width:95,alignment:.leading);TextField(name,text:text).disabled(!fields.contains(key)) } } @@ -201,7 +201,7 @@ struct MetadataLookupSheet: View { }.padding(.vertical,5) }.frame(height:300) HStack { Text("Suggestions update library metadata only.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } } - }.padding(24).frame(width:650).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { query = song.title+" "+song.artist } + }.padding(24).frame(width:650).background(Color(nsColor:.windowBackgroundColor)).onAppear { query = song.title+" "+song.artist } } private func search() { guard !busy else { return };busy = true;error = nil;Task { do { candidates = try await OnlineMetadata.shared.search(query) } catch { self.error = error.localizedDescription };busy = false } } } @@ -251,7 +251,7 @@ struct StoreCatalogueSheet: View { if item.previewURL != nil { Button("Preview Website") { open(item,preview:true) } };if item.url != nil { Button("View at Store") { open(item) } } } }.frame(height:310) HStack { Text(message.isEmpty ? "\(items.count) catalogue entries saved locally" : message).font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } } - }.padding(24).frame(width:760).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } } + }.padding(24).frame(width:760).background(Color(nsColor:.windowBackgroundColor)).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } } } private func importCatalogue() { let panel = NSOpenPanel();panel.allowedContentTypes = [.json];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false diff --git a/KJamsClone/SingerServer.swift b/KJamsClone/SingerServer.swift index 95c3be9..74f82e2 100644 --- a/KJamsClone/SingerServer.swift +++ b/KJamsClone/SingerServer.swift @@ -206,6 +206,6 @@ struct SingerServerPane: View { }.padding(.vertical,4) } if let invitation { Text(invitation).font(.callout).foregroundStyle(.secondary) } - }.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onChange(of:server.running) { running in if running { address = server.localURL } } + }.padding(22).background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onChange(of:server.running) { running in if running { address = server.localURL } } } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 5a25382..787e059 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -6,6 +6,118 @@ import UniformTypeIdentifiers @testable import KJamsClone final class KJamsCloneTests: XCTestCase { + @MainActor func testLargeImportReturnsImmediatelyAndReportsCompletion() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let inputs = root.appendingPathComponent("Inputs");try FileManager.default.createDirectory(at:inputs,withIntermediateDirectories:true) + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + var urls = [URL]() + for index in 0..<48 { let url = inputs.appendingPathComponent("Track \(index).mp3");try FileManager.default.copyItem(at:fixture,to:url);urls.append(url) } + let model = AppModel(storageURL:root.appendingPathComponent("Library")) + model.importFiles(urls) + XCTAssertTrue(model.isImporting,"Import must return to the UI before processing the files") + XCTAssertTrue(model.songs.isEmpty) + var heartbeat = false + Task { @MainActor in heartbeat = true } + for _ in 0..<500 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertTrue(heartbeat);XCTAssertFalse(model.isImporting) + XCTAssertEqual(model.importProgress?.completed,48);XCTAssertEqual(model.importProgress?.total,48) + XCTAssertEqual(model.importProgress?.finished,true);XCTAssertNil(model.importProgress?.failure) + XCTAssertEqual(model.songs.count,1,"Identical audio still deduplicates") + model.dismissImport();XCTAssertNil(model.importProgress) + } + func testImportCancellationReportsSavedPartialWork() 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("Library.sqlite")) + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + var cancel = false,latest = ImportProgress() + XCTAssertThrowsError(try Importer(media:root.appendingPathComponent("Media")).run([fixtures.appendingPathComponent("Demo.mp3"),fixtures.appendingPathComponent("TestTrack.mp3")],library:db,cancelled:{cancel},progress:{ latest = $0;if $0.completed == 1 { cancel = true } })) { XCTAssertTrue($0 is CancellationError) } + XCTAssertEqual(latest.completed,1);XCTAssertEqual(latest.total,2);XCTAssertEqual(try db.songs().count,1) + } + @MainActor func testImportFailureAndCancellationFinishTheDialog() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + model.importFiles([root.appendingPathComponent("missing.mp3")]) + for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertFalse(model.isImporting);XCTAssertEqual(model.importProgress?.finished,true);XCTAssertNotNil(model.importProgress?.failure) + model.dismissImport() + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + model.importFiles([fixture]);model.cancelImport() + for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertFalse(model.isImporting);XCTAssertEqual(model.importProgress?.cancelled,true) + model.dismissImport();XCTAssertNil(model.importProgress) + } + func testImportHistoryPersistsAndSkipsUnchangedTracksAndRenamedDuplicates() 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 fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let source = root.appendingPathComponent("Original.mp3");try FileManager.default.copyItem(at:fixture,to:source) + let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),importer = Importer(media:media) + let db = try Library(url:database) + let first = try importer.run([source],library:db) + let id = try XCTUnwrap(first.first);try db.rate(id,5) + let stored = try XCTUnwrap(db.song(id));let modified = try FileManager.default.attributesOfItem(atPath:stored.audio)[.modificationDate] as? Date + let reopened = try Library(url:database);var progress = ImportProgress() + XCTAssertEqual(try importer.run([source],library:reopened,progress:{progress = $0}),first) + XCTAssertEqual(progress.completed,1);XCTAssertEqual(progress.skipped,1) + let renamed = root.appendingPathComponent("Renamed.mp3");try FileManager.default.copyItem(at:source,to:renamed) + XCTAssertEqual(try importer.run([renamed],library:reopened,progress:{progress = $0}),first) + XCTAssertEqual(progress.skipped,1);XCTAssertEqual(try reopened.songs().count,1) + XCTAssertEqual(try reopened.song(id)?.rating,5) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath:stored.audio)[.modificationDate] as? Date,modified) + try FileManager.default.removeItem(at:URL(fileURLWithPath:stored.audio)) + _ = try importer.run([source],library:reopened,progress:{progress = $0}) + XCTAssertEqual(progress.skipped,0);XCTAssertTrue(FileManager.default.fileExists(atPath:stored.audio)) + } + func testUnchangedArchiveSkipsExtractionAndMissingGraphicsCanBeRestored() 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 fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip")) + let importer = Importer(media:root.appendingPathComponent("Media")),db = try Library(url:root.appendingPathComponent("Library.sqlite")) + let ids = try importer.run([fixture],library:db);var updates = [ImportProgress]() + XCTAssertEqual(try importer.run([fixture],library:db,progress:{updates.append($0)}),ids) + XCTAssertFalse(updates.contains{$0.current.hasPrefix("Extracting ")});XCTAssertEqual(updates.last?.skipped,1) + let cdg = try XCTUnwrap(try db.songs().first?.cdg);try FileManager.default.removeItem(atPath:cdg) + updates = [];_ = try importer.run([fixture],library:db,progress:{updates.append($0)}) + XCTAssertTrue(updates.contains{$0.current.hasPrefix("Extracting ")});XCTAssertEqual(updates.last?.skipped,0) + XCTAssertTrue(FileManager.default.fileExists(atPath:try XCTUnwrap(try db.songs().first?.cdg))) + } + func testChangedAudioAndGraphicsAreRechecked() 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 fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let audio = root.appendingPathComponent("Track.mp3"),cdg = root.appendingPathComponent("Track.cdg") + try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:audio) + try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:cdg) + let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media")) + let ids = try importer.run([audio],library:db) + let originalGraphics = try XCTUnwrap(try db.song(ids[0])?.cdg) + let changedGraphics = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg"));try changedGraphics.write(to:cdg) + var progress = ImportProgress() + XCTAssertEqual(try importer.run([audio],library:db,progress:{progress = $0}),ids);XCTAssertEqual(progress.skipped,0) + let newGraphics = try XCTUnwrap(try db.song(ids[0])?.cdg) + XCTAssertNotEqual(originalGraphics,newGraphics);XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:newGraphics)),changedGraphics) + try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.mp3")).write(to:audio) + _ = try importer.run([audio],library:db,progress:{progress = $0});XCTAssertEqual(progress.skipped,0);XCTAssertEqual(try db.songs().count,2) + } + func testArchiveDeduplicationStillCreatesManagedMediaForReferencedAudio() 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 fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:root.appendingPathComponent("Library.sqlite")),media = root.appendingPathComponent("Media"),importer = Importer(media:media) + let ids = try importer.run([fixtures.appendingPathComponent("Demo.mp3")],library:db,copyMedia:false) + _ = try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db,copyMedia:false) + XCTAssertEqual(try db.songs().count,1) + let song = try XCTUnwrap(try db.song(ids[0]));XCTAssertEqual(URL(fileURLWithPath:song.audio).deletingLastPathComponent().standardizedFileURL,media.standardizedFileURL) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertNotNil(song.cdg) + } 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() @@ -219,6 +331,8 @@ final class KJamsCloneTests: XCTestCase { _ = try model.createEntity(.singer,name:"Alex") let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) + for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertFalse(model.isImporting);XCTAssertNil(model.importProgress?.failure) 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) diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 88ac5dc..a81280a 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -1,3 +1,32 @@ +# KJMix Pro 0.6.6 (build 17) — responsive imports and import history + +Verified on 4 November 2026. + +- File copying, fingerprints, archive extraction and metadata reading now run on a utility worker with its own SQLite connection. Imports show a progress dialog with current file, completed count, imported/skipped totals, cancellation and completion/error feedback. Completed tracks survive cancellation or errors. +- Each source directory is indexed once per batch for companion CDG matching. Archive-to-track history mapping uses direct lookup; progress updates are throttled. +- SQLite schema 10 adds persistent import history. Unchanged sources and archives are skipped on later imports. Audio fingerprints recognise renamed/copied tracks, preserving existing metadata and singer preferences. Changed audio/graphics are rechecked; missing managed copies are restored. Archive history is recorded only after successful processing; partial archives are retried safely. +- Existing libraries get audio-fingerprint deduplication on their next import; source-history shortcuts are populated as files are encountered. ZIP shortcuts start after the first successful import using this version. +- The waveform playhead now has a bright white line with a dark outline/cyan glow, cyan pointer and moving precise-time label, plus shading over elapsed audio. The marker remains visible during waveform analysis. +- 80 XCTest cases passed, zero failures (`/tmp/kjmix-import-final-80-tests.log`). New cases cover async import return/UI scheduling, progress, cancellation/partial saves, errors, history persistence, unchanged ZIP extraction bypass, renamed audio, changed audio/CDG, missing media recovery and managed archive copies. Existing playback, singer and rotation tests also passed. +- Import dialog and playing waveform rendered with isolated original test media at 1320 × 900. No personal media used in previews or distributions. +- The release script records signing, Apple notarization, stapling and Gatekeeper results under `build-release/release-verification/KJMixPro-0.6.6-macOS/`. Only the current DMG is retained in `dist`. + +--- + +# KJMix Pro 0.6.5 (build 16) — native macOS appearance + +Verified on 3 November 2026. + +- Native SwiftUI sidebar list, contextual window title and venue subtitle, simplified toolbar. +- System appearance by default, with persisted System / Light / Dark selection in Settings. Editors and workspaces inherit the choice; waveform, video and tone mixer use dedicated dark monitoring surfaces. +- 73 XCTest cases passed with zero failures, including actual singer disclosure/navigation and rotation Add to Tonight, reorder and Start clicks. Log: `/tmp/kjmix-native-ui-tests-final.log`. +- Light-mode library, rotation and settings rendered using isolated original demo data; dark-mode rotation inspected from the native UI test capture. +- Final contrast-only change to playback-position labels compiled in the universal Release build. +- Distribution contains only the current DMG. Signing, notarization, stapling and Gatekeeper results are recorded by the release script under `build-release/release-verification/KJMixPro-0.6.5-macOS/`. +- This is a native macOS styling update, not a claim of Apple certification or an exhaustive accessibility audit. + +--- + # KJMix Pro 0.6.4 (build 15) verification Verified on 3 November 2026.