| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169 |
- 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")
- }
- Button { onExport() } label: {
- Label("Export", systemImage: "square.and.arrow.up")
- }
- .disabled(playlist.entries.isEmpty)
- }
- .controlSize(.small)
- }
- .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?
- /// 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: {
- GroupHeaderView(
- title: group.key,
- trackCount: group.entries.count,
- firstTrack: group.entries.first?.entry.track,
- showArtwork: viewConfig.showArtwork
- )
- }
- } 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()
- // 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)
- }
- }
- }
- }
- }
- 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
- @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)
- }
- .padding(.vertical, 2)
- }
- }
- // 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)
- }
- 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)
|