| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- 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<UUID> = []
- @State private var existingTrackIDs: Set<UUID> = []
- 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<PlaylistEntry>(
- predicate: #Predicate<PlaylistEntry> { $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
- }
- }
- }
- }
|