Files

225 lines
18 KiB
Swift

import SwiftUI
import AppKit
struct RotationSinger: Identifiable, Equatable { var id: Int64;var name: String;var here: Bool }
struct HostSettings: Codable, Equatable {
var smart = false
var cursor: Int64?
var fadeSeconds: Double = 3
var autoFade = true
var intermission: Int64?
}
enum SingerRotation {
/// Stable cyclic interleave of pending performances. Empty singers retain their roster slot.
static func plan(queue: [QueueEntry],roster: [RotationSinger],after cursor: Int64?) -> [QueueEntry] {
var singers = roster.map(\.id)
if let cursor,let i = singers.firstIndex(of:cursor) { singers = Array(singers.dropFirst(i+1))+Array(singers.prefix(i+1)) }
let here = Set(roster.filter(\.here).map(\.id));singers = singers.filter{here.contains($0)}
var groups = Dictionary(grouping:queue.filter{$0.singerID != nil},by:{$0.singerID!})
var result = [QueueEntry]()
while true {
var added = false
for singer in singers where groups[singer]?.isEmpty == false { result.append(groups[singer]!.removeFirst());added = true }
if !added { break }
}
return result+queue.filter{$0.singerID == nil}
}
}
struct ShowScreen: Identifiable, Equatable {
var id: Int64 = 0
var title = "Welcome to karaoke"
var body = "Welcome to {venue}\n\nNext up: {next}"
var footer = "Find your next song · Enjoy the show"
var tint = "indigo"
var seconds: Double = 12
func expanded(_ value: String,venue: String,singer: String,next: String,song: String) -> String {
value.replacingOccurrences(of:"{venue}",with:venue).replacingOccurrences(of:"{singer}",with:singer).replacingOccurrences(of:"{next}",with:next).replacingOccurrences(of:"{song}",with:song)
}
@MainActor func render(venue: String,singer: String,next: String,song: String) -> CGImage? {
let size = NSSize(width:1920,height:1080)
let image = NSImage(size:size,flipped:false) { rect in
let top: NSColor = tint == "teal" ? NSColor(srgbRed:0.02,green:0.24,blue:0.27,alpha:1) : tint == "rose" ? NSColor(srgbRed:0.26,green:0.06,blue:0.18,alpha:1) : NSColor(srgbRed:0.10,green:0.08,blue:0.28,alpha:1)
NSGradient(starting:top,ending:NSColor(srgbRed:0.015,green:0.02,blue:0.04,alpha:1))!.draw(in:rect,angle:270)
let paragraph = NSMutableParagraphStyle();paragraph.alignment = .center;paragraph.lineBreakMode = .byWordWrapping
func text(_ value: String,_ rect: NSRect,_ font: NSFont,_ color: NSColor) { (value as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:color,.paragraphStyle:paragraph]) }
text("KJMix Pro",NSRect(x:160,y:900,width:1600,height:60),.systemFont(ofSize:30,weight:.semibold),.white.withAlphaComponent(0.6))
text(title,NSRect(x:160,y:730,width:1600,height:140),.systemFont(ofSize:64,weight:.bold),.white)
text(expanded(body,venue:venue,singer:singer,next:next,song:song),NSRect(x:170,y:220,width:1580,height:460),.systemFont(ofSize:48,weight:.medium),.white)
text(expanded(footer,venue:venue,singer:singer,next:next,song:song),NSRect(x:160,y:90,width:1600,height:100),.systemFont(ofSize:27,weight:.regular),.white.withAlphaComponent(0.65))
return true
}
guard let bitmap = NSBitmapImageRep(bitmapDataPlanes:nil,pixelsWide:1920,pixelsHigh:1080,bitsPerSample:8,samplesPerPixel:4,hasAlpha:true,isPlanar:false,colorSpaceName:.deviceRGB,bytesPerRow:0,bitsPerPixel:0),let context = NSGraphicsContext(bitmapImageRep:bitmap) else { return nil }
NSGraphicsContext.saveGraphicsState();NSGraphicsContext.current = context
image.draw(in:NSRect(origin:.zero,size:size),from:.zero,operation:.copy,fraction:1)
NSGraphicsContext.restoreGraphicsState();return bitmap.cgImage
}
}
@MainActor extension AppModel {
var rotationPlan: [QueueEntry] { hostSettings.smart ? SingerRotation.plan(queue:queue,roster:rotationSingers,after:hostSettings.cursor) : queue }
func saveHostSettings() {
perform {
guard let library,let currentVenue else { return }
try library.setSetting("host.\(currentVenue)",String(decoding:JSONEncoder().encode(hostSettings),as:UTF8.self))
player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade
}
}
func markHere(_ singer: Int64,_ here: Bool) { perform { guard let library,let currentVenue else { return };try library.markHere(singer:singer,venue:currentVenue,here:here);try refresh() } }
func moveSinger(_ singer: Int64,by offset: Int) { perform { guard let library,let currentVenue else { return };try library.moveRotation(singer:singer,venue:currentVenue,by:offset);try refresh() } }
func resetRound() { hostSettings.cursor = nil;saveHostSettings() }
func assignQueue(_ entry: Int64,singer: Int64?) { perform { guard let library,let currentVenue else { return };try library.assignQueue(entry,singer:singer,venue:currentVenue);try refresh() } }
func saveScreen(_ screen: ShowScreen) { perform { try library?.saveScreen(screen);try refresh() } }
func playScreen(_ screen: ShowScreen) throws {
let next = rotationPlan.first{$0.songID != screen.id}
guard let image = screen.render(venue:currentVenueName,singer:singers.first{$0.id == playbackSinger}?.name ?? "",next:next?.singer ?? "You",song:next?.title ?? "") else { throw LibraryError.message("Could not render the slide") }
player.showScreen(image,seconds:screen.seconds,title:screen.title,songID:screen.id)
}
func advanceShow() {
if let id = hostSettings.intermission,let screen = showScreens.first(where:{$0.id == id}),!rotationPlan.isEmpty,!playingIntermission {
playingIntermission = true;perform { try playScreen(screen) }
} else { playingIntermission = false;playQueueNext() }
}
func rateSong(_ song: Song,_ rating: Int) {
perform {
guard let library else { return }
if let selectedSinger { var value = try library.preference(song:song.id,singer:selectedSinger);value.rating = rating;try library.savePreference(song:song.id,singer:selectedSinger,value:value) }
else { try library.rate(song.id,rating) };try refresh()
}
}
func displayedRating(_ song: Song) -> Int { selectedSinger == nil ? song.rating : preference(song.id).rating }
func batchMetadata(_ ids: Set<Int64>,patch: MetadataPatch) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.batchMetadata(ids,patch:patch);try refresh() }
}
struct RotationPane: View {
@ObservedObject var model: AppModel
var body: some View { RotationDashboard(model:model) }
}
struct ShowScreensPane: View {
@ObservedObject var model: AppModel
@State private var edit: ShowScreen?
var body: some View {
VStack(alignment:.leading,spacing:16) {
HStack { Text("Intermission slides").font(.headline);Spacer();Button("Create ShowScreen…") { edit = ShowScreen() } }
Text("Create timed slides that play like songs. Add them to the manual queue or choose one between singers in Rotation.").foregroundStyle(.secondary)
List(model.showScreens) { screen in
HStack { Image(systemName:"rectangle.on.rectangle").foregroundStyle(.tint);VStack(alignment:.leading) { Text(screen.title);Text("\(Int(screen.seconds)) seconds").font(.caption).foregroundStyle(.secondary) };Spacer();Button("Edit") { edit = screen };Button("Show Now") { model.perform { try model.playScreen(screen) } };Button("Queue") { model.enqueueScreen(screen.id) }.disabled(model.currentVenue == nil) }
}
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(item:$edit) { screen in ShowScreenEditor(model:model,screen:screen) }
}
}
private struct ShowScreenEditor: View {
@ObservedObject var model: AppModel
@State var screen: ShowScreen
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text(screen.id == 0 ? "Create ShowScreen" : "Edit ShowScreen").font(.title2.bold())
TextField("Title",text:$screen.title)
TextEditor(text:$screen.body).font(.body).frame(height:130).border(.quaternary)
TextField("Footer",text:$screen.footer)
Text("Placeholders: {venue}, {singer}, {next}, {song}").font(.caption).foregroundStyle(.secondary)
HStack { Picker("Colour",selection:$screen.tint) { Text("Indigo").tag("indigo");Text("Teal").tag("teal");Text("Rose").tag("rose") };Stepper("\(Int(screen.seconds)) seconds",value:$screen.seconds,in:3...300,step:1) }
HStack { Button("Cancel") { dismiss() };Spacer();Button("Save") { model.saveScreen(screen);if model.error == nil { dismiss() } }.keyboardShortcut(.defaultAction).disabled(screen.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(24).frame(width:530).background(Color(nsColor:.windowBackgroundColor)).textFieldStyle(.roundedBorder)
}
}
@MainActor extension AppModel {
func enqueueScreen(_ id: Int64) { perform { try library?.enqueue(song:id,singer:nil,venue:currentVenue);try refresh() } }
func writeFileTags(_ ids: Set<Int64>) {
guard !tagsBusy else { return }
guard !ids.contains(player.currentSongID ?? -1) else { error = "Stop the selected song before writing its file tags";return }
let selected = songs.filter{ids.contains($0.id)},backup = root.appendingPathComponent("TagBackups")
tagsBusy = true;tagsStatus = "Writing tags…"
Task {
var count = 0,issues = [String]()
for song in selected {
do {
guard player.currentSongID != song.id else { throw LibraryError.message("Song is now playing") }
try await FileTagWriter.write(song,backupDirectory:backup)
try library?.execute("UPDATE songs SET fingerprint='' WHERE id=?",[String(song.id)]);count += 1
} catch { issues.append(song.title+": "+error.localizedDescription) }
}
tagsBusy = false;tagsStatus = "Tags written: \(count) · Issues: \(issues.count)"
if !issues.isEmpty { error = issues.prefix(8).joined(separator:"\n") };perform { try refresh() }
}
}
}
struct PlaylistWorkspace: View {
@ObservedObject var model: AppModel
@Binding var query: String
@State private var selected: Int64?
@State private var nameSelection = Set<Int64>()
@State private var reinterpreting = false
@State private var reinterpretIDs = Set<Int64>()
@State private var name = ""
@State private var sort = "Saved Order"
@State private var ascending = true
@State private var adding = false
@State private var addIDs = Set<Int64>()
@State private var addQuery = ""
@State private var delete = false
private var rows: [Song] {
let ids = selected.flatMap{try? model.library?.playlistSongIDs($0)} ?? []
let index = Dictionary(uniqueKeysWithValues:model.songs.map{($0.id,$0)})
let songs = ids.compactMap{index[$0]}.filter{SongSearch.matches($0,query:query)}
guard sort != "Saved Order" else { return songs }
return songs.sorted { a,b in
let first: Bool
if sort == "Song ID" { first = a.id < b.id }
else if sort == "Track" { first = a.trackOrder == b.trackOrder ? a.id < b.id : a.trackOrder < b.trackOrder }
else if sort == "Duration" { first = a.duration == b.duration ? a.id < b.id : a.duration < b.duration }
else if sort == "Rating" { first = model.displayedRating(a) == model.displayedRating(b) ? a.id < b.id : model.displayedRating(a) < model.displayedRating(b) }
else { let av = sort == "Artist" ? a.artist : sort == "Album" ? a.album : sort == "Rendition" ? a.rendition : a.title,bv = sort == "Artist" ? b.artist : sort == "Album" ? b.album : sort == "Rendition" ? b.rendition : b.title;let comparison = av.localizedStandardCompare(bv);first = comparison == .orderedSame ? a.id < b.id : comparison == .orderedAscending }
return ascending ? first : !first
}
}
var body: some View {
VStack(alignment:.leading,spacing:16) {
HStack {
Picker("Playlist",selection:$selected) { Text("Choose a playlist…").tag(nil as Int64?);ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } }.frame(maxWidth:300)
TextField("Playlist name",text:$name).textFieldStyle(.roundedBorder)
Button("Create") { model.createPlaylist(name);selected = model.playlists.first{$0.name == name.trimmingCharacters(in:.whitespacesAndNewlines)}?.id;name = "" }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty)
Menu { Button("Rename") { if let selected { model.rename("playlists",id:selected,name:name) } }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty);Button("Delete…",role:.destructive) { delete = true } } label: { Image(systemName:"ellipsis.circle") }.disabled(selected == nil)
}
HStack {
Picker("Sort",selection:$sort) { ForEach(["Saved Order","Song ID","Title","Artist","Album","Track","Rendition","Duration","Rating"],id:\.self) { Text($0 == "Saved Order" ? "Saved Order (PLI)" : $0).tag($0) } }.frame(width:200)
Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.disabled(sort == "Saved Order").help("Reverse sort")
Text("\(rows.count) songs").font(.caption).foregroundStyle(.secondary);Spacer();Button(nameSelection.isEmpty ? "Reinterpret Visible Songs…" : "Reinterpret Selected…") { reinterpretIDs = nameSelection.isEmpty ? Set(rows.map(\.id)) : nameSelection.intersection(Set(rows.map(\.id)));reinterpreting = true }.disabled(rows.isEmpty);Button("Add Songs…") { adding = true }.disabled(selected == nil)
}
List(Array(rows.enumerated()),id:\.element.id,selection:$nameSelection) { index,song in
HStack {
Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:22)
VStack(alignment:.leading,spacing:4) { Text(song.title);Text(song.artist+" · "+song.album).font(.caption).foregroundStyle(.secondary) };Spacer()
Button("Play") { model.perform { try model.play(song) } }
Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil)
Button { move(song.id,-1) } label: { Image(systemName:"chevron.up") }.disabled(sort != "Saved Order" || !query.isEmpty || index == 0)
Button { move(song.id,1) } label: { Image(systemName:"chevron.down") }.disabled(sort != "Saved Order" || !query.isEmpty || index == rows.count-1)
Button { model.perform { if let selected { try model.library?.removeFromPlaylist(selected,song:song.id);try model.refresh() } } } label: { Image(systemName:"minus.circle") }.help("Remove from playlist")
}.padding(.vertical,5).tag(song.id).onDrag { model.dragReference(song.id).provider() }
.contextMenu { Button("Reinterpret Name…") { reinterpretIDs = nameSelection.contains(song.id) ? nameSelection : [song.id];reinterpreting = true } }
}.listStyle(.inset)
Text("Use toolbar search for this playlist. Quoted phrases, artist:, album:, title:, id: and exact = searches are supported. Saved order can be changed with the arrow buttons when search is empty.").font(.caption).foregroundStyle(.secondary)
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(isPresented:$adding) { addSheet }
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) }
.focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{ reinterpretIDs = nameSelection.isEmpty ? Set(rows.map(\.id)) : nameSelection.intersection(Set(rows.map(\.id)));reinterpreting = true },enabled:!rows.isEmpty))
.onStudioChange(of:query) { _ in nameSelection = [] }
.confirmationDialog("Delete this playlist? Library songs are kept.",isPresented:$delete) { Button("Delete Playlist",role:.destructive) { if let selected { model.delete("playlists",id:selected);self.selected = nil } } }
.onStudioChange(of:sort) { _ in saveSort() }.onStudioChange(of:ascending) { _ in saveSort() }
.onStudioChange(of:selected) { id in
nameSelection = []
if let id,let value = try? model.library?.setting("playlist.sort.\(id)") { let parts = value.components(separatedBy:"|");sort = parts[0];ascending = parts.count < 2 || parts[1] == "asc" } else { sort = "Saved Order";ascending = true }
}
}
private func move(_ song: Int64,_ by: Int) { model.perform { if let selected { try model.library?.reorderPlaylist(selected,song:song,by:by);try model.refresh() } } }
private func saveSort() { if let selected { model.perform { try model.library?.setSetting("playlist.sort.\(selected)",sort+"|"+(ascending ? "asc" : "desc")) } } }
private var addSheet: some View {
VStack(alignment:.leading,spacing:16) {
Text("Add Songs to Playlist").font(.title2.bold());TextField("Search library",text:$addQuery).textFieldStyle(.roundedBorder)
List(model.songs.filter{SongSearch.matches($0,query:addQuery)}) { song in Toggle(song.title,isOn:Binding(get:{addIDs.contains(song.id)},set:{if $0 { addIDs.insert(song.id) } else { addIDs.remove(song.id) }})).toggleStyle(.checkbox) }.frame(height:320)
HStack { Button("Cancel") { adding = false };Spacer();Button("Add \(addIDs.count) Songs") { model.perform { if let selected { for id in addIDs.sorted() { try model.library?.addToPlaylist(selected,song:id) };try model.refresh() } };adding = false;addIDs = [] }.disabled(addIDs.isEmpty) }
}.padding(24).frame(width:550)
}
}