| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387 |
- import SwiftData
- import SwiftUI
- import UniformTypeIdentifiers
- private extension UTType {
- /// OGG Vorbis audio — not built-in, so we define it manually.
- static let oggVorbis = UTType(filenameExtension: "ogg") ?? UTType.audio
- }
- /// Playlist view — manage tracks in a mix with transitions and export.
- struct PlaylistView: View {
- let playlist: Playlist
- @Environment(PlayerViewModel.self) private var playerVM
- @Environment(PlaylistViewModel.self) private var playlistVM
- @EnvironmentObject private var libraryManager: LibraryManager
- @Environment(\.modelContext) private var modelContext
- @Query(sort: \Track.title) private var allTracks: [Track]
- @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
- @ObservedObject private var viewConfig = PlaylistViewConfig.shared
- @State private var showExportSheet = false
- @State private var showAddTracksSheet = false
- @State private var showGroupEditor = false
- @State private var draggedEntry: PlaylistEntry?
- @State private var isDropTargeted = false
- var body: some View {
- VStack(spacing: 0) {
- // Playlist header
- PlaylistHeader(
- playlist: playlist,
- mixDuration: playlistVM.mixDuration(for: playlist),
- onExport: { showExportSheet = true },
- onAddTracks: { showAddTracksSheet = true },
- onAddFiles: { addFilesFromDisk() },
- onAddFolder: { addFolderFromDisk() },
- onEditGrouping: { showGroupEditor = true },
- viewConfig: viewConfig
- )
- Divider()
- // Track list
- if playlist.entries.isEmpty {
- EmptyPlaylistView(
- onAddTracks: { showAddTracksSheet = true },
- onAddFiles: { addFilesFromDisk() },
- onAddFolder: { addFolderFromDisk() }
- )
- } else {
- PlaylistEntryList(
- playlist: playlist,
- draggedEntry: $draggedEntry,
- viewConfig: viewConfig
- )
- }
- }
- .overlay {
- if isDropTargeted {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.accentColor, lineWidth: 3)
- .background(Color.accentColor.opacity(0.08))
- .padding(4)
- .allowsHitTesting(false)
- }
- }
- .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
- handleDrop(providers)
- return true
- }
- .sheet(isPresented: $showExportSheet) {
- ExportSheet(playlist: playlist)
- }
- .sheet(isPresented: $showAddTracksSheet) {
- AddTracksSheet(playlist: playlist, allTracks: allTracks)
- }
- .sheet(isPresented: $showGroupEditor) {
- GroupTemplateEditorSheet(playlist: playlist)
- }
- }
- // MARK: - Add Files from Disk
- private func addFilesFromDisk() {
- let panel = NSOpenPanel()
- panel.canChooseFiles = true
- panel.canChooseDirectories = false
- panel.allowsMultipleSelection = true
- panel.allowedContentTypes = [.audio, .mp3, .wav, .aiff, .oggVorbis]
- panel.message = "Select audio files to add to \"\(playlist.name)\""
- if panel.runModal() == .OK {
- Task {
- await playlistVM.importFilesToPlaylist(
- urls: panel.urls,
- playlist: playlist,
- libraryManager: libraryManager,
- context: modelContext
- )
- }
- }
- }
- private func addFolderFromDisk() {
- let panel = NSOpenPanel()
- panel.canChooseFiles = false
- panel.canChooseDirectories = true
- panel.allowsMultipleSelection = true
- panel.message = "Select a folder to scan for audio files"
- if panel.runModal() == .OK {
- let urls = expandDirectories(panel.urls)
- Task {
- await playlistVM.importFilesToPlaylist(
- urls: urls,
- playlist: playlist,
- libraryManager: libraryManager,
- context: modelContext
- )
- }
- }
- }
- private func handleDrop(_ providers: [NSItemProvider]) {
- for provider in providers {
- provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in
- guard let data = data as? Data,
- let urlString = String(data: data, encoding: .utf8),
- let url = URL(string: urlString) else { return }
- let urls = expandDirectories([url])
- Task { @MainActor in
- await playlistVM.importFilesToPlaylist(
- urls: urls,
- playlist: playlist,
- libraryManager: libraryManager,
- context: modelContext
- )
- }
- }
- }
- }
- private func expandDirectories(_ urls: [URL]) -> [URL] {
- var result: [URL] = []
- let fm = FileManager.default
- for url in urls {
- var isDir: ObjCBool = false
- if fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
- if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) {
- for case let fileURL as URL in enumerator {
- if MetadataService.isSupportedAudioFile(fileURL) {
- result.append(fileURL)
- }
- }
- }
- } else if MetadataService.isSupportedAudioFile(url) {
- result.append(url)
- }
- }
- // Sort by full path with numeric sorting to preserve folder structure
- // (like iOS FolderBrowserView) — e.g. "01/01.mp3" < "01/02.mp3" < "02/01.mp3"
- return result.sorted { $0.path.compare($1.path, options: [.numeric, .caseInsensitive]) == .orderedAscending }
- }
- }
- // MARK: - Playlist Header (compact toolbar)
- private struct PlaylistHeader: View {
- let playlist: Playlist
- let mixDuration: TimeInterval
- let onExport: () -> Void
- let onAddTracks: () -> Void
- let onAddFiles: () -> Void
- let onAddFolder: () -> Void
- let onEditGrouping: () -> Void
- @ObservedObject var viewConfig: PlaylistViewConfig
- @EnvironmentObject private var theme: AppTheme
- var body: some View {
- HStack(spacing: 6) {
- // Playlist name
- Text(playlist.name)
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(theme.primaryText)
- .lineLimit(1)
- // Stats
- Text("[\(playlist.trackCount) tracks · \(formatDuration(mixDuration))]")
- .font(.system(size: 11))
- .foregroundStyle(theme.secondaryText)
- Spacer()
- // Toolbar buttons
- HStack(spacing: 6) {
- Menu {
- Button("Add Files...") { onAddFiles() }
- Button("Add Folder...") { onAddFolder() }
- Divider()
- Button("From Library...") { onAddTracks() }
- } label: {
- Label("Add", systemImage: "plus")
- }
- .fixedSize()
- Menu {
- ForEach(GroupTemplateResolver.presets, id: \.template) { preset in
- Button {
- playlist.groupTemplate = preset.template
- } label: {
- HStack {
- Text(preset.name)
- if playlist.groupTemplate == preset.template {
- Image(systemName: "checkmark")
- }
- }
- }
- }
- Divider()
- Button("Custom...") { onEditGrouping() }
- } label: {
- Label(
- playlist.groupTemplate.isEmpty ? "Group" : "Grouped",
- systemImage: "rectangle.3.group"
- )
- }
- .fixedSize()
- Button {
- NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
- } label: {
- Label("Settings", systemImage: "gearshape")
- }
- PlaylistDownloadButton(playlist: playlist)
- PlaylistUploadButton(playlist: playlist)
- Button { onExport() } label: {
- Label("Export", systemImage: "square.and.arrow.up")
- }
- .disabled(playlist.entries.isEmpty)
- }
- .controlSize(.small)
- Button {
- NotificationCenter.default.post(name: .toggleBrowsePanel, object: nil)
- } label: {
- Image(systemName: "cloud.fill")
- .font(.system(size: 20))
- .foregroundStyle(theme.secondaryText)
- .frame(width: 32, height: 28)
- }
- .buttonStyle(.plain)
- .help("Chad Music (⌘B)")
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 7)
- .background(theme.toolbarBackground)
- }
- private func formatDuration(_ duration: TimeInterval) -> String {
- let total = Int(duration)
- let hours = total / 3600
- let minutes = (total % 3600) / 60
- let seconds = total % 60
- if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, seconds)
- }
- return String(format: "%d:%02d", minutes, seconds)
- }
- }
- // MARK: - Playlist Entry List (with Grouping)
- private struct PlaylistEntryList: View {
- let playlist: Playlist
- @Binding var draggedEntry: PlaylistEntry?
- @ObservedObject var viewConfig: PlaylistViewConfig
- @Environment(PlayerViewModel.self) private var playerVM
- @Environment(PlaylistViewModel.self) private var playlistVM
- @EnvironmentObject private var libraryManager: LibraryManager
- @Environment(\.modelContext) private var modelContext
- @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
- @State private var selectedEntryIDs: Set<UUID> = []
- @State private var scrollTarget: UUID?
- @State private var editingNotesTrack: Track?
- @AppStorage("playbackMode") private var playbackMode: String = "queue"
- /// Group entries by the playlist's groupTemplate.
- private var groupedEntries: [(key: String, entries: [(index: Int, entry: PlaylistEntry)])] {
- let sorted = playlist.sortedEntries
- let indexed = sorted.enumerated().map { (index: $0.offset, entry: $0.element) }
- guard !playlist.groupTemplate.isEmpty else {
- return [("", indexed)]
- }
- // Group consecutively by resolved template (preserves playlist order)
- var groups: [(String, [(index: Int, entry: PlaylistEntry)])] = []
- var currentHeader = ""
- var currentEntries: [(index: Int, entry: PlaylistEntry)] = []
- for item in indexed {
- let header: String
- if let track = item.entry.track {
- header = GroupTemplateResolver.resolve(template: playlist.groupTemplate, for: track)
- } else {
- header = "Unknown"
- }
- if header != currentHeader {
- if !currentEntries.isEmpty {
- groups.append((currentHeader, currentEntries))
- }
- currentHeader = header
- currentEntries = [item]
- } else {
- currentEntries.append(item)
- }
- }
- if !currentEntries.isEmpty {
- groups.append((currentHeader, currentEntries))
- }
- return groups.map { (key: $0.0, entries: $0.1) }
- }
- var body: some View {
- VStack(spacing: 0) {
- // Selection toolbar
- if !selectedEntryIDs.isEmpty {
- SelectionToolbar(
- count: selectedEntryIDs.count,
- onSelectAll: { selectedEntryIDs = Set(playlist.entries.map(\.id)) },
- onDeselect: { selectedEntryIDs.removeAll() },
- onRemove: {
- let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
- for entry in toRemove {
- playlistVM.removeEntry(entry, from: playlist, context: modelContext)
- }
- selectedEntryIDs.removeAll()
- }
- )
- Divider()
- }
- // Column headers
- ColumnHeaderRow(viewConfig: viewConfig)
- Divider()
- List(selection: $selectedEntryIDs) {
- ForEach(groupedEntries, id: \.key) { group in
- if !playlist.groupTemplate.isEmpty && !group.key.isEmpty {
- Section {
- groupContent(group.entries)
- } header: {
- let tracks = group.entries.compactMap(\.entry.track)
- GroupHeaderView(
- title: group.key,
- trackCount: group.entries.count,
- firstTrack: group.entries.first?.entry.track,
- showArtwork: viewConfig.showArtwork,
- tracks: tracks
- )
- }
- } else {
- groupContent(group.entries)
- }
- }
- }
- .listStyle(.inset)
- // Enter key always plays selected track (like foobar2000)
- .onKeyPress(.return) {
- playSelectedTrack()
- return .handled
- }
- // Arrow keys for navigation
- .onKeyPress(.upArrow) {
- moveSelection(by: -1)
- return .handled
- }
- .onKeyPress(.downArrow) {
- moveSelection(by: 1)
- return .handled
- }
- // Backspace/Delete key removes selected entries (onDeleteCommand is the macOS-native way)
- .onDeleteCommand {
- removeSelectedEntries()
- }
- // Cursor follows playback: select playing entry
- .onChange(of: playerVM.currentPlayingEntryID) { _, newID in
- guard viewConfig.cursorFollowsPlayback, let entryID = newID else { return }
- selectedEntryIDs = [entryID]
- }
- // Sync cursor position to PlayerViewModel for "Playback follows cursor"
- .onChange(of: selectedEntryIDs) { _, newIDs in
- playerVM.cursorEntryID = newIDs.first
- }
- // When PlayerViewModel moves cursor (auto-advance), update the UI selection
- .onChange(of: playerVM.cursorEntryID) { _, newID in
- if let newID, !selectedEntryIDs.contains(newID) {
- selectedEntryIDs = [newID]
- }
- }
- .sheet(item: $editingNotesTrack) { track in
- TrackNotesSheet(track: track)
- }
- // Double-click to play (via NSEvent monitor in MediaKeyHandler)
- .onReceive(NotificationCenter.default.publisher(for: .doubleClickPlayTrack)) { _ in
- playSelectedTrack()
- }
- // When this playlist view appears, restore cursor to playing entry if applicable
- .onAppear {
- if selectedEntryIDs.isEmpty,
- let playingID = playerVM.currentPlayingEntryID,
- playlist.sortedEntries.contains(where: { $0.id == playingID }) {
- selectedEntryIDs = [playingID]
- playerVM.cursorEntryID = playingID
- }
- }
- }
- }
- /// Play the first selected entry.
- private func playSelectedTrack() {
- guard let firstID = selectedEntryIDs.first,
- let entry = playlist.sortedEntries.first(where: { $0.id == firstID }),
- let track = entry.track else { return }
- playerVM.loadAndPlay(track, entryID: entry.id, playlist: playlist)
- }
- /// Remove all selected entries from the playlist.
- private func removeSelectedEntries() {
- guard !selectedEntryIDs.isEmpty else { return }
- let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
- for entry in toRemove {
- playlistVM.removeEntry(entry, from: playlist, context: modelContext)
- }
- selectedEntryIDs.removeAll()
- }
- /// Move selection up or down by `offset` positions.
- private func moveSelection(by offset: Int) {
- let sorted = playlist.sortedEntries
- guard !sorted.isEmpty else { return }
- if let currentID = selectedEntryIDs.first,
- let currentIndex = sorted.firstIndex(where: { $0.id == currentID }) {
- let newIndex = max(0, min(sorted.count - 1, currentIndex + offset))
- selectedEntryIDs = [sorted[newIndex].id]
- } else {
- // Nothing selected — select first or last
- let entry = offset > 0 ? sorted.first! : sorted.last!
- selectedEntryIDs = [entry.id]
- }
- }
- @ViewBuilder
- private func groupContent(_ entries: [(index: Int, entry: PlaylistEntry)]) -> some View {
- ForEach(entries, id: \.entry.id) { item in
- ConfigurableEntryRow(
- entry: item.entry,
- index: item.index,
- isLast: item.index == playlist.entries.count - 1,
- viewConfig: viewConfig,
- isPlaying: playerVM.currentPlayingEntryID == item.entry.id
- )
- .tag(item.entry.id)
- .id(item.entry.id)
- .draggable(item.entry.track?.id.uuidString ?? "")
- .contextMenu {
- if let track = item.entry.track {
- Button("Play") { playerVM.loadAndPlay(track, entryID: item.entry.id, playlist: playlist) }
- Divider()
- if playbackMode == "queue" {
- Button {
- playerVM.playNextInQueue(QueueEntry.from(track: track))
- } label: {
- Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
- }
- Button {
- playerVM.addToQueue(QueueEntry.from(track: track))
- } label: {
- Label("Add to Queue", systemImage: "text.append")
- }
- Divider()
- }
- // Add to other playlists
- let otherPlaylists = allPlaylists.filter { $0.id != playlist.id }
- if !otherPlaylists.isEmpty {
- Menu("Add to Playlist") {
- ForEach(otherPlaylists) { targetPlaylist in
- Button(targetPlaylist.name) {
- playlistVM.addTrack(track, to: targetPlaylist, context: modelContext)
- }
- }
- }
- }
- // Download actions for cloud tracks
- if track.isCloud {
- Divider()
- switch track.downloadState {
- case .none:
- Button {
- DownloadManager.shared.download(track: track, apiClient: ChadMusicAPIClient.shared)
- } label: {
- Label("Download", systemImage: "arrow.down.circle")
- }
- case .downloading:
- Button {
- DownloadManager.shared.cancel(track: track)
- } label: {
- Label("Cancel Download", systemImage: "stop.circle")
- }
- case .downloaded:
- Button(role: .destructive) {
- DownloadManager.shared.removeDownload(track: track)
- } label: {
- Label("Remove Download", systemImage: "trash")
- }
- case .error:
- Button {
- DownloadManager.shared.download(track: track, apiClient: ChadMusicAPIClient.shared)
- } label: {
- Label("Retry Download", systemImage: "arrow.clockwise")
- }
- }
- }
- // Upload local track to cloud
- if !track.isCloud,
- !track.filePath.isEmpty,
- ChadMusicAPIClient.shared.isConfigured {
- Divider()
- Button {
- UploadService.shared.startUpload(
- track: track,
- apiClient: ChadMusicAPIClient.shared
- )
- } label: {
- Label("Upload to Cloud", systemImage: "arrow.up.to.cloud")
- }
- }
- }
- Divider()
- Button {
- viewConfig.cursorFollowsPlayback = true
- viewConfig.playbackFollowsCursor = false
- } label: {
- HStack {
- Text("Cursor follows playback")
- if viewConfig.cursorFollowsPlayback {
- Spacer()
- Image(systemName: "checkmark")
- }
- }
- }
- Button {
- viewConfig.playbackFollowsCursor = true
- viewConfig.cursorFollowsPlayback = false
- } label: {
- HStack {
- Text("Playback follows cursor")
- if viewConfig.playbackFollowsCursor {
- Spacer()
- Image(systemName: "checkmark")
- }
- }
- }
- if let track = item.entry.track {
- Divider()
- Button("Analyze BPM & Key") {
- Task { await libraryManager.analyzeTrack(track) }
- }
- Button("Rescan Metadata") {
- Task {
- await libraryManager.rescanMetadata(track)
- try? modelContext.save()
- }
- }
- Button("Edit Notes...") {
- editingNotesTrack = track
- }
- // Quick add to mix targets
- let otherMixSlots = (0..<3).filter { playlistVM.mixTargets[$0] != nil && playlistVM.mixTargets[$0]?.id != playlist.id }
- if !otherMixSlots.isEmpty {
- let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
- Menu("Add to Mix") {
- ForEach(otherMixSlots, id: \.self) { slot in
- let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
- Button("\(playlistVM.mixTargetName(slot)) (\(hint))") {
- _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
- }
- }
- }
- }
- }
- Divider()
- if selectedEntryIDs.count > 1 {
- Button("Remove \(selectedEntryIDs.count) Selected", role: .destructive) {
- let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
- for e in toRemove {
- playlistVM.removeEntry(e, from: playlist, context: modelContext)
- }
- selectedEntryIDs.removeAll()
- }
- } else {
- Button("Remove from Playlist", role: .destructive) {
- playlistVM.removeEntry(item.entry, from: playlist, context: modelContext)
- }
- }
- }
- }
- .onMove { source, destination in
- if let first = source.first {
- playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext)
- }
- }
- }
- }
- // MARK: - Selection Toolbar
- private struct SelectionToolbar: View {
- let count: Int
- let onSelectAll: () -> Void
- let onDeselect: () -> Void
- let onRemove: () -> Void
- @EnvironmentObject private var theme: AppTheme
- var body: some View {
- HStack(spacing: 8) {
- Text("\(count) selected")
- .font(.system(size: theme.smallFontSize + 1))
- .foregroundStyle(theme.secondaryText)
- Spacer()
- Button("All", action: onSelectAll).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
- Button("None", action: onDeselect).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
- Button("Remove", role: .destructive, action: onRemove).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 2)
- .background(theme.toolbarBackground)
- }
- }
- // MARK: - Group Header with Artwork
- private struct GroupHeaderView: View {
- let title: String
- let trackCount: Int
- let firstTrack: Track?
- let showArtwork: Bool
- var tracks: [Track] = []
- @State private var isHovering = false
- @EnvironmentObject private var theme: AppTheme
- var body: some View {
- HStack(spacing: 6) {
- if showArtwork, let track = firstTrack {
- ArtworkView(track: track, size: 18)
- }
- Text(title)
- .font(.system(size: theme.dataFontSize, weight: .bold))
- .foregroundStyle(theme.groupHeaderText)
- Text("(\(trackCount))")
- .font(.system(size: theme.smallFontSize + 1))
- .foregroundStyle(theme.tertiaryText)
- uploadStatusBadge
- Spacer()
- groupCloudActionButton
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.vertical, 2)
- .contentShape(Rectangle())
- .onHover { isHovering = $0 }
- .contextMenu {
- let eligible = tracks.filter {
- !$0.isCloud && !$0.filePath.isEmpty
- && $0.uploadState != .uploaded
- && ChadMusicAPIClient.shared.isConfigured
- }
- if !eligible.isEmpty {
- Button {
- UploadService.shared.uploadBatch(tracks: eligible, apiClient: ChadMusicAPIClient.shared)
- } label: {
- Label("Upload All to Cloud", systemImage: "arrow.up.to.cloud")
- }
- }
- let failed = tracks.filter { $0.uploadState == .error }
- if !failed.isEmpty {
- Button {
- UploadService.shared.uploadBatch(tracks: failed, apiClient: ChadMusicAPIClient.shared)
- } label: {
- Label("Retry Failed Uploads", systemImage: "arrow.clockwise")
- }
- }
- if eligible.isEmpty && failed.isEmpty {
- Text("All tracks uploaded")
- .foregroundStyle(.secondary)
- }
- }
- }
- // MARK: - Status badge (left side, informational)
- @ViewBuilder
- private var uploadStatusBadge: some View {
- let localTracks = tracks.filter { !$0.isCloud && !$0.filePath.isEmpty }
- let uploading = localTracks.filter { $0.uploadState == .uploading }.count
- let uploaded = localTracks.filter { $0.uploadState == .uploaded }.count
- let errors = localTracks.filter { $0.uploadState == .error }.count
- let total = localTracks.count
- if uploading > 0 {
- HStack(spacing: 2) {
- Image(systemName: "arrow.up.circle.fill")
- .font(.system(size: 11))
- .foregroundStyle(.orange)
- Text("\(uploaded + uploading)/\(total)")
- .font(.system(size: 10, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- .help("Uploading \(uploading) track\(uploading == 1 ? "" : "s") to cloud")
- } else if errors > 0 {
- HStack(spacing: 2) {
- Image(systemName: "exclamationmark.circle.fill")
- .font(.system(size: 11))
- .foregroundStyle(.red)
- Text("\(uploaded)/\(total)")
- .font(.system(size: 10, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- .help("\(errors) upload\(errors == 1 ? "" : "s") failed")
- } else if uploaded > 0 && uploaded == total {
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 11))
- .foregroundStyle(.green)
- .help("All tracks uploaded to cloud")
- } else if uploaded > 0 {
- HStack(spacing: 2) {
- Image(systemName: "arrow.up.circle")
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- Text("\(uploaded)/\(total)")
- .font(.system(size: 10, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- .help("\(uploaded) of \(total) tracks uploaded to cloud")
- }
- }
- // MARK: - Action button (right edge, prominent)
- @ViewBuilder
- private var groupCloudActionButton: some View {
- let localTracks = tracks.filter { !$0.isCloud && !$0.filePath.isEmpty }
- let uploading = localTracks.filter { $0.uploadState == .uploading }.count
- let uploaded = localTracks.filter { $0.uploadState == .uploaded }.count
- let total = localTracks.count
- if ChadMusicAPIClient.shared.isConfigured && total > 0 && uploaded < total {
- if uploading > 0 {
- Button {
- UploadService.shared.cancel()
- } label: {
- Image(systemName: "stop.circle.fill")
- .font(.system(size: 16))
- .foregroundStyle(.orange)
- .frame(width: 28, height: 28)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .help("Cancel upload")
- } else {
- Button {
- let eligible = localTracks.filter { $0.uploadState != .uploaded }
- UploadService.shared.uploadBatch(tracks: eligible, apiClient: ChadMusicAPIClient.shared)
- } label: {
- Image(systemName: "arrow.up.circle")
- .font(.system(size: 16))
- .foregroundStyle(isHovering ? Color.accentColor : .secondary)
- .frame(width: 28, height: 28)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .help("Upload \(total - uploaded) tracks to cloud")
- }
- }
- }
- }
- // MARK: - Column Header Row
- private struct ColumnHeaderRow: View {
- @ObservedObject var viewConfig: PlaylistViewConfig
- @EnvironmentObject private var theme: AppTheme
- private let f = Font.system(size: 10, weight: .medium)
- private let fMono = Font.system(size: 10, weight: .medium, design: .monospaced)
- private var columns: [PlaylistViewConfig.Column] {
- viewConfig.visibleColumns
- }
- var body: some View {
- HStack(spacing: 0) {
- // # column or playing indicator space
- if columns.contains(.trackNumber) {
- Text("#")
- .font(fMono)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 32, alignment: .trailing)
- .padding(.trailing, 4)
- }
- // Artwork spacer
- if columns.contains(.artwork) && viewConfig.showArtwork {
- Color.clear
- .frame(width: 18)
- .padding(.trailing, 4)
- }
- // Artist / Title combined header
- if columns.contains(.artist) || columns.contains(.title) {
- let parts = [
- columns.contains(.artist) ? "Artist" : nil,
- columns.contains(.title) ? "Title" : nil
- ].compactMap { $0 }
- Text(parts.joined(separator: " / "))
- .font(f)
- .foregroundStyle(theme.secondaryText)
- .lineLimit(1)
- }
- Spacer(minLength: 8)
- if columns.contains(.album) {
- Text("Album")
- .font(f)
- .foregroundStyle(theme.secondaryText)
- .frame(maxWidth: 150, alignment: .leading)
- .padding(.trailing, 8)
- }
- if columns.contains(.genre) {
- Text("Genre")
- .font(f)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 70, alignment: .leading)
- }
- if columns.contains(.bpm) {
- Text("BPM")
- .font(fMono)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 45, alignment: .trailing)
- }
- if columns.contains(.key) {
- Text("Key")
- .font(f)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 42, alignment: .center)
- }
- if columns.contains(.duration) {
- Text("Time")
- .font(fMono)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 58, alignment: .trailing)
- }
- if columns.contains(.format) {
- Text("Fmt")
- .font(.system(size: 9, weight: .medium))
- .foregroundStyle(theme.secondaryText)
- .frame(width: 38, alignment: .center)
- }
- if columns.contains(.sampleRate) {
- Text("Rate")
- .font(.system(size: 9, weight: .medium, design: .monospaced))
- .foregroundStyle(theme.secondaryText)
- .frame(width: 58, alignment: .trailing)
- }
- if columns.contains(.bitDepth) {
- Text("Bit")
- .font(.system(size: 9, weight: .medium, design: .monospaced))
- .foregroundStyle(theme.secondaryText)
- .frame(width: 20, alignment: .trailing)
- }
- if columns.contains(.fileSize) {
- Text("Size")
- .font(.system(size: 9, weight: .medium, design: .monospaced))
- .foregroundStyle(theme.secondaryText)
- .frame(width: 65, alignment: .trailing)
- }
- if columns.contains(.rating) {
- Text("Rating")
- .font(.system(size: 9, weight: .medium))
- .foregroundStyle(theme.secondaryText)
- .frame(width: 50, alignment: .center)
- }
- if columns.contains(.playCount) {
- Text("Plays")
- .font(.system(size: 9, weight: .medium, design: .monospaced))
- .foregroundStyle(theme.secondaryText)
- .frame(width: 25, alignment: .trailing)
- }
- }
- .frame(height: 22)
- .padding(.horizontal, 20)
- .background(theme.columnHeaderBackground)
- }
- }
- // MARK: - Configurable Entry Row
- private struct ConfigurableEntryRow: View {
- let entry: PlaylistEntry
- let index: Int
- let isLast: Bool
- @ObservedObject var viewConfig: PlaylistViewConfig
- var isPlaying: Bool = false
- @Environment(PlayerViewModel.self) private var playerVM
- @Environment(PlaylistViewModel.self) private var playlistVM
- @Environment(\.modelContext) private var modelContext
- @State private var crossfade: Double = 0
- @State private var gain: Double = 0
- private var f: Font { .system(size: theme.dataFontSize) }
- private var fMono: Font { .system(size: theme.dataFontSize, design: .monospaced) }
- private var columns: [PlaylistViewConfig.Column] {
- viewConfig.visibleColumns
- }
- @EnvironmentObject private var theme: AppTheme
- var body: some View {
- HStack(spacing: 0) {
- if let track = entry.track {
- // Playing indicator (narrow)
- if isPlaying {
- Text("▶")
- .font(.system(size: 8))
- .foregroundStyle(theme.playingHighlight)
- .frame(width: 12)
- } else if columns.contains(.trackNumber) {
- Text("\(index + 1)")
- .font(fMono)
- .foregroundStyle(theme.tertiaryText)
- .frame(width: 32, alignment: .trailing)
- .padding(.trailing, 4)
- }
- // Artwork (small)
- if columns.contains(.artwork) && viewConfig.showArtwork {
- ArtworkView(track: track, size: 18)
- .padding(.trailing, 4)
- }
- // Artist - Title (main text, takes remaining space)
- if columns.contains(.artist) && !track.artist.isEmpty {
- Text(track.artist)
- .font(f)
- .foregroundStyle(theme.secondaryText)
- .lineLimit(1)
- Text(" – ")
- .font(f)
- .foregroundStyle(theme.tertiaryText)
- }
- if columns.contains(.title) {
- Text(track.title)
- .font(f.weight(isPlaying ? .bold : .regular))
- .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText)
- .lineLimit(1)
- }
- if track.isCloud {
- if track.downloadState == .downloaded || track.localCachePath != nil {
- Image(systemName: "arrow.down.circle.fill")
- .font(.system(size: 11))
- .foregroundStyle(.green)
- } else {
- Image(systemName: "cloud.fill")
- .font(.system(size: 11))
- .foregroundStyle(Color.accentColor.opacity(0.85))
- }
- }
- Spacer(minLength: 8)
- // Album
- if columns.contains(.album) && !track.album.isEmpty {
- Text(track.album)
- .font(f)
- .foregroundStyle(theme.tertiaryText)
- .lineLimit(1)
- .frame(maxWidth: 150, alignment: .leading)
- .padding(.trailing, 8)
- }
- // Genre
- if columns.contains(.genre) && !track.genre.isEmpty {
- Text(track.genre)
- .font(f)
- .foregroundStyle(theme.tertiaryText)
- .frame(width: 70, alignment: .leading)
- }
- // BPM
- if columns.contains(.bpm) {
- Text(track.bpm.map { String(format: "%.0f", $0) } ?? "")
- .font(fMono)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 45, alignment: .trailing)
- }
- // Key
- if columns.contains(.key) {
- Text(track.musicalKey ?? "")
- .font(f)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 42, alignment: .center)
- }
- // Duration
- if columns.contains(.duration) {
- Text(track.formattedDuration)
- .font(fMono)
- .foregroundStyle(theme.secondaryText)
- .frame(width: 58, alignment: .trailing)
- }
- // Format
- if columns.contains(.format) {
- Text(track.fileFormat)
- .font(.system(size: theme.smallFontSize))
- .foregroundStyle(theme.tertiaryText)
- .frame(width: 38, alignment: .center)
- }
- // Sample Rate
- if columns.contains(.sampleRate) {
- Text("\(Int(track.sampleRate))Hz")
- .font(.system(size: theme.smallFontSize, design: .monospaced))
- .foregroundStyle(theme.tertiaryText)
- .frame(width: 58, alignment: .trailing)
- }
- // Bit Depth
- if columns.contains(.bitDepth) {
- Text("\(track.bitDepth)")
- .font(.system(size: theme.smallFontSize, design: .monospaced))
- .foregroundStyle(theme.tertiaryText)
- .frame(width: 20, alignment: .trailing)
- }
- // File Size
- if columns.contains(.fileSize) {
- Text(track.formattedFileSize)
- .font(.system(size: theme.smallFontSize, design: .monospaced))
- .foregroundStyle(theme.tertiaryText)
- .frame(width: 65, alignment: .trailing)
- }
- // Rating
- if columns.contains(.rating) && track.rating > 0 {
- Text(String(repeating: "★", count: track.rating))
- .font(.system(size: theme.smallFontSize))
- .foregroundStyle(.yellow)
- .frame(width: 50, alignment: .center)
- }
- // Play Count
- if columns.contains(.playCount) && track.playCount > 0 {
- Text("\(track.playCount)×")
- .font(.system(size: theme.smallFontSize, design: .monospaced))
- .foregroundStyle(theme.tertiaryText)
- .frame(width: 25, alignment: .trailing)
- }
- }
- }
- .frame(height: theme.rowHeight)
- .onAppear {
- crossfade = entry.crossfadeDuration
- gain = entry.gainAdjustment
- }
- }
- }
- // MARK: - Empty Playlist
- private struct EmptyPlaylistView: View {
- let onAddTracks: () -> Void
- let onAddFiles: () -> Void
- let onAddFolder: () -> Void
- var body: some View {
- VStack(spacing: 12) {
- Spacer()
- Text("Empty playlist")
- .font(.system(size: 12))
- .foregroundStyle(.secondary)
- Text("Drop files here, or use Add menu above")
- .font(.system(size: 11))
- .foregroundStyle(.tertiary)
- HStack(spacing: 8) {
- Button("Add Files...") { onAddFiles() }
- .font(.system(size: 11))
- Button("Add Folder...") { onAddFolder() }
- .font(.system(size: 11))
- }
- Spacer()
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- }
- // MARK: - Column Config Sheet
- private struct ColumnConfigSheet: View {
- @ObservedObject var viewConfig: PlaylistViewConfig
- @Environment(\.dismiss) private var dismiss
- var body: some View {
- VStack(spacing: 0) {
- HStack {
- Text("Configure Playlist View")
- .font(.headline)
- Spacer()
- Button("Done") { dismiss() }
- .keyboardShortcut(.defaultAction)
- }
- .padding()
- Divider()
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- // Artwork settings
- VStack(alignment: .leading, spacing: 8) {
- Text("Artwork")
- .font(.subheadline.bold())
- Toggle("Show artwork", isOn: $viewConfig.showArtwork)
- if viewConfig.showArtwork {
- Picker("Size", selection: $viewConfig.artworkSize) {
- ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
- Text(size.rawValue).tag(size)
- }
- }
- .pickerStyle(.segmented)
- .frame(width: 250)
- }
- }
- Divider()
- // Playback behavior
- VStack(alignment: .leading, spacing: 8) {
- Text("Playback Behavior")
- .font(.subheadline.bold())
- Toggle("Cursor follows playback", isOn: $viewConfig.cursorFollowsPlayback)
- Text("Auto-select and scroll to the currently playing track")
- .font(.caption)
- .foregroundStyle(.secondary)
- Toggle("Playback follows cursor", isOn: $viewConfig.playbackFollowsCursor)
- Text("Press Enter/Return to play the selected track")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Divider()
- // Visible columns
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text("Visible Columns")
- .font(.subheadline.bold())
- Spacer()
- Button("Reset to Defaults") {
- viewConfig.resetToDefaults()
- }
- .font(.caption)
- }
- Text("Check the columns you want to display. Drag to reorder.")
- .font(.caption)
- .foregroundStyle(.secondary)
- LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 6) {
- ForEach(PlaylistViewConfig.Column.allCases) { column in
- Toggle(column.rawValue, isOn: Binding(
- get: { viewConfig.isColumnVisible(column) },
- set: { _ in viewConfig.toggleColumn(column) }
- ))
- .toggleStyle(.checkbox)
- .font(.caption)
- }
- }
- }
- }
- .padding(20)
- }
- }
- .frame(width: 450, height: 520)
- }
- }
- // MARK: - Add Tracks Sheet
- private struct AddTracksSheet: View {
- let playlist: Playlist
- let allTracks: [Track]
- @Environment(PlaylistViewModel.self) private var playlistVM
- @Environment(\.modelContext) private var modelContext
- @Environment(\.dismiss) private var dismiss
- @State private var searchText = ""
- @State private var selectedTracks: Set<UUID> = []
- var filteredTracks: [Track] {
- if searchText.isEmpty { return allTracks }
- let query = searchText.lowercased()
- return allTracks.filter {
- $0.title.lowercased().contains(query) ||
- $0.artist.lowercased().contains(query)
- }
- }
- var body: some View {
- VStack(spacing: 0) {
- HStack {
- Text("Add Tracks to \(playlist.name)")
- .font(.headline)
- Spacer()
- Text("\(selectedTracks.count) selected")
- .foregroundStyle(.secondary)
- }
- .padding()
- TextField("Search tracks...", text: $searchText)
- .textFieldStyle(.roundedBorder)
- .padding(.horizontal)
- List(filteredTracks, selection: $selectedTracks) { track in
- HStack {
- TrackRow(track: track)
- Spacer()
- if let bpm = track.bpm {
- Text("\(String(format: "%.0f", bpm))")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Text(track.formattedDuration)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- .listStyle(.inset)
- Divider()
- HStack {
- Button("Cancel") { dismiss() }
- .keyboardShortcut(.cancelAction)
- Spacer()
- Button("Add \(selectedTracks.count) Track\(selectedTracks.count == 1 ? "" : "s")") {
- let tracks = allTracks.filter { selectedTracks.contains($0.id) }
- playlistVM.addTracks(tracks, to: playlist, context: modelContext)
- dismiss()
- }
- .keyboardShortcut(.defaultAction)
- .disabled(selectedTracks.isEmpty)
- }
- .padding()
- }
- .frame(width: 500, height: 600)
- }
- }
- // MARK: - Track Notes Sheet
- private struct TrackNotesSheet: View {
- let track: Track
- @Environment(\.dismiss) private var dismiss
- @Environment(\.modelContext) private var modelContext
- @State private var notes: String = ""
- var body: some View {
- VStack(spacing: 12) {
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- Text(track.title)
- .font(.headline)
- if !track.artist.isEmpty {
- Text(track.artist)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- }
- Spacer()
- Button("Done") {
- track.notes = notes
- try? modelContext.save()
- dismiss()
- }
- .keyboardShortcut(.defaultAction)
- }
- TextEditor(text: $notes)
- .font(.system(size: 13))
- .frame(minHeight: 100)
- .scrollContentBackground(.hidden)
- .padding(4)
- .background(Color.gray.opacity(0.1))
- .cornerRadius(6)
- HStack {
- Text("Notes are saved with the track and included in exports")
- .font(.caption)
- .foregroundStyle(.secondary)
- Spacer()
- }
- }
- .padding(16)
- .frame(width: 400, height: 220)
- .onAppear {
- notes = track.notes
- }
- }
- }
- // MARK: - Double-Click Handler (NSViewRepresentable)
|