Files
KMix_Pro/KJamsClone/KJMixProApp.swift
T
2026-11-05 13:42:20 +00:00

1955 lines
139 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 = WaveformBatch.run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled},progress:{ update in
Task { @MainActor [weak self] in guard let self,self.waveformJobToken == token else { return };self.waveformJob = update }
})
await MainActor.run { [weak self] in
guard let self,self.waveformJobToken == token else { return }
self.waveformJob = result;self.waveformJobToken = nil;self.generatingWaveforms = false;self.waveformJobTask = nil
if let id = self.player.currentSongID,tracks.contains(where:{$0.id == id}) { self.player.refreshCachedWaveform() }
}
}
}
func cancelWaveformGeneration() { waveformJobTask?.cancel();waveformJob?.current = "Cancelling…" }
func dismissWaveformProgress() { if !generatingWaveforms { waveformJob = nil } }
let player: Player
private(set) var library: Library?
let root: URL
@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
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()
do {
let database = try Library(url:database)
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
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
}
}
}
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
var settings: () -> Void
}
private struct LibraryCommandsKey: FocusedValueKey { typealias Value = LibraryCommands }
private extension FocusedValues {
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(\.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…") { actions?.settings() }.keyboardShortcut(",").disabled(actions == 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: 600, height: 400)
}
}
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 {
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.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:380).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")
}
}
}
}
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 {
Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(contentMode: .fit).opacity(player.outputOpacity)
}
}
}
}
/// 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 showDJ = true
@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(showDJ ? 12 : 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) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
}
Spacer()
if showDJ,["Library","Singer Songs","Playlists"].contains(source) {
Button("Song Settings") { djSongSettings = true }
.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))
}
}
if source == "Library" {
Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered)
}
}.padding(.horizontal,22).padding(.vertical,showDJ ? 8 : 18)
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.waveformJob {
HStack(spacing:10) {
Image(systemName:"waveform").foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:2) { Text(progress.current).lineLimit(1);Text("\(progress.processed)/\(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").foregroundStyle(.secondary) }.font(.caption)
Spacer();if model.generatingWaveforms { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).frame(width:100);Button("Cancel") { model.cancelWaveformGeneration() }.controlSize(.small) }
if progress.issues > 0 { Button("Details") { waveformDetails = true }.controlSize(.small) }
if !model.generatingWaveforms { Button { model.dismissWaveformProgress() } label: { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(.horizontal,18).padding(.vertical,8)
}
Divider()
if !showDJ,["Library","Singer Songs","Playlists"].contains(source) { detailPanel.frame(height:135) }
Divider()
footer.padding(.horizontal,18).padding(.vertical,8)
}.frame(minWidth:780)
}
}
@ViewBuilder private var workspaceLayout: some View {
if showDJ {
VSplitView {
libraryWorkspace.frame(minHeight:400)
DJConsole(model:model,system:model.dj).frame(minHeight:440,idealHeight:510)
}
} else { libraryWorkspace }
}
private var workspace: some View {
workspaceLayout
.background(StudioTheme.frame)
.tint(.accentColor)
.focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty)))
.focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{studioSettingsModal = true}))
.sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model) }
.navigationTitle(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source)
.navigationSubtitle(model.currentVenueName)
.searchable(text:$search,placement:.toolbar,prompt:"Search Music")
.toolbar {
ToolbarItemGroup {
Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") }
Button { showDJ.toggle() } label: { Label(showDJ ? "Hide DJ" : "Show DJ",systemImage:"waveform.path") }.help("Show or hide the DJ workspace; playback continues")
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.importProgress != nil || model.storageProgress != nil)
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } else if model.importProgress != nil { ImportProgressDialog(model:model) } }
.onAppear { updateLibraryRows() }
.onChange(of:model.songs) { _ in updateLibraryRows() }
.onChange(of:model.tuningCache) { _ in updateLibraryRows() }
.onChange(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:$waveformDetails) {
VStack(alignment:.leading,spacing:14) {
Text("Waveform Analysis").font(.title2.bold())
ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300)
HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:560)
}
.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) }
}
.onChange(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) }
.onChange(of:selected) { song in
if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song }
}
.onChange(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")
Button { studioSettingsModal = true } label: { Label("Settings…",systemImage:"gearshape").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain)
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
}.listStyle(.sidebar)
Divider().padding(.horizontal,14)
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).controlSize(.small).buttonStyle(.borderless).padding(.horizontal,16).padding(.vertical,8)
}
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("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Reinterpret Names…") { beginReinterpretation(ids) }
Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true }
}
else if let id = ids.first,let song = model.songs.first(where:{$0.id == id}) { songActions(song) }
} 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 Info…") { editingSong = song }
Button("Generate Waveform") { model.generateWaveforms([song.id]) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Regenerate Waveform") { model.generateWaveforms([song.id],force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Reinterpret Name…") { beginReinterpretation([song.id]) }
Button("Find Metadata Online…") { metadataLookup = song }
Button("Batch Edit Selected…") { if tableSelection.isEmpty { tableSelection = [song.id] };batchEditing = true }
Button("Write File Tags…") { tableSelection = [song.id];confirmWriteTags = true }.disabled(model.tagsBusy || song.audio.hasPrefix("showscreen:"))
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 {
Button("Generate Waveform for Loaded Track") { if let id = model.player.currentSongID { model.generateWaveforms([id]) } }.disabled(model.player.currentSongID == nil)
Button("Generate Waveform for Selected Track") { if let selected { model.generateWaveforms([selected]) } }.disabled(tableSelection.count > 1 || selected == nil)
Button("Generate Waveforms for \(tableSelection.count) Selected Tracks") { model.generateWaveforms(tableSelection) }.disabled(tableSelection.isEmpty)
Button("Generate Waveforms for All Library Tracks") { model.generateWaveforms() }.disabled(model.songs.isEmpty)
Divider()
Button("Regenerate Selected Waveforms") { model.generateWaveforms(tableSelection,force:true) }.disabled(tableSelection.isEmpty)
Button("Regenerate All Library Waveforms") { model.generateWaveforms(force:true) }.disabled(model.songs.isEmpty)
}
private var footer: some View {
HStack(spacing:8) {
Circle().fill(Color.green.opacity(0.85)).frame(width:5,height:5)
Text("\(libraryRows.count) songs").font(.caption).foregroundStyle(.secondary)
if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil)
if tableSelection.count > 1 { Button("Edit \(tableSelection.count) Songs…") { batchEditing = true }.controlSize(.small) }
Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary)
RepeatButton(player:model.player).buttonStyle(.borderless)
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.
private 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)
}
}
private 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.importProgress != nil || model.storageProgress != nil)
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } else if model.importProgress != nil { ImportProgressDialog(model:model) } }
.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 hoverX: CGFloat?
@State private var scrubStart: Double?
@State private var resumeAfterScrub = false
@State private var scrubSong: Int64?
private var span: Double { max(0.001,player.duration/zoom) }
private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) }
private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 }
private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) }
private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) }
private func bandColor(_ index: Int) -> Color {
guard player.waveformBands.indices.contains(index) else { return .cyan }
let band = player.waveformBands[index]
let largest = max(0.000001,max(band.low,max(band.mid,band.high)))
let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest)
return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high))
}
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)
GeometryReader { geometry in
Canvas { context,size in
let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2
var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid))
context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5)
if player.duration > 0 {
let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600
let first = floor(windowStart/(step/4))*(step/4)
for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) {
let x = size.width*(t-windowStart)/span
if x < 0 { continue }
let major = abs(t/step-(t/step).rounded()) < 0.001
var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height))
context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5)
if major && x < size.width-25 {
context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading)
}
}
}
if !player.waveform.isEmpty {
let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count)
for bar in 0..<count {
let total = player.waveform.count
let left = windowStart+Double(bar)/Double(count)*span,right = windowStart+Double(bar+1)/Double(count)*span
let start = min(total-1,max(0,Int(left/max(player.duration,0.001)*Double(total))))
let end = min(total,max(start+1,Int(right/max(player.duration,0.001)*Double(total))))
let level = player.waveform[start..<min(end,player.waveform.count)].max() ?? 0
let height = max(0.7,CGFloat(level)*(plotHeight-3)),x = CGFloat(bar)*step
let path = Path(CGRect(x:x,y:mid-height/2,width:max(0.7,step-0.7),height:height))
context.fill(path,with:.color(bandColor(start).opacity(x <= size.width*progress ? 1 : 0.88)))
}
if let hoverX {
let hx = min(size.width,max(0,hoverX))
var hover = Path();hover.move(to:CGPoint(x:hx,y:top));hover.addLine(to:CGPoint(x:hx,y:size.height))
context.stroke(hover,with:.color(.white.opacity(0.7)),style:StrokeStyle(lineWidth:1,dash:[2,2]))
let time = windowStart+Double(hx/size.width)*span
context.draw(Text(preciseClock(time)).font(.system(size:9,weight:.bold,design:.monospaced)).foregroundColor(.white),at:CGPoint(x:min(size.width-28,max(28,hx)),y:3))
}
} else if player.waveformLoading {
context.draw(Text("Analysing waveform…").font(.system(size:9)).foregroundColor(.gray),at:CGPoint(x:size.width/2,y:mid))
}
if player.duration > 0 {
let x = min(size.width-2,max(2,size.width*progress))
context.fill(Path(CGRect(x:0,y:top,width:x,height:plotHeight)),with:.color(.black.opacity(0.18)))
var head = Path();head.move(to:CGPoint(x:x,y:top));head.addLine(to:CGPoint(x:x,y:size.height))
context.stroke(head,with:.color(.black.opacity(0.9)),lineWidth:6)
context.stroke(head,with:.color(.cyan.opacity(0.3)),lineWidth:5)
context.stroke(head,with:.color(.white),lineWidth:2)
var pointer = Path();pointer.move(to:CGPoint(x:x-5,y:top));pointer.addLine(to:CGPoint(x:x+5,y:top));pointer.addLine(to:CGPoint(x:x,y:top+7));pointer.closeSubpath()
context.fill(pointer,with:.color(.cyan))
let labelX = min(size.width-30,max(30,x))
context.fill(Path(roundedRect:CGRect(x:labelX-29,y:0,width:58,height:13),cornerRadius:3),with:.color(.cyan))
context.draw(Text(preciseClock(player.time)).font(.system(size:9,weight:.bold,design:.monospaced)).foregroundColor(.black),at:CGPoint(x:labelX,y:6))
}
}.contentShape(Rectangle()).background(WaveformJogWheel(player:player))
.onContinuousHover { phase in switch phase { case .active(let point):hoverX = point.x;case .ended:hoverX = nil } }
.gesture(DragGesture(minimumDistance:0).onChanged { value in
guard player.duration > 0,geometry.size.width > 0 else { return }
if scrubStart == nil {
scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing
if player.playing { player.toggle() }
}
guard scrubSong == player.currentSongID else { return }
hoverX = value.location.x
let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span
if abs(target-player.time) > 0.02 { player.seek(target) }
}.onEnded { value in
if scrubStart == nil {
if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) }
return
}
let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span
if scrubSong == player.currentSongID {
player.seek(target)
if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() }
}
scrubStart = nil;resumeAfterScrub = false;scrubSong = nil
}).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.")
.accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))")
.accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } }
}.frame(height:60)
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 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)
}
}
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")
}
}