removed unwanted bar now with waveforms, major updates

This commit is contained in:
Mark Gregory
2026-11-06 13:39:57 +00:00
parent e67acb8210
commit 2dc3c96ef3
6 changed files with 307 additions and 27 deletions
+1
View File
@@ -40,6 +40,7 @@ import UniformTypeIdentifiers
}
func remove(_ id: UUID) { entries.removeAll{$0.id == id};save() }
func clear() { entries = [];save() }
func removeSongs(_ ids: Set<Int64>) { entries.removeAll{ids.contains($0.song)};save() }
func shuffle() { entries.shuffle();save() }
func move(_ id: UUID,by offset: Int) {
guard let index = entries.firstIndex(where:{$0.id == id}) else { return }
+126 -17
View File
@@ -172,6 +172,64 @@ struct DJAudioDevice: Identifiable, Equatable {
}
@MainActor final class DJSystem: ObservableObject {
@Published private(set) var storageInSettings = true
@Published var cleanupPlan: DJCleanupPlan?
@Published private(set) var storageAction: String?
private func beginMaintenance(_ title: String,work: @escaping @Sendable (@escaping () -> Bool,@escaping (ImportProgress) -> Void) throws -> ImportProgress,completion: (() -> Void)? = nil) {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
storageAction = title;movingStorage = true;storageProgress = .init(current:title)
storageTask = Task.detached(priority:.utility) { [weak self] in
var latest = ImportProgress(current:title)
do { latest = try work({Task.isCancelled},{ value in latest = value;Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value } }) }
catch is CancellationError { latest.cancelled = true;latest.current = "Cancelled; incomplete work stopped" }
catch { latest.failure = error.localizedDescription;latest.current = "Library maintenance needs attention" }
latest.finished = true;let result = latest
await MainActor.run { [weak self] in
guard let self else { return };self.movingStorage = false;self.storageTask = nil
if !result.cancelled,result.failure == nil { completion?() }
self.storageProgress = result
}
}
}
func removeLibraryTracks(_ ids: Set<Int64>,fromSettings: Bool = false) {
guard !ids.isEmpty else { return }
guard ![a.player,b.player,preview].contains(where:{$0.playing && ($0.currentSongID.map{ids.contains($0)} ?? false)}) else { error = "Stop the affected deck before removing its track.";return }
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
storageInSettings = fromSettings
automix.stop("Library update")
for deck in [a,b] where deck.song.map({ids.contains($0.id)}) == true { deck.unload() }
if preview.currentSongID.map({ids.contains($0)}) == true { stopPreview() }
let database = root.appendingPathComponent("Library.sqlite"),removingAll = !songs.isEmpty && Set(songs.map(\.id)).isSubset(of:ids)
beginMaintenance("Removing Library Tracks",work:{ cancelled,_ in
try DJLibraryMaintenance.removeTracks(ids,database:database,cancelled:cancelled)
return ImportProgress(completed:ids.count,total:ids.count,current:"Removed \(ids.count) library tracks. Media files were kept.",finished:true,logLines:["[REMOVED] \(ids.count) library entries. Original and managed media files were kept."])
},completion:{ [weak self] in
guard let self else { return }
if removingAll { self.automix.clear();self.favourites.removeAll() }
else { self.automix.removeSongs(ids);self.favourites.subtract(ids) }
self.perform { try self.library?.setSetting("DJ.Favourites.v1",self.favourites.sorted().map(String.init).joined(separator:","));try self.refresh() }
})
}
func findUnusedFiles() {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
guard !a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback before cleaning DJ storage.";return }
let database = root.appendingPathComponent("Library.sqlite"),folder = root
storageInSettings = true;storageAction = "Finding Unused Files";movingStorage = true;storageProgress = .init(current:"Finding unused DJ files…")
storageTask = Task.detached(priority:.utility) { [weak self] in
do {
let plan = try DJLibraryMaintenance.cleanupPlan(database:database,root:folder,cancelled:{Task.isCancelled}) { value in Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value } }
await MainActor.run { [weak self] in self?.movingStorage = false;self?.storageTask = nil;self?.cleanupPlan = plan;self?.storageProgress = nil }
} catch {
let cancelled = error is CancellationError,message = error.localizedDescription
await MainActor.run { [weak self] in self?.movingStorage = false;self?.storageTask = nil;self?.storageProgress = .init(current:cancelled ? "Scan cancelled" : "Cleanup needs attention",finished:true,cancelled:cancelled,failure:cancelled ? nil : message) }
}
}
}
func cleanUnusedFiles(_ plan: DJCleanupPlan) {
let database = root.appendingPathComponent("Library.sqlite"),folder = root
beginMaintenance("Cleaning Unused Files",work:{ cancelled,progress in try DJLibraryMaintenance.clean(plan,database:database,root:folder,cancelled:cancelled,progress:progress) })
cleanupPlan = nil
}
@Published var copyImportedMedia = false { didSet { perform { try library?.setSetting("DJ.CopyImportedMedia.v1",copyImportedMedia ? "1" : "0") } } }
private var referenceAccess = [String:URL]()
private var referenceLocations = [MediaLocation]()
@@ -209,7 +267,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func moveStorage(to destination: URL) {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return }
let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root
a.unload();b.unload();stopPreview();movingStorage = true;storageProgress = .init(current:"Preparing DJ storage move…")
a.unload();b.unload();stopPreview();storageInSettings = true;storageAction = nil;movingStorage = true;storageProgress = .init(current:"Preparing DJ storage move…")
storageTask = Task.detached(priority:.utility) { [weak self] in
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
@@ -933,6 +991,8 @@ private struct DJLibrarySnapshot<Content: View>: View, Equatable {
struct DJConsole: View {
@ObservedObject var system: DJSystem
@State private var removingIDs = Set<Int64>()
@State private var confirmTrackRemoval = false
@State private var reinterpreting = false
@State private var reinterpretIDs = Set<Int64>()
@State private var editingTrack: Song?
@@ -957,6 +1017,9 @@ struct DJConsole: View {
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && (!favouritesOnly || system.favourites.contains($0.id)) && SongSearch.matches($0,query:query)}
if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});filteredTracks = playlistIDs.compactMap{lookup[$0]} }
else { filteredTracks = all }
selection.formIntersection(Set(filteredTracks.map(\.id)))
page = min(page,max(0,(filteredTracks.count+49)/50-1))
if let selected,!selection.contains(selected) { self.selected = nil }
}
private var selectedTracks: [Song] { songs.filter { selection.contains($0.id) } }
private func selectTrack(_ id: Int64) {
@@ -1000,6 +1063,12 @@ struct DJConsole: View {
.interactiveDismissDisabled(system.importing)
}
}
.sheet(isPresented:Binding(get:{system.storageProgress != nil && !system.storageInSettings},set:{if !$0,!system.movingStorage { system.storageProgress = nil }})) {
if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Library",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},activity:system.storageAction).interactiveDismissDisabled(system.movingStorage) }
}
.confirmationDialog("Remove selected tracks from DJ Studio?",isPresented:$confirmTrackRemoval,titleVisibility:.visible) {
Button("Remove \(removingIDs.count) Tracks",role:.destructive) { system.removeLibraryTracks(removingIDs) }
} message: { Text("Library entries and their queue/playlist links are removed. Media files and referenced originals are kept.") }
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:system,ids:reinterpretIDs) }
.sheet(item:$editingTrack) { DJTrackMetadataDialog(system:system,song:$0) }
.sheet(isPresented:$graphicEQVisible) { DJGraphicEQDialog(system:system) }
@@ -1054,8 +1123,19 @@ struct DJConsole: View {
}.controlSize(.small)
}
private var browser: some View {
VStack(spacing:5) {
HStack(spacing:8) {
VStack(spacing:0) {
VStack(spacing:12) {
HStack(alignment:.center) {
VStack(alignment:.leading,spacing:3) {
Text("DJ Library").font(.system(size:18,weight:.semibold))
Text("\(songs.count.formatted()) tracks"+(playlist == nil ? " in your collection" : " in this playlist")).font(.caption).foregroundStyle(.secondary)
}
Spacer()
LibrarySelectionBadge(count:selection.count)
Button { system.automix.enqueue(selectedTracks) } label: { Label("Add to Queue",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty)
Button { importMedia() } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan,prominent:true))
}
HStack(spacing:10) {
Picker("DJ library",selection:$playlist) { Text("All music").tag(nil as Int64?);ForEach(system.playlists) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:175)
Menu {
Button("New Playlist…") { renaming = false;playlistName = "";playlistEditor = true }
@@ -1063,18 +1143,19 @@ struct DJConsole: View {
Button("Rename Playlist…") { renaming = true;playlistName = system.playlists.first{$0.id == playlist}?.name ?? "";playlistEditor = true }
Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:playlist) }
}
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
Button { favouritesOnly.toggle() } label: { Label("Favourites",systemImage:favouritesOnly ? "star.fill" : "star") }.foregroundStyle(favouritesOnly ? Color.yellow : Color.secondary).help("Show favourite tracks only")
Spacer(minLength:12)
StudioSearchField(placeholder:"Search music",text:$query).frame(maxWidth:260)
Button { system.automix.enqueue(selectedTracks) } label: { Image(systemName:"text.badge.plus") }.help("Add selected tracks to the DJ queue").disabled(selectedTracks.isEmpty)
Text("\(selection.count) selected").font(.caption).foregroundStyle(.secondary)
Button("Import Files / Folder…") { importMedia() }
Spacer(minLength:0)
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0)
Text("\(min(page,pages-1)+1)/\(pages) · \(songs.count)").font(.caption.monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
}.controlSize(.small)
} label: { Label("Playlists",systemImage:"folder.badge.plus") }.menuStyle(.borderlessButton).fixedSize().padding(.horizontal,12).padding(.vertical,8).background(Color.purple.opacity(0.12),in:RoundedRectangle(cornerRadius:8)).foregroundStyle(.purple)
Button { favouritesOnly.toggle() } label: { Label("Favourites",systemImage:favouritesOnly ? "star.fill" : "star") }.buttonStyle(LibraryActionButtonStyle(tint:.yellow,prominent:favouritesOnly)).help("Show favourite tracks only")
Spacer(minLength:18)
StudioSearchField(placeholder:"Search music",text:$query).frame(minWidth:180,idealWidth:280,maxWidth:320)
Spacer(minLength:18)
HStack(spacing:10) {
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan)).disabled(page == 0)
Text("Page \(min(page,pages-1)+1) of \(pages)").font(.system(size:11,weight:.medium)).monospacedDigit().fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan)).disabled(page >= pages-1)
}
}
}.padding(16).background(DJStyle.panel)
Divider().overlay(Color.white.opacity(0.06))
ScrollViewReader { reader in
Table(visible,selection:$selection) {
TableColumn("Favourite") { song in
@@ -1112,6 +1193,8 @@ struct DJConsole: View {
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Button("Remove Selected Tracks…",role:.destructive) { removingIDs = Set(contextTracks(song).map(\.id));confirmTrackRemoval = true }
Divider()
Button("Song Info / Artist…") { editingTrack = song }
Button("Reinterpret Selected Names…") { reinterpretIDs = Set(contextTracks(song).map(\.id));reinterpreting = true }.disabled(system.namesBusy)
Button("Undo Last Name Reinterpretation") { Task { do { try await system.undoNameChanges() } catch { system.error = error.localizedDescription } } }.disabled(system.namesBusy)
@@ -1617,6 +1700,7 @@ struct DJScrollingSpectrum: View {
struct DJPreferencesPane: View {
@ObservedObject var system: DJSystem
@State private var confirmClear = false
let category: String
var body: some View {
Group {
@@ -1637,9 +1721,13 @@ struct DJPreferencesPane: View {
}
}
}
.sheet(isPresented:Binding(get:{system.storageProgress != nil},set:{if !$0,!system.movingStorage { system.storageProgress = nil }})) {
if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Storage",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},isMove:true).interactiveDismissDisabled(system.movingStorage) }
.sheet(isPresented:Binding(get:{system.storageInSettings && (system.storageProgress != nil || system.cleanupPlan != nil)},set:{if !$0,!system.movingStorage { system.storageProgress = nil;system.cleanupPlan = nil }})) {
if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Library",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},isMove:system.storageAction == nil,activity:system.storageAction).interactiveDismissDisabled(system.movingStorage) }
else if let plan = system.cleanupPlan { DJCleanupConfirmation(system:system,plan:plan) }
}
.confirmationDialog("Clear the entire DJ library?",isPresented:$confirmClear,titleVisibility:.visible) {
Button("Remove All \(system.songs.count) Tracks",role:.destructive) { system.removeLibraryTracks(Set(system.songs.map(\.id)),fromSettings:true) }
} message: { Text("All DJ track entries and queue entries are removed. Playlists remain empty. Audio files, saved waveforms and original source folders are kept; Clean Unused Files can remove unreferenced managed copies afterwards.") }
.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
}
private var general: some View {
@@ -1679,6 +1767,12 @@ struct DJPreferencesPane: View {
}
private var library: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Library Maintenance") {
VStack(alignment:.leading,spacing:14) {
HStack { Button("Clear DJ Library…",role:.destructive) { confirmClear = true }.disabled(system.songs.isEmpty);Button("Clean Unused Files…") { system.findUnusedFiles() } }
Text("Clear removes library entries while keeping your files. Cleanup previews unused DJ-managed copies and stale waveforms before moving them to Trash. Referenced original files are excluded.").font(.caption).foregroundStyle(.secondary)
}.padding(12).disabled(system.movingStorage || system.importing || system.generatingWaveforms || system.namesBusy)
}
PreferencesSection("Import Mode") {
VStack(alignment:.leading,spacing:12) {
Picker("Media files",selection:$system.copyImportedMedia) { Text("Reference files in their original location").tag(false);Text("Copy files into DJ storage").tag(true) }.pickerStyle(.radioGroup)
@@ -2077,3 +2171,18 @@ private struct DJTrackMetadataDialog: View {
}.padding(26).frame(width:480).onAppear { title = song.title;artist = song.artist;album = song.album }
}
}
private struct DJCleanupConfirmation: View {
@ObservedObject var system: DJSystem
let plan: DJCleanupPlan
var body: some View {
VStack(alignment:.leading,spacing:18) {
Label("Clean DJ Library Storage",systemImage:"trash").font(.title2.bold())
Text("\(plan.files.count) unused files · \(ByteCountFormatter.string(fromByteCount:plan.bytes,countStyle:.file))").font(.headline)
Text("Only unreferenced DJ-managed media and stale waveform caches are listed. Original source folders and files still referenced by the library are excluded.").font(.callout).foregroundStyle(.secondary)
ScrollView { LazyVStack(alignment:.leading,spacing:6) { ForEach(Array(plan.files.prefix(100))) { Text($0.url.path).font(.caption.monospaced()).textSelection(.enabled) } } }.frame(height:210)
if plan.files.count > 100 { Text("Showing the first 100 files.").font(.caption).foregroundStyle(.secondary) }
HStack { Button("Cancel") { system.cleanupPlan = nil };Spacer();Button("Move Unused Files to Trash",role:.destructive) { system.cleanUnusedFiles(plan) }.buttonStyle(.borderedProminent).disabled(plan.files.isEmpty) }
}.padding(26).frame(width:680).preferredColorScheme(.dark)
}
}
+75
View File
@@ -594,3 +594,78 @@ enum DJStorageMove {
state.finished = true;state.current = "DJ media and waveforms moved";progress(state);return state
}
}
struct DJCleanupCandidate: Identifiable, Sendable {
var id: String { url.path }
let url: URL
let size: Int64
let modified: Date?
}
struct DJCleanupPlan: Identifiable, Sendable {
let id = UUID()
var files: [DJCleanupCandidate]
var bytes: Int64 { files.reduce(0){$0+$1.size} }
}
enum DJLibraryMaintenance {
static func managedRoots(database: URL,root: URL) throws -> [URL] {
let db = try Library(url:database),location = try DJStorageMove.location(in:db)
return Array(Set(((location?.roots ?? [])+[location?.path ?? root.path,root.path]).map{URL(fileURLWithPath:$0).resolvingSymlinksInPath()}))
}
static func cleanupPlan(database: URL,root: URL,cancelled: () -> Bool = {false},progress: (ImportProgress) -> Void = {_ in}) throws -> DJCleanupPlan {
let db = try Library(url:database),songs = try db.songs(),roots = try managedRoots(database:database,root:root)
let referenced = Set(songs.flatMap{[$0.audio,$0.cdg].compactMap{$0}.map{URL(fileURLWithPath:$0).resolvingSymlinksInPath()}})
var canValidateCaches = true
var validCaches = Set<String>(),state = ImportProgress(current:"Checking referenced tracks…"),last = Date.distantPast
progress(state)
for (index,song) in songs.enumerated() {
if cancelled() { throw CancellationError() }
if let name = try? WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:root).lastPathComponent { validCaches.insert(name) }
else { canValidateCaches = false }
if Date().timeIntervalSince(last) >= 0.12 { state.current = "Checking reference \(index+1) of \(songs.count)";progress(state);last = Date() }
}
var files = [DJCleanupCandidate]()
for folder in roots.flatMap({[$0.appendingPathComponent("Media"),$0.appendingPathComponent("Waveforms")]}) {
if cancelled() { throw CancellationError() }
guard let items = FileManager.default.enumerator(at:folder,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey,.fileSizeKey,.contentModificationDateKey],options:[.skipsHiddenFiles,.skipsSubdirectoryDescendants]) else { continue }
for case let url as URL in items {
if cancelled() { throw CancellationError() }
let stem = url.deletingPathExtension().lastPathComponent
guard [64,129].contains(stem.count),stem.allSatisfy({$0.isHexDigit || $0 == "-"}) else { continue }
let isWaveform = folder.lastPathComponent == "Waveforms" && url.pathExtension == "waveform"
let isMedia = folder.lastPathComponent == "Media" && ["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac","cdg"].contains(url.pathExtension.lowercased())
guard isWaveform || isMedia else { continue }
if isWaveform,!canValidateCaches { continue }
let values = try url.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey,.fileSizeKey,.contentModificationDateKey])
guard values.isRegularFile == true,values.isSymbolicLink != true,!referenced.contains(url.resolvingSymlinksInPath()),!isWaveform || !validCaches.contains(url.lastPathComponent) else { continue }
files.append(.init(url:url,size:Int64(values.fileSize ?? 0),modified:values.contentModificationDate))
if Date().timeIntervalSince(last) >= 0.12 { state.current = "Found \(files.count) unused managed files";progress(state);last = Date() }
}
}
return .init(files:files.sorted{$0.url.path < $1.url.path})
}
static func clean(_ plan: DJCleanupPlan,database: URL,root: URL,cancelled: () -> Bool = {false},progress: (ImportProgress) -> Void = {_ in},trash: (URL) throws -> Void = { try FileManager.default.trashItem(at:$0,resultingItemURL:nil) }) throws -> ImportProgress {
let allowed = Set(try cleanupPlan(database:database,root:root,cancelled:cancelled).files.map(\.id))
var state = ImportProgress(total:plan.files.count,current:"Moving unused files to Trash…")
for file in plan.files {
if cancelled() { throw CancellationError() }
let values = try? file.url.resourceValues(forKeys:[.fileSizeKey,.contentModificationDateKey,.isSymbolicLinkKey])
if allowed.contains(file.id),values?.isSymbolicLink != true,Int64(values?.fileSize ?? -1) == file.size,values?.contentModificationDate == file.modified {
try trash(file.url);state.logLines.append("[REMOVED] "+file.url.path)
} else { state.skipped += 1;state.logLines.append("[SKIPPED] "+file.url.path+" (changed or now referenced)") }
state.completed += 1;state.current = file.url.lastPathComponent;progress(state)
}
state.finished = true;state.current = "Cleanup complete";return state
}
static func removeTracks(_ ids: Set<Int64>,database: URL,cancelled: () -> Bool = {false}) throws {
let db = try Library(url:database)
try db.execute("BEGIN IMMEDIATE")
do {
for id in ids {
if cancelled() { throw CancellationError() }
try db.execute("DELETE FROM reinterpret_undo WHERE song=?",[String(id)])
try db.execute("DELETE FROM songs WHERE id=?",[String(id)])
}
try db.execute("COMMIT")
} catch { try? db.execute("ROLLBACK");throw error }
}
}
+32 -10
View File
@@ -616,6 +616,26 @@ private struct ImportProgressDialog: View {
}
}
}
struct LibraryActionButtonStyle: ButtonStyle {
let tint: Color
var prominent = false
@Environment(\.isEnabled) private var enabled
func makeBody(configuration: Configuration) -> some View {
configuration.label.font(.system(size:12,weight:.semibold)).fixedSize()
.padding(.horizontal,12).padding(.vertical,8)
.foregroundStyle(prominent && enabled ? Color.black : enabled ? tint : Color.secondary)
.background(RoundedRectangle(cornerRadius:8).fill(tint.opacity(enabled ? (prominent ? (configuration.isPressed ? 0.65 : 0.95) : configuration.isPressed ? 0.25 : 0.12) : 0.04)))
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(tint.opacity(enabled ? 0.28 : 0.08)))
}
}
struct LibrarySelectionBadge: View {
let count: Int
var body: some View {
Text(count == 0 ? "No selection" : "\(count.formatted()) selected").font(.system(size:11,weight:.medium)).monospacedDigit()
.foregroundStyle(count > 0 ? Color.cyan : Color.secondary).padding(.horizontal,10).padding(.vertical,6)
.background((count > 0 ? Color.cyan : Color.primary).opacity(0.08),in:Capsule())
}
}
struct StudioSearchField: View {
let placeholder: String
@Binding var text: String
@@ -673,9 +693,10 @@ struct ImportProgressPanel: View {
let close: () -> Void
var background: (() -> Void)? = nil
var isMove = false
var activity: String? = nil
private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 }
private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan }
private var title: String { if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
private var title: String { if let activity { return progress.finished ? (progress.cancelled ? "Cancelled" : progress.failure == nil ? "Library Updated" : "Library Needs Attention") : activity };if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
var body: some View {
VStack(alignment:.leading,spacing:20) {
HStack(spacing:14) {
@@ -705,11 +726,11 @@ struct ImportProgressPanel: View {
Text(progress.failure ?? progress.current).font(.system(size:11)).foregroundStyle(.secondary).lineLimit(2).frame(height:30,alignment:.topLeading).frame(maxWidth:.infinity,alignment:.leading)
}
HStack(spacing:12) {
count(isMove ? "Copied" : "Imported",value:max(0,progress.completed-progress.skipped),color:.green)
count(activity != nil ? "Processed" : isMove ? "Copied" : "Imported",value:max(0,progress.completed-progress.skipped),color:.green)
count("Skipped",value:progress.skipped,color:.orange)
count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan)
}
ImportLogView(progress:progress,title:isMove ? "Transfer Log" : "Import Log").frame(height:260)
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:260)
Divider()
HStack {
Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
@@ -718,7 +739,7 @@ struct ImportProgressPanel: View {
Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction)
} else {
if let background { Button("Run in Background",action:background) }
Button(progress.current.hasPrefix("Cancelling") ? "Cancelling…" : isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction).disabled(progress.current.hasPrefix("Cancelling"))
Button(progress.current.hasPrefix("Cancelling") ? "Cancelling…" : activity != nil ? "Cancel" : isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction).disabled(progress.current.hasPrefix("Cancelling"))
}
}
}.padding(26).frame(width:660)
@@ -921,14 +942,15 @@ struct LibraryView: View {
HStack {
VStack(alignment:.leading,spacing:4) {
Text(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer")+" · "+singerSection : source).font(.title2.weight(.semibold))
Text(source == "Library" ? "\(model.songs.count) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
Text(source == "Library" ? "\(model.songs.count.formatted()) tracks in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
}
Spacer()
StudioSearchField(placeholder:"Search music",text:$search).frame(maxWidth:260)
Spacer(minLength:20)
LibrarySelectionBadge(count:tableSelection.count)
StudioSearchField(placeholder:"Search music",text:$search).frame(minWidth:180,idealWidth:280,maxWidth:320)
if source == "Library" {
Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered)
Button { importing = true } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan,prominent:true))
}
}.padding(.horizontal,22).padding(.vertical,18)
}.padding(.horizontal,20).padding(.vertical,14).background(Color.primary.opacity(0.025))
.sheet(isPresented:$djSongSettings) {
VStack(spacing:0) {
HStack { VStack(alignment:.leading,spacing:4) { Text("Song Settings").font(.title2.bold());Text(selectedSong?.title ?? "Select a karaoke track to adjust its settings").font(.callout).foregroundStyle(.secondary) };Spacer() }.padding(24)
@@ -1191,7 +1213,7 @@ struct LibraryView: View {
Text("Page \(pagination.number+1) of \(pagination.count)").monospacedDigit().frame(minWidth:95)
Button { changePage(pagination.number+1) } label: { Image(systemName:"chevron.right") }.disabled(pagination.number+1 >= pagination.count).help("Next page").accessibilityLabel("Next library page").accessibilityIdentifier("library.page.next")
Button { changePage(pagination.count-1) } label: { Image(systemName:"forward.end") }.disabled(pagination.number+1 >= pagination.count).help("Last page").accessibilityLabel("Last library page")
}.font(.caption).controlSize(.small).buttonStyle(.borderless).padding(.horizontal,16).padding(.vertical,8)
}.font(.caption).buttonStyle(LibraryActionButtonStyle(tint:.cyan)).padding(.horizontal,16).padding(.vertical,10)
}
private func changePage(_ requested:Int) { page = LibraryPage(total:libraryRows.count,requested:requested).number;tableSelection = [];selected = nil }
private var songTable: some View {
+65
View File
@@ -1822,6 +1822,7 @@ extension KJamsCloneTests {
let karaoke = try XCTUnwrap(model.library),dj = try XCTUnwrap(model.dj.library)
_ = try karaoke.add(title:"Karaoke only",audio:fixtures.appendingPathComponent("TestTrack.wav").path,cdg:nil)
try model.refresh();XCTAssertTrue(model.dj.songs.isEmpty)
model.dj.copyImportedMedia = true // Explicit managed-copy option; references are now the default.
model.dj.importFiles([root.appendingPathComponent("Input")])
for _ in 0..<200 { if !model.dj.importing { break };try await Task.sleep(nanoseconds:25_000_000) }
XCTAssertFalse(model.dj.importing);XCTAssertNil(model.dj.error)
@@ -2954,4 +2955,68 @@ extension KJamsCloneTests {
model.dj.storageProgress = nil
}
@MainActor func testDJReferenceImportsAvoidMediaCopiesAndRememberMode() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:root)
XCTAssertFalse(system.copyImportedMedia)
system.importFiles([fixture])
for _ in 0..<300 where system.importing { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertNil(system.lastImportReport?.failure)
XCTAssertEqual(system.songs.first?.audio,fixture.standardizedFileURL.path)
XCTAssertTrue((try? FileManager.default.contentsOfDirectory(atPath:system.mediaDirectory.path))?.isEmpty ?? true)
XCTAssertEqual(try system.library?.mediaLocations().count,1)
system.copyImportedMedia = true
XCTAssertTrue(DJSystem(storageURL:root).copyImportedMedia)
}
func testDJCleanupNeverRemovesReferencedOriginalsAndRechecksItsPreview() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let local = root.appendingPathComponent("DJ"),media = local.appendingPathComponent("Media"),waves = local.appendingPathComponent("Waveforms")
try FileManager.default.createDirectory(at:media,withIntermediateDirectories:true);try FileManager.default.createDirectory(at:waves,withIntermediateDirectories:true)
let database = local.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try Importer(media:media,waveformCache:waves).run([fixture],library:db,copyMedia:false)
let orphan = media.appendingPathComponent(String(repeating:"a",count:64)+".mp3")
let stale = waves.appendingPathComponent(String(repeating:"b",count:64)+".waveform")
let personal = media.appendingPathComponent("My Music.mp3")
try FileManager.default.copyItem(at:fixture,to:orphan);try FileManager.default.copyItem(at:fixture,to:personal)
try Data("stale cache".utf8).write(to:stale)
let plan = try DJLibraryMaintenance.cleanupPlan(database:database,root:local)
XCTAssertEqual(Set(plan.files.map{$0.url.lastPathComponent}),[orphan.lastPathComponent,stale.lastPathComponent])
_ = try db.add(title:"Now referenced",audio:orphan.path,cdg:nil)
var removed = [String]()
let result = try DJLibraryMaintenance.clean(plan,database:database,root:local,trash:{removed.append($0.lastPathComponent);try FileManager.default.removeItem(at:$0)})
XCTAssertEqual(removed,[stale.lastPathComponent]);XCTAssertEqual(result.skipped,1)
XCTAssertTrue(FileManager.default.fileExists(atPath:fixture.path));XCTAssertTrue(FileManager.default.fileExists(atPath:orphan.path));XCTAssertTrue(FileManager.default.fileExists(atPath:personal.path))
}
@MainActor func testDJClearLibraryKeepsSourceAndManagedFilesAndClearsQueue() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try Importer(media:system.mediaDirectory).run([fixture],library:try XCTUnwrap(system.library))
try system.refresh();let song = try XCTUnwrap(system.songs.first)
system.automix.enqueue([song]);system.toggleFavourite(song.id)
system.removeLibraryTracks([song.id])
for _ in 0..<300 where system.movingStorage { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertTrue(system.songs.isEmpty);XCTAssertTrue(system.automix.entries.isEmpty);XCTAssertTrue(system.favourites.isEmpty)
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertTrue(FileManager.default.fileExists(atPath:fixture.path))
XCTAssertNil(system.storageProgress?.failure)
}
func testDJCleanupPreservesWaveformsWhileSourceDriveIsUnavailable() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let waves = root.appendingPathComponent("Waveforms")
try FileManager.default.createDirectory(at:waves,withIntermediateDirectories:true)
let database = root.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
_ = try db.add(title:"Offline",audio:root.appendingPathComponent("Offline/source.mp3").path,cdg:nil)
let cache = waves.appendingPathComponent(String(repeating:"c",count:64)+".waveform")
try Data("cached waveform".utf8).write(to:cache)
XCTAssertTrue(try DJLibraryMaintenance.cleanupPlan(database:database,root:root).files.isEmpty)
XCTAssertTrue(FileManager.default.fileExists(atPath:cache.path))
}
}
+8
View File
@@ -119,3 +119,11 @@ DJ Settings → Library → Move Media and Waveforms selects a destination folde
Changes to a playing loop's length are queued with the audio node at a loop boundary, avoiding a node restart. A three-millisecond seam blend reduces edge clicks without changing the loop's frame count. Loop Off still cancels repetition immediately.
Move progress is presented by the DJ settings pane that observes the move directly. Discovery reports progress before file matching begins, skips empty cache folders and checks each audio stamp once. Cancel responds during scanning, hashing and streaming copy; copies use 1 MB chunks and remove incomplete staging files. Large file verification/copy phases report byte counts instead of leaving the interface unchanged.
## Referenced imports and library cleanup
New DJ imports default to referencing their original files, avoiding a second media copy. Choose Copy files into DJ storage under DJ Settings → Library → Import Mode when a managed copy is wanted. The mode is remembered. Selected files/folders retain access bookmarks; keep their source drive connected. Saved waveforms are still generated. ZIP audio must be extracted into managed storage. Existing copied tracks are unchanged automatically.
Right-click selected DJ tracks to remove their library entries. DJ Settings → Library also has Clear DJ Library and Clean Unused Files. Removal requires confirmation and keeps media files. Cleanup scans on a worker, previews count/size/paths, and requires confirmation before moving unreferenced hash-named managed media and stale waveform caches to Trash. Referenced originals and unrecognised personal files are excluded. The cleanup preview is rechecked before use; files newly referenced or changed are skipped. Waveform cleanup is deferred when source-file availability prevents cache validation. Clearing the DJ library also clears its queue/favourites while leaving original files and empty playlist definitions.
Both library toolbars share coloured, rounded action buttons and matching search fields. DJ Library groups its heading/selection/actions above filters/search/page navigation: cyan Import, green Add to Queue, purple Playlists and amber Favourites. Filter/library changes prune stale selections and clamp pagination to the current results.