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