ContentView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import SwiftData
  2. import SwiftUI
  3. /// Main content view — Sidebar (library + playlists) | Central content | Player.
  4. struct ContentView: View {
  5. @Environment(PlayerViewModel.self) private var playerVM
  6. @Environment(PlaylistViewModel.self) private var playlistVM
  7. @EnvironmentObject private var libraryManager: LibraryManager
  8. @EnvironmentObject private var theme: AppTheme
  9. @Environment(\.modelContext) private var modelContext
  10. @Environment(\.openWindow) private var openWindow
  11. @State private var sidebarSelection: SidebarSection?
  12. @State private var showNewPlaylistSheet = false
  13. @State private var columnVisibility: NavigationSplitViewVisibility = .all
  14. @State private var hasRestoredState = false
  15. @State private var showGlobalSearch = false
  16. @State private var showInlineNowPlaying = false
  17. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  18. @Query(sort: \Playlist.dateModified, order: .reverse)
  19. private var playlists: [Playlist]
  20. @Query private var allTracks: [Track]
  21. /// The currently selected playlist, if any.
  22. private var selectedPlaylist: Playlist? {
  23. if case .playlist(let p) = sidebarSelection { return p }
  24. return nil
  25. }
  26. var body: some View {
  27. NavigationSplitView(columnVisibility: $columnVisibility) {
  28. SidebarView(
  29. selection: $sidebarSelection,
  30. showNewPlaylistSheet: $showNewPlaylistSheet
  31. )
  32. .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
  33. } detail: {
  34. VStack(spacing: 0) {
  35. MixTargetBar()
  36. // ── Central content area ──
  37. Group {
  38. if showInlineNowPlaying, playerVM.currentTrack != nil {
  39. NowPlayingView(displayMode: .inline)
  40. } else {
  41. switch sidebarSelection {
  42. case .library(let dest):
  43. CloudBrowserView(initialDestination: dest)
  44. .id(dest)
  45. case .queue:
  46. QueueView()
  47. case .downloads:
  48. DownloadsView()
  49. case .playlist(let playlist):
  50. PlaylistView(playlist: playlist)
  51. case nil:
  52. WelcomeView(onNewPlaylist: { showNewPlaylistSheet = true })
  53. }
  54. }
  55. }
  56. .frame(maxWidth: .infinity, maxHeight: .infinity)
  57. if let status = playlistVM.statusMessage {
  58. HStack(spacing: 6) {
  59. Image(systemName: "checkmark.circle.fill")
  60. .font(.system(size: 10))
  61. .foregroundStyle(.green)
  62. Text(status)
  63. .font(.system(size: 11))
  64. .foregroundStyle(.secondary)
  65. }
  66. .padding(.horizontal, 12)
  67. .padding(.vertical, 4)
  68. .background(.bar)
  69. .transition(.move(edge: .bottom).combined(with: .opacity))
  70. .animation(.easeInOut(duration: 0.3), value: playlistVM.statusMessage)
  71. }
  72. Divider()
  73. if theme.isDJBoard {
  74. DJPlayerView()
  75. } else {
  76. PlayerView()
  77. }
  78. }
  79. }
  80. .onAppear {
  81. libraryManager.setModelContext(modelContext)
  82. playerVM.modelContext = modelContext
  83. playlistVM.restoreTargetPlaylist(from: playlists)
  84. restoreLastState()
  85. }
  86. .task {
  87. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.year == nil || $0.year! < 1900 || $0.year! > 2100 })
  88. if let needsYear = try? modelContext.fetch(descriptor), !needsYear.isEmpty {
  89. print("Backfilling year for \(needsYear.count) tracks...")
  90. await libraryManager.rescanAllMetadata(tracks: needsYear)
  91. print("Year backfill complete.")
  92. }
  93. }
  94. .onChange(of: playlists) { _, _ in
  95. if !hasRestoredState {
  96. restoreLastState()
  97. }
  98. }
  99. .onChange(of: sidebarSelection) { _, newSelection in
  100. if case .playlist(let pl) = newSelection {
  101. AppState.saveLastPlaylist(id: pl.id)
  102. }
  103. }
  104. .sheet(isPresented: $showNewPlaylistSheet) {
  105. NewPlaylistSheet(sidebarSelection: $sidebarSelection)
  106. }
  107. .sheet(isPresented: $showGlobalSearch) {
  108. GlobalSearchSheet(playlists: playlists)
  109. }
  110. .onReceive(NotificationCenter.default.publisher(for: .newPlaylist)) { _ in
  111. showNewPlaylistSheet = true
  112. }
  113. .onReceive(NotificationCenter.default.publisher(for: .quickAddToTarget)) { notification in
  114. if let track = notification.object as? Track {
  115. _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
  116. }
  117. }
  118. .onReceive(NotificationCenter.default.publisher(for: .quickAddToMix)) { notification in
  119. guard let info = notification.userInfo,
  120. let slot = info["slot"] as? Int,
  121. let track = info["track"] as? Track else { return }
  122. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  123. }
  124. .onReceive(NotificationCenter.default.publisher(for: .globalSearch)) { _ in
  125. showGlobalSearch = true
  126. }
  127. .onReceive(NotificationCenter.default.publisher(for: .toggleNowPlaying)) { _ in
  128. if playerVM.currentTrack != nil {
  129. showInlineNowPlaying.toggle()
  130. }
  131. }
  132. .onReceive(NotificationCenter.default.publisher(for: .popOutNowPlaying)) { _ in
  133. showInlineNowPlaying = false
  134. openWindow(id: "now-playing")
  135. }
  136. .onReceive(NotificationCenter.default.publisher(for: .closeInlineNowPlaying)) { _ in
  137. showInlineNowPlaying = false
  138. }
  139. .onReceive(NotificationCenter.default.publisher(for: .toggleBrowsePanel)) { _ in
  140. if case .library = sidebarSelection {
  141. // Already in library → go back to nil (or could restore last playlist)
  142. sidebarSelection = nil
  143. } else {
  144. sidebarSelection = .library(.browse)
  145. }
  146. }
  147. }
  148. private func restoreLastState() {
  149. guard !hasRestoredState else { return }
  150. guard !playlists.isEmpty else { return }
  151. // Restore last selected playlist
  152. if let lastPlaylistID = AppState.lastPlaylistID,
  153. let lastPlaylist = playlists.first(where: { $0.id == lastPlaylistID }) {
  154. sidebarSelection = .playlist(lastPlaylist)
  155. hasRestoredState = true
  156. // Restore last playing entry in that playlist
  157. if let lastEntryID = AppState.lastEntryID,
  158. let entry = lastPlaylist.sortedEntries.first(where: { $0.id == lastEntryID }),
  159. let track = entry.track {
  160. // Load the track paused at the last position
  161. do {
  162. try playerVM.audioEngine.loadTrack(track)
  163. playerVM.currentPlayingEntryID = entry.id
  164. playerVM.currentPlaylist = lastPlaylist
  165. let lastTime = AppState.lastPlaybackTime
  166. if lastTime > 0 {
  167. playerVM.audioEngine.seek(to: lastTime)
  168. }
  169. // Sync state without playing
  170. playerVM.audioEngine.updateCurrentTime()
  171. playerVM.duration = playerVM.audioEngine.duration
  172. playerVM.currentTime = playerVM.audioEngine.currentTime
  173. playerVM.currentTrack = playerVM.audioEngine.currentTrack
  174. playerVM.loadWaveform(for: track)
  175. } catch {
  176. print("Failed to restore last track: \(error)")
  177. }
  178. }
  179. } else if let first = playlists.first {
  180. sidebarSelection = .playlist(first)
  181. hasRestoredState = true
  182. }
  183. }
  184. }
  185. // MARK: - Welcome View (no playlist selected)
  186. private struct WelcomeView: View {
  187. let onNewPlaylist: () -> Void
  188. var body: some View {
  189. VStack(spacing: 16) {
  190. Spacer()
  191. Image(systemName: "music.note.house")
  192. .font(.system(size: 64))
  193. .foregroundStyle(.tertiary)
  194. Text("Welcome to MixBoard")
  195. .font(.title2)
  196. .foregroundStyle(.secondary)
  197. Text("Create a playlist to get started")
  198. .foregroundStyle(.tertiary)
  199. Button("New Playlist") { onNewPlaylist() }
  200. Spacer()
  201. }
  202. .frame(maxWidth: .infinity, maxHeight: .infinity)
  203. }
  204. }
  205. // MARK: - New Playlist Sheet
  206. struct NewPlaylistSheet: View {
  207. @Binding var sidebarSelection: SidebarSection?
  208. @Environment(PlaylistViewModel.self) private var playlistVM
  209. @Environment(\.modelContext) private var modelContext
  210. @Environment(\.dismiss) private var dismiss
  211. @State private var playlistName = ""
  212. @FocusState private var isNameFieldFocused: Bool
  213. var body: some View {
  214. VStack(spacing: 20) {
  215. Text("New Playlist")
  216. .font(.headline)
  217. TextField("Playlist name", text: $playlistName)
  218. .textFieldStyle(.roundedBorder)
  219. .frame(width: 300)
  220. .accessibilityIdentifier("newPlaylistNameField")
  221. .focused($isNameFieldFocused)
  222. HStack {
  223. Button("Cancel") { dismiss() }
  224. .keyboardShortcut(.cancelAction)
  225. Button("Create") {
  226. guard !playlistName.isEmpty else { return }
  227. let pl = playlistVM.createPlaylist(name: playlistName, context: modelContext)
  228. sidebarSelection = .playlist(pl)
  229. playlistVM.selectedPlaylist = pl
  230. dismiss()
  231. }
  232. .keyboardShortcut(.defaultAction)
  233. .disabled(playlistName.isEmpty)
  234. }
  235. }
  236. .padding(30)
  237. .onAppear {
  238. // Ensure the text field gets focus when the sheet opens
  239. DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
  240. isNameFieldFocused = true
  241. }
  242. }
  243. }
  244. }
  245. // MARK: - Mix Target Buttons Bar
  246. /// Colored numbered buttons at the top of the detail area for quick-adding to mix slots.
  247. private struct MixTargetBar: View {
  248. @Environment(PlayerViewModel.self) private var playerVM
  249. @Environment(PlaylistViewModel.self) private var playlistVM
  250. @EnvironmentObject private var theme: AppTheme
  251. @Environment(\.modelContext) private var modelContext
  252. @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared
  253. private let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  254. var body: some View {
  255. HStack(spacing: 0) {
  256. ForEach(0..<3, id: \.self) { slot in
  257. let hasTarget = playlistVM.mixTargets[slot] != nil
  258. let color = mixTargetColors[slot]
  259. Button {
  260. guard let track = playerVM.currentTrack else {
  261. playlistVM.showStatus("No track playing")
  262. return
  263. }
  264. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  265. } label: {
  266. HStack(spacing: 6) {
  267. Text("\(slot + 1)")
  268. .font(.system(size: 11, weight: .bold, design: .rounded))
  269. .foregroundStyle(hasTarget ? color : theme.tertiaryText)
  270. Text(playlistVM.mixTargetName(slot))
  271. .font(.system(size: 11))
  272. .foregroundStyle(hasTarget ? theme.primaryText : theme.tertiaryText)
  273. .lineLimit(1)
  274. }
  275. .padding(.horizontal, 10)
  276. .padding(.vertical, 4)
  277. .frame(maxWidth: .infinity)
  278. .background(hasTarget ? color.opacity(0.08) : Color.clear)
  279. .contentShape(Rectangle())
  280. }
  281. .buttonStyle(.plain)
  282. .help(hasTarget
  283. ? "Add to \(playlistVM.mixTargetName(slot)) (\(shortcutConfig.binding(for: mixActions[slot]).displayString))"
  284. : "Mix \(slot + 1) not set — configure in Settings")
  285. if slot < 2 {
  286. Divider().frame(height: 20)
  287. }
  288. }
  289. }
  290. .frame(height: 28)
  291. .background(theme.toolbarBackground.opacity(0.5))
  292. .overlay(alignment: .bottom) {
  293. Divider()
  294. }
  295. }
  296. }