225 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|