2125 lines
149 KiB
Swift
2125 lines
149 KiB
Swift
import SwiftUI
|
||
import Combine
|
||
import UniformTypeIdentifiers
|
||
import AVFoundation
|
||
import AppKit
|
||
|
||
enum EntityKind: String, Identifiable {
|
||
case singer, venue
|
||
var id: String { rawValue }
|
||
var title: String { self == .singer ? "Singer" : "Venue" }
|
||
var table: String { self == .singer ? "singers" : "venues" }
|
||
}
|
||
|
||
/// Only an internal, same-process song reference is exported, never media or file URLs.
|
||
struct SongDragReference: Codable, Sendable {
|
||
static let type = UTType.plainText
|
||
var song: Int64
|
||
var library: String
|
||
var singer: Int64?
|
||
var venue: Int64?
|
||
var fromTonight: Bool
|
||
var transportText: String { "KJMix.song.v1:"+((try? JSONEncoder().encode(self)) ?? Data()).base64EncodedString() }
|
||
func provider() -> NSItemProvider {
|
||
let provider = NSItemProvider(object:transportText as NSString)
|
||
provider.suggestedName = "KJMix song"
|
||
return provider
|
||
}
|
||
static func load(_ provider: NSItemProvider) async throws -> Self {
|
||
let text: String = try await withCheckedThrowingContinuation { continuation in
|
||
_ = provider.loadObject(ofClass:NSString.self) { object,error in
|
||
if let string = object as? String { continuation.resume(returning:string) }
|
||
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read dragged song")) }
|
||
}
|
||
}
|
||
guard text.hasPrefix("KJMix.song.v1:"),let decoded = Data(base64Encoded:String(text.dropFirst("KJMix.song.v1:".count))) else { throw LibraryError.message("Drag a song from the KJMix Pro library") }
|
||
return try JSONDecoder().decode(Self.self,from:decoded)
|
||
}
|
||
|
||
}
|
||
@MainActor final class AppModel: ObservableObject {
|
||
@Published var songs = [Song]()
|
||
@Published var singers = [PersonPlace]()
|
||
@Published var singerSongIDs = [Int64:[Int64]]()
|
||
@Published var favourites = [Int64:Set<Int64>]()
|
||
@Published var musicStores = [MusicStore]()
|
||
@Published var readingDurations = false
|
||
@Published var mediaLocations = [MediaLocation]()
|
||
@Published var scanProgress: SourceScanProgress?
|
||
@Published private(set) var scanningSource: Int64?
|
||
private var scanTask: Task<Void,Never>?
|
||
private var scanToken: UUID?
|
||
var newSingerSongs = Set<Int64>()
|
||
private var songIndex = [Int64:Song]()
|
||
private let dragSession = UUID().uuidString
|
||
@Published var venues = [PersonPlace]()
|
||
@Published var venueSingers = [PersonPlace]()
|
||
@Published var playlists = [PersonPlace]()
|
||
@Published var queue = [QueueEntry]()
|
||
@Published var tonightSongIDs = [Int64:[Int64]]()
|
||
@Published var history = [Performance]()
|
||
@Published private(set) var currentVenue: Int64?
|
||
@Published var selectedSinger: Int64? { didSet { if selectedSinger != oldValue { refreshTuning() } } }
|
||
@Published private(set) var tuningCache = [Int64:SongPreference]()
|
||
@Published var expandedSingerIDs = Set<Int64>()
|
||
@Published var activeSection = "Library"
|
||
@Published var studio = StudioSettings() { didSet { applyStudioSettings(save:true,previous:oldValue) } }
|
||
let backgroundDeck = BackgroundDeck()
|
||
@Published var karaokeOutputUID = ""
|
||
let dj: DJSystem
|
||
var backgroundAutomationReady = false
|
||
var backgroundPlaybackObserver: AnyCancellable?
|
||
var backgroundHandoff: Task<Void,Never>?
|
||
let rotationSession = UUID().uuidString
|
||
var rotationDeckSongID: Int64?
|
||
var rotationDeckVenue: Int64?
|
||
@Published var rotationPlaybackActive = false
|
||
@Published var autoAdvance = true
|
||
@Published var playbackSinger: Int64?
|
||
@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()
|
||
var playingIntermission = false
|
||
@Published var error: String?
|
||
@Published var creating: EntityKind?
|
||
@Published var automaticWaveformProgress: WaveformJobProgress?
|
||
let automaticWaveforms = PostImportWaveformQueue()
|
||
private func queueImportedWaveforms(_ ids: Set<Int64>) {
|
||
automaticWaveforms.enqueue(songs.filter{ids.contains($0.id)},locations:mediaLocations,cache:root.appendingPathComponent("Waveforms"),update:{ [weak self] in self?.automaticWaveformProgress = $0 },completed:{ [weak self] tracks in
|
||
guard let self,let id = self.player.currentSongID,tracks.contains(where:{$0.id == id}) else { return };self.player.refreshCachedWaveform()
|
||
})
|
||
}
|
||
@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,!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")
|
||
waveformJobToken = token;generatingWaveforms = true;waveformJob = .init(total:tracks.count)
|
||
waveformJobTask = Task.detached(priority:.utility) { [weak self] in
|
||
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force,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
|
||
@Published private(set) var managedMediaDirectory: URL
|
||
@Published private(set) var movingMedia = false
|
||
@Published private(set) var storageProgress: ImportProgress?
|
||
private var storageTask: Task<Void,Never>?
|
||
private var storageToken: UUID?
|
||
private var mediaAccessURLs = [URL]()
|
||
var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" }
|
||
init(storageURL: URL? = nil) {
|
||
root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone")
|
||
dj = DJSystem(storageURL:root.appendingPathComponent("DJ"))
|
||
managedMediaDirectory = root.appendingPathComponent("Media")
|
||
player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
|
||
perform {
|
||
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
|
||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));loadMediaStorage();try refresh()
|
||
if try library?.setting("store.profiles.v1") == nil { installStoreProfiles();try library?.setSetting("store.profiles.v1","1") }
|
||
}
|
||
if let data = try? Data(contentsOf:root.appendingPathComponent("StudioSettings.json")),let saved = try? JSONDecoder().decode(StudioSettings.self,from:data) { studio = saved.sanitized }
|
||
applyStudioSettings(save:false)
|
||
backgroundPlaybackObserver = player.$playing.dropFirst().sink { [weak self] _ in
|
||
Task { @MainActor [weak self] in self?.scheduleBackgroundHandoff() }
|
||
}
|
||
player.onWillStartPlayback = { [weak self] in self?.backgroundDeck.prepareForSinger() ?? 0 }
|
||
player.onPlaybackReady = { [weak self] in self?.backgroundDeck.pauseForSinger() }
|
||
player.onGlobalMixChanged = { [weak self] mix in self?.backgroundDeck.apply(mix:mix) }
|
||
player.onFinished = { [weak self] in
|
||
guard let self else { return }
|
||
guard self.autoAdvance, !self.rotationPlan.isEmpty else { self.rotationPlaybackActive = false;return }
|
||
self.advanceShow()
|
||
}
|
||
backgroundAutomationReady = true
|
||
}
|
||
func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } }
|
||
func refresh() throws {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
songs = try library.songs();songIndex = Dictionary(uniqueKeysWithValues:songs.map { ($0.id,$0) }); singers = try library.entities("singers"); venues = try library.entities("venues")
|
||
singerSongIDs = try library.singerSongIDs();favourites = try library.favouriteSongIDs();musicStores = try library.stores();mediaLocations = try library.mediaLocations()
|
||
playlists = try library.entities("playlists"); currentVenue = try library.currentVenue()
|
||
venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers
|
||
if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil }
|
||
tonightSongIDs = try library.tonightSongIDs(venue:currentVenue)
|
||
queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue)
|
||
showScreens = try library.screens()
|
||
if let currentVenue {
|
||
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();canUndoNames = try library.hasNameUndo()
|
||
}
|
||
func chooseVenue(_ id: Int64?) {
|
||
perform {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
try library.selectVenue(id); selectedSinger = nil;playingIntermission = false;try refresh()
|
||
}
|
||
}
|
||
func membership(singer: Int64, included: Bool) {
|
||
perform {
|
||
guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") }
|
||
try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh()
|
||
}
|
||
}
|
||
@discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines)
|
||
try library.addEntity(kind.table,name:trimmed)
|
||
guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") }
|
||
if kind == .singer {
|
||
if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) }
|
||
selectedSinger = entity.id
|
||
} else { try library.selectVenue(entity.id); selectedSinger = nil }
|
||
try refresh(); return entity.id
|
||
}
|
||
func beginSinger(song: Int64? = nil) { newSingerSongs = Set(song.map { [$0] } ?? []); creating = .singer }
|
||
func songs(for singer: Int64) -> [Song] {
|
||
(singerSongIDs[singer] ?? []).compactMap { songIndex[$0] }
|
||
}
|
||
func assign(_ song: Int64, to singer: Int64, included: Bool = true) {
|
||
perform {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
if included { try library.assignSong(song,singer:singer) } else { try library.removeSingerSong(song,singer:singer) }
|
||
try refresh()
|
||
}
|
||
}
|
||
func createSinger(name: String, venue: Int64, songs: Set<Int64>) throws -> Int64 {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
guard venues.contains(where: { $0.id == venue }) else { throw LibraryError.message("Choose a venue first") }
|
||
let name = name.trimmingCharacters(in:.whitespacesAndNewlines)
|
||
try library.execute("BEGIN IMMEDIATE")
|
||
let id: Int64
|
||
do {
|
||
try library.addEntity("singers",name:name)
|
||
guard let singer = try library.entities("singers").first(where: { $0.name == name }) else { throw LibraryError.message("Could not load singer") }
|
||
id = singer.id
|
||
try library.setMembership(singer:id,venue:venue,included:true)
|
||
try library.markHere(singer:id,venue:venue,here:true)
|
||
for song in songs.sorted() { try library.addTonight(song:song,singer:id,venue:venue,transaction:false) }
|
||
try library.selectVenue(venue);try library.execute("COMMIT")
|
||
} catch { try? library.execute("ROLLBACK");throw error }
|
||
try refresh();selectedSinger = id;expandedSingerIDs.insert(id);return id
|
||
}
|
||
func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } }
|
||
func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } }
|
||
func refreshTuning() { if let library { tuningCache = (try? library.tuningSnapshot(singer:selectedSinger)) ?? [:] } }
|
||
func preference(_ song: Int64) -> SongPreference { tuningCache[song] ?? .init() }
|
||
func savePreference(song: Int64, value: SongPreference) {
|
||
perform {
|
||
guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") }
|
||
try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh()
|
||
}
|
||
}
|
||
func play(_ song: Song, singer: Int64? = nil) throws {
|
||
rotationPlaybackActive = false;rotationDeckSongID = nil;rotationDeckVenue = nil
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
let singer = singer ?? selectedSinger
|
||
if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") }
|
||
let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? library.defaultPreference(song:song.id)
|
||
if let screen = showScreens.first(where:{$0.id == song.id}) { try playScreen(screen);return }
|
||
try player.load(song,preference:preference);playbackSinger = singer
|
||
do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer
|
||
if let singer,let currentVenue { hostSettings.cursor = singer;try library.setSetting("host.\(currentVenue)",String(decoding:JSONEncoder().encode(hostSettings),as:UTF8.self)) }
|
||
try refresh() }
|
||
catch { player.stop(); throw error }
|
||
}
|
||
func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } }
|
||
func tonightSongs(for singer: Int64) -> [Song] { (tonightSongIDs[singer] ?? []).compactMap { songIndex[$0] } }
|
||
func addToTonight(_ song: Int64,singer: Int64? = nil) {
|
||
perform {
|
||
guard let library,let currentVenue,let singer = singer ?? selectedSinger else { throw LibraryError.message("Choose a current venue and singer, then add the song to Tonight") }
|
||
try library.addTonight(song:song,singer:singer,venue:currentVenue);try library.markHere(singer:singer,venue:currentVenue,here:true);try refresh()
|
||
}
|
||
}
|
||
func removeFromTonight(_ song: Int64,singer: Int64) {
|
||
perform { guard let library,let currentVenue else { return };try library.removeTonight(song:song,singer:singer,venue:currentVenue);try refresh() }
|
||
}
|
||
func playTonight(_ song: Song,singer: Int64) {
|
||
perform {
|
||
try play(song,singer:singer)
|
||
if let entry = queue.first(where:{ $0.songID == song.id && $0.singerID == singer }) { try library?.removeQueue(entry.id) }
|
||
try refresh()
|
||
}
|
||
}
|
||
func dragReference(_ song: Int64,singer: Int64? = nil,tonight: Bool = false) -> SongDragReference {
|
||
.init(song:song,library:dragSession,singer:singer,venue:currentVenue,fromTonight:tonight)
|
||
}
|
||
func placeDroppedSong(_ reference: SongDragReference,singer: Int64,venue: Int64,before: Int64?) throws {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
guard reference.library == dragSession, songIndex[reference.song] != nil else { throw LibraryError.message("Drop a song from this library") }
|
||
guard currentVenue == venue else { throw LibraryError.message("The current venue changed. Please drop again.") }
|
||
let reorder = reference.fromTonight && reference.singer == singer && reference.venue == venue && tonightSongIDs[singer]?.contains(reference.song) == true
|
||
try library.placeTonight(song:reference.song,singer:singer,venue:venue,before:before,addIfNeeded:!reorder)
|
||
if !reorder { try library.markHere(singer:singer,venue:venue,here:true) }
|
||
try refresh()
|
||
}
|
||
func acceptSongDrop(_ providers: [NSItemProvider],singer: Int64,venue: Int64?,before: Int64?) -> Bool {
|
||
guard let venue else { return false }
|
||
let providers = providers.filter { $0.hasItemConformingToTypeIdentifier(SongDragReference.type.identifier) }
|
||
guard !providers.isEmpty else { return false }
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
do { for provider in providers { let reference = try await SongDragReference.load(provider);try self.placeDroppedSong(reference,singer:singer,venue:venue,before:before) } }
|
||
catch { self.error = error.localizedDescription }
|
||
}
|
||
return true
|
||
}
|
||
func moveTonight(_ song: Int64,singer: Int64,by offset: Int) {
|
||
perform {
|
||
guard let library,let currentVenue,let ids = tonightSongIDs[singer],let index = ids.firstIndex(of:song),ids.indices.contains(index+offset) else { return }
|
||
let destination = index+offset
|
||
let before = offset < 0 ? ids[destination] : destination+1 < ids.count ? ids[destination+1] : nil
|
||
try library.placeTonight(song:song,singer:singer,venue:currentVenue,before:before,addIfNeeded:false);try refresh()
|
||
}
|
||
}
|
||
func playQueueNext() {
|
||
perform {
|
||
guard let row = rotationPlan.first, let song = songs.first(where: { $0.id == row.songID }) else { return }
|
||
// Explicitly clear selection for unassigned queue entries.
|
||
playingIntermission = false;selectedSinger = row.singerID
|
||
try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh();rotationPlaybackActive = true;rotationDeckSongID = song.id;rotationDeckVenue = currentVenue
|
||
}
|
||
}
|
||
func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } }
|
||
func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } }
|
||
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } }
|
||
func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } }
|
||
func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } }
|
||
func tuningForControls(_ song:Int64) -> SongPreference {
|
||
var value = preference(song)
|
||
if player.currentSongID == song,player.duration > 0 { value.pitch = player.pitch;value.tempo = player.tempo }
|
||
return value
|
||
}
|
||
var liveSingerName: String { playbackSinger.flatMap { id in singers.first{$0.id == id}?.name } ?? "No singer · Temporary" }
|
||
func updateLiveTuning(pitch:Double? = nil,tempo:Double? = nil) {
|
||
perform {
|
||
guard let song = player.currentSongID,player.duration > 0,let library else { throw LibraryError.message("Load a song before adjusting live key or tempo") }
|
||
let nextPitch = pitch ?? player.pitch,nextTempo = tempo ?? player.tempo
|
||
guard nextPitch.isFinite,nextTempo.isFinite,(-12...12).contains(nextPitch),(0.5...1.5).contains(nextTempo) else { throw LibraryError.message("Use a key from −12 to +12 and tempo from 50% to 150%") }
|
||
if let singer = playbackSinger {
|
||
var saved = try library.preference(song:song,singer:singer)
|
||
saved.pitch = nextPitch;saved.tempo = nextTempo
|
||
try library.savePreference(song:song,singer:singer,value:saved)
|
||
if selectedSinger == singer { tuningCache[song] = saved }
|
||
}
|
||
player.pitch = nextPitch;player.tempo = nextTempo;objectWillChange.send()
|
||
}
|
||
}
|
||
func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) {
|
||
if player.currentSongID == song,player.duration > 0 { updateLiveTuning(pitch:pitch,tempo:tempo);return }
|
||
perform {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
var value = preference(song)
|
||
if let pitch { value.pitch = pitch };if let tempo { value.tempo = tempo }
|
||
if let selectedSinger { try library.savePreference(song:song,singer:selectedSinger,value:value) }
|
||
else { try library.saveDefaultPreference(song:song,value:value) }
|
||
if player.currentSongID == song && playbackSinger == selectedSinger { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } }
|
||
tuningCache[song] = value
|
||
}
|
||
}
|
||
func saveStore(_ store: MusicStore) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.saveStore(store);try refresh() }
|
||
func deleteStore(_ id: Int64) { perform { try library?.deleteStore(id);try refresh() } }
|
||
func scanDurations() {
|
||
guard !readingDurations else { return };readingDurations = true
|
||
let songs = self.songs.filter { $0.duration == 0 }
|
||
Task.detached(priority:.utility) { [weak self] in
|
||
var durations = [(Int64,Double)]()
|
||
for song in songs {
|
||
if let file = try? AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) { durations.append((song.id,Double(file.length)/file.processingFormat.sampleRate)) }
|
||
}
|
||
let completedDurations = durations
|
||
await MainActor.run { [weak self] in
|
||
guard let self else { return }
|
||
self.perform { for (id,value) in completedDurations { try self.library?.updateDuration(id,seconds:value) };try self.refresh() }
|
||
self.readingDurations = false
|
||
}
|
||
}
|
||
}
|
||
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,trackNumber:trackNumber,rendition:rendition)
|
||
if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) }
|
||
try refresh()
|
||
}
|
||
func chooseMediaLocation() {
|
||
let panel = NSOpenPanel();panel.title = "Add Library Location";panel.message = "Choose a folder or mounted external drive. KJMix Pro will index it and prepare its waveforms."
|
||
panel.canChooseDirectories = true;panel.canChooseFiles = false;panel.allowsMultipleSelection = false;panel.canCreateDirectories = false;panel.prompt = "Add & Prepare"
|
||
panel.directoryURL = URL(fileURLWithPath:"/Volumes")
|
||
panel.begin { [weak self] response in
|
||
guard response == .OK,let url = panel.url else { return }
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
self.perform { let id = try self.addMediaLocation(url);self.startSourceScan(id) }
|
||
}
|
||
}
|
||
}
|
||
@discardableResult func addMediaLocation(_ url: URL) throws -> Int64 {
|
||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||
let access = url.startAccessingSecurityScopedResource();defer { if access { url.stopAccessingSecurityScopedResource() } }
|
||
let bookmark = (try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? url.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil))
|
||
let external = (try? url.resourceValues(forKeys:[.volumeIsInternalKey]).volumeIsInternal) == false || url.path.hasPrefix("/Volumes/")
|
||
let id = try library.saveMediaLocation(name:url.lastPathComponent.isEmpty ? "Library location" : url.lastPathComponent,url:url,bookmark:bookmark,external:external)
|
||
try refresh();return id
|
||
}
|
||
func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) {
|
||
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")
|
||
scanTask = Task.detached(priority:.utility) { [weak self] in
|
||
let result: Result<SourceScanProgress,Error>
|
||
do {
|
||
result = .success(try LibraryScanner.scan(source,database:database,media:media,cache:cache,forceWaveforms:forceWaveforms,cancelled:{ Task.isCancelled },progress:{ update in
|
||
Task { @MainActor [weak self] in guard let self,self.scanToken == token else { return };self.scanProgress = update }
|
||
}))
|
||
} catch { result = .failure(error) }
|
||
await MainActor.run { [weak self] in
|
||
guard let self,self.scanToken == token else { return }
|
||
switch result {
|
||
case .success(let progress):self.scanProgress = progress
|
||
case .failure(let error):
|
||
var progress = SourceScanProgress(source:id);progress.finished = true;progress.issues = 1;progress.current = error.localizedDescription;progress.messages = [error.localizedDescription];self.scanProgress = progress
|
||
try? self.library?.finishSourceScan(id,path:source.path,message:error.localizedDescription)
|
||
}
|
||
self.scanToken = nil;self.scanningSource = nil;self.scanTask = nil;self.perform { try self.refresh() }
|
||
}
|
||
}
|
||
}
|
||
func cancelSourceScan() { scanTask?.cancel();scanProgress?.current = "Cancelling after the current read/extraction…" }
|
||
func removeMediaLocation(_ id: Int64) {
|
||
guard scanningSource == nil else { error = "Finish or cancel the scan before removing a location";return }
|
||
perform { try library?.removeMediaLocation(id);try refresh() }
|
||
}
|
||
deinit { for url in mediaAccessURLs { url.stopAccessingSecurityScopedResource() } }
|
||
private func mediaStorageLocation() -> MediaStorageLocation? {
|
||
guard let json = try? library?.setting(MediaStorageLocation.key),let data = json.data(using:.utf8) else { return nil }
|
||
return try? JSONDecoder().decode(MediaStorageLocation.self,from:data)
|
||
}
|
||
private func loadMediaStorage() {
|
||
guard let location = mediaStorageLocation() else { return }
|
||
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 }
|
||
}
|
||
managedMediaDirectory = url
|
||
if !mediaAccessURLs.contains(url),url.startAccessingSecurityScopedResource() { mediaAccessURLs.append(url) }
|
||
}
|
||
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
|
||
panel.title = moveExisting ? "Move Imported Media" : "Choose Import Folder"
|
||
panel.prompt = moveExisting ? "Move Media Here" : "Use Folder"
|
||
panel.message = moveExisting ? "Existing imported audio and CDG files will be copied, verified and moved here. Library links will update automatically." : "New imports will be stored in this folder. Use Move Existing Media to relocate previous imports too."
|
||
panel.directoryURL = managedMediaDirectory
|
||
panel.begin { [weak self] response in
|
||
guard response == .OK,let url = panel.url else { return }
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
if moveExisting { self.moveMedia(to:url) } else { self.perform { try self.setMediaDirectory(url) } }
|
||
}
|
||
}
|
||
}
|
||
func setMediaDirectory(_ url:URL) throws {
|
||
guard !storageBusy,let library else { throw LibraryError.message("Finish the current library job before changing storage") }
|
||
let scoped = url.startAccessingSecurityScopedResource();defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||
try ManagedMediaStorage.prepare(url)
|
||
let roots = (mediaStorageLocation()?.roots ?? [])+[root.appendingPathComponent("Media").path,managedMediaDirectory.path]
|
||
try ManagedMediaStorage.save(ManagedMediaStorage.location(url,roots:roots),to:library)
|
||
loadMediaStorage()
|
||
}
|
||
func moveMedia(to url:URL) {
|
||
guard !storageBusy else { error = "Finish or cancel the current library job first.";return }
|
||
guard !player.playing,!backgroundDeck.playing else { error = "Stop playback before moving imported media.";return }
|
||
let roots = Array(Set((mediaStorageLocation()?.roots ?? [])+[root.appendingPathComponent("Media").path,managedMediaDirectory.path])).map{URL(fileURLWithPath:$0)}
|
||
let token = UUID(),database = root.appendingPathComponent("Library.sqlite")
|
||
storageToken = token;movingMedia = true;storageProgress = ImportProgress()
|
||
storageTask = Task.detached(priority:.utility) { [weak self] in
|
||
let scoped = url.startAccessingSecurityScopedResource();defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||
var latest = ImportProgress()
|
||
do { latest = try ManagedMediaStorage.move(database:database,roots:roots,destination:url,cancelled:{Task.isCancelled},progress:{ update in
|
||
latest = update
|
||
Task { @MainActor [weak self] in guard let self,self.storageToken == token else { return };self.storageProgress = update }
|
||
}) } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; original media kept" }
|
||
catch { latest.failure = error.localizedDescription;latest.current = "Move stopped; original media kept" }
|
||
latest.finished = true;let result = latest
|
||
await MainActor.run { [weak self] in
|
||
guard let self,self.storageToken == token else { return }
|
||
self.storageToken = nil;self.storageTask = nil;self.movingMedia = false;self.loadMediaStorage()
|
||
if let data = try? Data(contentsOf:self.root.appendingPathComponent("StudioSettings.json")),let saved = try? JSONDecoder().decode(StudioSettings.self,from:data),saved.backgroundPath != self.studio.backgroundPath {
|
||
var next = self.studio;next.backgroundPath = saved.backgroundPath;next.backgroundBookmark = saved.backgroundBookmark;self.studio = next
|
||
}
|
||
self.perform { try self.refresh() };self.storageProgress = result
|
||
}
|
||
}
|
||
}
|
||
func cancelMediaMove() { storageTask?.cancel();storageProgress?.current = "Cancelling after the current copy…" }
|
||
func dismissMediaMove() { if !movingMedia { storageProgress = nil } }
|
||
@Published private(set) var importProgress: ImportProgress?
|
||
@Published private(set) var isImporting = false
|
||
private var importTask: Task<Void,Never>?
|
||
private var importToken: UUID?
|
||
func importFiles(_ urls: [URL]) {
|
||
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,beforeIDs = Set(songs.map(\.id))
|
||
importToken = token;isImporting = true;importProgress = ImportProgress()
|
||
importTask = Task.detached(priority:.utility) { [weak self] in
|
||
let accessed = urls.filter { $0.startAccessingSecurityScopedResource() }
|
||
defer { for url in accessed { url.stopAccessingSecurityScopedResource() } }
|
||
var latest = ImportProgress(),importedIDs = [Int64]()
|
||
do {
|
||
let database = try Library(url:database)
|
||
importedIDs = try Importer(media:media).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
|
||
latest = update
|
||
Task { @MainActor [weak self] in
|
||
guard let self,self.importToken == token else { return };self.importProgress = update
|
||
}
|
||
})
|
||
} catch is CancellationError { latest.cancelled = true;latest.current = "Import cancelled" }
|
||
catch { latest.failure = error.localizedDescription;latest.current = "Import stopped" }
|
||
latest.finished = true
|
||
let result = latest,ids = Set(importedIDs)
|
||
await MainActor.run { [weak self] in
|
||
guard let self,self.importToken == token else { return }
|
||
self.importToken = nil;self.importTask = nil;self.isImporting = false
|
||
self.perform { try self.refresh() }
|
||
self.importProgress = result
|
||
if let failure = result.failure { self.error = failure }
|
||
if !result.cancelled { self.queueImportedWaveforms(ids.union(self.songs.filter{!beforeIDs.contains($0.id)}.map(\.id))) }
|
||
}
|
||
}
|
||
}
|
||
func cancelImport() { importTask?.cancel();importProgress?.current = "Cancelling after the current file…" }
|
||
func dismissImport() { if !isImporting { importProgress = nil } }
|
||
|
||
}
|
||
private struct LibraryCommands {
|
||
var importMusic: () -> Void
|
||
var songInfo: () -> Void
|
||
var canEdit: Bool
|
||
var play: () -> Void
|
||
var next: () -> Void
|
||
var video: () -> Void
|
||
}
|
||
private struct StudioSettingsAction { var open: () -> Void }
|
||
private struct StudioSettingsActionKey: FocusedValueKey { typealias Value = StudioSettingsAction }
|
||
private struct LibraryCommandsKey: FocusedValueKey { typealias Value = LibraryCommands }
|
||
private extension FocusedValues {
|
||
var studioSettings: StudioSettingsAction? {
|
||
get { self[StudioSettingsActionKey.self] }
|
||
set { self[StudioSettingsActionKey.self] = newValue }
|
||
}
|
||
var libraryCommands: LibraryCommands? {
|
||
get { self[LibraryCommandsKey.self] }
|
||
set { self[LibraryCommandsKey.self] = newValue }
|
||
}
|
||
}
|
||
@main struct KJMixProApp: App {
|
||
@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
|
||
@FocusedValue(\.studioSettings) private var settingsAction
|
||
@FocusedValue(\.nameReinterpretation) private var nameActions
|
||
var body: some Scene {
|
||
Window("KJMix Pro", id: "main") {
|
||
LibraryView(model: model).frame(minWidth: 1060, minHeight: 760)
|
||
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
|
||
}.defaultSize(width: 1320, height: 900).windowToolbarStyle(.unified)
|
||
.commands {
|
||
CommandGroup(replacing: .newItem) {
|
||
Button("Import Music…") { actions?.importMusic() }.keyboardShortcut("o").disabled(actions == nil)
|
||
Divider()
|
||
Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift])
|
||
Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option])
|
||
}
|
||
CommandGroup(replacing:.appSettings) {
|
||
Button("Settings…") { settingsAction?.open() }.keyboardShortcut(",").disabled(settingsAction == nil)
|
||
}
|
||
CommandMenu("Library") {
|
||
Button("Song Info…") { actions?.songInfo() }.keyboardShortcut("i").disabled(actions?.canEdit != true)
|
||
Button("Add Drive or Folder…") { model.chooseMediaLocation() }
|
||
}
|
||
CommandMenu("Advanced") {
|
||
Button("Reinterpret Names…") { nameActions?.perform() }.disabled(nameActions?.enabled != 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])
|
||
Button("Return to Cue") { model.player.returnToCue() }
|
||
Button("Set Cue Here") { model.player.setCue() }
|
||
Menu("Hot Cues") { ForEach(0..<4) { index in Button("Hot Cue \(index+1)") { model.player.hotCue(index) } } }
|
||
Button("Loop In") { model.player.markLoopIn() }
|
||
Button("Loop Out") { model.player.markLoopOut() }
|
||
Button("Clear Loop") { model.player.clearLoop() }
|
||
Divider()
|
||
Button("Fade Out & Stop") { model.player.fadeOutAndStop() }.keyboardShortcut(".")
|
||
Divider()
|
||
Button("Back 10 Seconds") { model.player.seek(model.player.time-10) }.keyboardShortcut(.leftArrow)
|
||
Button("Forward 10 Seconds") { model.player.seek(model.player.time+10) }.keyboardShortcut(.rightArrow)
|
||
Button("Next Song") { actions?.next() }.disabled(actions == nil)
|
||
Divider()
|
||
Button("Show Video Output") { actions?.video() }.keyboardShortcut("v",modifiers:[.command,.shift]).disabled(actions == nil)
|
||
}
|
||
}
|
||
Window("Video", id: "lyrics") {
|
||
LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark)
|
||
}.defaultSize(width: 1280, height: 720)
|
||
}
|
||
}
|
||
private struct ImportProgressDialog: View {
|
||
@ObservedObject var model: AppModel
|
||
var storageMode = false
|
||
var body: some View {
|
||
ZStack {
|
||
Color.black.opacity(0.16).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { }
|
||
if let progress = storageMode ? model.storageProgress : model.importProgress {
|
||
if !storageMode {
|
||
ImportProgressPanel(progress:progress,studio:"Karaoke",cancel:{ model.cancelImport() },close:{ model.dismissImport() })
|
||
} else {
|
||
VStack(alignment:.leading,spacing:16) {
|
||
HStack(spacing:12) {
|
||
Image(systemName:progress.finished ? (progress.failure == nil ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") : "square.and.arrow.down")
|
||
.font(.system(size:28)).foregroundStyle(Color.accentColor)
|
||
VStack(alignment:.leading,spacing:3) {
|
||
Text(storageMode ? (progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Media Moved" : "Move Needs Attention") : "Moving Imported Media") : (progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Stopped") : "Importing Music")).font(.headline)
|
||
Text(progress.total > 0 ? "\(progress.completed) of \(progress.total) \(storageMode ? "files verified" : "tracks checked")" : "Preparing files…").font(.subheadline).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if progress.total > 0 { ProgressView(value:Double(progress.completed),total:Double(progress.total)) }
|
||
else if !progress.finished { ProgressView().controlSize(.small) }
|
||
if progress.completed > 0 && !storageMode { Text("\(progress.completed-progress.skipped) imported · \(progress.skipped) already in library").font(.callout.weight(.medium)) }
|
||
Text(progress.failure ?? progress.current).font(.callout).foregroundStyle(.secondary).lineLimit(3).frame(maxWidth:.infinity,alignment:.leading)
|
||
if progress.finished,!storageMode { ImportLogView(progress:progress).frame(height:240) }
|
||
if progress.cancelled || progress.failure != nil { Text(storageMode ? "Original files are kept until their copies and library links are verified." : "Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) }
|
||
HStack { Spacer();if progress.finished { Button("Done") { if storageMode { model.dismissMediaMove() } else { model.dismissImport() } }.keyboardShortcut(.defaultAction) } else { Button(storageMode ? "Cancel Move" : "Cancel Import") { if storageMode { model.cancelMediaMove() } else { model.cancelImport() } }.keyboardShortcut(.cancelAction) } }
|
||
}.padding(24).frame(width:storageMode || !progress.finished ? 380 : 660).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14))
|
||
.overlay(RoundedRectangle(cornerRadius:14).strokeBorder(.primary.opacity(0.08)))
|
||
.shadow(color:.black.opacity(0.2),radius:24,y:8)
|
||
.accessibilityElement(children:.contain).accessibilityLabel(storageMode ? "Media move progress" : "Music import progress")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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
|
||
@FocusState private var focused: Bool
|
||
var body: some View {
|
||
HStack(spacing:7) {
|
||
Image(systemName:"magnifyingglass").foregroundStyle(.secondary)
|
||
TextField(placeholder,text:$text).textFieldStyle(.plain).focused($focused)
|
||
if !text.isEmpty {
|
||
Button { text = "";focused = true } label: { Image(systemName:"xmark.circle.fill").foregroundStyle(.secondary) }.buttonStyle(.plain).help("Clear search").accessibilityLabel("Clear search")
|
||
}
|
||
}.font(.system(size:12)).padding(.horizontal,9).padding(.vertical,7)
|
||
.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:7))
|
||
.overlay(RoundedRectangle(cornerRadius:7).strokeBorder(.primary.opacity(0.12)))
|
||
}
|
||
}
|
||
struct BackgroundWaveformStatus: View {
|
||
let progress: WaveformJobProgress
|
||
let cancel: () -> Void
|
||
let dismiss: () -> Void
|
||
var body: some View {
|
||
HStack(spacing:10) {
|
||
Image(systemName:progress.finished ? "checkmark.circle.fill" : "waveform").foregroundStyle(progress.cancelled || progress.issues > 0 ? Color.orange : progress.finished ? .green : .cyan)
|
||
VStack(alignment:.leading,spacing:2) {
|
||
Text(progress.finished ? progress.cancelled ? "Background waveforms cancelled" : "Background waveforms complete" : "Preparing waveforms in the background").font(.caption.weight(.semibold))
|
||
Text("\(progress.processed) / \(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
if progress.finished { Button(action:dismiss) { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
|
||
else { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).tint(.cyan).frame(width:120);Button("Cancel",action:cancel).controlSize(.small) }
|
||
}.padding(10).background(Color.cyan.opacity(0.05),in:RoundedRectangle(cornerRadius:8))
|
||
}
|
||
}
|
||
struct WaveformProgressCard: View {
|
||
let progress: WaveformJobProgress
|
||
let running: Bool
|
||
var studio = "Karaoke"
|
||
let cancel: () -> Void
|
||
let details: () -> Void
|
||
let dismiss: () -> Void
|
||
private var fraction: Double { min(1,max(0,Double(progress.processed)/Double(max(1,progress.total)))) }
|
||
private var colour: Color { progress.cancelled || progress.issues > 0 ? .orange : running ? .cyan : .green }
|
||
var body: some View {
|
||
HStack(spacing:16) {
|
||
ZStack {
|
||
Circle().stroke(colour.opacity(0.15),lineWidth:5)
|
||
Circle().trim(from:0,to:fraction).stroke(colour,style:StrokeStyle(lineWidth:5,lineCap:.round)).rotationEffect(.degrees(-90))
|
||
Text("\(Int(fraction*100))%").font(.system(size:11,weight:.semibold)).monospacedDigit()
|
||
}.frame(width:46,height:46).accessibilityLabel("Waveform progress").accessibilityValue("\(Int(fraction*100)) percent")
|
||
VStack(alignment:.leading,spacing:7) {
|
||
HStack { Text(running ? "Preparing "+studio+" Waveforms" : "Waveform Results").font(.callout.weight(.semibold));Spacer();Text("\(progress.processed.formatted()) / \(progress.total.formatted())").font(.caption).monospacedDigit().foregroundStyle(.secondary) }
|
||
GeometryReader { proxy in
|
||
ZStack(alignment:.leading) {
|
||
Capsule().fill(colour.opacity(0.12))
|
||
Capsule().fill(LinearGradient(colors:[colour.opacity(0.6),colour],startPoint:.leading,endPoint:.trailing)).frame(width:proxy.size.width*fraction)
|
||
}
|
||
}.frame(height:7)
|
||
Text(progress.current).font(.caption).lineLimit(1).foregroundStyle(.secondary)
|
||
Text("\(progress.generated) generated · \(progress.cached) reused · \(progress.skipped) skipped · \(progress.issues) issues").font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
if running { Button("Cancel",action:cancel).controlSize(.small) }
|
||
if progress.issues > 0 { Button("Details",action:details).controlSize(.small) }
|
||
if !running { Button(action:dismiss) { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
|
||
}.padding(16).background(colour.opacity(0.05),in:RoundedRectangle(cornerRadius:12))
|
||
.overlay(RoundedRectangle(cornerRadius:12).strokeBorder(colour.opacity(0.18)))
|
||
}
|
||
}
|
||
struct ImportProgressPanel: View {
|
||
let progress: ImportProgress
|
||
let studio: String
|
||
let cancel: () -> Void
|
||
let close: () -> Void
|
||
var background: (() -> Void)? = nil
|
||
var isMove = false
|
||
var activity: String? = nil
|
||
var waveformStatus: BackgroundWaveformStatus? = 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 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) {
|
||
Image(systemName:progress.finished ? (progress.failure != nil || progress.cancelled ? "exclamationmark.circle.fill" : "checkmark.circle.fill") : "square.and.arrow.down.fill")
|
||
.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()+(isMove ? "" : " LIBRARY")).font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary)
|
||
Text(title).font(.system(size:22,weight:.semibold))
|
||
}
|
||
Spacer()
|
||
if !progress.finished { ProgressView().controlSize(.small).tint(accent) }
|
||
}
|
||
VStack(alignment:.leading,spacing:9) {
|
||
HStack {
|
||
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)
|
||
}
|
||
GeometryReader { proxy in
|
||
ZStack(alignment:.leading) {
|
||
Capsule().fill(.primary.opacity(0.08))
|
||
Capsule().fill(LinearGradient(colors:[accent.opacity(0.65),accent],startPoint:.leading,endPoint:.trailing))
|
||
.frame(width:progress.total > 0 ? proxy.size.width*fraction : proxy.size.width*0.08)
|
||
}
|
||
}.frame(height:9).accessibilityLabel("Import progress").accessibilityValue("\(Int(fraction*100)) percent")
|
||
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(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)
|
||
}
|
||
if let waveformStatus { waveformStatus }
|
||
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:waveformStatus == nil ? 260 : 200)
|
||
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)
|
||
Spacer()
|
||
if progress.finished {
|
||
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…" : activity != nil ? "Cancel" : isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction).disabled(progress.current.hasPrefix("Cancelling"))
|
||
}
|
||
}
|
||
}.padding(26).frame(width:660)
|
||
.background(Color(nsColor:.windowBackgroundColor),in:RoundedRectangle(cornerRadius:16))
|
||
.overlay(RoundedRectangle(cornerRadius:16).strokeBorder(.primary.opacity(0.1)))
|
||
.shadow(color:.black.opacity(0.2),radius:24,y:8)
|
||
.accessibilityElement(children:.contain).accessibilityLabel(studio+" import progress")
|
||
}
|
||
private func count(_ title: String,value: Int,color: Color) -> some View {
|
||
VStack(alignment:.leading,spacing:5) {
|
||
Text(value >= 0 ? value.formatted() : "—").font(.system(size:22,weight:.semibold,design:.rounded)).monospacedDigit().foregroundStyle(color)
|
||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||
}.frame(maxWidth:.infinity,alignment:.leading).padding(12).background(color.opacity(0.07),in:RoundedRectangle(cornerRadius:8))
|
||
}
|
||
}
|
||
struct ImportLogView: View {
|
||
let progress: ImportProgress
|
||
var title = "Import Log"
|
||
@State private var follow = true
|
||
var body: some View {
|
||
let allLines = progress.reportLines
|
||
let lines = Array(allLines.suffix(300))
|
||
VStack(alignment:.leading,spacing:8) {
|
||
HStack {
|
||
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) }
|
||
}
|
||
if allLines.count > lines.count { Text("Showing the latest \(lines.count.formatted()) of \(allLines.count.formatted()) entries. Copy Log includes the full report.").font(.caption).foregroundStyle(.secondary) }
|
||
ScrollViewReader { reader in
|
||
ScrollView {
|
||
LazyVStack(alignment:.leading,spacing:7) {
|
||
ForEach(lines.indices,id:\.self) { index in
|
||
let line = lines[index]
|
||
HStack(alignment:.top,spacing:8) {
|
||
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : (line.hasPrefix("[SKIPPED]") || line.hasPrefix("[WARNING]")) ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
|
||
Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)
|
||
}.id(index)
|
||
}
|
||
if lines.isEmpty { Text(progress.finished ? "No tracks processed." : "Waiting for the first track…").font(.callout).foregroundStyle(.secondary) }
|
||
}.padding(12)
|
||
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
|
||
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06)))
|
||
.onStudioChange(of:allLines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
private enum StudioTheme {
|
||
static let frame = Color(nsColor:.windowBackgroundColor)
|
||
static let navy = Color(nsColor:.textBackgroundColor)
|
||
static let blue = Color.accentColor.opacity(0.15)
|
||
static let header = Color(nsColor:.controlBackgroundColor)
|
||
static let line = Color.primary.opacity(0.08)
|
||
}
|
||
private struct StudioSidebarLabelStyle: LabelStyle {
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
HStack(spacing:9) {
|
||
configuration.icon.foregroundStyle(.secondary).frame(width:18)
|
||
configuration.title.foregroundStyle(.primary)
|
||
}
|
||
}
|
||
}
|
||
struct LyricsView: View {
|
||
@ObservedObject var player: Player
|
||
var body: some View {
|
||
ZStack {
|
||
Color.black
|
||
if let image = player.image {
|
||
NativeVideoSurface(image:image,opacity:player.outputOpacity)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
private struct NativeVideoSurface: NSViewRepresentable {
|
||
let image: CGImage
|
||
let opacity: Double
|
||
func makeNSView(context: Context) -> NativeVideoView { NativeVideoView() }
|
||
func updateNSView(_ view: NativeVideoView,context: Context) {
|
||
if view.image !== image { view.image = image;view.picture.contents = image }
|
||
view.picture.opacity = Float(opacity)
|
||
}
|
||
}
|
||
private final class NativeVideoView: NSView {
|
||
let picture = CALayer()
|
||
var image: CGImage?
|
||
override init(frame: NSRect) {
|
||
super.init(frame:frame);wantsLayer = true;layer?.backgroundColor = NSColor.black.cgColor
|
||
picture.contentsGravity = .resizeAspect;picture.magnificationFilter = .linear;picture.minificationFilter = .trilinear
|
||
picture.actions = ["contents":NSNull(),"bounds":NSNull(),"position":NSNull(),"opacity":NSNull()]
|
||
layer?.addSublayer(picture)
|
||
}
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
override func layout() { super.layout();picture.frame = bounds }
|
||
override func viewDidMoveToWindow() { super.viewDidMoveToWindow();updateScale() }
|
||
override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties();updateScale() }
|
||
private func updateScale() { let scale = window?.backingScaleFactor ?? 1;layer?.contentsScale = scale;picture.contentsScale = scale;picture.frame = bounds }
|
||
}
|
||
/// This is another view of the same published CGImage used by the Video window.
|
||
/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical.
|
||
private struct SidebarVideoMonitor: View {
|
||
@ObservedObject var player: Player
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
Label("Video Monitor", systemImage: "tv")
|
||
.font(.caption.weight(.medium)).foregroundStyle(.secondary)
|
||
LyricsView(player: player)
|
||
.frame(height:140)
|
||
.clipped()
|
||
.clipShape(RoundedRectangle(cornerRadius:8)).overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.white.opacity(0.1),lineWidth:1))
|
||
.accessibilityLabel("Video monitor, synchronized with the Video window")
|
||
|
||
}.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
private struct StudioButtonStyle: ButtonStyle {
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label.font(.system(size:12,weight:.medium)).foregroundStyle(.primary)
|
||
.padding(.horizontal,9).padding(.vertical,5)
|
||
.background(.primary.opacity(configuration.isPressed ? 0.12 : 0.05),in:RoundedRectangle(cornerRadius:6))
|
||
}
|
||
}
|
||
private struct TransportButton: View {
|
||
let symbol: String
|
||
let label: String
|
||
var size: CGFloat = 34
|
||
var active = false
|
||
var enabled = true
|
||
let action: () -> Void
|
||
var body: some View {
|
||
Button(action:action) {
|
||
Image(systemName:symbol).font(.system(size:size > 40 ? 19 : 14,weight:.semibold))
|
||
.frame(width:size,height:size)
|
||
.foregroundStyle(size > 40 ? Color.white : Color.primary)
|
||
.background(size > 40 ? Color.accentColor : Color.primary.opacity(0.055),in:Circle())
|
||
}.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.3)
|
||
.help(label).accessibilityLabel(label)
|
||
}
|
||
}
|
||
struct LibraryView: View {
|
||
@ObservedObject var model: AppModel
|
||
@State var workspaceTab = "Karaoke"
|
||
@State private var djSongSettings = false
|
||
@State private var studioSettingsModal = false
|
||
@State private var selected: Int64?
|
||
@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 = ""
|
||
@State private var name = ""
|
||
@State private var importing = false
|
||
@State private var importingServer = false
|
||
@State private var serverImportURLs = [URL]()
|
||
private var source: String { get { model.activeSection } nonmutating set { model.activeSection = newValue } }
|
||
@State private var singersExpanded = true
|
||
private var expandedSingers: Set<Int64> { get { model.expandedSingerIDs } nonmutating set { model.expandedSingerIDs = newValue } }
|
||
@State private var expandedSongLists = Set<Int64>()
|
||
@State private var songPickerSinger: PersonPlace?
|
||
@State private var songPickerTonight = false
|
||
@State private var singerSections = [Int64:String]()
|
||
@State private var singerSelections = [Int64:Int64]()
|
||
@State private var presentedSinger: Int64?
|
||
@State private var singerSection = "All Songs"
|
||
@State private var storesExpanded = true
|
||
@State private var focusedStore: Int64?
|
||
@State private var focusedLocation: Int64?
|
||
@State private var editingSong: Song?
|
||
@State private var libraryRows = [Song]()
|
||
@State private var page = 0
|
||
@State private var tableSort = [KeyPathComparator(\Song.title)]
|
||
@State private var librarySort = "Song Name"
|
||
@State private var sortAscending = true
|
||
@Environment(\.openWindow) private var openWindow
|
||
private var pagination: LibraryPage { LibraryPage(total:libraryRows.count,requested:page) }
|
||
private var visibleSongs: [Song] { Array(libraryRows[pagination.range]) }
|
||
private func updateLibraryRows(resetPage:Bool = false) {
|
||
let filtered = model.songs.map { source -> Song in var song = source;if model.selectedSinger != nil { song.rating = model.displayedRating(source) };return song }.filter { SongSearch.matches($0,query:search) }
|
||
libraryRows = filtered.sorted(using:tableSort)
|
||
page = LibraryPage(total:libraryRows.count,requested:resetPage ? 0 : page).number
|
||
if source == "Library" {
|
||
let ids = Set(visibleSongs.map(\.id));tableSelection.formIntersection(ids)
|
||
if let selected,!ids.contains(selected) { self.selected = nil }
|
||
}
|
||
}
|
||
|
||
private var selectedSong: Song? { model.songs.first { $0.id == selected } }
|
||
private var libraryWorkspace: some View {
|
||
HSplitView {
|
||
sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260)
|
||
VStack(spacing:0) {
|
||
BackgroundDeckStrip(model:model,deck:model.backgroundDeck).padding(.horizontal,16).padding(.vertical,6)
|
||
Divider()
|
||
TransportView(player:model.player,model:model,search:$search,hasSelection:selectedSong != nil,
|
||
play:playSelected,next:playNext,display:{ openWindow(id:"lyrics") })
|
||
.padding(20)
|
||
Divider()
|
||
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.formatted()) tracks in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
|
||
}
|
||
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:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan,prominent:true))
|
||
}
|
||
}.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)
|
||
Divider()
|
||
detailPanel.frame(height:150).padding(16)
|
||
Divider()
|
||
HStack { Text("Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { djSongSettings = false }.keyboardShortcut(.defaultAction) }.padding(20)
|
||
}.frame(width:920).background(Color(nsColor:.windowBackgroundColor))
|
||
}
|
||
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
|
||
if let progress = model.automaticWaveformProgress {
|
||
BackgroundWaveformStatus(progress:progress,cancel:{model.automaticWaveforms.cancel()},dismiss:{model.automaticWaveformProgress = nil}).padding(.horizontal,18).padding(.vertical,8)
|
||
}
|
||
Divider()
|
||
footer.padding(.horizontal,18).padding(.vertical,8)
|
||
}.frame(minWidth:780)
|
||
}
|
||
}
|
||
private var workspaceLayout: some View {
|
||
TabView(selection:$workspaceTab) {
|
||
libraryWorkspace.tabItem { Label("Karaoke Studio",systemImage:"music.mic") }.tag("Karaoke")
|
||
DJConsole(system:model.dj)
|
||
.tabItem { Label("DJ Studio",systemImage:"opticaldisc") }.tag("DJ Studio")
|
||
}.accessibilityIdentifier("workspace.tabs")
|
||
}
|
||
private var workspace: some View {
|
||
workspaceLayout
|
||
.background(StudioTheme.frame)
|
||
.tint(.accentColor)
|
||
.focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:workspaceTab == "Karaoke" && ["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty)))
|
||
.focusedSceneValue(\.studioSettings,StudioSettingsAction(open:{studioSettingsModal = true}))
|
||
.focusedSceneValue(\.libraryCommands,workspaceTab == "Karaoke" ? LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")}) : nil)
|
||
.sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model,scope:workspaceTab == "DJ Studio" ? "DJ" : "Karaoke") }
|
||
.navigationTitle(workspaceTab == "DJ Studio" ? "DJ Studio" : source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source)
|
||
.navigationSubtitle(model.currentVenueName)
|
||
.toolbar {
|
||
ToolbarItemGroup {
|
||
if workspaceTab == "DJ Studio" { Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") } }
|
||
if workspaceTab == "Karaoke" {
|
||
Menu {
|
||
Button("Import Files or Folder…") { importing = true }
|
||
Button("Import from SMB/NAS…") { importingServer = true }
|
||
} label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import from files, folders or a server drive")
|
||
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 }
|
||
Button("Add Drive or Folder…") { model.chooseMediaLocation() }
|
||
} label: { Label("Add",systemImage:"plus") }
|
||
Button { editingSong = selectedSong } label: { Label("Song Info",systemImage:"info.circle") }.disabled(selectedSong == nil)
|
||
Button { openWindow(id:"lyrics") } label: { Label("Video Output",systemImage:"rectangle.on.rectangle") }
|
||
}
|
||
|
||
}
|
||
}
|
||
}
|
||
var body: some View {
|
||
workspace
|
||
.disabled(model.storageProgress != nil)
|
||
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } }
|
||
.onAppear { updateLibraryRows() }
|
||
.onStudioChange(of:model.songs) { _ in updateLibraryRows() }
|
||
.onStudioChange(of:model.tuningCache) { _ in updateLibraryRows() }
|
||
.onStudioChange(of:search) { _ in updateLibraryRows(resetPage:true) }
|
||
.sheet(item: $model.creating) { kind in
|
||
AddEntitySheet(model: model, kind: kind) { id in
|
||
if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) }
|
||
}
|
||
}
|
||
.sheet(isPresented:Binding(get:{model.waveformJob != nil},set:{if !$0 { model.dismissWaveformProgress() }})) {
|
||
if let progress = model.waveformJob {
|
||
VStack(alignment:.leading,spacing:20) {
|
||
Label("Karaoke Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(.cyan)
|
||
Text(model.generatingWaveforms ? "Preparing saved waveforms in the background. Playback remains available." : progress.cancelled ? "Generation cancelled. Completed waveforms have been kept." : progress.issues > 0 ? "Some tracks could not be analysed. See the log below." : "Waveforms are saved and ready to use.").font(.callout).foregroundStyle(.secondary)
|
||
WaveformProgressCard(progress:progress,running:model.generatingWaveforms,studio:"Karaoke Studio",cancel:{model.cancelWaveformGeneration()},details:{waveformDetails.toggle()},dismiss:{model.dismissWaveformProgress()})
|
||
if waveformDetails || !progress.messages.isEmpty {
|
||
Text("Analysis Log").font(.headline)
|
||
ScrollView { Text(progress.messages.isEmpty ? "No issues reported." : progress.messages.joined(separator:"\n\n")).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:180)
|
||
}
|
||
HStack {
|
||
if model.generatingWaveforms { ProgressView().controlSize(.small).tint(.cyan);Text("Cancel keeps completed waveforms.").font(.caption).foregroundStyle(.secondary) }
|
||
Spacer()
|
||
if !model.generatingWaveforms { Button("Done") { model.dismissWaveformProgress() }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) }
|
||
}
|
||
}.padding(26).frame(width:660).background(Color(nsColor:.windowBackgroundColor))
|
||
.interactiveDismissDisabled(model.generatingWaveforms)
|
||
}
|
||
}
|
||
.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) }
|
||
}
|
||
.onStudioChange(of:tableSelection) { ids in selected = ids.sorted().first }
|
||
.sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) }
|
||
.sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer,tonight:songPickerTonight) }
|
||
.onStudioChange(of:selected) { song in
|
||
if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song }
|
||
}
|
||
.onStudioChange(of:model.selectedSinger) { singer in
|
||
updateLibraryRows()
|
||
if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) }
|
||
}
|
||
.sheet(isPresented:$importingServer,onDismiss:{
|
||
let urls = serverImportURLs;serverImportURLs = []
|
||
model.importFiles(urls)
|
||
}) { ServerImportSheet { serverImportURLs = $0 } }
|
||
.fileImporter(isPresented: $importing, allowedContentTypes: [.folder, .audio, .movie, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in
|
||
switch result {
|
||
case .success(let urls): model.importFiles(urls)
|
||
case .failure(let error): model.error = error.localizedDescription
|
||
}
|
||
}
|
||
.alert("KJMix Pro", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) {
|
||
Button("OK") { model.error = nil }
|
||
} message: { Text(model.error ?? "") }
|
||
}
|
||
@ViewBuilder private var sourceContent: some View {
|
||
if source == "Library" { VStack(spacing:0) { songTable;Divider();libraryPagination } }
|
||
else if source == "Queue" { queuePane }
|
||
else if source == "Settings" { StudioSettingsPane(model:model) }
|
||
else if source == "Rotation" { RotationPane(model:model) }
|
||
else if source == "ShowScreens" { ShowScreensPane(model:model) }
|
||
else if source == "Singer Web" { SingerServerPane(model:model,server:model.singerServer) }
|
||
else if source == "History" { historyPane }
|
||
else if source == "Playlists" { playlistPane }
|
||
else if source == "Singer Songs" { singerSongPane }
|
||
else if source == "Music Stores" { MusicStoresPane(model:model,selected:$focusedStore) }
|
||
else if source == "Library Locations" { LibraryLocationsPane(model:model,selected:$focusedLocation) }
|
||
else { directory }
|
||
}
|
||
private func showSinger(_ id: Int64?,section: String? = nil) {
|
||
if source == "Singer Songs",let previous = presentedSinger {
|
||
singerSections[previous] = singerSection
|
||
if let selected { singerSelections[previous] = selected }
|
||
}
|
||
presentedSinger = id;model.selectedSinger = id
|
||
singerSection = section ?? id.flatMap { singerSections[$0] } ?? "Tonight"
|
||
if let id { singerSections[id] = singerSection;selected = singerSelections[id] }
|
||
source = "Singer Songs"
|
||
}
|
||
private func sourceCount(_ source: String) -> Int {
|
||
switch source {
|
||
case "Library": return model.songs.count
|
||
case "Singers": return model.venueSingers.count
|
||
case "Venues": return model.venues.count
|
||
case "Queue": return model.queue.count
|
||
case "History": return model.history.count
|
||
default: return model.playlists.count
|
||
}
|
||
}
|
||
private var sidebar: some View {
|
||
VStack(spacing:0) {
|
||
HStack(spacing:10) {
|
||
Image("BrandMark").resizable().scaledToFit().frame(width:32,height:32)
|
||
VStack(alignment:.leading,spacing:2) { Text("KJMix Pro").font(.headline);Text("Karaoke Studio").font(.caption).foregroundStyle(.secondary) };Spacer()
|
||
}.padding(16)
|
||
List {
|
||
Group {
|
||
sidebarHeading("Workspace")
|
||
navigationRow("Library",symbol:"music.note.list",count:model.songs.count)
|
||
navigationRow("Rotation",symbol:"person.3.sequence",count:model.rotationPlan.count)
|
||
navigationRow("Queue",symbol:"list.number",count:model.queue.count)
|
||
navigationRow("History",symbol:"clock",count:model.history.count)
|
||
navigationRow("Playlists",symbol:"music.note",count:model.playlists.count)
|
||
sidebarHeading("Singers · \(model.currentVenueName)")
|
||
ForEach(model.venueSingers) { singer in
|
||
VStack(spacing:2) {
|
||
HStack(spacing:0) {
|
||
Button { toggleSinger(singer.id) } label: {
|
||
Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right")
|
||
.font(.system(size:10,weight:.semibold)).frame(width:28,height:34).contentShape(Rectangle())
|
||
}.buttonStyle(.plain).accessibilityLabel("\(expandedSingers.contains(singer.id) ? "Collapse" : "Expand") \(singer.name)").accessibilityValue(expandedSingers.contains(singer.id) ? "Expanded" : "Collapsed").accessibilityIdentifier("singer.expand.\(singer.id)")
|
||
Button { expandedSingers.insert(singer.id);showSinger(singer.id) } label: {
|
||
HStack(spacing:7) { Image(systemName:"person").foregroundStyle(.secondary);Text(singer.name).lineLimit(1);Spacer() }.frame(height:34).contentShape(Rectangle())
|
||
}.buttonStyle(.plain).modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true))
|
||
Button { expandedSingers.insert(singer.id);showSinger(singer.id,section:"Tonight");songPickerTonight = true;songPickerSinger = singer } label: { Image(systemName:"plus").frame(width:25,height:30) }.buttonStyle(.plain).help("Add songs for \(singer.name)")
|
||
}.background(source == "Singer Songs" && model.selectedSinger == singer.id ? Color.accentColor.opacity(0.13) : Color.clear,in:RoundedRectangle(cornerRadius:7))
|
||
if expandedSingers.contains(singer.id) {
|
||
singerDestination("Tonight",symbol:"moon.stars",singer:singer)
|
||
.modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true))
|
||
singerDestination("Favourites",symbol:"star",singer:singer)
|
||
singerDestination("History",symbol:"clock",singer:singer)
|
||
singerDestination("All Songs",symbol:"music.note.list",singer:singer)
|
||
}
|
||
}
|
||
}
|
||
Button { model.beginSinger() } label: { Label("Add Singer…",systemImage:"plus").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain).foregroundStyle(.secondary)
|
||
sidebarHeading("Manage")
|
||
navigationRow("Venues",symbol:"building.2",count:model.venues.count)
|
||
navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count)
|
||
navigationRow("ShowScreens",symbol:"rectangle.on.rectangle")
|
||
navigationRow("Music Stores",symbol:"bag")
|
||
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
|
||
}.listStyle(.sidebar)
|
||
Divider().padding(.horizontal,14)
|
||
SidebarVideoMonitor(player:model.player).padding(14)
|
||
}.background(.ultraThinMaterial)
|
||
}
|
||
private func sidebarHeading(_ value: String) -> some View { Text(value).font(.system(size:11,weight:.semibold)).foregroundStyle(.secondary).lineLimit(1).padding(.top,14).padding(.bottom,5).padding(.horizontal,9) }
|
||
private func navigationRow(_ value: String,symbol: String,count: Int? = nil) -> some View {
|
||
Button { source = value } label: {
|
||
HStack(spacing:9) { Image(systemName:symbol).frame(width:18).foregroundStyle(source == value ? Color.accentColor : Color.secondary);Text(value);Spacer();if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) } }.padding(.horizontal,9).frame(height:34).contentShape(Rectangle())
|
||
}.buttonStyle(.plain).accessibilityIdentifier("nav."+value).background(source == value ? Color.accentColor.opacity(0.15) : .clear,in:RoundedRectangle(cornerRadius:7))
|
||
}
|
||
private func singerDestination(_ value: String,symbol: String,singer: PersonPlace) -> some View {
|
||
Button { showSinger(singer.id,section:value) } label: {
|
||
HStack(spacing:8) { Image(systemName:symbol).frame(width:16).foregroundStyle(.secondary);Text(value);Spacer() }.padding(.leading,35).padding(.trailing,9).frame(height:29).contentShape(Rectangle())
|
||
}.buttonStyle(.plain).background(source == "Singer Songs" && model.selectedSinger == singer.id && singerSection == value ? Color.accentColor.opacity(0.15) : .clear,in:RoundedRectangle(cornerRadius:6))
|
||
}
|
||
private func toggleSinger(_ id: Int64) { if expandedSingers.contains(id) { expandedSingers.remove(id) } else { expandedSingers.insert(id) } }
|
||
private var singerSongPane: some View {
|
||
VStack(alignment:.leading,spacing:12) {
|
||
if let singer = model.singers.first(where:{ $0.id == model.selectedSinger }) {
|
||
HStack { Text("\(singer.name) · \(singerSection)").font(.headline);Spacer();Button(singerSection == "Tonight" ? "Add to Tonight…" : "Add Songs…") { songPickerTonight = singerSection == "Tonight";songPickerSinger = singer } }
|
||
Text("\(model.currentVenueName) · Singer song lists follow them across venues.").font(.caption).foregroundStyle(.secondary)
|
||
if singerSection == "History" {
|
||
ScrollView { LazyVStack(alignment:.leading,spacing:12) { ForEach(model.history.filter { $0.singerID == singer.id }) { row in HStack { Text(row.date).font(.caption.monospaced());Text(row.song);Spacer() } } } }
|
||
} else if singerSection == "Tonight" {
|
||
ScrollView {
|
||
VStack(spacing:10) {
|
||
ForEach(Array(model.tonightSongs(for:singer.id).enumerated()),id:\.element.id) { index,song in
|
||
HStack {
|
||
Image(systemName:"line.3.horizontal").foregroundStyle(.secondary).help("Drag to reorder Tonight")
|
||
Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:18)
|
||
Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite")
|
||
Text(song.title).lineLimit(1);Spacer()
|
||
Text(model.queue.contains(where:{ $0.songID == song.id && $0.singerID == singer.id }) ? "Queued" : "Ready").font(.caption).foregroundStyle(.secondary)
|
||
Button("Play") { model.playTonight(song,singer:singer.id) }
|
||
Button("Queue Again") { model.selectedSinger = singer.id;model.enqueue(song.id) }
|
||
Button { model.moveTonight(song.id,singer:singer.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0).help("Move earlier")
|
||
Button { model.moveTonight(song.id,singer:singer.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.tonightSongs(for:singer.id).count-1).help("Move later")
|
||
Button("Remove") { model.removeFromTonight(song.id,singer:singer.id) }
|
||
}.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle())
|
||
.onDrag { model.dragReference(song.id,singer:singer.id,tonight:true).provider() }
|
||
.modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:song.id,enabled:true))
|
||
}
|
||
Text(model.tonightSongs(for:singer.id).isEmpty ? "Drop a library song here to add it to \(singer.name)’s Tonight." : "Drop here to append · Drop on a row to place before it")
|
||
.font(.callout).foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(18)
|
||
.overlay(RoundedRectangle(cornerRadius:4).strokeBorder(.white.opacity(0.16),style:StrokeStyle(lineWidth:1,dash:[5,4])))
|
||
.modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true))
|
||
}
|
||
}
|
||
} else {
|
||
ScrollView {
|
||
VStack(spacing:10) {
|
||
ForEach(model.songs(for:singer.id).filter { singerSection != "Favourites" || model.favourites[singer.id]?.contains($0.id) == true }) { song in
|
||
HStack {
|
||
Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Favourite for this singer")
|
||
Text(song.title).lineLimit(1);Spacer()
|
||
Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } }
|
||
Button("Add to Tonight") { model.addToTonight(song.id,singer:singer.id) }.disabled(model.currentVenue == nil)
|
||
Button("Remove") { model.assign(song.id,to:singer.id,included:false) }
|
||
}.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id }
|
||
.onDrag { model.dragReference(song.id).provider() }
|
||
}
|
||
if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) }
|
||
}
|
||
}
|
||
}
|
||
} else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() }
|
||
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
|
||
}
|
||
private var libraryPagination: some View {
|
||
HStack(spacing:12) {
|
||
Text(pagination.total == 0 ? "No songs" : "Showing \(pagination.range.lowerBound+1)–\(pagination.range.upperBound) of \(pagination.total) songs").foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button { changePage(0) } label: { Image(systemName:"backward.end") }.disabled(pagination.number == 0).help("First page").accessibilityLabel("First library page")
|
||
Button { changePage(pagination.number-1) } label: { Image(systemName:"chevron.left") }.disabled(pagination.number == 0).help("Previous page").accessibilityLabel("Previous library page")
|
||
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).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 {
|
||
Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows(resetPage:true)})) {
|
||
TableColumn("ID",value:\Song.id) { song in
|
||
HStack(spacing:5) { NativeSongDragHandle(reference:model.dragReference(song.id),title:song.title).frame(width:14,height:24);Text("\(song.id)").foregroundStyle(.tertiary).monospacedDigit() }
|
||
}.width(min:48,ideal:55,max:75)
|
||
TableColumn("Title",value:\Song.title) { song in
|
||
HStack(spacing:8) {
|
||
if model.player.currentSongID == song.id { Image(systemName:"waveform").foregroundStyle(Color.accentColor).font(.caption) }
|
||
Text(song.title).fontWeight(.medium).lineLimit(1)
|
||
}.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() }
|
||
}.width(min:180,ideal:220,max:600)
|
||
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("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("Time",value:\Song.duration) { song in Text(durationLabel(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(50)
|
||
TableColumn("Key") { song in
|
||
HStack(spacing:2) {
|
||
Text(String(format:"%+.0f",model.tuningForControls(song.id).pitch)).monospacedDigit().frame(width:24)
|
||
Stepper("Key",value:Binding(get:{model.tuningForControls(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini)
|
||
}.help(model.player.currentSongID == song.id ? "Live key · \(model.liveSingerName)" : "Saved key for the selected singer or song")
|
||
}.width(65)
|
||
TableColumn("Tempo") { song in
|
||
Picker("Tempo",selection:Binding(get:{model.tuningForControls(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) {
|
||
ForEach(50...150,id:\.self) { Text("\($0)%").tag(Double($0)/100) }
|
||
}.labelsHidden().controlSize(.small)
|
||
}.width(78)
|
||
TableColumn("Rating",value:\Song.rating) { song in
|
||
GoldRating(rating:Binding(get:{model.displayedRating(song)},set:{model.rateSong(song,$0)}))
|
||
}.width(94)
|
||
}.tableStyle(.inset(alternatesRowBackgrounds:true))
|
||
.contextMenu(forSelectionType:Int64.self) { ids in
|
||
if ids.count > 1 {
|
||
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) }
|
||
Divider()
|
||
Menu("Waveforms") { waveformActions(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
|
||
} primaryAction: { ids in if let id = ids.first { selected = id;tableSelection = [id];playSelected() } }
|
||
.overlay {
|
||
if visibleSongs.isEmpty {
|
||
VStack(spacing:12) {
|
||
Image(systemName:search.isEmpty ? "music.note.list" : "magnifyingglass").font(.system(size:34,weight:.light)).foregroundStyle(.tertiary)
|
||
Text(search.isEmpty ? "Your stage starts here" : "No matching songs").font(.headline)
|
||
Text(search.isEmpty ? "Import music or add a drive to build your library." : "Try a different title, artist or album.").foregroundStyle(.secondary)
|
||
if search.isEmpty { Button("Import Music…") { importing = true }.buttonStyle(.borderedProminent) }
|
||
}.padding(24)
|
||
}
|
||
}
|
||
}
|
||
@ViewBuilder private func songActions(_ song: Song) -> some View {
|
||
Button("Play") { selected = song.id;playSelected() }
|
||
Button("Song Settings…") { selected = song.id;tableSelection = [song.id];djSongSettings = true }
|
||
Button("Song Info…") { editingSong = song }
|
||
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:"))
|
||
Divider()
|
||
Menu("Add to Tonight") { ForEach(model.venueSingers) { singer in Button(singer.name) { model.addToTonight(song.id,singer:singer.id) } } }.disabled(model.currentVenue == nil)
|
||
Menu("Add to Singer") {
|
||
ForEach(model.venueSingers) { singer in Button(singer.name) { model.assign(song.id,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } }
|
||
Divider();Button("New Singer with This Song…") { model.beginSinger(song:song.id) }
|
||
}
|
||
Button("Add to Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil)
|
||
Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } }
|
||
}
|
||
private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" }
|
||
@State private var managingRoster = false
|
||
@State private var rosterNewName = ""
|
||
@State private var editingEntity: PersonPlace?
|
||
@State private var renameText = ""
|
||
@State private var pendingDelete: PersonPlace?
|
||
private var directory: some View {
|
||
VStack(alignment:.leading,spacing:0) {
|
||
HStack {
|
||
Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline)
|
||
Spacer()
|
||
if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) }
|
||
Button(source == "Singers" ? "New Singer…" : "New Venue…") { if source == "Singers" { model.beginSinger() } else { model.creating = .venue } }
|
||
}.padding(12).background(StudioTheme.header)
|
||
ScrollView {
|
||
VStack(alignment:.leading,spacing:0) {
|
||
if source == "Singers" && model.venueSingers.isEmpty {
|
||
Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding()
|
||
}
|
||
ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in
|
||
HStack {
|
||
Button {
|
||
if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) }
|
||
} label: {
|
||
HStack {
|
||
Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill")
|
||
Text(entity.name); Spacer()
|
||
if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) {
|
||
Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan)
|
||
Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan)
|
||
}
|
||
}.contentShape(Rectangle())
|
||
}.buttonStyle(.plain)
|
||
Button("Rename") { renameText = entity.name; editingEntity = entity }
|
||
Button("Delete",role:.destructive) { pendingDelete = entity }
|
||
}.padding(12)
|
||
}
|
||
}
|
||
}
|
||
HStack {
|
||
TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity)
|
||
Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty)
|
||
}.padding(12)
|
||
}.foregroundStyle(.primary).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
|
||
.sheet(isPresented:$managingRoster) { rosterSheet }
|
||
.sheet(item:$editingEntity) { entity in
|
||
VStack(alignment:.leading,spacing:16) {
|
||
Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline)
|
||
TextField("Name",text:$renameText).textFieldStyle(.roundedBorder)
|
||
HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
|
||
}.padding(24).frame(width:360)
|
||
}
|
||
.confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) {
|
||
Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil }
|
||
}
|
||
}
|
||
private var rosterSheet: some View {
|
||
VStack(alignment:.leading,spacing:16) {
|
||
Text("Singers at \(model.currentVenueName)").font(.title2.bold())
|
||
Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary)
|
||
ScrollView {
|
||
VStack(alignment:.leading,spacing:12) {
|
||
ForEach(model.singers) { singer in
|
||
Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) }))
|
||
}
|
||
}.frame(maxWidth:.infinity,alignment:.leading)
|
||
}.frame(minHeight:120,maxHeight:280)
|
||
HStack {
|
||
TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder)
|
||
Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty)
|
||
Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction)
|
||
}
|
||
}.padding(24).frame(width:460)
|
||
}
|
||
private var detailPanel: some View {
|
||
VStack(alignment:.leading,spacing:9) {
|
||
HStack(spacing:10) {
|
||
Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) {
|
||
Text("Select venue…").tag(nil as Int64?)
|
||
ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) }
|
||
}
|
||
Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue")
|
||
Picker("Singer",selection:$model.selectedSinger) {
|
||
Text("None").tag(nil as Int64?)
|
||
ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) }
|
||
}
|
||
Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer")
|
||
Menu("Add to Singer") {
|
||
ForEach(model.venueSingers) { singer in Button(singer.name) { if let selected { model.assign(selected,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } }
|
||
Divider();Button("New Singer + Song…") { model.beginSinger(song:selected) }
|
||
}.disabled(selected == nil)
|
||
Button("Add to Tonight") { if let selected { model.addToTonight(selected) } }.disabled(selected == nil || model.currentVenue == nil || model.selectedSinger == nil)
|
||
}
|
||
PlaybackSettings(player:model.player,model:model,selectedSong:selected)
|
||
SeekBar(player:model.player)
|
||
}.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity)
|
||
.background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
|
||
}
|
||
@State private var playlistName = ""
|
||
@State private var selectedPlaylist: Int64?
|
||
private var queuePane: some View {
|
||
VStack(alignment:.leading,spacing:12) {
|
||
HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.studio.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) }
|
||
ScrollView {
|
||
VStack(spacing:0) {
|
||
ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in
|
||
HStack {
|
||
Text("\(index+1)").foregroundStyle(.secondary).frame(width:24)
|
||
Text(row.singer).frame(width:140,alignment:.leading)
|
||
Text(row.title).lineLimit(1); Spacer()
|
||
Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0)
|
||
Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1)
|
||
Picker("Singer",selection:Binding(get:{row.singerID},set:{model.assignQueue(row.id,singer:$0)})) { Text("No singer / Filler").tag(nil as Int64?);ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:130)
|
||
Button("Remove") { model.removeQueue(row.id) }
|
||
}.padding(.vertical,8)
|
||
}
|
||
if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) }
|
||
}
|
||
}
|
||
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
|
||
}
|
||
@State private var historyAllVenues = false
|
||
private var displayedHistory: [Performance] {
|
||
if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] }
|
||
return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name }
|
||
}
|
||
private var historyPane: some View {
|
||
VStack(alignment:.leading,spacing:12) {
|
||
HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) }
|
||
ScrollView {
|
||
LazyVStack(alignment:.leading,spacing:12) {
|
||
ForEach(displayedHistory) { row in
|
||
HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) }
|
||
}
|
||
}
|
||
}
|
||
}.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 { waveformActions(tableSelection) }
|
||
@ViewBuilder private func waveformActions(_ ids: Set<Int64>) -> some View {
|
||
Button("Generate Waveform for Loaded Track") { if let id = model.player.currentSongID { model.generateWaveforms([id]) } }.disabled(model.player.currentSongID == nil)
|
||
if ids.count == 1 {
|
||
Button("Generate Waveform for Selected Track") { model.generateWaveforms(ids) }
|
||
} else {
|
||
Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(ids.isEmpty)
|
||
}
|
||
Button("Generate Waveforms for All Library Tracks") { model.generateWaveforms() }.disabled(model.songs.isEmpty)
|
||
Divider()
|
||
Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(ids.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("\(libraryRows.count) songs").font(.caption).foregroundStyle(.secondary)
|
||
if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||
Spacer()
|
||
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)
|
||
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)
|
||
Divider()
|
||
Button("Read Missing Durations") { model.scanDurations() }.disabled(model.readingDurations)
|
||
Button("Song Info…") { editingSong = selectedSong }.disabled(selectedSong == nil)
|
||
} 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) }
|
||
}
|
||
private func playNext() {
|
||
guard !libraryRows.isEmpty else { return }
|
||
let index = libraryRows.firstIndex { $0.id == selected } ?? -1
|
||
let next = (index+1)%libraryRows.count
|
||
page = next/LibraryPage.size;selected = libraryRows[next].id;tableSelection = [libraryRows[next].id];playSelected()
|
||
}
|
||
private func addEntity() {
|
||
model.perform {
|
||
let kind: EntityKind = source == "Singers" ? .singer : .venue
|
||
let id = try model.createEntity(kind, name: name)
|
||
if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) }
|
||
name = ""
|
||
}
|
||
}
|
||
}
|
||
/// AppKit starts this drag directly, so row click/double-click recognisers cannot swallow it.
|
||
private struct NativeSongDragHandle: NSViewRepresentable {
|
||
let reference: SongDragReference
|
||
let title: String
|
||
func makeNSView(context: Context) -> NativeSongDragView { let view = NativeSongDragView();view.reference = reference;view.title = title;return view }
|
||
func updateNSView(_ view: NativeSongDragView,context: Context) { view.reference = reference;view.title = title }
|
||
}
|
||
private final class NativeSongDragView: NSView,NSDraggingSource {
|
||
var reference: SongDragReference?
|
||
var title = "Song"
|
||
private var start: NSPoint?
|
||
private var dragging = false
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
NSColor.secondaryLabelColor.setStroke()
|
||
let path = NSBezierPath();path.lineWidth = 1
|
||
for y in [7.0,10.0,13.0] { path.move(to:NSPoint(x:3,y:y));path.line(to:NSPoint(x:13,y:y)) };path.stroke()
|
||
}
|
||
override func resetCursorRects() { addCursorRect(bounds,cursor:.openHand) }
|
||
override func mouseDown(with event: NSEvent) { start = event.locationInWindow;dragging = false }
|
||
override func mouseDragged(with event: NSEvent) {
|
||
guard !dragging,let reference else { return }
|
||
if let start,hypot(event.locationInWindow.x-start.x,event.locationInWindow.y-start.y) < 3 { return }
|
||
dragging = true
|
||
let item = NSDraggingItem(pasteboardWriter:reference.transportText as NSString)
|
||
let text = title
|
||
let image = NSImage(size:NSSize(width:240,height:28),flipped:false) { rect in
|
||
NSColor.windowBackgroundColor.setFill();NSBezierPath(roundedRect:rect,xRadius:5,yRadius:5).fill()
|
||
let paragraph = NSMutableParagraphStyle();paragraph.lineBreakMode = .byTruncatingTail
|
||
(text as NSString).draw(in:NSRect(x:10,y:6,width:220,height:18),withAttributes:[.font:NSFont.systemFont(ofSize:12),.foregroundColor:NSColor.labelColor,.paragraphStyle:paragraph]);return true
|
||
}
|
||
item.setDraggingFrame(NSRect(origin:convert(event.locationInWindow,from:nil),size:NSSize(width:240,height:28)),contents:image)
|
||
beginDraggingSession(with:[item],event:event,source:self).animatesToStartingPositionsOnCancelOrFail = true
|
||
}
|
||
override func mouseUp(with event: NSEvent) { start = nil;dragging = false }
|
||
func draggingSession(_ session: NSDraggingSession,sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { [.copy,.move] }
|
||
func draggingSession(_ session: NSDraggingSession,endedAt screenPoint: NSPoint,operation: NSDragOperation) { start = nil;dragging = false }
|
||
}
|
||
private struct TonightSongDropTarget: ViewModifier {
|
||
@ObservedObject var model: AppModel
|
||
let singer: Int64
|
||
let venue: Int64?
|
||
let before: Int64?
|
||
let enabled: Bool
|
||
@State private var targeted = false
|
||
func body(content: Content) -> some View {
|
||
content.contentShape(Rectangle())
|
||
.background(targeted && enabled ? Color.cyan.opacity(0.14) : .clear)
|
||
.overlay(alignment:.top) { if targeted && enabled { Rectangle().fill(.cyan).frame(height:2) } }
|
||
.onDrop(of:enabled && venue != nil ? [SongDragReference.type] : [],isTargeted:$targeted) { providers in
|
||
guard enabled else { return false }
|
||
return model.acceptSongDrop(providers,singer:singer,venue:venue,before:before)
|
||
}
|
||
}
|
||
}
|
||
/// Drawn with literal colours, so native menu/button tint cannot turn favourite stars white.
|
||
struct GoldStar: View {
|
||
var filled: Bool
|
||
var size: CGFloat = 14
|
||
var body: some View {
|
||
Canvas { context,bounds in
|
||
let centre = CGPoint(x:bounds.width/2,y:bounds.height/2),outer = min(bounds.width,bounds.height)*0.46
|
||
var star = Path()
|
||
for point in 0..<10 {
|
||
let angle = Double(point)*Double.pi/5-Double.pi/2,radius = point % 2 == 0 ? outer : outer*0.43
|
||
let p = CGPoint(x:centre.x+CGFloat(cos(angle))*radius,y:centre.y+CGFloat(sin(angle))*radius)
|
||
if point == 0 { star.move(to:p) } else { star.addLine(to:p) }
|
||
}
|
||
star.closeSubpath()
|
||
let gold = Color(red:1,green:0.73,blue:0.08)
|
||
if filled { context.fill(star,with:.color(gold)) }
|
||
else { context.stroke(star,with:.color(gold.opacity(0.7)),lineWidth:1) }
|
||
}.frame(width:size,height:size).accessibilityHidden(true)
|
||
}
|
||
}
|
||
struct GoldRating: View {
|
||
@Binding var rating: Int
|
||
var body: some View {
|
||
HStack(spacing:2) { ForEach(1..<6) { value in Button { rating = rating == value ? 0 : value } label: { GoldStar(filled:value <= rating) }.buttonStyle(.plain).help("\(value) stars; click the selected rating to clear").accessibilityLabel("Set \(value) stars") } }
|
||
}
|
||
}
|
||
private struct SongMetadataSheet: View {
|
||
@ObservedObject var model: AppModel
|
||
let song: Song
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var title = ""
|
||
@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 {
|
||
VStack(alignment:.leading,spacing:14) {
|
||
Text("Edit Song Metadata").font(.title2.bold())
|
||
Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:10) {
|
||
GridRow { Text("Title");TextField("Title",text:$title) }
|
||
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)
|
||
Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio)
|
||
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;track = song.trackNumber;rendition = song.rendition }
|
||
}
|
||
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
|
||
@Binding var selected: Int64?
|
||
@State private var removing: Int64?
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:14) {
|
||
HStack { Text("Library Locations").font(.headline);Spacer();Button("Add Drive / Folder…") { model.chooseMediaLocation() }.disabled(model.scanningSource != nil) }
|
||
Text("Index a mounted drive or folder in place and prepare its waveforms. Reconnect external drives to play their referenced audio. ZIP contents are copied into managed media for playback.").font(.callout).foregroundStyle(.secondary)
|
||
ScrollView {
|
||
VStack(alignment:.leading,spacing:12) {
|
||
ForEach(model.mediaLocations) { location in
|
||
VStack(alignment:.leading,spacing:8) {
|
||
HStack {
|
||
Image(systemName:location.external ? "externaldrive.fill" : "folder.fill").foregroundStyle(.cyan)
|
||
Text(location.name).font(.headline)
|
||
Text(location.online ? "Online" : "Offline").font(.caption).foregroundStyle(location.online ? Color.green : Color.orange)
|
||
Spacer();Text("\(location.songCount) songs").font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Text(location.path).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
|
||
HStack {
|
||
Button("Scan / Prepare") { model.startSourceScan(location.id) }.disabled(model.scanningSource != nil)
|
||
Button("Rebuild Waveforms") { model.startSourceScan(location.id,forceWaveforms:true) }.disabled(model.scanningSource != nil)
|
||
Spacer();Button("Remove Location",role:.destructive) { removing = location.id }.disabled(model.scanningSource != nil)
|
||
}
|
||
if !location.lastScan.isEmpty { Text("Last scan: \(location.lastScan)").font(.caption).foregroundStyle(.secondary) }
|
||
if !location.lastError.isEmpty { Text(location.lastError).font(.caption).foregroundStyle(.orange) }
|
||
}.padding(12).background(selected == location.id ? Color.blue.opacity(0.13) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = location.id }
|
||
}
|
||
if model.mediaLocations.isEmpty { Text("No locations added. Choose Add Drive / Folder… to index an external drive or music folder.").foregroundStyle(.secondary).padding(.vertical,25) }
|
||
}
|
||
}
|
||
if let progress = model.scanProgress {
|
||
Divider()
|
||
HStack { Text(progress.current).font(.callout).lineLimit(1);Spacer();if model.scanningSource != nil { ProgressView().controlSize(.small);Button("Cancel") { model.cancelSourceScan() } } }
|
||
Text("\(progress.files) files scanned · \(progress.indexed) songs indexed · \(progress.generated) waveforms built · \(progress.cached) cached · \(progress.issues) issues").font(.caption).foregroundStyle(.secondary)
|
||
if !progress.messages.isEmpty { DisclosureGroup("Scan details") { ScrollView { Text(progress.messages.joined(separator:"\n")).font(.caption.monospaced()).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(maxHeight:100) } }
|
||
}
|
||
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
|
||
.confirmationDialog("Remove this location? Its files and library songs will be kept.",isPresented:Binding(get:{ removing != nil },set:{ if !$0 { removing = nil } })) {
|
||
Button("Remove Location",role:.destructive) { if let removing { model.removeMediaLocation(removing) };removing = nil }
|
||
}
|
||
}
|
||
}
|
||
private struct MusicStoresPane: View {
|
||
@ObservedObject var model: AppModel
|
||
@Binding var selected: Int64?
|
||
@Environment(\.openURL) private var openURL
|
||
@State private var editor = false
|
||
@State private var editing: MusicStore?
|
||
@State private var catalogue: MusicStore?
|
||
@State private var search = ""
|
||
@State private var deleting: Int64?
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:14) {
|
||
HStack { Text("Music Stores").font(.headline);Spacer();Button("Add All Provider Profiles") { model.installStoreProfiles() };Button("Add Store…") { editing = nil;editor = true } }
|
||
Text("Define websites and optional catalogue-search URLs. Purchases and subscriptions are handled by the store in your browser.").font(.callout).foregroundStyle(.secondary)
|
||
HStack { TextField("Search store catalogue",text:$search).textFieldStyle(.roundedBorder);Text("{query} in a store’s search URL is replaced with this text.").font(.caption).foregroundStyle(.secondary) }
|
||
ScrollView {
|
||
VStack(alignment:.leading,spacing:12) {
|
||
ForEach(model.musicStores) { store in
|
||
VStack(alignment:.leading,spacing:8) {
|
||
HStack { Image(systemName:"bag.fill").foregroundStyle(.green);Text(store.name).font(.headline);Spacer()
|
||
Button("Catalogue…") { catalogue = store }
|
||
Button("Open Store") { open(store,query:"") }
|
||
Button("Search") { open(store,query:search) }.disabled(search.isEmpty || store.searchTemplate.isEmpty)
|
||
Button("Edit") { editing = store;editor = true }
|
||
Button("Delete",role:.destructive) { deleting = store.id }
|
||
}
|
||
Text(store.url).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
|
||
}.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id }
|
||
}
|
||
if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) }
|
||
}
|
||
}
|
||
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
|
||
.sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) }
|
||
.sheet(item:$catalogue) { store in StoreCatalogueSheet(model:model,store:store) }
|
||
.confirmationDialog("Delete this store definition?",isPresented:Binding(get:{ deleting != nil },set:{ if !$0 { deleting = nil } })) {
|
||
Button("Delete",role:.destructive) { if let deleting { model.deleteStore(deleting) };deleting = nil }
|
||
}
|
||
}
|
||
private func open(_ store: MusicStore,query: String) {
|
||
do { openURL(try store.destination(query:query)) } catch { model.error = error.localizedDescription }
|
||
}
|
||
}
|
||
private struct MusicStoreEditor: View {
|
||
@ObservedObject var model: AppModel
|
||
let store: MusicStore?
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var name = ""
|
||
@State private var url = ""
|
||
@State private var search = ""
|
||
@State private var error: String?
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:14) {
|
||
Text(store == nil ? "Add Music Store" : "Edit Music Store").font(.title2.bold())
|
||
TextField("Store name",text:$name).textFieldStyle(.roundedBorder)
|
||
TextField("Website URL — https://example.com",text:$url).textFieldStyle(.roundedBorder)
|
||
TextField("Optional search URL — https://example.com/search?q={query}",text:$search).textFieldStyle(.roundedBorder)
|
||
Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary)
|
||
if let error { Text(error).foregroundStyle(.red).font(.callout) }
|
||
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) }
|
||
}.padding(24).frame(width:500).background(StudioTheme.frame)
|
||
.onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" }
|
||
}
|
||
private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } }
|
||
}
|
||
/// Finder owns server authentication and stores credentials through macOS.
|
||
/// Only the user-selected mounted folder is passed to the ordinary copy importer.
|
||
enum SMBImportAddress {
|
||
static func url(_ address:String) throws -> URL {
|
||
let text = address.trimmingCharacters(in:.whitespacesAndNewlines)
|
||
guard let url = URL(string:text),url.scheme?.lowercased() == "smb",
|
||
let host = url.host,!host.isEmpty,url.user == nil,url.password == nil,
|
||
url.query == nil,url.fragment == nil else {
|
||
throw LibraryError.message("Enter an SMB address such as smb://nas/Music. Leave usernames and passwords out; macOS will ask you to sign in.")
|
||
}
|
||
return url
|
||
}
|
||
}
|
||
private struct ServerImportSheet: View {
|
||
let selected: ([URL]) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var address = "smb://"
|
||
@State private var choosingFolder = false
|
||
@State private var error: String?
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:16) {
|
||
Text("Import from SMB/NAS").font(.title2.bold())
|
||
Text("Connect to your server, then choose a folder on its mounted drive. Supported files in that folder and its subfolders will be copied into your media library.").fixedSize(horizontal:false,vertical:true)
|
||
TextField("smb://nas/Music",text:$address).textFieldStyle(.roundedBorder)
|
||
Button("Connect in Finder…") {
|
||
do {
|
||
let url = try SMBImportAddress.url(address)
|
||
guard NSWorkspace.shared.open(url) else { throw LibraryError.message("Could not open the server connection in Finder.") }
|
||
error = nil
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
Text("Finish signing in through macOS. Then use Choose Server Folder to select the connected drive under Locations. You can also choose a drive that is already connected.").font(.callout).foregroundStyle(.secondary).fixedSize(horizontal:false,vertical:true)
|
||
if let error { Text(error).foregroundStyle(.red).fixedSize(horizontal:false,vertical:true) }
|
||
HStack {
|
||
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
|
||
Spacer()
|
||
Button("Choose Server Folder…") {
|
||
choosingFolder = true
|
||
}.keyboardShortcut(.defaultAction)
|
||
}
|
||
}.padding(24).frame(width:500)
|
||
.fileImporter(isPresented:$choosingFolder,allowedContentTypes:[.folder],allowsMultipleSelection:false) { result in
|
||
switch result {
|
||
case .success(let urls):
|
||
guard !urls.isEmpty else { return }
|
||
selected(urls)
|
||
dismiss()
|
||
case .failure(let error): self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
}
|
||
private struct SingerSongsSheet: View {
|
||
@ObservedObject var model: AppModel
|
||
let singer: PersonPlace
|
||
var tonight = false
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var search = ""
|
||
@State private var importing = false
|
||
@State private var importingServer = false
|
||
@State private var serverImportURLs = [URL]()
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:14) {
|
||
Text(tonight ? "Tonight for \(singer.name)" : "Songs for \(singer.name)").font(.title2.bold())
|
||
Text(tonight ? "Tick songs for this venue’s Tonight list. They stay here after playback and when switching singers." : "Tick songs to add them to this singer’s repertoire.").font(.callout).foregroundStyle(.secondary)
|
||
TextField("Search library",text:$search).textFieldStyle(.roundedBorder)
|
||
ScrollView {
|
||
LazyVStack(alignment:.leading,spacing:12) {
|
||
ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in
|
||
HStack {
|
||
Toggle(song.title,isOn:Binding(get:{ tonight ? model.tonightSongIDs[singer.id]?.contains(song.id) == true : model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ included in
|
||
if tonight { if included { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) } }
|
||
else { model.assign(song.id,to:singer.id,included:included) }
|
||
})).toggleStyle(.checkbox)
|
||
Spacer()
|
||
Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite")
|
||
}
|
||
}
|
||
if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) }
|
||
}.frame(maxWidth:.infinity,alignment:.leading)
|
||
}.frame(height:270)
|
||
HStack { Button("Import Songs…") { importing = true };Button("SMB/NAS…") { importingServer = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
|
||
}.padding(24).frame(width:520).background(StudioTheme.frame)
|
||
.disabled(model.storageProgress != nil)
|
||
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } }
|
||
.sheet(isPresented:$importingServer,onDismiss:{
|
||
let urls = serverImportURLs;serverImportURLs = []
|
||
model.importFiles(urls)
|
||
}) { ServerImportSheet { serverImportURLs = $0 } }
|
||
.fileImporter(isPresented:$importing,allowedContentTypes:[.folder,.audio,.movie,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in
|
||
switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription }
|
||
}
|
||
}
|
||
}
|
||
private struct AddEntitySheet: View {
|
||
@ObservedObject var model: AppModel
|
||
let kind: EntityKind
|
||
let saved: (Int64) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var name = ""
|
||
@State private var error: String?
|
||
@State private var venue: Int64?
|
||
@State private var songs = Set<Int64>()
|
||
@State private var search = ""
|
||
@FocusState private var focused: Bool
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:16) {
|
||
Text(kind == .singer ? "New Singer + Songs" : "New Venue").font(.title2.bold())
|
||
TextField("\(kind.title) name",text:$name).textFieldStyle(.roundedBorder).focused($focused).onSubmit(save)
|
||
if kind == .singer {
|
||
Picker("Venue",selection:$venue) { Text("Choose a venue…").tag(nil as Int64?);ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } }
|
||
Text(venue == nil ? "Create a venue first, then add a singer to it." : "Singer will be marked Here. Selected songs join Tonight and the rotation.").font(.caption).foregroundStyle(.secondary)
|
||
Text("Add songs to Tonight (optional)").font(.headline)
|
||
TextField("Search library",text:$search).textFieldStyle(.roundedBorder)
|
||
ScrollView {
|
||
LazyVStack(alignment:.leading,spacing:10) {
|
||
ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in
|
||
Toggle(song.title,isOn:Binding(get:{ songs.contains(song.id) },set:{ if $0 { songs.insert(song.id) } else { songs.remove(song.id) } })).toggleStyle(.checkbox)
|
||
}
|
||
if model.songs.isEmpty { Text("No library songs yet. You can add songs from the singer’s + button after importing.").font(.callout).foregroundStyle(.secondary) }
|
||
}.frame(maxWidth:.infinity,alignment:.leading)
|
||
}.frame(height:190)
|
||
}
|
||
if let error { Text(error).foregroundStyle(.red).font(.callout) }
|
||
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
|
||
Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction)
|
||
.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil))
|
||
}
|
||
}.padding(24).frame(width:kind == .singer ? 500 : 360).background(StudioTheme.frame)
|
||
.onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs }
|
||
}
|
||
private func save() {
|
||
do {
|
||
let id: Int64
|
||
if kind == .singer { guard let venue else { throw LibraryError.message("Choose a venue") };id = try model.createSinger(name:name,venue:venue,songs:songs) }
|
||
else { id = try model.createEntity(kind,name:name) }
|
||
saved(id);dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|
||
private struct LiveTuningControls: View {
|
||
@ObservedObject var model: AppModel
|
||
@ObservedObject var player: Player
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:5) {
|
||
Text(model.liveSingerName).font(.caption2).foregroundStyle(.secondary).lineLimit(1).help("Live changes save to the performing singer and this song")
|
||
HStack {
|
||
Text("Key").frame(width:40,alignment:.leading)
|
||
Stepper(value:Binding(get:{player.pitch},set:{model.updateLiveTuning(pitch:$0)}),in:-12...12,step:1) { Text(String(format:"%+.0f",player.pitch)).monospacedDigit().frame(width:30) }.accessibilityLabel("Live key for performing singer")
|
||
}
|
||
HStack {
|
||
Text("Tempo").frame(width:40,alignment:.leading)
|
||
Stepper(value:Binding(get:{player.tempo},set:{model.updateLiveTuning(tempo:$0)}),in:0.5...1.5,step:0.01) { Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) }.accessibilityLabel("Live tempo for performing singer")
|
||
Button { model.updateLiveTuning(pitch:0,tempo:1) } label: { Image(systemName:"arrow.counterclockwise") }.buttonStyle(.borderless).help("Reset this singer’s key and tempo")
|
||
}
|
||
}.font(.caption).frame(width:190).disabled(player.currentSongID == nil || player.duration <= 0)
|
||
}
|
||
}
|
||
private struct TransportView: View {
|
||
@ObservedObject var player: Player
|
||
@ObservedObject var model: AppModel
|
||
@Binding var search: String
|
||
let hasSelection: Bool
|
||
let play: () -> Void
|
||
let next: () -> Void
|
||
let display: () -> Void
|
||
var body: some View {
|
||
HStack(spacing:24) {
|
||
VStack(spacing:10) {
|
||
HStack(spacing:8) {
|
||
TransportButton(symbol:"gobackward.10",label:"Back 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time-10) }
|
||
TransportButton(symbol:player.playing ? "pause.fill" : "play.fill",label:player.playing ? "Pause" : "Play",size:48,enabled:hasSelection || player.duration > 0) {
|
||
if player.duration > 0 { player.toggle() } else { play() }
|
||
}
|
||
TransportButton(symbol:"stop.fill",label:"Stop",size:28,enabled:player.duration > 0) { player.stopDeck() }
|
||
TransportButton(symbol:"goforward.10",label:"Forward 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time+10) }
|
||
TransportButton(symbol:"forward.end.fill",label:"Next song",size:26,enabled:hasSelection,action:next)
|
||
}
|
||
HStack(spacing:8) {
|
||
Image(systemName:"speaker.fill").font(.caption2).foregroundStyle(.secondary)
|
||
Slider(value:$player.volume,in:0...1).controlSize(.small).accessibilityLabel("Global master volume")
|
||
Image(systemName:"speaker.wave.3.fill").font(.caption2).foregroundStyle(.secondary)
|
||
}.frame(width:172)
|
||
LiveTuningControls(model:model,player:player)
|
||
}.frame(width:200)
|
||
NowPlayingWaveform(player:player)
|
||
}.frame(height:174)
|
||
}
|
||
}
|
||
|
||
private struct NowPlayingWaveform: View {
|
||
@ObservedObject var player: Player
|
||
@State private var mixer = false
|
||
@State private var zoom: Double = 1
|
||
@State private var bpm: Double?
|
||
private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) }
|
||
var body: some View {
|
||
VStack(alignment:.leading,spacing:5) {
|
||
Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94))
|
||
.lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title)
|
||
DJWaveform(player:player,color:.cyan,bpm:bpm).frame(height:14)
|
||
DJScrollingSpectrum(player:player,bpm:bpm,seconds:16/zoom)
|
||
.frame(height:46).background(.black)
|
||
.background(WaveformJogWheel(player:player))
|
||
.accessibilityAdjustableAction { direction in player.seek(player.time+(direction == .increment ? 5 : -5)) }
|
||
HStack(spacing:5) {
|
||
Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4)
|
||
Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1)
|
||
if let bpm { Text(String(format:"· %.1f BPM~",bpm*player.tempo)).monospacedDigit() }
|
||
if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) }
|
||
Spacer(minLength:0)
|
||
Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out")
|
||
Button { zoom = min(32,zoom*2) } label: { Image(systemName:"plus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom >= 32).help("Zoom in for precise scrubbing")
|
||
Menu("\(Int(zoom))×") { ForEach([1.0,2,4,8,16,32],id:\.self) { value in Button("\(Int(value))× zoom") { zoom = value } } }.menuStyle(.borderlessButton).frame(width:34).help("Waveform zoom around the playhead")
|
||
Menu { Button("Re-analyse Waveform") { player.reanalyseWaveform() }.disabled(player.duration == 0)
|
||
Text(player.waveformLoading ? "Analysing…" : player.waveformFromCache ? "Loaded saved waveform" : player.waveformSaved ? "Analysis saved" : "No saved analysis")
|
||
} label: { Image(systemName:"waveform") }.menuStyle(.borderlessButton).frame(width:20).help("Waveform cache and re-analysis")
|
||
Button("Mixer") { mixer.toggle() }.buttonStyle(.plain).foregroundStyle(.white.opacity(0.85))
|
||
.padding(.horizontal,6).padding(.vertical,2).background(.white.opacity(0.08),in:RoundedRectangle(cornerRadius:3))
|
||
.popover(isPresented:$mixer) { ToneMixerView(player:player) }
|
||
Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5)
|
||
}.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6))
|
||
DJDeckControls(player:player)
|
||
}.padding(.horizontal,14).padding(.vertical,10).frame(maxWidth:.infinity).frame(height:174)
|
||
.background(Color(.sRGB,red:0.055,green:0.065,blue:0.08,opacity:1),in:RoundedRectangle(cornerRadius:10))
|
||
.overlay(RoundedRectangle(cornerRadius:10).strokeBorder(.white.opacity(0.1),lineWidth:1))
|
||
.environment(\.colorScheme,.dark)
|
||
.task(id:player.currentSongID) {
|
||
bpm = nil
|
||
guard let source = player.sourceAudioURL,!player.isShowScreen else { return }
|
||
let analysis = Task.detached(priority:.utility) { try? DJBPM.analyse(source,cancelled:{Task.isCancelled}) }
|
||
let result = await withTaskCancellationHandler(operation:{ await analysis.value },onCancel:{ analysis.cancel() })
|
||
if !Task.isCancelled { bpm = result }
|
||
}
|
||
}
|
||
}
|
||
private struct ToneKnob: View {
|
||
let title: String
|
||
@Binding var value: Double
|
||
let range: ClosedRange<Double>
|
||
var filter = false
|
||
@State private var initial: Double?
|
||
private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) }
|
||
private var display: String {
|
||
if filter { return abs(value) < 0.01 ? "OFF" : value < 0 ? "LOW PASS" : "HIGH PASS" }
|
||
return String(format:"%+.1f dB",value)
|
||
}
|
||
var body: some View {
|
||
VStack(spacing:4) {
|
||
ZStack {
|
||
Circle().trim(from:0,to:0.75).stroke(Color(white:0.21),style:StrokeStyle(lineWidth:4,lineCap:.round)).rotationEffect(.degrees(135))
|
||
Circle().trim(from:0,to:0.75*fraction).stroke(Color(white:0.48),style:StrokeStyle(lineWidth:3,lineCap:.round)).rotationEffect(.degrees(135))
|
||
Circle().fill(LinearGradient(colors:[Color(white:0.19),Color(white:0.09)],startPoint:.top,endPoint:.bottom)).padding(6).overlay(Circle().stroke(.black.opacity(0.65),lineWidth:1.5).padding(6))
|
||
Capsule().fill(.white).frame(width:2,height:12).offset(y:-13).rotationEffect(.degrees(-135+270*fraction))
|
||
}.frame(width:50,height:50).contentShape(Circle())
|
||
.gesture(DragGesture(minimumDistance:0).onChanged { gesture in
|
||
if initial == nil { initial = value }
|
||
value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(gesture.translation.height)/130*(range.upperBound-range.lowerBound)))
|
||
}.onEnded { _ in initial = nil })
|
||
.simultaneousGesture(TapGesture(count:2).onEnded { value = 0 })
|
||
.accessibilityElement().accessibilityLabel(title).accessibilityValue(display)
|
||
.accessibilityAdjustableAction { direction in
|
||
let change = (range.upperBound-range.lowerBound)/24
|
||
value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? change : -change)))
|
||
}.help("Drag up/down to adjust. Double-click to reset.")
|
||
Text(title).font(.system(size:10,weight:.bold)).foregroundStyle(Color(white:0.6))
|
||
Text(display).font(.system(size:8,design:.monospaced)).foregroundStyle(Color(white:0.45))
|
||
}
|
||
}
|
||
}
|
||
private struct StereoMeter: View {
|
||
@ObservedObject var player: Player
|
||
private func normal(_ level: Float) -> Double { max(0,min(1,(20*log10(Double(max(level,0.000001)))+60)/60)) }
|
||
var body: some View {
|
||
HStack(spacing:3) {
|
||
ForEach(0..<2) { channel in
|
||
VStack(spacing:2) {
|
||
ForEach((0..<24).reversed(),id:\.self) { segment in
|
||
let on = Double(segment)/24 < normal(channel == 0 ? player.leftLevel : player.rightLevel)
|
||
Rectangle().fill(on ? (segment > 21 ? Color.red : segment > 17 ? Color.yellow : Color.green) : Color.black.opacity(0.5))
|
||
}
|
||
}
|
||
}
|
||
}.frame(width:23,height:125).accessibilityLabel("Stereo output levels")
|
||
}
|
||
}
|
||
private struct MixerFader: View {
|
||
@Binding var value: Double
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
let travel = geometry.size.height-22
|
||
ZStack(alignment:.top) {
|
||
VStack { ForEach(0..<7) { _ in Rectangle().fill(.white.opacity(0.09)).frame(height:1);Spacer(minLength:0) } }.padding(.vertical,11)
|
||
Capsule().fill(.black.opacity(0.8)).frame(width:5).padding(.vertical,8)
|
||
RoundedRectangle(cornerRadius:5).fill(LinearGradient(colors:[Color(white:0.45),Color(white:0.23)],startPoint:.top,endPoint:.bottom))
|
||
.frame(width:45,height:20).overlay(Rectangle().fill(.white.opacity(0.15)).frame(width:18,height:1))
|
||
.offset(y:(1-value)*travel)
|
||
}.frame(maxWidth:.infinity).contentShape(Rectangle())
|
||
.gesture(DragGesture(minimumDistance:0).onChanged { value = min(1,max(0,1-Double(($0.location.y-11)/travel))) })
|
||
}.frame(width:56,height:135).accessibilityLabel("Master volume").accessibilityValue("\(Int(value*100)) percent")
|
||
.accessibilityAdjustableAction { direction in value = min(1,max(0,value+(direction == .increment ? 0.05 : -0.05))) }
|
||
}
|
||
}
|
||
private struct ToneMixerView: View {
|
||
@ObservedObject var player: Player
|
||
var body: some View {
|
||
VStack(spacing:14) {
|
||
Text("Global settings · Saved automatically").font(.caption).foregroundStyle(.secondary)
|
||
HStack { Text("GLOBAL TONE / MASTER").font(.system(size:12,weight:.bold));Spacer();Toggle("EQ",isOn:$player.toneEnabled).toggleStyle(.switch).controlSize(.mini) }
|
||
HStack(alignment:.top,spacing:28) {
|
||
VStack(spacing:8) {
|
||
ToneKnob(title:"HIGH",value:$player.highGain,range:-12...12)
|
||
ToneKnob(title:"MID",value:$player.midGain,range:-12...12)
|
||
ToneKnob(title:"LOW",value:$player.lowGain,range:-12...12)
|
||
}
|
||
VStack(spacing:15) {
|
||
ToneKnob(title:"FILTER",value:$player.filter,range:-1...1,filter:true)
|
||
HStack(spacing:20) { StereoMeter(player:player);MixerFader(value:$player.volume) }
|
||
Text(player.volume <= 0 ? "−∞ dB" : String(format:"%+.1f dB",20*log10(player.volume))).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary)
|
||
}
|
||
}.padding(.horizontal,15)
|
||
HStack { Text("Drag knobs · Double-click to reset").font(.system(size:9)).foregroundStyle(.secondary);Spacer();Button("Reset EQ") { player.resetTone() }.controlSize(.small) }
|
||
}.padding(18).frame(width:270).background(Color(white:0.105)).preferredColorScheme(.dark)
|
||
}
|
||
}
|
||
private struct PlaybackSettings: View {
|
||
@ObservedObject var player: Player
|
||
@ObservedObject var model: AppModel
|
||
let selectedSong: Int64?
|
||
private var target: Int64? { selectedSong ?? player.currentSongID }
|
||
private var live: Bool { player.currentSongID != nil && player.duration > 0 }
|
||
private var pitch: Binding<Double> { Binding(get:{live ? player.pitch : target.map{model.preference($0).pitch} ?? 0},set:{value in if live { model.updateLiveTuning(pitch:value) } else if let target { model.updateTuning(target,pitch:value) }}) }
|
||
private var tempo: Binding<Double> { Binding(get:{live ? player.tempo : target.map{model.preference($0).tempo} ?? 1},set:{value in if live { model.updateLiveTuning(tempo:value) } else if let target { model.updateTuning(target,tempo:value) }}) }
|
||
private var rating: Binding<Int> { Binding(get:{target.flatMap { id in model.songs.first{$0.id == id} }.map{model.displayedRating($0)} ?? 0},set:{value in if let song = model.songs.first(where:{$0.id == target}) { model.rateSong(song,value) }}) }
|
||
var body: some View {
|
||
HStack(spacing:10) {
|
||
Text("Key")
|
||
Picker("Key",selection:pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70)
|
||
Text("Tempo");Slider(value:tempo,in:0.5...1.5,step:0.05).frame(width:85)
|
||
Text(String(format:"%.0f%%",tempo.wrappedValue*100)).monospacedDigit().frame(width:40)
|
||
Text("Selected rating");GoldRating(rating:rating).frame(width:88)
|
||
Text(live ? "Live: \(model.liveSingerName)" : model.selectedSinger == nil ? "Song defaults · Saved" : "Singer settings · Saved").font(.caption).foregroundStyle(.secondary)
|
||
Menu {
|
||
Button("Reset key / tempo") { pitch.wrappedValue = 0;tempo.wrappedValue = 1 }
|
||
ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } }
|
||
} label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22)
|
||
}.font(.system(size:11)).disabled(target == nil && !live)
|
||
}
|
||
}
|
||
private struct SeekBar: View {
|
||
@ObservedObject var player: Player
|
||
private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) }
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
Text(clock(player.time))
|
||
Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration))
|
||
.controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position")
|
||
Text(clock(player.duration))
|
||
}.font(.system(size: 10, design: .monospaced)).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
private struct RepeatButton: View {
|
||
@ObservedObject var player: Player
|
||
var body: some View {
|
||
Button { player.repeating.toggle() } label: {
|
||
Image(systemName: "repeat").font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(player.repeating ? Color.accentColor : Color.secondary).frame(width: 32, height: 24)
|
||
}.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off")
|
||
}
|
||
}
|
||
|
||
|
||
// Keep one change-observer API across the supported macOS versions.
|
||
extension View {
|
||
@ViewBuilder func onStudioChange<Value: Equatable>(of value: Value,perform action: @escaping (Value) -> Void) -> some View {
|
||
if #available(macOS 14.0, *) { onChange(of:value) { _,newValue in action(newValue) } }
|
||
else { legacyStudioChange(of:value,perform:action) }
|
||
}
|
||
@available(macOS,introduced:10.15,deprecated:14.0)
|
||
private func legacyStudioChange<Value: Equatable>(of value: Value,perform action: @escaping (Value) -> Void) -> some View {
|
||
onChange(of:value,perform:action)
|
||
}
|
||
}
|