removed unwanted bar now with waveforms, major updates
This commit is contained in:
+126
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user