ContentView.swift 13 KB

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