From 3efe4f616ebd7305bf338d479c51b587708575ec Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 12:08:11 +0000 Subject: [PATCH] importer chnges --- KJamsClone/KJMixProApp.swift | 102 +++++++++++++++++++++----- KJamsClone/Library.swift | 11 +++ KJamsCloneTests/KJamsCloneTests.swift | 50 +++++++++++++ 3 files changed, 143 insertions(+), 20 deletions(-) diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 56b5ba0..fb88428 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -295,7 +295,28 @@ struct SongDragReference: Codable, Sendable { func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } } + func tuningForControls(_ song:Int64) -> SongPreference { + var value = preference(song) + if player.currentSongID == song,player.duration > 0 { value.pitch = player.pitch;value.tempo = player.tempo } + return value + } + var liveSingerName: String { playbackSinger.flatMap { id in singers.first{$0.id == id}?.name } ?? "No singer · Temporary" } + func updateLiveTuning(pitch:Double? = nil,tempo:Double? = nil) { + perform { + guard let song = player.currentSongID,player.duration > 0,let library else { throw LibraryError.message("Load a song before adjusting live key or tempo") } + let nextPitch = pitch ?? player.pitch,nextTempo = tempo ?? player.tempo + guard nextPitch.isFinite,nextTempo.isFinite,(-12...12).contains(nextPitch),(0.5...1.5).contains(nextTempo) else { throw LibraryError.message("Use a key from −12 to +12 and tempo from 50% to 150%") } + if let singer = playbackSinger { + var saved = try library.preference(song:song,singer:singer) + saved.pitch = nextPitch;saved.tempo = nextTempo + try library.savePreference(song:song,singer:singer,value:saved) + if selectedSinger == singer { tuningCache[song] = saved } + } + player.pitch = nextPitch;player.tempo = nextTempo;objectWillChange.send() + } + } func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) { + if player.currentSongID == song,player.duration > 0 { updateLiveTuning(pitch:pitch,tempo:tempo);return } perform { guard let library else { throw LibraryError.message("Library unavailable") } var value = preference(song) @@ -677,14 +698,21 @@ struct LibraryView: View { @State private var focusedLocation: Int64? @State private var editingSong: Song? @State private var libraryRows = [Song]() + @State private var page = 0 @State private var tableSort = [KeyPathComparator(\Song.title)] @State private var librarySort = "Song Name" @State private var sortAscending = true @Environment(\.openWindow) private var openWindow - private var visibleSongs: [Song] { libraryRows } - private func updateLibraryRows() { + private var pagination: LibraryPage { LibraryPage(total:libraryRows.count,requested:page) } + private var visibleSongs: [Song] { Array(libraryRows[pagination.range]) } + private func updateLibraryRows(resetPage:Bool = false) { let filtered = model.songs.map { source -> Song in var song = source;if model.selectedSinger != nil { song.rating = model.displayedRating(source) };return song }.filter { SongSearch.matches($0,query:search) } libraryRows = filtered.sorted(using:tableSort) + page = LibraryPage(total:libraryRows.count,requested:resetPage ? 0 : page).number + if source == "Library" { + let ids = Set(visibleSongs.map(\.id));tableSelection.formIntersection(ids) + if let selected,!ids.contains(selected) { self.selected = nil } + } } private var selectedSong: Song? { model.songs.first { $0.id == selected } } @@ -692,7 +720,7 @@ struct LibraryView: View { HSplitView { sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260) VStack(spacing:0) { - TransportView(player:model.player,search:$search,hasSelection:selectedSong != nil, + TransportView(player:model.player,model:model,search:$search,hasSelection:selectedSong != nil, play:playSelected,next:playNext,display:{ openWindow(id:"lyrics") }) .padding(20) Divider() @@ -756,7 +784,7 @@ struct LibraryView: View { .onAppear { updateLibraryRows() } .onChange(of:model.songs) { _ in updateLibraryRows() } .onChange(of:model.tuningCache) { _ in updateLibraryRows() } - .onChange(of:search) { _ in updateLibraryRows() } + .onChange(of:search) { _ in updateLibraryRows(resetPage:true) } .sheet(item: $model.creating) { kind in AddEntitySheet(model: model, kind: kind) { id in if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) } @@ -800,7 +828,7 @@ struct LibraryView: View { } message: { Text(model.error ?? "") } } @ViewBuilder private var sourceContent: some View { - if source == "Library" { songTable } + if source == "Library" { VStack(spacing:0) { songTable;Divider();libraryPagination } } else if source == "Queue" { queuePane } else if source == "Settings" { StudioSettingsPane(model:model) } else if source == "Rotation" { RotationPane(model:model) } @@ -947,8 +975,20 @@ struct LibraryView: View { } else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() } }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) } + private var libraryPagination: some View { + HStack(spacing:12) { + Text(pagination.total == 0 ? "No songs" : "Showing \(pagination.range.lowerBound+1)–\(pagination.range.upperBound) of \(pagination.total) songs").foregroundStyle(.secondary) + Spacer() + Button { changePage(0) } label: { Image(systemName:"backward.end") }.disabled(pagination.number == 0).help("First page").accessibilityLabel("First library page") + Button { changePage(pagination.number-1) } label: { Image(systemName:"chevron.left") }.disabled(pagination.number == 0).help("Previous page").accessibilityLabel("Previous library page") + Text("Page \(pagination.number+1) of \(pagination.count)").monospacedDigit().frame(minWidth:95) + Button { changePage(pagination.number+1) } label: { Image(systemName:"chevron.right") }.disabled(pagination.number+1 >= pagination.count).help("Next page").accessibilityLabel("Next library page").accessibilityIdentifier("library.page.next") + Button { changePage(pagination.count-1) } label: { Image(systemName:"forward.end") }.disabled(pagination.number+1 >= pagination.count).help("Last page").accessibilityLabel("Last library page") + }.font(.caption).controlSize(.small).buttonStyle(.borderless).padding(.horizontal,16).padding(.vertical,8) + } + private func changePage(_ requested:Int) { page = LibraryPage(total:libraryRows.count,requested:requested).number;tableSelection = [];selected = nil } private var songTable: some View { - Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows()})) { + Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows(resetPage:true)})) { TableColumn("ID",value:\Song.id) { song in HStack(spacing:5) { NativeSongDragHandle(reference:model.dragReference(song.id),title:song.title).frame(width:14,height:24);Text("\(song.id)").foregroundStyle(.tertiary).monospacedDigit() } }.width(min:48,ideal:55,max:75) @@ -965,12 +1005,12 @@ struct LibraryView: View { 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") + Text(String(format:"%+.0f",model.tuningForControls(song.id).pitch)).monospacedDigit().frame(width:24) + Stepper("Key",value:Binding(get:{model.tuningForControls(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini) + }.help(model.player.currentSongID == song.id ? "Live key · \(model.liveSingerName)" : "Saved key for the selected singer or song") }.width(65) TableColumn("Tempo") { song in - Picker("Tempo",selection:Binding(get:{model.preference(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) { + Picker("Tempo",selection:Binding(get:{model.tuningForControls(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) @@ -1167,7 +1207,7 @@ struct LibraryView: View { 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) + Text("\(libraryRows.count) songs").font(.caption).foregroundStyle(.secondary) if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil) @@ -1194,9 +1234,10 @@ struct LibraryView: View { 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() + guard !libraryRows.isEmpty else { return } + let index = libraryRows.firstIndex { $0.id == selected } ?? -1 + let next = (index+1)%libraryRows.count + page = next/LibraryPage.size;selected = libraryRows[next].id;tableSelection = [libraryRows[next].id];playSelected() } private func addEntity() { model.perform { @@ -1567,8 +1608,27 @@ private struct AddEntitySheet: View { } catch { self.error = error.localizedDescription } } } +private struct LiveTuningControls: View { + @ObservedObject var model: AppModel + @ObservedObject var player: Player + var body: some View { + VStack(alignment:.leading,spacing:5) { + Text(model.liveSingerName).font(.caption2).foregroundStyle(.secondary).lineLimit(1).help("Live changes save to the performing singer and this song") + HStack { + Text("Key").frame(width:40,alignment:.leading) + Stepper(value:Binding(get:{player.pitch},set:{model.updateLiveTuning(pitch:$0)}),in:-12...12,step:1) { Text(String(format:"%+.0f",player.pitch)).monospacedDigit().frame(width:30) }.accessibilityLabel("Live key for performing singer") + } + HStack { + Text("Tempo").frame(width:40,alignment:.leading) + Stepper(value:Binding(get:{player.tempo},set:{model.updateLiveTuning(tempo:$0)}),in:0.5...1.5,step:0.01) { Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) }.accessibilityLabel("Live tempo for performing singer") + Button { model.updateLiveTuning(pitch:0,tempo:1) } label: { Image(systemName:"arrow.counterclockwise") }.buttonStyle(.borderless).help("Reset this singer’s key and tempo") + } + }.font(.caption).frame(width:190).disabled(player.currentSongID == nil || player.duration <= 0) + } +} private struct TransportView: View { @ObservedObject var player: Player + @ObservedObject var model: AppModel @Binding var search: String let hasSelection: Bool let play: () -> Void @@ -1576,7 +1636,7 @@ private struct TransportView: View { let display: () -> Void var body: some View { HStack(spacing:24) { - VStack(spacing:18) { + VStack(spacing:10) { HStack(spacing:8) { TransportButton(symbol:"gobackward.10",label:"Back 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time-10) } TransportButton(symbol:player.playing ? "pause.fill" : "play.fill",label:player.playing ? "Pause" : "Play",size:48,enabled:hasSelection || player.duration > 0) { @@ -1591,6 +1651,7 @@ private struct TransportView: View { Slider(value:$player.volume,in:0...1).controlSize(.small).accessibilityLabel("Global master volume") Image(systemName:"speaker.wave.3.fill").font(.caption2).foregroundStyle(.secondary) }.frame(width:172) + LiveTuningControls(model:model,player:player) }.frame(width:200) NowPlayingWaveform(player:player) }.frame(height:174) @@ -1819,8 +1880,9 @@ private struct PlaybackSettings: View { @ObservedObject var model: AppModel let selectedSong: Int64? private var target: Int64? { selectedSong ?? player.currentSongID } - private var pitch: Binding { Binding(get:{target.map{model.preference($0).pitch} ?? 0},set:{value in if let target { model.updateTuning(target,pitch:value) }}) } - private var tempo: Binding { Binding(get:{target.map{model.preference($0).tempo} ?? 1},set:{value in if let target { model.updateTuning(target,tempo:value) }}) } + private var live: Bool { player.currentSongID != nil && player.duration > 0 } + private var pitch: Binding { Binding(get:{live ? player.pitch : target.map{model.preference($0).pitch} ?? 0},set:{value in if live { model.updateLiveTuning(pitch:value) } else if let target { model.updateTuning(target,pitch:value) }}) } + private var tempo: Binding { Binding(get:{live ? player.tempo : target.map{model.preference($0).tempo} ?? 1},set:{value in if live { model.updateLiveTuning(tempo:value) } else if let target { model.updateTuning(target,tempo:value) }}) } private var rating: Binding { 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) { @@ -1828,13 +1890,13 @@ private struct PlaybackSettings: View { 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) + Text("Selected rating");GoldRating(rating:rating).frame(width:88) + Text(live ? "Live: \(model.liveSingerName)" : model.selectedSinger == nil ? "Song defaults · Saved" : "Singer settings · Saved").font(.caption).foregroundStyle(.secondary) Menu { Button("Reset key / tempo") { pitch.wrappedValue = 0;tempo.wrappedValue = 1 } ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22) - }.font(.system(size:11)).disabled(target == nil) + }.font(.system(size:11)).disabled(target == nil && !live) } } private struct SeekBar: View { diff --git a/KJamsClone/Library.swift b/KJamsClone/Library.swift index 2db6de4..90d5725 100644 --- a/KJamsClone/Library.swift +++ b/KJamsClone/Library.swift @@ -627,3 +627,14 @@ extension Library { try execute("UPDATE songs SET title=?,artist=?,album=?,track_number=?,rendition=?,metadata_edited=? WHERE id=?",[value.title,value.artist,value.album,value.track,value.rendition,String(edited),String(id)]) } } + +struct LibraryPage { + static let size = 20 + let total: Int + let count: Int + let number: Int + var range: Range { let start = number*Self.size;return start.. [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } func testPresetTileXORAndInvalidCoordinates() { var d = CDGDecoder()