removed unwanted bar now with waveforms, major updates and enhancements
This commit is contained in:
+130
-50
@@ -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<Content: View>: 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<Int64>,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<Int64>()
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user