Files
KMix_Pro/KJamsClone/KJMixProApp.swift
T
2026-11-03 14:31:32 +00:00

1614 lines
113 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()
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 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 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
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")
player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite")); 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()
}
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 updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) {
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) 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)
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 else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard let source = mediaLocations.first(where:{ $0.id == id }) else { return }
let token = UUID();scanToken = token;scanningSource = id;scanProgress = SourceScanProgress(source:id)
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms")
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() }
}
func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } }
}
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()
@FocusedValue(\.libraryCommands) private var actions
var body: some Scene {
Window("KJMix Pro", id: "main") {
LibraryView(model: model).frame(minWidth: 1060, minHeight: 760).preferredColorScheme(.dark)
}.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("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 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(Color(white:0.66)).frame(width:18)
configuration.title.foregroundStyle(Color(white:0.88))
}
}
}
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(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7))
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 private var selected: Int64?
@State private var tableSelection = Set<Int64>()
@State private var waveformDetails = false
@State private var batchEditing = false
@State private var metadataLookup: Song?
@State private var confirmWriteTags = false
@State private var search = ""
@State private var name = ""
@State private var importing = false
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 tableSort = [KeyPathComparator(\Song.title)]
@State private var librarySort = "Song Name"
@State private var sortAscending = true
@Environment(\.openWindow) private var openWindow
private var visibleSongs: [Song] { libraryRows }
private func updateLibraryRows() {
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)
}
private var selectedSong: Song? { model.songs.first { $0.id == selected } }
private var workspace: some View {
HSplitView {
sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260)
VStack(spacing:0) {
TransportView(player:model.player,search:$search,hasSelection:selectedSong != nil,
play:playSelected,next:playNext,display:{ openWindow(id:"lyrics") })
.padding(20)
Divider()
HStack {
VStack(alignment:.leading,spacing:4) {
Text(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer")+" · "+singerSection : source).font(.title2.weight(.semibold))
Text(source == "Library" ? "\(model.songs.count) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
}
Spacer()
if source == "Library" {
Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered)
}
}.padding(.horizontal,22).padding(.vertical,18)
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.waveformJob {
HStack(spacing:10) {
Image(systemName:"waveform").foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:2) { Text(progress.current).lineLimit(1);Text("\(progress.processed)/\(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").foregroundStyle(.secondary) }.font(.caption)
Spacer();if model.generatingWaveforms { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).frame(width:100);Button("Cancel") { model.cancelWaveformGeneration() }.controlSize(.small) }
if progress.issues > 0 { Button("Details") { waveformDetails = true }.controlSize(.small) }
if !model.generatingWaveforms { Button { model.dismissWaveformProgress() } label: { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(.horizontal,18).padding(.vertical,8)
}
Divider()
if ["Library","Singer Songs","Playlists"].contains(source) { detailPanel.frame(height:135) }
Divider()
BackgroundDeckStrip(model:model,deck:model.backgroundDeck).padding(.horizontal,16).padding(.vertical,8)
Divider()
footer.padding(.horizontal,18).padding(.vertical,8)
}.frame(minWidth:780)
}
.background(StudioTheme.frame)
.tint(.accentColor)
.focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"}))
.navigationTitle("KJMix Pro")
.searchable(text:$search,placement:.toolbar,prompt:"Search Music")
.toolbar {
ToolbarItemGroup {
Button { importing = true } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import music or video")
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.disabled(model.generatingWaveforms || model.scanningSource != nil).help("Prepare waveforms for a track, selected tracks or the whole library")
Menu {
Button("New Singer…") { model.beginSinger() }
Button("New Venue…") { model.creating = .venue }
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") }
Button { source = "Settings" } label: { Label("Settings",systemImage:"gearshape") }
}
}
}
var body: some View {
workspace
.onAppear { updateLibraryRows() }
.onChange(of:model.songs) { _ in updateLibraryRows() }
.onChange(of:model.tuningCache) { _ in updateLibraryRows() }
.onChange(of:search) { _ in updateLibraryRows() }
.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).preferredColorScheme(.dark)
}
.sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) }
.sheet(item:$metadataLookup) { song in MetadataLookupSheet(model:model,song:song) }
.confirmationDialog("Write file tags for \(tableSelection.count) songs? Original files will be backed up in TagBackups before replacement. Unsupported files are reported and left unchanged.",isPresented:$confirmWriteTags) {
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) }
}
.fileImporter(isPresented: $importing, allowedContentTypes: [.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" { songTable }
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)
ScrollView {
LazyVStack(alignment:.leading,spacing:3) {
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("Singer Web",symbol:"network")
navigationRow("Music Stores",symbol:"bag")
navigationRow("Settings",symbol:"gearshape")
}.font(.system(size:13)).padding(.horizontal,10).padding(.bottom,12)
}
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(.white.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(.white.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 songTable: some View {
Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows()})) {
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("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.preference(song.id).pitch)).monospacedDigit().frame(width:24)
Stepper("Key",value:Binding(get:{model.preference(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini)
}.help("Saved key for this singer or song")
}.width(65)
TableColumn("Tempo") { song in
Picker("Tempo",selection:Binding(get:{model.preference(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) {
ForEach(10..<31) { Text("\($0*5)%").tag(Double($0)/20) }
}.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("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("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(.white).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("\(visibleSongs.count) songs").font(.caption).foregroundStyle(.secondary)
if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil)
if tableSelection.count > 1 { Button("Edit \(tableSelection.count) Songs…") { batchEditing = true }.controlSize(.small) }
Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary)
RepeatButton(player:model.player).buttonStyle(.borderless)
Menu {
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 playSelected() {
guard let song = selectedSong else { return }
model.perform { try model.play(song) }
}
private func playNext() {
guard !visibleSongs.isEmpty else { return }
let index = visibleSongs.firstIndex { $0.id == selected } ?? -1
selected = visibleSongs[(index + 1) % visibleSongs.count].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 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("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).preferredColorScheme(.dark)
.onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating }
}
private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } }
}
private 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.white.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.white.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).preferredColorScheme(.dark)
.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 } }
}
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
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 };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:520).background(StudioTheme.frame).preferredColorScheme(.dark)
.fileImporter(isPresented:$importing,allowedContentTypes:[.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).preferredColorScheme(.dark)
.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 TransportView: View {
@ObservedObject var player: Player
@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:18) {
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)
}.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)))
}
let x = size.width*progress
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(.red),lineWidth:1)
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))
}
}.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,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:174)
.background(Color.black.opacity(0.22),in:RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).strokeBorder(.white.opacity(0.07),lineWidth:1))
}
}
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 pitch: Binding<Double> { Binding(get:{target.map{model.preference($0).pitch} ?? 0},set:{value in if let target { model.updateTuning(target,pitch:value) }}) }
private var tempo: Binding<Double> { Binding(get:{target.map{model.preference($0).tempo} ?? 1},set:{value in 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("Rating");GoldRating(rating:rating).frame(width:88)
Text(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)
}
}
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(.white.opacity(0.65))
}
}
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")
}
}