ContentView.swift 12 KB

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