importer chnges
This commit is contained in:
@@ -77,6 +77,8 @@ struct SongDragReference: Codable, Sendable {
|
||||
@Published var hostSettings = HostSettings()
|
||||
@Published var rotationSingers = [RotationSinger]()
|
||||
@Published var showScreens = [ShowScreen]()
|
||||
@Published var namesBusy = false
|
||||
@Published private(set) var canUndoNames = false
|
||||
@Published var tagsBusy = false
|
||||
@Published var tagsStatus = ""
|
||||
let singerServer = SingerWebServer()
|
||||
@@ -88,7 +90,7 @@ struct SongDragReference: Codable, Sendable {
|
||||
private var waveformJobTask: Task<Void,Never>?
|
||||
private var waveformJobToken: UUID?
|
||||
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
|
||||
guard !generatingWaveforms,scanningSource == nil,!isImporting,!movingMedia else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
|
||||
guard !generatingWaveforms,scanningSource == nil,!isImporting,!movingMedia,!namesBusy 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")
|
||||
@@ -155,7 +157,7 @@ struct SongDragReference: Codable, Sendable {
|
||||
rotationSingers = try library.rotation(currentVenue)
|
||||
hostSettings = try library.setting("host.\(currentVenue)").flatMap { $0.data(using:.utf8) }.flatMap { try? JSONDecoder().decode(HostSettings.self,from:$0) } ?? .init()
|
||||
} else { rotationSingers = [];hostSettings = .init() }
|
||||
player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade;refreshTuning()
|
||||
player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade;refreshTuning();canUndoNames = try library.hasNameUndo()
|
||||
}
|
||||
func chooseVenue(_ id: Int64?) {
|
||||
perform {
|
||||
@@ -322,9 +324,9 @@ struct SongDragReference: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int) throws {
|
||||
func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int,trackNumber: String? = nil,rendition: String? = nil) throws {
|
||||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||||
try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating)
|
||||
try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating,trackNumber:trackNumber,rendition:rendition)
|
||||
if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) }
|
||||
try refresh()
|
||||
}
|
||||
@@ -349,7 +351,7 @@ struct SongDragReference: Codable, Sendable {
|
||||
try refresh();return id
|
||||
}
|
||||
func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) {
|
||||
guard scanningSource == nil,!generatingWaveforms,!isImporting,!movingMedia else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
|
||||
guard scanningSource == nil,!generatingWaveforms,!isImporting,!movingMedia,!namesBusy 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 = managedMediaDirectory,cache = root.appendingPathComponent("Waveforms")
|
||||
@@ -392,7 +394,7 @@ struct SongDragReference: Codable, Sendable {
|
||||
managedMediaDirectory = url
|
||||
if !mediaAccessURLs.contains(url),url.startAccessingSecurityScopedResource() { mediaAccessURLs.append(url) }
|
||||
}
|
||||
private var storageBusy: Bool { isImporting || movingMedia || scanningSource != nil || generatingWaveforms }
|
||||
private var storageBusy: Bool { isImporting || movingMedia || namesBusy || scanningSource != nil || generatingWaveforms }
|
||||
func chooseMediaStorage(moveExisting:Bool = false) {
|
||||
guard !storageBusy else { error = "Finish or cancel the current import, scan or move first.";return }
|
||||
let panel = NSOpenPanel();panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true;panel.allowsMultipleSelection = false
|
||||
@@ -448,7 +450,7 @@ struct SongDragReference: Codable, Sendable {
|
||||
private var importTask: Task<Void,Never>?
|
||||
private var importToken: UUID?
|
||||
func importFiles(_ urls: [URL]) {
|
||||
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia else { error = "Wait for the current import, scan or waveform job to finish first.";return }
|
||||
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia,!namesBusy else { error = "Wait for the current import, scan or waveform job to finish first.";return }
|
||||
guard !urls.isEmpty else { return }
|
||||
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory
|
||||
importToken = token;isImporting = true;importProgress = ImportProgress()
|
||||
@@ -482,6 +484,8 @@ struct SongDragReference: Codable, Sendable {
|
||||
}
|
||||
private struct LibraryCommands {
|
||||
var importMusic: () -> Void
|
||||
var reinterpret: () -> Void
|
||||
var canReinterpret: Bool
|
||||
var songInfo: () -> Void
|
||||
var canEdit: Bool
|
||||
var play: () -> Void
|
||||
@@ -497,7 +501,7 @@ private extension FocusedValues {
|
||||
}
|
||||
}
|
||||
@main struct KJMixProApp: App {
|
||||
@StateObject private var model = AppModel()
|
||||
@StateObject private var model = AppModel(storageURL:ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil ? nil : FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-TestHost-"+String(ProcessInfo.processInfo.processIdentifier)))
|
||||
@AppStorage("studioAppearance") private var appearance = "system"
|
||||
@FocusedValue(\.libraryCommands) private var actions
|
||||
var body: some Scene {
|
||||
@@ -519,6 +523,10 @@ private extension FocusedValues {
|
||||
Button("Song Info…") { actions?.songInfo() }.keyboardShortcut("i").disabled(actions?.canEdit != true)
|
||||
Button("Add Drive or Folder…") { model.chooseMediaLocation() }
|
||||
}
|
||||
CommandMenu("Advanced") {
|
||||
Button("Reinterpret Names…") { actions?.reinterpret() }.disabled(actions?.canReinterpret != true || model.namesBusy)
|
||||
Button("Undo Last Name Reinterpretation") { Task { do { try await model.undoNameChanges() } catch { model.error = error.localizedDescription } } }.disabled(!model.canUndoNames || model.namesBusy)
|
||||
}
|
||||
CommandMenu("Playback") {
|
||||
Button("Play / Pause") { if model.player.duration > 0 { model.player.toggle() } else { actions?.play() } }.keyboardShortcut(.space,modifiers:[])
|
||||
Button("Stop Deck (Keep Track Loaded)") { model.player.stopDeck() }.keyboardShortcut(".",modifiers:[.command,.shift])
|
||||
@@ -646,6 +654,8 @@ struct LibraryView: View {
|
||||
@State private var tableSelection = Set<Int64>()
|
||||
@State private var waveformDetails = false
|
||||
@State private var batchEditing = false
|
||||
@State private var reinterpreting = false
|
||||
@State private var reinterpretIDs = Set<Int64>()
|
||||
@State private var metadataLookup: Song?
|
||||
@State private var confirmWriteTags = false
|
||||
@State private var search = ""
|
||||
@@ -717,7 +727,7 @@ struct LibraryView: View {
|
||||
}
|
||||
.background(StudioTheme.frame)
|
||||
.tint(.accentColor)
|
||||
.focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"}))
|
||||
.focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},reinterpret:{beginReinterpretation()},canReinterpret:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty),songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"}))
|
||||
.navigationTitle(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source)
|
||||
.navigationSubtitle(model.currentVenueName)
|
||||
.searchable(text:$search,placement:.toolbar,prompt:"Search Music")
|
||||
@@ -760,6 +770,7 @@ struct LibraryView: View {
|
||||
}.padding(24).frame(width:560)
|
||||
}
|
||||
.sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) }
|
||||
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) }
|
||||
.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) {
|
||||
Button("Write File Tags") { model.writeFileTags(tableSelection) }
|
||||
@@ -947,6 +958,8 @@ struct LibraryView: View {
|
||||
Text(song.title).fontWeight(.medium).lineLimit(1)
|
||||
}.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() }
|
||||
}.width(min:180,ideal:220,max:600)
|
||||
TableColumn("Track",value:\Song.trackOrder) { song in Text(song.trackNumber.isEmpty ? "—" : song.trackNumber).monospacedDigit().foregroundStyle(.secondary) }.width(45)
|
||||
TableColumn("Rendition",value:\Song.rendition) { song in Text(song.rendition.isEmpty ? "—" : song.rendition).foregroundStyle(.secondary) }.width(min:70,ideal:100,max:220)
|
||||
TableColumn("Artist",value:\Song.artist) { song in Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary) }.width(min:90,ideal:110,max:240)
|
||||
TableColumn("Album",value:\Song.album) { song in Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary) }.width(min:85,ideal:95,max:220)
|
||||
TableColumn("Time",value:\Song.duration) { song in Text(durationLabel(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(50)
|
||||
@@ -969,6 +982,7 @@ struct LibraryView: View {
|
||||
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("Reinterpret Names…") { beginReinterpretation(ids) }
|
||||
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) }
|
||||
@@ -989,6 +1003,7 @@ struct LibraryView: View {
|
||||
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("Reinterpret Name…") { beginReinterpretation([song.id]) }
|
||||
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:"))
|
||||
@@ -1160,6 +1175,7 @@ struct LibraryView: View {
|
||||
Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary)
|
||||
RepeatButton(player:model.player).buttonStyle(.borderless)
|
||||
Menu {
|
||||
Button("Reinterpret Selected Names…") { beginReinterpretation() }.disabled(tableSelection.isEmpty && selectedSong == nil)
|
||||
Button("Batch Edit Selected…") { batchEditing = true }.disabled(tableSelection.isEmpty)
|
||||
Button("Find Metadata Online…") { metadataLookup = selectedSong }.disabled(selectedSong == nil)
|
||||
Button("Write File Tags…") { confirmWriteTags = true }.disabled(tableSelection.isEmpty || model.tagsBusy)
|
||||
@@ -1169,6 +1185,10 @@ struct LibraryView: View {
|
||||
} label: { Image(systemName:"ellipsis.circle") }.menuStyle(.borderlessButton).frame(width:20).help("Library actions")
|
||||
}
|
||||
}
|
||||
private func beginReinterpretation(_ ids:Set<Int64>? = nil) {
|
||||
let chosen = ids ?? (source == "Library" && !tableSelection.isEmpty ? tableSelection : Set(selected.map{[$0]} ?? []))
|
||||
guard !chosen.isEmpty else { return };reinterpretIDs = chosen;reinterpreting = true
|
||||
}
|
||||
private func playSelected() {
|
||||
guard let song = selectedSong else { return }
|
||||
model.perform { try model.play(song) }
|
||||
@@ -1275,6 +1295,8 @@ private struct SongMetadataSheet: View {
|
||||
@State private var artist = ""
|
||||
@State private var album = ""
|
||||
@State private var label = ""
|
||||
@State private var track = ""
|
||||
@State private var rendition = ""
|
||||
@State private var rating = 0
|
||||
@State private var error: String?
|
||||
var body: some View {
|
||||
@@ -1285,6 +1307,8 @@ private struct SongMetadataSheet: View {
|
||||
GridRow { Text("Artist");TextField("Artist",text:$artist) }
|
||||
GridRow { Text("Album");TextField("Album",text:$album) }
|
||||
GridRow { Text("Label");TextField("Label / publisher",text:$label) }
|
||||
GridRow { Text("Track");TextField("Track number",text:$track) }
|
||||
GridRow { Text("Rendition");TextField("Version / vocals",text:$rendition) }
|
||||
GridRow { Text("Rating");GoldRating(rating:$rating) }
|
||||
}.textFieldStyle(.roundedBorder)
|
||||
Text("Saved to your library. Embedded audio-file tags are not changed, and re-importing will preserve your library edits.").font(.caption).foregroundStyle(.secondary)
|
||||
@@ -1292,9 +1316,9 @@ private struct SongMetadataSheet: View {
|
||||
if let error { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
|
||||
}.padding(24).frame(width:500).background(StudioTheme.frame)
|
||||
.onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating }
|
||||
.onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating;track = song.trackNumber;rendition = song.rendition }
|
||||
}
|
||||
private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } }
|
||||
private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating,trackNumber:track,rendition:rendition);dismiss() } catch { self.error = error.localizedDescription } }
|
||||
}
|
||||
private struct LibraryLocationsPane: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
Reference in New Issue
Block a user