From 0b0061681bffd442568f5b1644b47c7d9471b915 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sat, 7 Nov 2026 10:10:00 +0000 Subject: [PATCH] removed unwanted bar now with waveforms, major updates and enhancements --- KJamsClone/DJWorkstation.swift | 180 ++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 50 deletions(-) diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 6317006..cebc6bb 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -909,10 +909,10 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { } private enum DJStyle { - static let background = Color(red:0.055,green:0.065,blue:0.085) - static let panel = Color(red:0.095,green:0.11,blue:0.14) - static let a = Color(red:0.20,green:0.80,blue:0.95) - static let b = Color(red:1.0,green:0.66,blue:0.22) + static let background = Color(red:0.045,green:0.055,blue:0.075) + static let panel = Color(red:0.085,green:0.10,blue:0.13) + static let a = Color(red:0.18,green:0.85,blue:0.76) + static let b = Color(red:0.69,green:0.55,blue:1.0) static func color(_ deck: String) -> Color { deck == "A" ? a : b } static func stamp(_ value: Double) -> String { guard value.isFinite else { return "0:00" };let seconds = Int(max(0,value));return String(format:"%d:%02d",seconds/60,seconds%60) @@ -1000,6 +1000,47 @@ private struct DJLibrarySnapshot: View, Equatable { var body: some View { content() } } +enum DJCollection: String,CaseIterable,Identifiable { + case all = "All Music",favourites = "Favourites",rated = "Top Rated",unrated = "Unrated",missingArtist = "Needs Metadata" + var id: String { rawValue } + var icon: String { switch self { case .all:return "music.note.list";case .favourites:return "star.fill";case .rated:return "rosette";case .unrated:return "star";case .missingArtist:return "tag" } } +} +enum DJLibrarySort: String,CaseIterable,Identifiable { + case title = "Title",artist = "Artist",album = "Album",duration = "Duration",rating = "Rating",source = "Playlist / Import Order" + var id: String { rawValue } +} +enum DJLibraryBrowser { + static func rows(_ songs: [Song],collection: DJCollection,favourites: Set,playlist: [Int64]? = nil,query: String = "",sort: DJLibrarySort = .title,ascending: Bool = true) -> [Song] { + let source: [Song] + if let playlist { let lookup = Dictionary(uniqueKeysWithValues:songs.map{($0.id,$0)});source = playlist.compactMap{lookup[$0]} } + else { source = songs } + let filtered = source.filter { song in + guard !song.audio.hasPrefix("showscreen:"),SongSearch.matches(song,query:query) else { return false } + switch collection { + case .all:return true + case .favourites:return favourites.contains(song.id) + case .rated:return song.rating >= 4 + case .unrated:return song.rating == 0 + case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty + } + } + guard sort != .source else { return ascending ? filtered : Array(filtered.reversed()) } + return filtered.sorted { lhs,rhs in + let comparison: ComparisonResult + switch sort { + case .title:comparison = lhs.title.localizedStandardCompare(rhs.title) + case .artist:comparison = lhs.artist.localizedStandardCompare(rhs.artist) + case .album:comparison = lhs.album.localizedStandardCompare(rhs.album) + case .duration:comparison = lhs.duration == rhs.duration ? .orderedSame : lhs.duration < rhs.duration ? .orderedAscending : .orderedDescending + case .rating:comparison = lhs.rating == rhs.rating ? .orderedSame : lhs.rating < rhs.rating ? .orderedAscending : .orderedDescending + case .source:comparison = .orderedSame + } + if comparison == .orderedSame { return ascending ? lhs.id < rhs.id : lhs.id > rhs.id } + return ascending ? comparison == .orderedAscending : comparison == .orderedDescending + } + } +} + struct DJConsole: View { @ObservedObject var system: DJSystem @State private var removingIDs = Set() @@ -1009,7 +1050,11 @@ struct DJConsole: View { @State private var editingTrack: Song? @State private var graphicEQVisible = false @State private var waveformDetails = false - @State private var favouritesOnly = false + @State private var collection = DJCollection.all + @State private var librarySort = DJLibrarySort.title + @State private var ascending = true + @State private var collectionCounts = [DJCollection:Int]() + @AppStorage("DJStudio.librarySidebar") private var sidebarVisible = true @State private var queueVisible = false @State private var query = "" @State private var playlist: Int64? @@ -1025,9 +1070,10 @@ struct DJConsole: View { private var songs: [Song] { filteredTracks } private func updateFilteredTracks() { libraryRevision = UUID() - let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && (!favouritesOnly || system.favourites.contains($0.id)) && SongSearch.matches($0,query:query)} - if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});filteredTracks = playlistIDs.compactMap{lookup[$0]} } - else { filteredTracks = all } + filteredTracks = DJLibraryBrowser.rows(system.songs,collection:collection,favourites:system.favourites,playlist:playlist == nil ? nil : playlistIDs,query:query,sort:librarySort,ascending:ascending) + collectionCounts = Dictionary(uniqueKeysWithValues:DJCollection.allCases.map { scope in + (scope,DJLibraryBrowser.rows(system.songs,collection:scope,favourites:system.favourites,sort:.source).count) + }) selection.formIntersection(Set(filteredTracks.map(\.id))) page = min(page,max(0,(filteredTracks.count+49)/50-1)) if let selected,!selection.contains(selected) { self.selected = nil } @@ -1051,8 +1097,10 @@ struct DJConsole: View { var body: some View { VStack(spacing:10) { header - DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) - 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) + 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) + } if let progress = system.automaticWaveformProgress { BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) } @@ -1122,54 +1170,75 @@ struct DJConsole: View { .onStudioChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() } .onStudioChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() } .onStudioChange(of:query) { _ in updateFilteredTracks();page = 0 } - .onStudioChange(of:favouritesOnly) { _ in updateFilteredTracks();page = 0 } + .onStudioChange(of:collection) { _ in updateFilteredTracks();page = 0 } + .onStudioChange(of:librarySort) { _ in updateFilteredTracks();page = 0 } + .onStudioChange(of:ascending) { _ in updateFilteredTracks();page = 0 } + .onStudioChange(of:sidebarVisible) { _ in libraryRevision = UUID() } .onStudioChange(of:system.favourites) { _ in updateFilteredTracks() } } private var header: some View { - HStack { - Image(systemName:"waveform.path").foregroundStyle(DJStyle.a) - Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded)) - Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary) + 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() - Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.tint(system.graphicEQ.enabled ? .cyan : .accentColor) + 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}) - if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) } - }.controlSize(.small) + if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green) } + }.controlSize(.small).padding(.horizontal,2).padding(.bottom,2) } - private var browser: some View { - VStack(spacing:0) { - VStack(spacing:12) { - HStack(alignment:.center) { - VStack(alignment:.leading,spacing:3) { - Text("DJ Library").font(.system(size:18,weight:.semibold)) - Text("\(songs.count.formatted()) tracks"+(playlist == nil ? " in your collection" : " in this playlist")).font(.caption).foregroundStyle(.secondary) + 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 { + 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 } + .contextMenu { + Button("Rename Playlist…") { playlist = list.id;renaming = true;playlistName = list.name;playlistEditor = true } + Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:list.id) } + } } - Spacer() - LibrarySelectionBadge(count:selection.count) - Button { system.automix.enqueue(selectedTracks) } label: { Label("Add to Queue",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty) - Button { importMedia() } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan,prominent:true)) - } - HStack(spacing:10) { - Picker("DJ library",selection:$playlist) { Text("All music").tag(nil as Int64?);ForEach(system.playlists) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:175) - Menu { - Button("New Playlist…") { renaming = false;playlistName = "";playlistEditor = true } - if let playlist { - Button("Rename Playlist…") { renaming = true;playlistName = system.playlists.first{$0.id == playlist}?.name ?? "";playlistEditor = true } - Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:playlist) } - } - } label: { Label("Playlists",systemImage:"folder.badge.plus") }.menuStyle(.borderlessButton).fixedSize().padding(.horizontal,12).padding(.vertical,8).background(Color.purple.opacity(0.12),in:RoundedRectangle(cornerRadius:8)).foregroundStyle(.purple) - Button { favouritesOnly.toggle() } label: { Label("Favourites",systemImage:favouritesOnly ? "star.fill" : "star") }.buttonStyle(LibraryActionButtonStyle(tint:.yellow,prominent:favouritesOnly)).help("Show favourite tracks only") - Spacer(minLength:18) - StudioSearchField(placeholder:"Search music",text:$query).frame(minWidth:180,idealWidth:280,maxWidth:320) - Spacer(minLength:18) - HStack(spacing:10) { - Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan)).disabled(page == 0) - Text("Page \(min(page,pages-1)+1) of \(pages)").font(.system(size:11,weight:.medium)).monospacedDigit().fixedSize() - Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.buttonStyle(LibraryActionButtonStyle(tint:.cyan)).disabled(page >= pages-1) } } - }.padding(16).background(DJStyle.panel) - Divider().overlay(Color.white.opacity(0.06)) + 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) + } + 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)) + }.buttonStyle(.plain).padding(.horizontal,6).accessibilityValue(active ? "Selected" : "") + } + private var browser: some View { + HSplitView { + if sidebarVisible { collectionSidebar } + 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) } + Spacer(minLength:8) + StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340) + Button { importMedia() } label: { Label("Import",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a,prominent:true)) + }.padding(14) + HStack(spacing:10) { + Picker("Sort tracks",selection:$librarySort) { ForEach(DJLibrarySort.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width:155) + Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.buttonStyle(.plain).help("Reverse sort order") + LibrarySelectionBadge(count:selection.count) + Spacer(minLength:4) + Button { system.automix.enqueue(selectedTracks) } label: { Label("Queue Selected",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty) + HStack(spacing:8) { + Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0) + Text("\(min(page,pages-1)+1) / \(pages)").font(.system(size:10).monospacedDigit()).fixedSize() + Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1) + }.buttonStyle(.plain) + }.controlSize(.small).padding(.horizontal,14).padding(.bottom,10) + Divider().overlay(Color.white.opacity(0.05)) ScrollViewReader { reader in Table(visible,selection:$selection) { TableColumn("Favourite") { song in @@ -1204,7 +1273,18 @@ struct DJConsole: View { if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } } } } - } + if let song = selectedSong { + HStack(spacing:10) { + Image(systemName:"music.note").foregroundStyle(DJStyle.a) + VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:11,weight:.semibold)).lineLimit(1);Text(song.artist.isEmpty ? "Artist not set" : song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + Spacer() + Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray)) + Button("Load A") { system.load(song,into:"A") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)) + Button("Load B") { system.load(song,into:"B") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.b)) + }.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 { Button("Remove Selected Tracks…",role:.destructive) { removingIDs = Set(contextTracks(song).map(\.id));confirmTrackRemoval = true }