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,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) { 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() @State private var reinterpreting = false @State private var reinterpretIDs = Set() @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() @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) } }