ContentView.swift 13 KB

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