import SwiftData import SwiftUI import UniformTypeIdentifiers /// Detail view for a single playlist — shows tracks with reorder, play, remove. struct PlaylistDetailView: View { let playlist: Playlist @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var theme: AppTheme @EnvironmentObject private var syncManager: SyncManager @Environment(\.modelContext) private var modelContext @AppStorage("trackTapAction") private var trackTapAction = "playNow" @State private var showAddTracks = false @State private var showEntryNotes: PlaylistEntry? @State private var showGroupEditor = false @State private var isEditing = false var body: some View { List { // Header stats playlistHeader // Track entries — grouped if template is set if playlist.groupTemplate.isEmpty { // No grouping — flat list flatEntryList } else { // Grouped by template groupedEntryList } } .listStyle(.plain) .navigationTitle(playlist.name) .accessibilityIdentifier("playlistDetailView") .toolbar { ToolbarItem(placement: .topBarTrailing) { EditButton() } ToolbarItem(placement: .topBarTrailing) { Menu { Button { showAddTracks = true } label: { Label("Add Tracks", systemImage: "plus") } Button { playlistVM.targetPlaylist = playlist playlistVM.showStatus("Target: \(playlist.name)") } label: { Label("Set as Target", systemImage: "star.fill") } Divider() Button { showGroupEditor = true } label: { Label( playlist.groupTemplate.isEmpty ? "Grouping..." : "Grouping: \(playlist.groupTemplate)", systemImage: "rectangle.3.group" ) } Divider() Button { syncManager.exportPlaylists([playlist]) playlistVM.showStatus("Playlist exported to Sync folder") } label: { Label("Export for Mac", systemImage: "square.and.arrow.up") } // Play all if let firstEntry = playlist.sortedEntries.first, let track = firstEntry.track { Button { playerVM.playFromPlaylist(track: track, entryID: firstEntry.id, playlist: playlist) } label: { Label("Play All", systemImage: "play.fill") } } } label: { Image(systemName: "ellipsis.circle") } } } .sheet(isPresented: $showAddTracks) { AddTracksToPlaylistSheet(playlist: playlist) .environmentObject(theme) } .sheet(item: $showEntryNotes) { entry in EntryNotesSheet(entry: entry) .environmentObject(theme) } .sheet(isPresented: $showGroupEditor) { GroupTemplateEditorSheet(playlist: playlist) .environmentObject(theme) } } // MARK: - Header private var playlistHeader: some View { VStack(alignment: .leading, spacing: 8) { HStack { Circle() .fill(Color(hex: playlist.color) ?? theme.accent) .frame(width: 16, height: 16) Text("\(playlist.trackCount) tracks") .font(.subheadline) .foregroundStyle(theme.secondaryText) Text("•") .foregroundStyle(theme.tertiaryText) Text(playlist.formattedTotalDuration) .font(.subheadline.monospacedDigit()) .foregroundStyle(theme.secondaryText) if let bpm = playlist.targetBPM { Text("•") .foregroundStyle(theme.tertiaryText) Text("\(String(format: "%.0f", bpm)) BPM") .font(.subheadline.monospacedDigit()) .foregroundStyle(theme.tertiaryText) } } if !playlist.notes.isEmpty { Text(playlist.notes) .font(.caption) .foregroundStyle(theme.tertiaryText) } } .padding(.vertical, 4) .listRowBackground(Color.clear) } // MARK: - Flat Entry List (no grouping) private var flatEntryList: some View { ForEach(playlist.sortedEntries) { entry in entryRow(entry) } .onMove { source, destination in if let first = source.first { playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext) } } .onDelete { offsets in let entries = playlist.sortedEntries for index in offsets { playlistVM.removeEntry(entries[index], from: playlist, context: modelContext) } } } // MARK: - Grouped Entry List (by template) private var groupedEntryList: some View { let sorted = playlist.sortedEntries let groups = groupEntries(sorted, template: playlist.groupTemplate) return ForEach(groups, id: \.header) { group in Section { ForEach(group.entries) { entry in entryRow(entry) } } header: { Text(group.header) .font(.subheadline.weight(.semibold)) .foregroundStyle(theme.groupHeaderText) } } } private struct EntryGroup { let header: String let entries: [PlaylistEntry] } private func groupEntries(_ entries: [PlaylistEntry], template: String) -> [EntryGroup] { var groups: [(String, [PlaylistEntry])] = [] var currentHeader = "" var currentEntries: [PlaylistEntry] = [] for entry in entries { let header: String if let track = entry.track { header = GroupTemplateResolver.resolve(template: template, for: track) } else { header = "Unknown" } if header != currentHeader { if !currentEntries.isEmpty { groups.append((currentHeader, currentEntries)) } currentHeader = header currentEntries = [entry] } else { currentEntries.append(entry) } } if !currentEntries.isEmpty { groups.append((currentHeader, currentEntries)) } return groups.map { EntryGroup(header: $0.0, entries: $0.1) } } // MARK: - Entry Row @ViewBuilder private func entryRow(_ entry: PlaylistEntry) -> some View { if let track = entry.track { Button { if trackTapAction == "addToQueue" { playerVM.addToQueue(QueueEntry.from(track: track)) } else { playerVM.playFromPlaylist(track: track, entryID: entry.id, playlist: playlist) } } label: { PlaylistEntryRow(entry: entry, track: track) .contentShape(Rectangle()) } .buttonStyle(.plain) .contextMenu { Button { playerVM.playFromPlaylist(track: track, entryID: entry.id, playlist: playlist) } label: { Label("Play Now", systemImage: "play.fill") } Button { playerVM.playNextInQueue(QueueEntry.from(track: track)) } label: { Label("Play Next", systemImage: "text.insert") } Button { playerVM.addToQueue(QueueEntry.from(track: track)) } label: { Label("Add to Queue", systemImage: "text.append") } } .swipeActions(edge: .trailing) { Button(role: .destructive) { playlistVM.removeEntry(entry, from: playlist, context: modelContext) } label: { Label("Remove", systemImage: "minus.circle") } } .swipeActions(edge: .leading) { Button { showEntryNotes = entry } label: { Label("Notes", systemImage: "note.text") } .tint(theme.accent) } } else { HStack { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) Text(entry.notes.isEmpty ? "Track not found" : entry.notes) .font(.subheadline) .foregroundStyle(theme.secondaryText) } } } } // MARK: - Playlist Entry Row struct PlaylistEntryRow: View { let entry: PlaylistEntry let track: Track @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme private var isPlaying: Bool { playerVM.currentPlayingEntryID == entry.id } var body: some View { HStack(spacing: 12) { // Position number Text("\(entry.position + 1)") .font(.system(size: 14, design: .monospaced)) .foregroundStyle(isPlaying ? theme.playingHighlight : theme.tertiaryText) .frame(width: 24) // Track info VStack(alignment: .leading, spacing: 2) { Text(track.title) .font(.system(size: theme.dataFontSize, weight: isPlaying ? .semibold : .regular)) .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText) .lineLimit(1) HStack(spacing: 6) { if !track.artist.isEmpty { Text(track.artist) .font(.system(size: theme.smallFontSize)) .foregroundStyle(theme.secondaryText) .lineLimit(1) } if entry.crossfadeDuration > 0 { Text("⤬ \(String(format: "%.1fs", entry.crossfadeDuration))") .font(.system(size: 10, design: .monospaced)) .foregroundStyle(theme.tertiaryText) } } } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(track.formattedDuration) .font(.system(size: theme.smallFontSize, design: .monospaced)) .foregroundStyle(theme.secondaryText) if let bpm = track.bpm { Text("\(String(format: "%.0f", bpm))") .font(.system(size: 10, design: .monospaced)) .foregroundStyle(theme.tertiaryText) } } if isPlaying && playerVM.isPlaying { Image(systemName: "speaker.wave.2.fill") .font(.caption) .foregroundStyle(theme.playingHighlight) } } .padding(.vertical, 4) } } // MARK: - Add Tracks to Playlist Sheet struct AddTracksToPlaylistSheet: View { let playlist: Playlist @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var theme: AppTheme @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @Query(sort: \Track.title) private var allTracks: [Track] @State private var searchText = "" @State private var selectedTracks: Set = [] @State private var existingTrackIDs: Set = [] private var filteredTracks: [Track] { if searchText.isEmpty { return allTracks } let q = searchText.lowercased() return allTracks.filter { $0.title.lowercased().contains(q) || $0.artist.lowercased().contains(q) } } var body: some View { NavigationStack { List(filteredTracks) { track in let isInPlaylist = existingTrackIDs.contains(track.id) let isSelected = selectedTracks.contains(track.id) HStack { TrackRow(track: track) Spacer() if isInPlaylist { Image(systemName: "checkmark.circle.fill") .foregroundStyle(theme.tertiaryText) } else if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(theme.accent) } else { Image(systemName: "circle") .foregroundStyle(theme.tertiaryText) } } .contentShape(Rectangle()) .onTapGesture { guard !isInPlaylist else { return } if isSelected { selectedTracks.remove(track.id) } else { selectedTracks.insert(track.id) } } .opacity(isInPlaylist ? 0.5 : 1) } .searchable(text: $searchText, prompt: "Search library") .navigationTitle("Add Tracks") .navigationBarTitleDisplayMode(.inline) .onAppear { // Pre-compute existing track IDs once — avoids per-row database queries let playlistID = playlist.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.playlist?.id == playlistID } ) if let entries = try? modelContext.fetch(descriptor) { existingTrackIDs = Set(entries.compactMap { $0.track?.id }) } } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Add \(selectedTracks.count)") { let tracksToAdd = allTracks.filter { selectedTracks.contains($0.id) } playlistVM.addTracks(tracksToAdd, to: playlist, context: modelContext) dismiss() } .disabled(selectedTracks.isEmpty) } } } } } // MARK: - Entry Notes Sheet struct EntryNotesSheet: View { let entry: PlaylistEntry @EnvironmentObject private var theme: AppTheme @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @State private var notes: String = "" var body: some View { NavigationStack { VStack(alignment: .leading, spacing: 12) { if let track = entry.track { Text("\(track.artist) — \(track.title)") .font(.headline) .foregroundStyle(theme.primaryText) } TextEditor(text: $notes) .font(.body) .frame(minHeight: 200) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(theme.separatorColor, lineWidth: 1) ) Spacer() } .padding() .navigationTitle("Notes") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { entry.notes = notes try? modelContext.save() dismiss() } } } .onAppear { notes = entry.notes } } } }