PlaylistDetailView.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import SwiftData
  2. import SwiftUI
  3. import UniformTypeIdentifiers
  4. /// Detail view for a single playlist — shows tracks with reorder, play, remove.
  5. struct PlaylistDetailView: View {
  6. let playlist: Playlist
  7. @Environment(PlayerViewModel.self) private var playerVM
  8. @Environment(PlaylistViewModel.self) private var playlistVM
  9. @EnvironmentObject private var libraryManager: LibraryManager
  10. @EnvironmentObject private var theme: AppTheme
  11. @EnvironmentObject private var syncManager: SyncManager
  12. @Environment(\.modelContext) private var modelContext
  13. @AppStorage("trackTapAction") private var trackTapAction = "playNow"
  14. @State private var showAddTracks = false
  15. @State private var showEntryNotes: PlaylistEntry?
  16. @State private var showGroupEditor = false
  17. @State private var isEditing = false
  18. var body: some View {
  19. List {
  20. // Header stats
  21. playlistHeader
  22. // Track entries — grouped if template is set
  23. if playlist.groupTemplate.isEmpty {
  24. // No grouping — flat list
  25. flatEntryList
  26. } else {
  27. // Grouped by template
  28. groupedEntryList
  29. }
  30. }
  31. .listStyle(.plain)
  32. .navigationTitle(playlist.name)
  33. .accessibilityIdentifier("playlistDetailView")
  34. .toolbar {
  35. ToolbarItem(placement: .topBarTrailing) {
  36. EditButton()
  37. }
  38. ToolbarItem(placement: .topBarTrailing) {
  39. Menu {
  40. Button {
  41. showAddTracks = true
  42. } label: {
  43. Label("Add Tracks", systemImage: "plus")
  44. }
  45. Button {
  46. playlistVM.targetPlaylist = playlist
  47. playlistVM.showStatus("Target: \(playlist.name)")
  48. } label: {
  49. Label("Set as Target", systemImage: "star.fill")
  50. }
  51. Divider()
  52. Button {
  53. showGroupEditor = true
  54. } label: {
  55. Label(
  56. playlist.groupTemplate.isEmpty ? "Grouping..." : "Grouping: \(playlist.groupTemplate)",
  57. systemImage: "rectangle.3.group"
  58. )
  59. }
  60. Divider()
  61. Button {
  62. syncManager.exportPlaylists([playlist])
  63. playlistVM.showStatus("Playlist exported to Sync folder")
  64. } label: {
  65. Label("Export for Mac", systemImage: "square.and.arrow.up")
  66. }
  67. // Play all
  68. if let firstEntry = playlist.sortedEntries.first,
  69. let track = firstEntry.track {
  70. Button {
  71. playerVM.playFromPlaylist(track: track, entryID: firstEntry.id, playlist: playlist)
  72. } label: {
  73. Label("Play All", systemImage: "play.fill")
  74. }
  75. }
  76. } label: {
  77. Image(systemName: "ellipsis.circle")
  78. }
  79. }
  80. }
  81. .sheet(isPresented: $showAddTracks) {
  82. AddTracksToPlaylistSheet(playlist: playlist)
  83. .environmentObject(theme)
  84. }
  85. .sheet(item: $showEntryNotes) { entry in
  86. EntryNotesSheet(entry: entry)
  87. .environmentObject(theme)
  88. }
  89. .sheet(isPresented: $showGroupEditor) {
  90. GroupTemplateEditorSheet(playlist: playlist)
  91. .environmentObject(theme)
  92. }
  93. }
  94. // MARK: - Header
  95. private var playlistHeader: some View {
  96. VStack(alignment: .leading, spacing: 8) {
  97. HStack {
  98. Circle()
  99. .fill(Color(hex: playlist.color) ?? theme.accent)
  100. .frame(width: 16, height: 16)
  101. Text("\(playlist.trackCount) tracks")
  102. .font(.subheadline)
  103. .foregroundStyle(theme.secondaryText)
  104. Text("•")
  105. .foregroundStyle(theme.tertiaryText)
  106. Text(playlist.formattedTotalDuration)
  107. .font(.subheadline.monospacedDigit())
  108. .foregroundStyle(theme.secondaryText)
  109. if let bpm = playlist.targetBPM {
  110. Text("•")
  111. .foregroundStyle(theme.tertiaryText)
  112. Text("\(String(format: "%.0f", bpm)) BPM")
  113. .font(.subheadline.monospacedDigit())
  114. .foregroundStyle(theme.tertiaryText)
  115. }
  116. }
  117. if !playlist.notes.isEmpty {
  118. Text(playlist.notes)
  119. .font(.caption)
  120. .foregroundStyle(theme.tertiaryText)
  121. }
  122. }
  123. .padding(.vertical, 4)
  124. .listRowBackground(Color.clear)
  125. }
  126. // MARK: - Flat Entry List (no grouping)
  127. private var flatEntryList: some View {
  128. ForEach(playlist.sortedEntries) { entry in
  129. entryRow(entry)
  130. }
  131. .onMove { source, destination in
  132. if let first = source.first {
  133. playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext)
  134. }
  135. }
  136. .onDelete { offsets in
  137. let entries = playlist.sortedEntries
  138. for index in offsets {
  139. playlistVM.removeEntry(entries[index], from: playlist, context: modelContext)
  140. }
  141. }
  142. }
  143. // MARK: - Grouped Entry List (by template)
  144. private var groupedEntryList: some View {
  145. let sorted = playlist.sortedEntries
  146. let groups = groupEntries(sorted, template: playlist.groupTemplate)
  147. return ForEach(groups, id: \.header) { group in
  148. Section {
  149. ForEach(group.entries) { entry in
  150. entryRow(entry)
  151. }
  152. } header: {
  153. Text(group.header)
  154. .font(.subheadline.weight(.semibold))
  155. .foregroundStyle(theme.groupHeaderText)
  156. }
  157. }
  158. }
  159. private struct EntryGroup {
  160. let header: String
  161. let entries: [PlaylistEntry]
  162. }
  163. private func groupEntries(_ entries: [PlaylistEntry], template: String) -> [EntryGroup] {
  164. var groups: [(String, [PlaylistEntry])] = []
  165. var currentHeader = ""
  166. var currentEntries: [PlaylistEntry] = []
  167. for entry in entries {
  168. let header: String
  169. if let track = entry.track {
  170. header = GroupTemplateResolver.resolve(template: template, for: track)
  171. } else {
  172. header = "Unknown"
  173. }
  174. if header != currentHeader {
  175. if !currentEntries.isEmpty {
  176. groups.append((currentHeader, currentEntries))
  177. }
  178. currentHeader = header
  179. currentEntries = [entry]
  180. } else {
  181. currentEntries.append(entry)
  182. }
  183. }
  184. if !currentEntries.isEmpty {
  185. groups.append((currentHeader, currentEntries))
  186. }
  187. return groups.map { EntryGroup(header: $0.0, entries: $0.1) }
  188. }
  189. // MARK: - Entry Row
  190. @ViewBuilder
  191. private func entryRow(_ entry: PlaylistEntry) -> some View {
  192. if let track = entry.track {
  193. Button {
  194. if trackTapAction == "addToQueue" {
  195. playerVM.addToQueue(QueueEntry.from(track: track))
  196. } else {
  197. playerVM.playFromPlaylist(track: track, entryID: entry.id, playlist: playlist)
  198. }
  199. } label: {
  200. PlaylistEntryRow(entry: entry, track: track)
  201. .contentShape(Rectangle())
  202. }
  203. .buttonStyle(.plain)
  204. .contextMenu {
  205. Button {
  206. playerVM.playFromPlaylist(track: track, entryID: entry.id, playlist: playlist)
  207. } label: {
  208. Label("Play Now", systemImage: "play.fill")
  209. }
  210. Button {
  211. playerVM.playNextInQueue(QueueEntry.from(track: track))
  212. } label: {
  213. Label("Play Next", systemImage: "text.insert")
  214. }
  215. Button {
  216. playerVM.addToQueue(QueueEntry.from(track: track))
  217. } label: {
  218. Label("Add to Queue", systemImage: "text.append")
  219. }
  220. }
  221. .swipeActions(edge: .trailing) {
  222. Button(role: .destructive) {
  223. playlistVM.removeEntry(entry, from: playlist, context: modelContext)
  224. } label: {
  225. Label("Remove", systemImage: "minus.circle")
  226. }
  227. }
  228. .swipeActions(edge: .leading) {
  229. Button {
  230. showEntryNotes = entry
  231. } label: {
  232. Label("Notes", systemImage: "note.text")
  233. }
  234. .tint(theme.accent)
  235. }
  236. } else {
  237. HStack {
  238. Image(systemName: "exclamationmark.triangle")
  239. .foregroundStyle(.orange)
  240. Text(entry.notes.isEmpty ? "Track not found" : entry.notes)
  241. .font(.subheadline)
  242. .foregroundStyle(theme.secondaryText)
  243. }
  244. }
  245. }
  246. }
  247. // MARK: - Playlist Entry Row
  248. struct PlaylistEntryRow: View {
  249. let entry: PlaylistEntry
  250. let track: Track
  251. @Environment(PlayerViewModel.self) private var playerVM
  252. @EnvironmentObject private var theme: AppTheme
  253. private var isPlaying: Bool {
  254. playerVM.currentPlayingEntryID == entry.id
  255. }
  256. var body: some View {
  257. HStack(spacing: 12) {
  258. // Position number
  259. Text("\(entry.position + 1)")
  260. .font(.system(size: 14, design: .monospaced))
  261. .foregroundStyle(isPlaying ? theme.playingHighlight : theme.tertiaryText)
  262. .frame(width: 24)
  263. // Track info
  264. VStack(alignment: .leading, spacing: 2) {
  265. Text(track.title)
  266. .font(.system(size: theme.dataFontSize, weight: isPlaying ? .semibold : .regular))
  267. .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText)
  268. .lineLimit(1)
  269. HStack(spacing: 6) {
  270. if !track.artist.isEmpty {
  271. Text(track.artist)
  272. .font(.system(size: theme.smallFontSize))
  273. .foregroundStyle(theme.secondaryText)
  274. .lineLimit(1)
  275. }
  276. if entry.crossfadeDuration > 0 {
  277. Text("⤬ \(String(format: "%.1fs", entry.crossfadeDuration))")
  278. .font(.system(size: 10, design: .monospaced))
  279. .foregroundStyle(theme.tertiaryText)
  280. }
  281. }
  282. }
  283. Spacer()
  284. VStack(alignment: .trailing, spacing: 2) {
  285. Text(track.formattedDuration)
  286. .font(.system(size: theme.smallFontSize, design: .monospaced))
  287. .foregroundStyle(theme.secondaryText)
  288. if let bpm = track.bpm {
  289. Text("\(String(format: "%.0f", bpm))")
  290. .font(.system(size: 10, design: .monospaced))
  291. .foregroundStyle(theme.tertiaryText)
  292. }
  293. }
  294. if isPlaying && playerVM.isPlaying {
  295. Image(systemName: "speaker.wave.2.fill")
  296. .font(.caption)
  297. .foregroundStyle(theme.playingHighlight)
  298. }
  299. }
  300. .padding(.vertical, 4)
  301. }
  302. }
  303. // MARK: - Add Tracks to Playlist Sheet
  304. struct AddTracksToPlaylistSheet: View {
  305. let playlist: Playlist
  306. @Environment(PlaylistViewModel.self) private var playlistVM
  307. @EnvironmentObject private var theme: AppTheme
  308. @Environment(\.modelContext) private var modelContext
  309. @Environment(\.dismiss) private var dismiss
  310. @Query(sort: \Track.title) private var allTracks: [Track]
  311. @State private var searchText = ""
  312. @State private var selectedTracks: Set<UUID> = []
  313. @State private var existingTrackIDs: Set<UUID> = []
  314. private var filteredTracks: [Track] {
  315. if searchText.isEmpty { return allTracks }
  316. let q = searchText.lowercased()
  317. return allTracks.filter {
  318. $0.title.lowercased().contains(q) ||
  319. $0.artist.lowercased().contains(q)
  320. }
  321. }
  322. var body: some View {
  323. NavigationStack {
  324. List(filteredTracks) { track in
  325. let isInPlaylist = existingTrackIDs.contains(track.id)
  326. let isSelected = selectedTracks.contains(track.id)
  327. HStack {
  328. TrackRow(track: track)
  329. Spacer()
  330. if isInPlaylist {
  331. Image(systemName: "checkmark.circle.fill")
  332. .foregroundStyle(theme.tertiaryText)
  333. } else if isSelected {
  334. Image(systemName: "checkmark.circle.fill")
  335. .foregroundStyle(theme.accent)
  336. } else {
  337. Image(systemName: "circle")
  338. .foregroundStyle(theme.tertiaryText)
  339. }
  340. }
  341. .contentShape(Rectangle())
  342. .onTapGesture {
  343. guard !isInPlaylist else { return }
  344. if isSelected {
  345. selectedTracks.remove(track.id)
  346. } else {
  347. selectedTracks.insert(track.id)
  348. }
  349. }
  350. .opacity(isInPlaylist ? 0.5 : 1)
  351. }
  352. .searchable(text: $searchText, prompt: "Search library")
  353. .navigationTitle("Add Tracks")
  354. .navigationBarTitleDisplayMode(.inline)
  355. .onAppear {
  356. // Pre-compute existing track IDs once — avoids per-row database queries
  357. let playlistID = playlist.id
  358. let descriptor = FetchDescriptor<PlaylistEntry>(
  359. predicate: #Predicate<PlaylistEntry> { $0.playlist?.id == playlistID }
  360. )
  361. if let entries = try? modelContext.fetch(descriptor) {
  362. existingTrackIDs = Set(entries.compactMap { $0.track?.id })
  363. }
  364. }
  365. .toolbar {
  366. ToolbarItem(placement: .cancellationAction) {
  367. Button("Cancel") { dismiss() }
  368. }
  369. ToolbarItem(placement: .confirmationAction) {
  370. Button("Add \(selectedTracks.count)") {
  371. let tracksToAdd = allTracks.filter { selectedTracks.contains($0.id) }
  372. playlistVM.addTracks(tracksToAdd, to: playlist, context: modelContext)
  373. dismiss()
  374. }
  375. .disabled(selectedTracks.isEmpty)
  376. }
  377. }
  378. }
  379. }
  380. }
  381. // MARK: - Entry Notes Sheet
  382. struct EntryNotesSheet: View {
  383. let entry: PlaylistEntry
  384. @EnvironmentObject private var theme: AppTheme
  385. @Environment(\.modelContext) private var modelContext
  386. @Environment(\.dismiss) private var dismiss
  387. @State private var notes: String = ""
  388. var body: some View {
  389. NavigationStack {
  390. VStack(alignment: .leading, spacing: 12) {
  391. if let track = entry.track {
  392. Text("\(track.artist) — \(track.title)")
  393. .font(.headline)
  394. .foregroundStyle(theme.primaryText)
  395. }
  396. TextEditor(text: $notes)
  397. .font(.body)
  398. .frame(minHeight: 200)
  399. .overlay(
  400. RoundedRectangle(cornerRadius: 8)
  401. .stroke(theme.separatorColor, lineWidth: 1)
  402. )
  403. Spacer()
  404. }
  405. .padding()
  406. .navigationTitle("Notes")
  407. .navigationBarTitleDisplayMode(.inline)
  408. .toolbar {
  409. ToolbarItem(placement: .cancellationAction) {
  410. Button("Cancel") { dismiss() }
  411. }
  412. ToolbarItem(placement: .confirmationAction) {
  413. Button("Save") {
  414. entry.notes = notes
  415. try? modelContext.save()
  416. dismiss()
  417. }
  418. }
  419. }
  420. .onAppear {
  421. notes = entry.notes
  422. }
  423. }
  424. }
  425. }