latest updates

This commit is contained in:
Mark Gregory
2026-11-03 14:31:32 +00:00
parent 83acfc63c9
commit 1668361e83
16 changed files with 888 additions and 341 deletions
+59 -2
View File
@@ -83,6 +83,29 @@ struct SongDragReference: Codable, Sendable {
var playingIntermission = false
@Published var error: String?
@Published var creating: EntityKind?
@Published private(set) var waveformJob: WaveformJobProgress?
@Published private(set) var generatingWaveforms = false
private var waveformJobTask: Task<Void,Never>?
private var waveformJobToken: UUID?
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
guard !generatingWaveforms,scanningSource == nil else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
let tracks = songs.filter{ids == nil || ids!.contains($0.id)}
guard !tracks.isEmpty else { error = "Select a library track first.";return }
let token = UUID(),locations = mediaLocations,cache = root.appendingPathComponent("Waveforms")
waveformJobToken = token;generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformJobTask = Task.detached(priority:.utility) { [weak self] in
let result = WaveformBatch.run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled},progress:{ update in
Task { @MainActor [weak self] in guard let self,self.waveformJobToken == token else { return };self.waveformJob = update }
})
await MainActor.run { [weak self] in
guard let self,self.waveformJobToken == token else { return }
self.waveformJob = result;self.waveformJobToken = nil;self.generatingWaveforms = false;self.waveformJobTask = nil
if let id = self.player.currentSongID,tracks.contains(where:{$0.id == id}) { self.player.refreshCachedWaveform() }
}
}
}
func cancelWaveformGeneration() { waveformJobTask?.cancel();waveformJob?.current = "Cancelling…" }
func dismissWaveformProgress() { if !generatingWaveforms { waveformJob = nil } }
let player: Player
private(set) var library: Library?
let root: URL
@@ -319,7 +342,7 @@ struct SongDragReference: Codable, Sendable {
try refresh();return id
}
func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) {
guard scanningSource == nil else { error = "A location is already being prepared. Cancel it or wait for it to finish.";return }
guard scanningSource == nil,!generatingWaveforms else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard let source = mediaLocations.first(where:{ $0.id == id }) else { return }
let token = UUID();scanToken = token;scanningSource = id;scanProgress = SourceScanProgress(source:id)
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms")
@@ -481,6 +504,7 @@ struct LibraryView: View {
@ObservedObject var model: AppModel
@State private var selected: Int64?
@State private var tableSelection = Set<Int64>()
@State private var waveformDetails = false
@State private var batchEditing = false
@State private var metadataLookup: Song?
@State private var confirmWriteTags = false
@@ -532,6 +556,15 @@ struct LibraryView: View {
}
}.padding(.horizontal,22).padding(.vertical,18)
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.waveformJob {
HStack(spacing:10) {
Image(systemName:"waveform").foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:2) { Text(progress.current).lineLimit(1);Text("\(progress.processed)/\(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").foregroundStyle(.secondary) }.font(.caption)
Spacer();if model.generatingWaveforms { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).frame(width:100);Button("Cancel") { model.cancelWaveformGeneration() }.controlSize(.small) }
if progress.issues > 0 { Button("Details") { waveformDetails = true }.controlSize(.small) }
if !model.generatingWaveforms { Button { model.dismissWaveformProgress() } label: { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(.horizontal,18).padding(.vertical,8)
}
Divider()
if ["Library","Singer Songs","Playlists"].contains(source) { detailPanel.frame(height:135) }
Divider()
@@ -548,6 +581,7 @@ struct LibraryView: View {
.toolbar {
ToolbarItemGroup {
Button { importing = true } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import music or video")
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.disabled(model.generatingWaveforms || model.scanningSource != nil).help("Prepare waveforms for a track, selected tracks or the whole library")
Menu {
Button("New Singer…") { model.beginSinger() }
Button("New Venue…") { model.creating = .venue }
@@ -570,6 +604,13 @@ struct LibraryView: View {
if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) }
}
}
.sheet(isPresented:$waveformDetails) {
VStack(alignment:.leading,spacing:14) {
Text("Waveform Analysis").font(.title2.bold())
ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300)
HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:560).preferredColorScheme(.dark)
}
.sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) }
.sheet(item:$metadataLookup) { song in MetadataLookupSheet(model:model,song:song) }
.confirmationDialog("Write file tags for \(tableSelection.count) songs? Original files will be backed up in TagBackups before replacement. Unsupported files are reported and left unchanged.",isPresented:$confirmWriteTags) {
@@ -773,7 +814,11 @@ struct LibraryView: View {
}.width(94)
}.tableStyle(.inset(alternatesRowBackgrounds:true))
.contextMenu(forSelectionType:Int64.self) { ids in
if ids.count > 1 { Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true } }
if ids.count > 1 {
Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true }
}
else if let id = ids.first,let song = model.songs.first(where:{$0.id == id}) { songActions(song) }
} primaryAction: { ids in if let id = ids.first { selected = id;tableSelection = [id];playSelected() } }
.overlay {
@@ -790,6 +835,8 @@ struct LibraryView: View {
@ViewBuilder private func songActions(_ song: Song) -> some View {
Button("Play") { selected = song.id;playSelected() }
Button("Song Info…") { editingSong = song }
Button("Generate Waveform") { model.generateWaveforms([song.id]) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Regenerate Waveform") { model.generateWaveforms([song.id],force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Find Metadata Online…") { metadataLookup = song }
Button("Batch Edit Selected…") { if tableSelection.isEmpty { tableSelection = [song.id] };batchEditing = true }
Button("Write File Tags…") { tableSelection = [song.id];confirmWriteTags = true }.disabled(model.tagsBusy || song.audio.hasPrefix("showscreen:"))
@@ -941,12 +988,22 @@ struct LibraryView: View {
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
}
private var playlistPane: some View { PlaylistWorkspace(model:model,query:$search) }
@ViewBuilder private var waveformActions: some View {
Button("Generate Waveform for Loaded Track") { if let id = model.player.currentSongID { model.generateWaveforms([id]) } }.disabled(model.player.currentSongID == nil)
Button("Generate Waveform for Selected Track") { if let selected { model.generateWaveforms([selected]) } }.disabled(tableSelection.count > 1 || selected == nil)
Button("Generate Waveforms for \(tableSelection.count) Selected Tracks") { model.generateWaveforms(tableSelection) }.disabled(tableSelection.isEmpty)
Button("Generate Waveforms for All Library Tracks") { model.generateWaveforms() }.disabled(model.songs.isEmpty)
Divider()
Button("Regenerate Selected Waveforms") { model.generateWaveforms(tableSelection,force:true) }.disabled(tableSelection.isEmpty)
Button("Regenerate All Library Waveforms") { model.generateWaveforms(force:true) }.disabled(model.songs.isEmpty)
}
private var footer: some View {
HStack(spacing:8) {
Circle().fill(Color.green.opacity(0.85)).frame(width:5,height:5)
Text("\(visibleSongs.count) songs").font(.caption).foregroundStyle(.secondary)
if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil)
if tableSelection.count > 1 { Button("Edit \(tableSelection.count) Songs…") { batchEditing = true }.controlSize(.small) }
Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary)
RepeatButton(player:model.player).buttonStyle(.borderless)
+55
View File
@@ -79,3 +79,58 @@ enum LibraryScanner {
progress(state);return state
}
}
struct WaveformJobProgress: Sendable {
var total: Int
var processed = 0
var generated = 0
var cached = 0
var skipped = 0
var issues = 0
var current = "Preparing waveforms…"
var messages = [String]()
var cancelled = false
var finished = false
}
/// Prepares existing library media without importing, playing or modifying track metadata.
enum WaveformBatch {
static func run(songs: [Song],locations: [MediaLocation],cache: URL,force: Bool = false,cancelled: () -> Bool = { false },progress: (WaveformJobProgress) -> Void = { _ in }) -> WaveformJobProgress {
var accessed = [URL]()
for location in locations {
var url = URL(fileURLWithPath:location.path)
if let bookmark = location.bookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { url = resolved } }
if url.startAccessingSecurityScopedResource() { accessed.append(url) }
}
defer { for url in accessed { url.stopAccessingSecurityScopedResource() } }
var ids = Set<Int64>(),analysedPaths = Set<String>()
let tracks = songs.filter{ids.insert($0.id).inserted}
var state = WaveformJobProgress(total:tracks.count),lastReport = Date.distantPast
for song in tracks {
if cancelled() { state.cancelled = true;break }
state.current = song.title
if song.audio.hasPrefix("showscreen:") { state.skipped += 1;state.processed += 1;continue }
let url = URL(fileURLWithPath:song.audio),path = url.standardizedFileURL.path
let scoped = url.startAccessingSecurityScopedResource()
do {
if (!force || analysedPaths.contains(path)),(try? WaveformCache.load(audio:url,directory:cache)) != nil { state.cached += 1 }
else {
let value = try AudioWaveform.analyse(url:url,cancelled:cancelled)
guard value.levels.count == 4096,value.bands.count == 4096 else { throw LibraryError.message("No audio samples available for waveform analysis") }
if cancelled() { throw CancellationError() }
try WaveformCache.save(value,audio:url,directory:cache);state.generated += 1
}
analysedPaths.insert(path)
} catch is CancellationError {
if scoped { url.stopAccessingSecurityScopedResource() };state.cancelled = true;break
} catch {
state.issues += 1
if state.messages.count < 100 { state.messages.append(song.title+": "+error.localizedDescription) }
}
if scoped { url.stopAccessingSecurityScopedResource() }
state.processed += 1
if Date().timeIntervalSince(lastReport) >= 0.12 { progress(state);lastReport = Date() }
}
state.finished = true;state.current = state.cancelled ? "Cancelled · completed waveforms kept" : (state.issues > 0 ? "Waveform analysis finished with issues" : "Waveforms ready")
progress(state);return state
}
}
+1
View File
@@ -139,6 +139,7 @@ import CoreImage
}
restoringMix = false;updateTone()
}
func refreshCachedWaveform() { if let audioURL { analyseWaveform(audioURL) } }
func reanalyseWaveform() { if let audioURL { analyseWaveform(audioURL,force:true) } }
func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true }
private func updateTone() {