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 {