latest updates
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user