removed unwanted bar now with waveforms, major updates
This commit is contained in:
@@ -666,6 +666,9 @@ struct StudioSettingsDialog: View {
|
||||
Divider()
|
||||
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
|
||||
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
|
||||
.sheet(isPresented:Binding(get:{model.dj.storageProgress != nil},set:{if !$0,!model.dj.movingStorage { model.dj.storageProgress = nil }})) {
|
||||
if let progress = model.dj.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Storage",cancel:{model.dj.cancelStorageMove()},close:{model.dj.storageProgress = nil},isMove:true).interactiveDismissDisabled(model.dj.movingStorage) }
|
||||
}
|
||||
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
|
||||
.onChange(of:category) { _ in model.dj.midi.learning = nil }
|
||||
.onDisappear { model.dj.midi.learning = nil }
|
||||
|
||||
@@ -172,6 +172,51 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
@MainActor final class DJSystem: ObservableObject {
|
||||
@Published private(set) var mediaDirectory: URL
|
||||
@Published private(set) var waveformDirectory: URL
|
||||
@Published private(set) var movingStorage = false
|
||||
@Published var storageProgress: ImportProgress?
|
||||
private var storageTask: Task<Void,Never>?
|
||||
private var storageAccess: URL?
|
||||
private func loadStorage() throws {
|
||||
let location = try library.flatMap{try DJStorageMove.location(in:$0)}
|
||||
var folder = location.map{URL(fileURLWithPath:$0.path)} ?? root
|
||||
if let bookmark = location?.bookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { folder = resolved } }
|
||||
if storageAccess != folder {
|
||||
storageAccess?.stopAccessingSecurityScopedResource();storageAccess = folder.startAccessingSecurityScopedResource() ? folder : nil
|
||||
}
|
||||
mediaDirectory = folder.appendingPathComponent("Media");waveformDirectory = folder.appendingPathComponent("Waveforms")
|
||||
for player in [a.player,b.player,preview] { player.useWaveformCache(at:waveformDirectory) }
|
||||
}
|
||||
func chooseStorage() {
|
||||
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 both decks and headphone preview before moving DJ files.";return }
|
||||
let panel = NSOpenPanel();panel.title = "Move DJ Media and Waveforms";panel.prompt = "Move Here"
|
||||
panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true
|
||||
panel.message = "Choose a folder on your external drive. DJ media and saved waveforms move into Media and Waveforms folders here. The database and playlists stay on this Mac."
|
||||
panel.begin { [weak self] response in guard response == .OK,let url = panel.url else { return };Task { @MainActor in self?.moveStorage(to:url) } }
|
||||
}
|
||||
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()
|
||||
storageTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
|
||||
var latest = ImportProgress()
|
||||
do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in
|
||||
latest = value;Task { @MainActor [weak self] in self?.storageProgress = value }
|
||||
} } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" }
|
||||
catch { latest.failure = error.localizedDescription;latest.current = "Move 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
|
||||
self.perform { try self.loadStorage();try self.refresh() }
|
||||
for (id,deck) in [(loadedA,self.a),(loadedB,self.b)] { if let id,let song = self.songs.first(where:{$0.id == id}) { self.perform { try deck.load(song) } } }
|
||||
self.storageProgress = result
|
||||
}
|
||||
}
|
||||
}
|
||||
func cancelStorageMove() { storageTask?.cancel() }
|
||||
@Published var namesBusy = false
|
||||
private var graphicSaveTask: Task<Void,Never>?
|
||||
@Published var graphicEQ = DJGraphicEQSettings() { didSet {
|
||||
@@ -193,10 +238,10 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
@Published private(set) var generatingWaveforms = false
|
||||
private var waveformTask: Task<Void,Never>?
|
||||
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
|
||||
guard !generatingWaveforms,!importing else { error = "Wait for the current import or waveform job to finish first.";return }
|
||||
guard !generatingWaveforms,!importing,!movingStorage else { error = "Wait for the current import or waveform job to finish first.";return }
|
||||
let tracks = songs.filter { ids == nil || ids!.contains($0.id) }
|
||||
guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return }
|
||||
let cache = root.appendingPathComponent("Waveforms")
|
||||
let cache = waveformDirectory
|
||||
generatingWaveforms = true;waveformJob = .init(total:tracks.count)
|
||||
waveformTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:[],cache:cache,force:force) { update in
|
||||
@@ -253,6 +298,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
|
||||
init(storageURL: URL? = nil) {
|
||||
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
|
||||
mediaDirectory = root.appendingPathComponent("Media");waveformDirectory = root.appendingPathComponent("Waveforms")
|
||||
a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms"))
|
||||
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
|
||||
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
|
||||
@@ -266,7 +312,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}.store(in:&syncObservers)
|
||||
perform {
|
||||
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
|
||||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
|
||||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try loadStorage();try refresh()
|
||||
if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized }
|
||||
applyGraphicEQ()
|
||||
favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)})
|
||||
@@ -292,9 +338,9 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
func addToPlaylist(_ playlist: Int64,song: Int64) { perform { try library?.addToPlaylist(playlist,song:song);try refresh() } }
|
||||
func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) }
|
||||
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
|
||||
guard !importing,!generatingWaveforms,!urls.isEmpty else { return }
|
||||
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
|
||||
importing = true;importProgress = .init();importLogVisible = true
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms"),replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
importTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
|
||||
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
|
||||
@@ -326,7 +372,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song)
|
||||
}
|
||||
func cancelReplacement() { pendingReplacement = nil;continueFileLoads() }
|
||||
deinit { importTask?.cancel();waveformTask?.cancel() }
|
||||
deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource() }
|
||||
|
||||
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
|
||||
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 }
|
||||
@@ -341,6 +387,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
} else { load(song,into:target) }
|
||||
}
|
||||
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
|
||||
guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return }
|
||||
perform {
|
||||
let deck = channel(letter)
|
||||
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
|
||||
@@ -1609,6 +1656,14 @@ struct DJPreferencesPane: View {
|
||||
}
|
||||
private var library: some View {
|
||||
VStack(alignment:.leading,spacing:26) {
|
||||
PreferencesSection("Media and Waveform Storage") {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) }
|
||||
LabeledContent("Waveforms") { Text(system.waveformDirectory.path).textSelection(.enabled) }
|
||||
Button("Move Media and Waveforms…") { system.chooseStorage() }.disabled(system.movingStorage || system.importing || system.generatingWaveforms)
|
||||
Text("Choose a folder on an external drive. Files are copied and verified before originals are removed. The DJ database and playlists stay on this Mac. Stop playback before moving files.").font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(12)
|
||||
}
|
||||
PreferencesSection("Independent DJ Collection") {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)")
|
||||
|
||||
@@ -411,7 +411,7 @@ enum ManagedMediaStorage {
|
||||
guard try hash(temporary,cancelled:cancelled) == expected else { throw LibraryError.message("Verification failed for \(source.lastPathComponent). The original has been kept.") }
|
||||
try FileManager.default.moveItem(at:temporary,to:target)
|
||||
}
|
||||
verified.append((source,target));state.completed += 1
|
||||
verified.append((source,target));state.completed += 1;state.logLines.append("[COPIED] "+source.path)
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 || state.completed == 1 || state.completed == state.total { progress(state);lastReport = Date() }
|
||||
}
|
||||
if cancelled() { throw CancellationError() }
|
||||
@@ -488,3 +488,79 @@ enum MediaByteCopy {
|
||||
catch { throw LibraryError.message("Could not import \(source.lastPathComponent) from \(source.deletingLastPathComponent().path) into \(destination.deletingLastPathComponent().path): \(error.localizedDescription)") }
|
||||
}
|
||||
}
|
||||
|
||||
/// DJ media and waveform cache relocation; the library database stays on this Mac.
|
||||
enum DJStorageMove {
|
||||
static let key = "dj.storage.v1"
|
||||
static func location(in library: Library) throws -> MediaStorageLocation? {
|
||||
guard let text = try library.setting(key) else { return nil }
|
||||
return try JSONDecoder().decode(MediaStorageLocation.self,from:Data(text.utf8))
|
||||
}
|
||||
static func run(database: URL,defaultRoot: URL,destination: URL,cancelled: () -> Bool = {false},progress: (ImportProgress) -> Void = {_ in}) throws -> ImportProgress {
|
||||
let db = try Library(url:database),old = try location(in:db)
|
||||
let oldRoots = Array(Set((old?.roots ?? [])+[defaultRoot.path,old?.path ?? defaultRoot.path])).map{URL(fileURLWithPath:$0)}
|
||||
let target = destination.resolvingSymlinksInPath(),media = target.appendingPathComponent("Media"),waves = target.appendingPathComponent("Waveforms")
|
||||
try ManagedMediaStorage.prepare(media);try ManagedMediaStorage.prepare(waves)
|
||||
let oldSongs = try db.songs()
|
||||
var cacheSources = [Int64:URL](),allCaches = Set<URL>()
|
||||
for folder in oldRoots.map({$0.appendingPathComponent("Waveforms")}) {
|
||||
if let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) {
|
||||
for file in files where file.pathExtension == "waveform" {
|
||||
let attributes = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
|
||||
if attributes.isRegularFile == true,attributes.isSymbolicLink != true { allCaches.insert(file.resolvingSymlinksInPath()) }
|
||||
}
|
||||
}
|
||||
for song in oldSongs where cacheSources[song.id] == nil {
|
||||
if let file = try? WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:folder),FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file.resolvingSymlinksInPath() }
|
||||
}
|
||||
}
|
||||
let manifestKey = "dj.storage.waveform.sources.v1"
|
||||
if let json = try db.setting(manifestKey),let saved = try? JSONDecoder().decode([String:String].self,from:Data(json.utf8)) {
|
||||
let folders = Set(oldRoots.map{$0.appendingPathComponent("Waveforms").resolvingSymlinksInPath()})
|
||||
for song in oldSongs where cacheSources[song.id] == nil {
|
||||
if let path = saved[String(song.id)] {
|
||||
let file = URL(fileURLWithPath:path).resolvingSymlinksInPath()
|
||||
if folders.contains(file.deletingLastPathComponent()),file.pathExtension == "waveform",FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file;allCaches.insert(file) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancelled() { throw CancellationError() }
|
||||
try db.setSetting(manifestKey,String(decoding:try JSONEncoder().encode(Dictionary(uniqueKeysWithValues:cacheSources.map{(String($0.key),$0.value.path)})),as:UTF8.self))
|
||||
var mediaRoots = oldRoots.map{$0.appendingPathComponent("Media")}
|
||||
if let json = try db.setting(MediaStorageLocation.key),let location = try? JSONDecoder().decode(MediaStorageLocation.self,from:Data(json.utf8)) { mediaRoots += (location.roots+[location.path]).map{URL(fileURLWithPath:$0)} }
|
||||
var state = try ManagedMediaStorage.move(database:database,roots:mediaRoots,destination:media,cancelled:cancelled,progress:{ value in var update = value;update.finished = false;progress(update) })
|
||||
state.finished = false
|
||||
// Commit the new location before the cache phase so a cancelled cache copy
|
||||
// still reopens the successfully relocated media on the next launch.
|
||||
let saved = ManagedMediaStorage.location(target,roots:oldRoots.map(\.path))
|
||||
try db.setSetting(key,String(decoding:try JSONEncoder().encode(saved),as:UTF8.self))
|
||||
let songs = try db.songs()
|
||||
var transfers = [URL:Set<URL>]()
|
||||
let referencedCaches = Set(cacheSources.values)
|
||||
for source in allCaches where !referencedCaches.contains(source) { transfers[source,default:[]].insert(waves.appendingPathComponent(source.lastPathComponent)) }
|
||||
for song in songs {
|
||||
if let source = cacheSources[song.id] { transfers[source,default:[]].insert(try WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:waves)) }
|
||||
}
|
||||
state.total += transfers.values.reduce(0){$0+$1.count}
|
||||
var lastCacheReport = Date.distantPast
|
||||
progress(state)
|
||||
for source in transfers.keys.sorted(by:{$0.path < $1.path}) {
|
||||
if cancelled() { throw CancellationError() }
|
||||
let hash = try ManagedMediaStorage.hash(source,cancelled:cancelled).map{String(format:"%02x",$0)}.joined()
|
||||
for target in transfers[source]!.sorted(by:{$0.path < $1.path}) {
|
||||
if cancelled() { throw CancellationError() }
|
||||
state.current = "Moving waveform "+source.lastPathComponent
|
||||
if source.standardizedFileURL != target.standardizedFileURL { try MediaByteCopy.copy(source,to:target,expectedHash:hash,cancelled:cancelled) }
|
||||
state.completed += 1;state.logLines.append("[COPIED] "+target.path)
|
||||
if Date().timeIntervalSince(lastCacheReport) >= 0.12 || state.completed == state.total { progress(state);lastCacheReport = Date() }
|
||||
}
|
||||
// All copies of this cache are verified before its original is removed.
|
||||
if source.deletingLastPathComponent().standardizedFileURL != waves.standardizedFileURL {
|
||||
let unchanged = try ManagedMediaStorage.hash(source,cancelled:cancelled).map{String(format:"%02x",$0)}.joined() == hash
|
||||
if unchanged { try FileManager.default.removeItem(at:source) }
|
||||
}
|
||||
}
|
||||
try db.setSetting(manifestKey,"")
|
||||
state.finished = true;state.current = "DJ media and waveforms moved";progress(state);return state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,9 +672,10 @@ struct ImportProgressPanel: View {
|
||||
let cancel: () -> Void
|
||||
let close: () -> Void
|
||||
var background: (() -> Void)? = nil
|
||||
var isMove = false
|
||||
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 { progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
|
||||
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" }
|
||||
var body: some View {
|
||||
VStack(alignment:.leading,spacing:20) {
|
||||
HStack(spacing:14) {
|
||||
@@ -682,7 +683,7 @@ struct ImportProgressPanel: View {
|
||||
.font(.system(size:26,weight:.medium)).foregroundStyle(accent).frame(width:52,height:52)
|
||||
.background(accent.opacity(0.12),in:RoundedRectangle(cornerRadius:12))
|
||||
VStack(alignment:.leading,spacing:4) {
|
||||
Text(studio.uppercased()+" LIBRARY").font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary)
|
||||
Text(studio.uppercased()+(isMove ? "" : " LIBRARY")).font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary)
|
||||
Text(title).font(.system(size:22,weight:.semibold))
|
||||
}
|
||||
Spacer()
|
||||
@@ -690,7 +691,7 @@ struct ImportProgressPanel: View {
|
||||
}
|
||||
VStack(alignment:.leading,spacing:9) {
|
||||
HStack {
|
||||
Text(progress.total > 0 ? "\(progress.completed.formatted()) of \(progress.total.formatted()) tracks checked" : "Scanning files and folders…").font(.callout)
|
||||
Text(progress.total > 0 ? "\(progress.completed.formatted()) of \(progress.total.formatted()) \(isMove ? "files verified" : "tracks checked")" : "Scanning files and folders…").font(.callout)
|
||||
Spacer()
|
||||
Text(progress.total > 0 ? "\(Int(fraction*100))%" : "Preparing").font(.callout.weight(.semibold)).monospacedDigit().foregroundStyle(accent)
|
||||
}
|
||||
@@ -704,11 +705,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("Imported",value:max(0,progress.completed-progress.skipped),color:.green)
|
||||
count(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).frame(height:260)
|
||||
ImportLogView(progress:progress,title:isMove ? "Transfer Log" : "Import Log").frame(height:260)
|
||||
Divider()
|
||||
HStack {
|
||||
Text(progress.finished ? "Review the log or copy it before closing." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
|
||||
@@ -717,7 +718,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("Cancel Import",action:cancel).keyboardShortcut(.cancelAction)
|
||||
Button(isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
}.padding(26).frame(width:660)
|
||||
@@ -735,12 +736,13 @@ struct ImportProgressPanel: View {
|
||||
}
|
||||
struct ImportLogView: View {
|
||||
let progress: ImportProgress
|
||||
var title = "Import Log"
|
||||
@State private var follow = true
|
||||
var body: some View {
|
||||
let lines = progress.reportLines
|
||||
VStack(alignment:.leading,spacing:8) {
|
||||
HStack {
|
||||
Text("Import Log").font(.headline)
|
||||
Text(title).font(.headline)
|
||||
Spacer()
|
||||
if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) }
|
||||
Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) }
|
||||
@@ -1110,7 +1112,6 @@ struct LibraryView: View {
|
||||
navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count)
|
||||
navigationRow("ShowScreens",symbol:"rectangle.on.rectangle")
|
||||
navigationRow("Music Stores",symbol:"bag")
|
||||
Button { studioSettingsModal = true } label: { Label("Settings…",systemImage:"gearshape").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain)
|
||||
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
|
||||
}.listStyle(.sidebar)
|
||||
Divider().padding(.horizontal,14)
|
||||
|
||||
+22
-4
@@ -59,7 +59,22 @@ import CoreAudio
|
||||
let first = AVAudioFramePosition(start*source.processingFormat.sampleRate)
|
||||
let count = AVAudioFrameCount(max(1,min(source.length-first,AVAudioFramePosition((end-start)*source.processingFormat.sampleRate))))
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat:source.processingFormat,frameCapacity:count) else { throw LibraryError.message("Cannot prepare loop audio") }
|
||||
source.framePosition = first;try source.read(into:buffer,frameCount:count);try Task.checkCancellation();return buffer
|
||||
source.framePosition = first;try source.read(into:buffer,frameCount:count);try Task.checkCancellation()
|
||||
// Meet the first sample at the loop seam without changing its beat length.
|
||||
if let data = buffer.floatChannelData {
|
||||
let frames = Int(buffer.frameLength),stride = Int(buffer.stride)
|
||||
let blend = min(frames/4,Int(source.processingFormat.sampleRate*0.003))
|
||||
if blend > 1 {
|
||||
for channel in 0..<Int(source.processingFormat.channelCount) {
|
||||
let samples = data[channel],head = samples[0]
|
||||
for i in 0..<blend {
|
||||
let mix = Float(i)/Float(blend-1),index = (frames-blend+i)*stride
|
||||
samples[index] = samples[index]*(1-mix)+head*mix
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
do {
|
||||
let buffer = try await withTaskCancellationHandler(operation:{try await worker.value},onCancel:{worker.cancel()})
|
||||
@@ -72,7 +87,7 @@ import CoreAudio
|
||||
if let pending = self.pendingLoopSwitch,raw >= pending.at { activeRange = pending.range }
|
||||
else { activeRange = self.scheduledLoop ?? start...end }
|
||||
let remaining = max(0,activeRange.upperBound-self.loopPosition(raw))
|
||||
self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop])
|
||||
self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil)
|
||||
self.pendingLoopSwitch = (raw+remaining,start...end)
|
||||
} else {
|
||||
self.loopAudioRestartCount += 1
|
||||
@@ -158,6 +173,9 @@ import CoreAudio
|
||||
private var videoPlayer: AVPlayer?
|
||||
private var videoOutput: AVPlayerItemVideoOutput?
|
||||
private let videoContext = CIContext()
|
||||
func useWaveformCache(at directory: URL) {
|
||||
waveformTask?.cancel();waveformGeneration = UUID();waveformCacheDirectory = directory
|
||||
}
|
||||
private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") }
|
||||
private var restoringMix = true
|
||||
private var persistsMix = true
|
||||
@@ -245,7 +263,7 @@ import CoreAudio
|
||||
@Published private(set) var waveformUnavailable = false
|
||||
@Published private(set) var waveformFromCache = false
|
||||
@Published private(set) var waveformSaved = false
|
||||
private let waveformCacheDirectory: URL
|
||||
private var waveformCacheDirectory: URL
|
||||
private var audioURL: URL?
|
||||
var sourceAudioURL: URL? { audioURL }
|
||||
private var waveformTask: Task<Void, Never>?
|
||||
@@ -665,7 +683,7 @@ enum WaveformCache {
|
||||
return analysis
|
||||
}
|
||||
static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws {
|
||||
try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true)
|
||||
try ManagedMediaStorage.prepare(directory)
|
||||
let encoder = PropertyListEncoder();encoder.outputFormat = .binary
|
||||
try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user