importer chnges

This commit is contained in:
Mark Gregory
2026-11-04 16:10:32 +00:00
parent c35dd4b20e
commit 1f397685dd
10 changed files with 399 additions and 53 deletions
+112 -29
View File
@@ -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 {