MixBoardApp.swift 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import SwiftData
  2. import SwiftUI
  3. /// MixBoard — A macOS music player and mix preparation tool with DAW export.
  4. @main
  5. struct MixBoardApp: App {
  6. @NSApplicationDelegateAdaptor(MixBoardAppDelegate.self) var appDelegate
  7. @State private var playerVM = PlayerViewModel()
  8. @State private var playlistVM = PlaylistViewModel()
  9. @StateObject private var libraryManager = LibraryManager()
  10. @StateObject private var theme = AppTheme()
  11. @StateObject private var syncWatcher = SyncWatcher()
  12. @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared
  13. var body: some Scene {
  14. WindowGroup {
  15. ContentView()
  16. .environment(playerVM)
  17. .environment(playlistVM)
  18. .environmentObject(libraryManager)
  19. .environmentObject(theme)
  20. .environmentObject(syncWatcher)
  21. .preferredColorScheme(theme.preferredScheme)
  22. .onAppear {
  23. // Faster tooltips (200ms instead of default ~1000ms)
  24. UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay")
  25. MediaKeyHandler.shared.register(playerVM: playerVM)
  26. syncWatcher.createSyncFolders()
  27. syncWatcher.startWatching()
  28. AppIconConfig.shared.applyIcon()
  29. // Start managed slskd if configured
  30. if SlskdAPIClient.shared.serverMode == .managed {
  31. Task {
  32. try? await SlskdProcessManager.shared.start()
  33. }
  34. }
  35. }
  36. }
  37. .modelContainer(for: [Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self])
  38. .windowStyle(.titleBar)
  39. .windowToolbarStyle(.unified(showsTitle: true))
  40. .defaultSize(width: 1200, height: 800)
  41. .commands {
  42. CommandGroup(replacing: .newItem) {
  43. Button("New Playlist...") {
  44. NotificationCenter.default.post(name: .newPlaylist, object: nil)
  45. }
  46. .keyboardShortcut(
  47. shortcutConfig.binding(for: .newPlaylist).keyEquivalent,
  48. modifiers: shortcutConfig.binding(for: .newPlaylist).eventModifiers
  49. )
  50. }
  51. CommandMenu("View") {
  52. Button("Now Playing") {
  53. NotificationCenter.default.post(name: .toggleNowPlaying, object: nil)
  54. }
  55. .keyboardShortcut(
  56. shortcutConfig.binding(for: .nowPlaying).keyEquivalent,
  57. modifiers: shortcutConfig.binding(for: .nowPlaying).eventModifiers
  58. )
  59. Divider()
  60. Button("Library") {
  61. NotificationCenter.default.post(name: .toggleBrowsePanel, object: nil)
  62. }
  63. .keyboardShortcut("b", modifiers: .command)
  64. }
  65. CommandMenu("Playback") {
  66. Button("Play / Pause") {
  67. playerVM.togglePlayPause()
  68. }
  69. .keyboardShortcut(
  70. shortcutConfig.binding(for: .playPause).keyEquivalent,
  71. modifiers: shortcutConfig.binding(for: .playPause).eventModifiers
  72. )
  73. Button("Stop") {
  74. playerVM.stop()
  75. }
  76. .keyboardShortcut(
  77. shortcutConfig.binding(for: .stop).keyEquivalent,
  78. modifiers: shortcutConfig.binding(for: .stop).eventModifiers
  79. )
  80. Divider()
  81. Button("Next Track") {
  82. playerVM.playNext()
  83. }
  84. .keyboardShortcut(
  85. shortcutConfig.binding(for: .nextTrack).keyEquivalent,
  86. modifiers: shortcutConfig.binding(for: .nextTrack).eventModifiers
  87. )
  88. Button("Previous Track") {
  89. playerVM.playPrevious()
  90. }
  91. .keyboardShortcut(
  92. shortcutConfig.binding(for: .previousTrack).keyEquivalent,
  93. modifiers: shortcutConfig.binding(for: .previousTrack).eventModifiers
  94. )
  95. Divider()
  96. Button("Skip Forward 10s") {
  97. playerVM.skipForward()
  98. }
  99. .keyboardShortcut(
  100. shortcutConfig.binding(for: .skipForward).keyEquivalent,
  101. modifiers: shortcutConfig.binding(for: .skipForward).eventModifiers
  102. )
  103. Button("Skip Backward 10s") {
  104. playerVM.skipBackward()
  105. }
  106. .keyboardShortcut(
  107. shortcutConfig.binding(for: .skipBackward).keyEquivalent,
  108. modifiers: shortcutConfig.binding(for: .skipBackward).eventModifiers
  109. )
  110. Divider()
  111. Button(playerVM.shuffleEnabled ? "Shuffle: On" : "Shuffle: Off") {
  112. playerVM.shuffleEnabled.toggle()
  113. }
  114. .keyboardShortcut(
  115. shortcutConfig.binding(for: .toggleShuffle).keyEquivalent,
  116. modifiers: shortcutConfig.binding(for: .toggleShuffle).eventModifiers
  117. )
  118. Button("Repeat: \(playerVM.repeatMode.rawValue)") {
  119. switch playerVM.repeatMode {
  120. case .off: playerVM.repeatMode = .all
  121. case .all: playerVM.repeatMode = .one
  122. case .one: playerVM.repeatMode = .off
  123. }
  124. }
  125. .keyboardShortcut(
  126. shortcutConfig.binding(for: .toggleRepeat).keyEquivalent,
  127. modifiers: shortcutConfig.binding(for: .toggleRepeat).eventModifiers
  128. )
  129. }
  130. CommandMenu("Mix") {
  131. // Per-slot quick-add
  132. let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  133. ForEach(0..<3, id: \.self) { slot in
  134. Button("Add to \(playlistVM.mixTargetName(slot))") {
  135. if let track = playerVM.currentTrack {
  136. NotificationCenter.default.post(
  137. name: .quickAddToMix,
  138. object: nil,
  139. userInfo: ["slot": slot, "track": track]
  140. )
  141. }
  142. }
  143. .keyboardShortcut(
  144. shortcutConfig.binding(for: mixActions[slot]).keyEquivalent,
  145. modifiers: shortcutConfig.binding(for: mixActions[slot]).eventModifiers
  146. )
  147. }
  148. Divider()
  149. Button("Export to DAW...") {
  150. playlistVM.showExportSheet = true
  151. }
  152. .keyboardShortcut(
  153. shortcutConfig.binding(for: .exportToDAW).keyEquivalent,
  154. modifiers: shortcutConfig.binding(for: .exportToDAW).eventModifiers
  155. )
  156. Divider()
  157. Button("Import from iPhone...") {
  158. NotificationCenter.default.post(name: .importFromiPhone, object: nil)
  159. }
  160. .keyboardShortcut(
  161. shortcutConfig.binding(for: .importFromiPhone).keyEquivalent,
  162. modifiers: shortcutConfig.binding(for: .importFromiPhone).eventModifiers
  163. )
  164. Divider()
  165. Button("Search All Playlists...") {
  166. NotificationCenter.default.post(name: .globalSearch, object: nil)
  167. }
  168. .keyboardShortcut(
  169. shortcutConfig.binding(for: .search).keyEquivalent,
  170. modifiers: shortcutConfig.binding(for: .search).eventModifiers
  171. )
  172. }
  173. }
  174. // Settings (⌘,)
  175. Settings {
  176. SettingsView()
  177. .environment(playlistVM)
  178. .environmentObject(theme)
  179. .preferredColorScheme(theme.preferredScheme)
  180. }
  181. .modelContainer(for: [Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self])
  182. // Now Playing window (Tidal-style separate window)
  183. Window("Now Playing", id: "now-playing") {
  184. NowPlayingView(displayMode: .floating)
  185. .environment(playerVM)
  186. .environment(playlistVM)
  187. .environmentObject(libraryManager)
  188. .environmentObject(theme)
  189. .preferredColorScheme(theme.preferredScheme)
  190. }
  191. .defaultSize(width: 850, height: 600)
  192. .windowStyle(.titleBar)
  193. }
  194. }
  195. // MARK: - Notification Names
  196. extension Notification.Name {
  197. static let newPlaylist = Notification.Name("newPlaylist")
  198. static let quickAddToTarget = Notification.Name("quickAddToTarget")
  199. static let quickAddToMix = Notification.Name("quickAddToMix")
  200. static let globalSearch = Notification.Name("globalSearch")
  201. static let importFromiPhone = Notification.Name("importFromiPhone")
  202. static let toggleNowPlaying = Notification.Name("toggleNowPlaying")
  203. static let toggleBrowsePanel = Notification.Name("toggleBrowsePanel")
  204. static let popOutNowPlaying = Notification.Name("popOutNowPlaying")
  205. static let closeInlineNowPlaying = Notification.Name("closeInlineNowPlaying")
  206. static let doubleClickPlayTrack = Notification.Name("doubleClickPlayTrack")
  207. }
  208. // MARK: - App Delegate
  209. /// Handles app lifecycle events that SwiftUI doesn't expose (e.g., termination).
  210. class MixBoardAppDelegate: NSObject, NSApplicationDelegate {
  211. func applicationWillTerminate(_ notification: Notification) {
  212. // Stop managed slskd on app quit to avoid orphaned processes
  213. SlskdProcessManager.shared.stop()
  214. }
  215. }