importer chnges
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+96
-10
@@ -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<Int64>()
|
||||
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 {
|
||||
|
||||
+112
-29
@@ -88,7 +88,7 @@ struct SongDragReference: Codable, Sendable {
|
||||
private var waveformJobTask: Task<Void,Never>?
|
||||
private var waveformJobToken: UUID?
|
||||
func generateWaveforms(_ ids: Set<Int64>? = 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<Void,Never>?
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Bool> { Binding(get:{fields.contains(key)},set:{if $0 { fields.insert(key) } else { fields.remove(key) }}) }
|
||||
private func fieldRow(_ name: String,key: String,text: Binding<String>) -> 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
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user