From a1c25cdb23287acf53a056ac569bea54ce51724e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sat, 7 Nov 2026 10:23:45 +0000 Subject: [PATCH] removed unwanted bar now with waveforms, major updates and enhancements --- KJamsClone/DJWorkstation.swift | 188 ++++++++++++++++---------- KJamsCloneTests/KJamsCloneTests.swift | 59 +++++++- docs/DJ_WORKSTATION.md | 4 + 3 files changed, 176 insertions(+), 75 deletions(-) diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index cebc6bb..71a9fec 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -1024,7 +1024,10 @@ enum DJLibraryBrowser { case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty } } - guard sort != .source else { return ascending ? filtered : Array(filtered.reversed()) } + guard sort != .source else { + let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered + return ascending ? ordered : Array(ordered.reversed()) + } return filtered.sorted { lhs,rhs in let comparison: ComparisonResult switch sort { @@ -1055,6 +1058,10 @@ struct DJConsole: View { @State private var ascending = true @State private var collectionCounts = [DJCollection:Int]() @AppStorage("DJStudio.librarySidebar") private var sidebarVisible = true + @AppStorage("DJStudio.performanceHeight") private var performanceHeight = 454.0 + @AppStorage("DJStudio.sidebarWidth") private var sidebarWidth = 184.0 + @State private var performanceDragStart: Double? + @State private var sidebarDragStart: Double? @State private var queueVisible = false @State private var query = "" @State private var playlist: Int64? @@ -1097,9 +1104,19 @@ struct DJConsole: View { var body: some View { VStack(spacing:10) { header - VSplitView { - DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).frame(minHeight:408,idealHeight:438,maxHeight:560) - DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(minHeight:210,maxHeight:.infinity) + GeometryReader { geometry in + VStack(spacing:0) { + DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).frame(height:performanceSize(geometry.size.height)) + RoundedRectangle(cornerRadius:2).fill(Color.white.opacity(0.16)).frame(width:44,height:3).frame(maxWidth:.infinity).frame(height:10).contentShape(Rectangle()) + .help("Drag to resize the performance area; double-click to reset") + .onTapGesture(count:2) { performanceHeight = 454 } + .gesture(DragGesture(coordinateSpace:.global).onChanged { value in + if performanceDragStart == nil { performanceDragStart = performanceSize(geometry.size.height) } + performanceHeight = min(max(418,(performanceDragStart ?? 454)+value.translation.height),max(418,geometry.size.height-220)) + }.onEnded { _ in performanceDragStart = nil }) + .accessibilityLabel("Resize DJ performance area") + DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(maxWidth:.infinity,maxHeight:.infinity) + } } if let progress = system.automaticWaveformProgress { BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) @@ -1112,7 +1129,7 @@ struct DJConsole: View { else { Button("View Log") { system.importLogVisible = true };Button("Dismiss") { system.importProgress = nil } } }.font(.caption).controlSize(.mini) } - }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top) + }.padding(14).frame(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top) .background(DJStyle.background).foregroundStyle(.white) .preferredColorScheme(.dark) .alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in @@ -1174,13 +1191,16 @@ struct DJConsole: View { .onStudioChange(of:librarySort) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:ascending) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:sidebarVisible) { _ in libraryRevision = UUID() } + .onStudioChange(of:sidebarWidth) { _ in libraryRevision = UUID() } .onStudioChange(of:system.favourites) { _ in updateFilteredTracks() } } + private func performanceSize(_ available: Double) -> Double { max(0,min(max(418,performanceHeight),min(580,max(0,available-220)))) } private var header: some View { HStack(spacing:14) { ZStack { RoundedRectangle(cornerRadius:10).fill(DJStyle.a.opacity(0.14));Image(systemName:"waveform.path").font(.system(size:20,weight:.semibold)).foregroundStyle(DJStyle.a) }.frame(width:40,height:40) VStack(alignment:.leading,spacing:3) { Text("DJ STUDIO").font(.system(size:15,weight:.bold,design:.rounded)).tracking(2);Text("PERFORMANCE WORKSPACE").font(.system(size:8,weight:.medium)).tracking(1.8).foregroundStyle(.secondary) } Spacer() + HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28) } Button { sidebarVisible.toggle() } label: { Image(systemName:"sidebar.left") }.help("Show or hide collections").buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)) Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray)) DJQueueButton(automix:system.automix,open:{queueVisible = true}) @@ -1190,13 +1210,13 @@ struct DJConsole: View { private var libraryTitle: String { playlist.flatMap { id in system.playlists.first{$0.id == id}?.name } ?? collection.rawValue } private func chooseCollection(_ value: DJCollection) { playlist = nil;collection = value;librarySort = .title;ascending = true;refreshPlaylist();page = 0 } private var collectionSidebar: some View { + ScrollView { VStack(alignment:.leading,spacing:0) { Text("COLLECTION").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary).padding(.horizontal,16).padding(.top,18).padding(.bottom,10) ForEach(DJCollection.allCases) { scope in collectionRow(scope.rawValue,icon:scope.icon,count:collectionCounts[scope] ?? 0,active:playlist == nil && collection == scope) { chooseCollection(scope) } } HStack { Text("PLAYLISTS").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary);Spacer();Button { renaming = false;playlistName = "";playlistEditor = true } label: { Image(systemName:"plus") }.buttonStyle(.plain).help("Create playlist") }.padding(.horizontal,16).padding(.top,24).padding(.bottom,8) - ScrollView { VStack(spacing:2) { ForEach(system.playlists) { list in collectionRow(list.name,icon:"music.note",count:nil,active:playlist == list.id) { collection = .all;playlist = list.id;librarySort = .source;ascending = true;refreshPlaylist();page = 0 } @@ -1206,19 +1226,30 @@ struct DJConsole: View { } } } - } if system.playlists.isEmpty { Text("Build your next set with playlists.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,16).padding(.bottom,18) } Spacer(minLength:0) - }.frame(minWidth:156,idealWidth:184,maxWidth:250,maxHeight:.infinity).background(DJStyle.background) + }.padding(.bottom,12) + }.frame(minWidth:156,idealWidth:176,maxWidth:210,maxHeight:.infinity).background(DJStyle.background) } private func collectionRow(_ name: String,icon: String,count: Int?,active: Bool,action: @escaping () -> Void) -> some View { Button(action:action) { - HStack(spacing:9) { Image(systemName:icon).frame(width:16).foregroundStyle(active ? DJStyle.a : .secondary);Text(name).lineLimit(1);Spacer(minLength:2);if let count { Text(count.formatted()).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) } .font(.system(size:11,weight:active ? .semibold : .regular)).padding(.horizontal,12).padding(.vertical,9).background(active ? DJStyle.a.opacity(0.12) : .clear,in:RoundedRectangle(cornerRadius:7)) + HStack(spacing:9) { Image(systemName:icon).frame(width:16).foregroundStyle(active ? DJStyle.a : .secondary);Text(name).lineLimit(1);Spacer(minLength:2);if let count { Text(count.formatted()).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) } }.font(.system(size:11,weight:active ? .semibold : .regular)).padding(.horizontal,12).padding(.vertical,9).background(active ? DJStyle.a.opacity(0.12) : .clear,in:RoundedRectangle(cornerRadius:7)) }.buttonStyle(.plain).padding(.horizontal,6).accessibilityValue(active ? "Selected" : "") } private var browser: some View { - HSplitView { - if sidebarVisible { collectionSidebar } + GeometryReader { geometry in + HStack(spacing:0) { + if sidebarVisible { + collectionSidebar.frame(width:min(max(156,sidebarWidth),210)) + Rectangle().fill(Color.white.opacity(0.07)).frame(width:6).contentShape(Rectangle()) + .help("Drag to resize collections; double-click to reset") + .onTapGesture(count:2) { sidebarWidth = 184 } + .gesture(DragGesture(coordinateSpace:.global).onChanged { value in + if sidebarDragStart == nil { sidebarDragStart = sidebarWidth } + sidebarWidth = min(210,max(156,(sidebarDragStart ?? 184)+value.translation.width)) + }.onEnded { _ in sidebarDragStart = nil }) + .accessibilityLabel("Resize DJ collection sidebar") + } VStack(spacing:0) { HStack(spacing:12) { VStack(alignment:.leading,spacing:3) { Text(libraryTitle).font(.system(size:17,weight:.semibold));Text("\(songs.count.formatted()) tracks").font(.caption).foregroundStyle(.secondary) } @@ -1241,25 +1272,19 @@ struct DJConsole: View { Divider().overlay(Color.white.opacity(0.05)) ScrollViewReader { reader in Table(visible,selection:$selection) { - TableColumn("Favourite") { song in + TableColumn("Fav") { song in Button { system.toggleFavourite(song.id) } label: { GoldStar(filled:system.favourites.contains(song.id),size:15) }.buttonStyle(.plain).accessibilityLabel(system.favourites.contains(song.id) ? "Remove \(song.title) from favourites" : "Favourite \(song.title)") - }.width(55) + }.width(38) TableColumn("Title") { song in DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()}) .frame(maxWidth:.infinity,alignment:.leading).frame(minHeight:system.settings.libraryRowHeight) - }.width(min:180,ideal:400,max:1600) - TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:100,ideal:200,max:800) - TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:90,ideal:180,max:800) + }.width(min:150,ideal:260,max:1200) + TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:90,ideal:150,max:800) + TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:80,ideal:120,max:800) TableColumn("Time") { song in Text(DJStyle.stamp(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(min:45,ideal:60,max:100) TableColumn("Rating") { song in GoldRating(rating:Binding(get:{song.rating},set:{value in system.perform { try system.library?.rate(song.id,value);try system.refresh() } })) }.width(min:95,ideal:105,max:140) - TableColumn("Load") { song in - HStack(spacing:7) { - Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A") - Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B") - }.buttonStyle(.bordered).controlSize(.small) - }.width(90) }.font(.system(size:system.settings.libraryFont)) .contextMenu(forSelectionType:Int64.self) { ids in if let song = songs.first(where:{ids.contains($0.id)}) { playlistMenu(song) } @@ -1284,6 +1309,7 @@ struct DJConsole: View { }.padding(10).background(DJStyle.background) } }.frame(minWidth:530,maxWidth:.infinity,maxHeight:.infinity).background(DJStyle.panel) + } }.clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(.white.opacity(0.07))) } @ViewBuilder private func playlistMenu(_ song: Song) -> some View { @@ -1372,11 +1398,10 @@ private struct DJDeckSurfaces { @ObservedObject var system: DJSystem private var color: Color { DJStyle.color(deck.letter) } var tempoStrip: some View { - VStack(spacing:5) { + VStack(spacing:3) { Button("SYNC") { system.sync(deck) }.foregroundStyle(system.syncActive ? color : Color.secondary).font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(system.syncActive ? color : nil,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Match the other deck to this deck’s BPM; click again to restore both original tempos").accessibilityIdentifier("dj.\(deck.letter).sync") - Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : "—").font(.system(size:11,weight:.semibold).monospacedDigit()) Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).buttonStyle(.plain).foregroundStyle(.secondary).help("Reset tempo") - DJHardwareFader(value:Binding(get:{system.settings.position(for:player.tempo)},set:{player.tempo = system.settings.tempo(at:$0)}),label:"Deck \(deck.letter) tempo",vertical:true).frame(width:52,height:94) + DJHardwareFader(value:Binding(get:{system.settings.position(for:player.tempo)},set:{player.tempo = system.settings.tempo(at:$0)}),label:"Deck \(deck.letter) tempo",vertical:true).frame(width:44,height:74) HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) };Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) } }.buttonStyle(.plain).font(.system(size:15,weight:.medium)) }.frame(width:62).disabled(!player.canJog) } @@ -1387,13 +1412,13 @@ private struct DJDeckSurfaces { DJKnob(label:"MID",value:$player.midGain,range:-12...12,reset:0,color:color) DJKnob(label:"LOW",value:$player.lowGain,range:-12...12,reset:0,color:color) } - VStack(spacing:12) { + VStack(spacing:8) { DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)}) HStack(spacing:3) { VStack(spacing:5) { ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:5,height:5) } }.accessibilityLabel("Deck output meter") - DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:94) + DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:70) } Text("LEVEL").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary) Button { player.vocalReductionEnabled.toggle() } label: { Image(systemName:player.vocalReductionEnabled ? "mic.slash.fill" : "mic.fill") }.buttonStyle(.plain).foregroundStyle(player.vocalReductionEnabled ? color : .secondary).help("Centre vocal reduction: reduces centred stereo audio, including some instruments; mono tracks pass through").accessibilityLabel("Deck \(deck.letter) centre vocal reduction").accessibilityValue(player.vocalReductionEnabled ? "On" : "Off") @@ -1639,35 +1664,50 @@ struct DJContinuousConsole: View { let loadA: () -> Void let loadB: () -> Void var body: some View { - VStack(spacing:0) { - HStack(spacing:0) { - DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) } - Divider() - DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) } - }.frame(height:92).background(Color.black.opacity(0.4)) - Divider() - HStack(spacing:0) { - DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:344) - Divider() - VStack(spacing:1) { - DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) } - DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) } - }.frame(maxWidth:.infinity).background(.black) - Divider() - DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:344) - }.frame(height:198).background(Color(white:0.12)) - Divider() - HStack(spacing:12) { - DJConsoleTransport(deck:system.a,player:system.a.player,system:system) - VStack(spacing:0) { - DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36).disabled(system.routing.mode == "External") - HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("Centre") { system.crossfader = 0.5 }.buttonStyle(.plain);Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.system(size:9)) - }.frame(width:195) - DJConsoleTransport(deck:system.b,player:system.b.player,system:system) - }.padding(.horizontal,12).padding(.vertical,10).background(Color(white:0.14)) - }.overlay(Rectangle().stroke(.white.opacity(0.09))) + VStack(spacing:8) { + HStack(spacing:10) { + DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10)) + DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10)) + }.frame(height:82) + VStack(spacing:2) { + DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) } + DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) } + }.frame(minHeight:64,maxHeight:.infinity).background(.black).clipShape(RoundedRectangle(cornerRadius:8)).clipped() + HStack(alignment:.top,spacing:10) { + performance(system.a) + mixer.frame(width:292) + performance(system.b) + }.frame(height:252) + }.accessibilityIdentifier("dj.performance.workspace") + } + private func performance(_ deck: DJChannel) -> some View { + VStack(spacing:6) { + HStack { Text("DECK \(deck.letter)").foregroundStyle(DJStyle.color(deck.letter));Spacer();Text("PERFORMANCE").foregroundStyle(.secondary) }.font(.system(size:9,weight:.bold)).tracking(1.2) + DJDeckDropZone(system:system,letter:deck.letter) { DJConsoleWing(deck:deck,player:deck.player,system:system) } + Divider().overlay(.white.opacity(0.04)) + DJConsoleTransport(deck:deck,player:deck.player,system:system) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top) + .background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12)) + .overlay(alignment:.top) { RoundedRectangle(cornerRadius:2).fill(DJStyle.color(deck.letter)).frame(height:2).padding(.horizontal,14) } + } + private var mixer: some View { + VStack(spacing:8) { + HStack { Text("CHANNEL MIXER");Spacer();Text(system.routing.mode.uppercased()).foregroundStyle(.secondary) }.font(.system(size:8,weight:.bold)).tracking(1) + HStack(alignment:.top,spacing:12) { + VStack(spacing:4) { Text("A").foregroundStyle(DJStyle.a);DJDeckSurfaces(deck:system.a,player:system.a.player,system:system).tonePanel } + Divider().frame(height:152) + VStack(spacing:4) { Text("B").foregroundStyle(DJStyle.b);DJDeckSurfaces(deck:system.b,player:system.b.player,system:system).tonePanel } + }.font(.system(size:9,weight:.bold)) + HStack(spacing:8) { + Text("A").foregroundStyle(DJStyle.a) + DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:30).disabled(system.routing.mode == "External") + Text("B").foregroundStyle(DJStyle.b) + Button { system.crossfader = 0.5 } label: { Image(systemName:"scope") }.buttonStyle(.plain).help("Centre crossfader") + }.font(.system(size:10,weight:.semibold)) + }.padding(12).frame(maxHeight:.infinity,alignment:.top).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12)) } } + private struct DJTrackHeader: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @@ -1712,25 +1752,21 @@ private struct DJConsoleWing: View { @ObservedObject var system: DJSystem var body: some View { let face = DJDeckSurfaces(deck:deck,player:player,system:system) - HStack(spacing:10) { - if deck.letter == "B" { face.tonePanel } - if deck.letter == "A" { face.tempoStrip } + HStack(spacing:18) { + face.tempoStrip + Spacer(minLength:0) ZStack { - DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:134,height:134) + DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:112,height:112) VStack(spacing:3) { - Text(String(format:"%+.1f%%",(player.tempo-1)*100)).font(.system(size:9)).foregroundStyle(.secondary) - Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:21,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter)) - Divider().frame(width:62) + Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:19,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter)) Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) - Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:11).monospacedDigit()) - }.frame(width:95,height:95).background(Color(white:0.095),in:Circle()).allowsHitTesting(false) + }.frame(width:76,height:76).background(DJStyle.background,in:Circle()).allowsHitTesting(false) } - if deck.letter == "A" { face.tonePanel } - if deck.letter == "B" { face.tempoStrip } - }.padding(.horizontal,9).frame(width:344).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1) - + Spacer(minLength:0) + }.frame(maxWidth:.infinity).frame(height:130).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1) } } + private struct DJConsoleTransport: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @@ -1750,15 +1786,19 @@ private struct DJConsoleTransport: View { var body: some View { VStack(spacing:6) { HStack(spacing:8) { - if deck.letter == "A" { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) } - else { Spacer(minLength:0);headphones;loopButtons;cueButton;playButton } + playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) + }.controlSize(.regular) - HStack(spacing:6) { - if deck.letter == "B" { Spacer(minLength:0) } + HStack(spacing:4) { ForEach(0..<4) { index in Button("\(index+1)") { system.hotCue(index,deck:deck) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)).contextMenu { Button("Clear hot cue") { player.hotCue(index,clear:true) } } } - Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() };Button("Loop ✎") { system.loopEditor = deck.letter } + Menu { + Button("Edit Cue · \(DJStyle.stamp(player.cuePosition))") { system.editCue(deck) } + Button("Set Cue Here") { player.setCue() } + Button("Edit Loop…") { system.loopEditor = deck.letter } + } label: { Image(systemName:"flag") } + Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() } Menu { Button("Stop") { player.stopDeck() };Button("Exit loop") { player.clearLoop() };Button("Nudge back") { player.jog(-0.05) };Button("Nudge forward") { player.jog(0.05) } } label: { Image(systemName:"ellipsis") } - if deck.letter == "A" { Spacer(minLength:0) } + Spacer(minLength:0) }.controlSize(.mini) }.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog) } @@ -1935,7 +1975,7 @@ private struct DJConsoleButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var enabled init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active } func makeBody(configuration: Configuration) -> some View { - configuration.label.padding(.horizontal,size == .mini ? 6 : 12).padding(.vertical,size == .mini ? 3 : 7) + configuration.label.padding(.horizontal,size == .mini ? 5 : 7).padding(.vertical,size == .mini ? 3 : 6) .background(active ? (accent ?? .blue).opacity(0.45) : Color(white:configuration.isPressed ? 0.20 : 0.12),in:RoundedRectangle(cornerRadius:5)) .overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2)) .opacity(enabled ? 1 : 0.35) diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index d47bffc..808ed60 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2358,7 +2358,10 @@ extension KJamsCloneTests { } model.dj.automix.enqueue([song,song]);try await selectTab(1) let libraryScroll = try XCTUnwrap(descendants(host).compactMap{$0 as? NSScrollView}.filter{$0.bounds.width > 600 && !$0.isHiddenOrHasHiddenAncestor}.max(by:{$0.bounds.height < $1.bounds.height})) - XCTAssertGreaterThan(libraryScroll.bounds.height,380,"DJ library grows into the remaining workspace height") + XCTAssertGreaterThan(libraryScroll.bounds.height,250,"The resizable DJ library retains a useful track viewport") + let libraryFrame = libraryScroll.convert(libraryScroll.bounds,to:host) + if host.isFlipped { XCTAssertGreaterThan(libraryFrame.maxY,host.bounds.maxY-50,"The library fills the bottom of the workspace") } + else { XCTAssertLessThan(libraryFrame.minY,host.bounds.minY+50,"The library fills the bottom of the workspace") } if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/dj-tab.png")) } try await selectTab(0) if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/karaoke-tab.png")) } @@ -3189,3 +3192,57 @@ extension KJamsCloneTests { } + +extension KJamsCloneTests { + func testDJCollectionsCombineSearchRatingsAndPlaylistOrder() { + let tracks = [ + Song(id:1,title:"Track 10",audio:"/music/10.mp3",cdg:nil,rating:5,plays:0,artist:"Northbound",album:"Night"), + Song(id:2,title:"Track 2",audio:"/music/2.mp3",cdg:nil,rating:0,plays:0,artist:"Mira Vale",album:"Night"), + Song(id:3,title:"Track 1",audio:"/music/1.mp3",cdg:nil,rating:4,plays:0,artist:"") + ] + XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[]).map(\.id),[3,2,1],"Titles use natural numeric ordering") + XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.rated,favourites:[],query:"Northbound").map(\.id),[1]) + XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.favourites,favourites:[2],query:"Night").map(\.id),[2]) + XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.missingArtist,favourites:[]).map(\.id),[3]) + XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.unrated,favourites:[]).map(\.id),[2]) + XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[],playlist:[2,1,99,3],sort:.source).map(\.id),[2,1,3],"Playlist order is retained; missing rows are ignored") + XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[],sort:.rating,ascending:false).map(\.id),[1,3,2]) + XCTAssertEqual(DJLibraryBrowser.rows(Array(tracks.reversed()),collection:.all,favourites:[],sort:.source).map(\.id),[1,2,3],"Import order follows stable database IDs, not the prior display sort") + } + @MainActor func testRedesignedDJWorkspaceAtCompactAndWideSizes() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-DJ-Design-"+UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + let system = DJSystem(storageURL:root.appendingPathComponent("Library")) + defer { system.a.player.stop();system.b.player.stop();system.preview.stop() } + let library = try XCTUnwrap(system.library) + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let titles = ["Night Transit","Glass City","After Hours","Satellite","Deep Blue","Into the Light","Parallel Lines","Last Train"] + for (index,title) in titles.enumerated() { + let url = root.appendingPathComponent("Track-\(index).mp3");try FileManager.default.copyItem(at:fixture,to:url) + let id = try library.add(title:title,audio:url.path,cdg:nil,artist:index.isMultiple(of:2) ? "Northbound" : "Mira Vale",album:"Night Sessions",duration:180+Double(index*12)) + try library.rate(id,index.isMultiple(of:3) ? 5 : 3) + } + try system.refresh();system.createPlaylist("Opening Set");system.createPlaylist("Late Night") + if let list = system.playlists.first { for song in system.songs.prefix(3) { system.addToPlaylist(list.id,song:song.id) } } + system.toggleFavourite(try XCTUnwrap(system.songs.first?.id)) + system.load(system.songs[0],into:"A");system.load(system.songs[1],into:"B") + system.a.bpm = 120;system.a.bpmConfirmed = true;system.b.bpm = 124;system.b.bpmConfirmed = true + for _ in 0..<100 where system.a.player.waveformLoading || system.b.player.waveformLoading { try await Task.sleep(nanoseconds:20_000_000) } + for size in [CGSize(width:1320,height:900),CGSize(width:1060,height:760)] { + let view = NSHostingView(rootView:DJConsole(system:system).frame(width:size.width,height:size.height).preferredColorScheme(.dark)) + view.frame = NSRect(origin:.zero,size:size) + let window = NSWindow(contentRect:view.frame,styleMask:[.borderless],backing:.buffered,defer:false) + window.contentView = view;window.appearance = NSAppearance(named:.darkAqua);window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil) + try await Task.sleep(nanoseconds:250_000_000);view.layoutSubtreeIfNeeded() + let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds));view.cacheDisplay(in:view.bounds,to:rep) + let png = try XCTUnwrap(rep.representation(using:.png,properties:[:])) + let path = root.appendingPathComponent("DJ-Studio-\(Int(size.width)).png");try png.write(to:path) + print("DJ_DESIGN_CAPTURE \(path.path)") + XCTContext.runActivity(named:"DJ Studio \(Int(size.width)) layout") { activity in + let capture = XCTAttachment(data:png,uniformTypeIdentifier:"public.png");capture.lifetime = .keepAlways;activity.add(capture) + } + XCTAssertFalse(system.a.player.playing);XCTAssertFalse(system.b.player.playing) + window.orderOut(nil);window.contentView = nil + } + } +} diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 2d26368..8f6d1b2 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -132,3 +132,7 @@ Both library toolbars share coloured, rounded action buttons and matching search Loop activation and cancellation prepare an idle transport node while the audible node keeps running. Both feed the same effects chain. A shared host-clock start (up to 50 ms lookahead) and per-sample 12 ms complementary envelopes overlap the streams before retiring the outgoing node. The first partial loop pass uses the same processed seam as subsequent passes. Regression checks measure silent runs and adjacent-sample jumps across repeated start/stop operations, including two playing decks and changed tempos, as well as actual loop content and natural end-of-track completion. + +DJ Studio’s performance workspace uses a teal/violet palette, separate track headers, full-width stacked scrolling waveforms, two deck performance panels and a central two-channel mixer. Each performance panel groups pitch/sync, platter, transport, hot cues and loop controls. Cue and loop editors are in the flag menu. The mixer groups both channels’ EQ/filter/level controls above the crossfader; master level is in the studio header. The performance/library divider and collection/table divider resize independently. + +The library sidebar provides All Music, Favourites, Top Rated (four or five stars), Unrated and Needs Metadata (missing artist), plus existing playlists. Sorting supports title, artist, album, duration, rating and original playlist/import order; use the arrow to reverse it. Playlist selection retains playlist order by default. Search, multiple selection, select-all, native column resizing, ratings, context actions, waveform jobs and paged scrolling remain available. Selecting a track reveals Info and Load A/B controls below the table. The collection sidebar scrolls and can be hidden from the header. All views use the existing independent DJ database and playlists; no external resources or third-party artwork are included.