SettingsView.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import SwiftData
  2. import SwiftUI
  3. /// Full settings window using macOS native TabView.
  4. /// Contains: Mix Targets, Appearance (themes), Playlist (columns, artwork, grouping), Playback, General.
  5. struct SettingsView: View {
  6. var body: some View {
  7. TabView {
  8. MixTargetSettings()
  9. .tabItem {
  10. Label("Mix Targets", systemImage: "target")
  11. }
  12. ChadMusicSettings()
  13. .tabItem {
  14. Label("Chad Music", systemImage: "cloud.fill")
  15. }
  16. AppearanceSettings()
  17. .tabItem {
  18. Label("Appearance", systemImage: "paintbrush")
  19. }
  20. PlaylistSettings()
  21. .tabItem {
  22. Label("Playlist", systemImage: "list.bullet")
  23. }
  24. PlaybackSettings()
  25. .tabItem {
  26. Label("Playback", systemImage: "play.circle")
  27. }
  28. KeyboardShortcutSettings()
  29. .tabItem {
  30. Label("Shortcuts", systemImage: "keyboard")
  31. }
  32. GeneralSettings()
  33. .tabItem {
  34. Label("General", systemImage: "gearshape")
  35. }
  36. }
  37. .frame(width: 580, height: 500)
  38. }
  39. }
  40. // MARK: - Mix Colors (shared across views)
  41. /// The 3 mix target colors: Red, Blue, Gold.
  42. let mixTargetColors: [Color] = [
  43. Color(red: 0.95, green: 0.3, blue: 0.3), // Red
  44. Color(red: 0.3, green: 0.75, blue: 0.95), // Blue
  45. Color(red: 0.95, green: 0.75, blue: 0.2), // Yellow/Gold
  46. ]
  47. // MARK: - Mix Target Settings
  48. private struct MixTargetSettings: View {
  49. @Environment(PlaylistViewModel.self) private var playlistVM
  50. @EnvironmentObject private var theme: AppTheme
  51. @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared
  52. @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
  53. private let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  54. var body: some View {
  55. VStack(alignment: .leading, spacing: 20) {
  56. Text("Mix Targets")
  57. .font(.title3.bold())
  58. Text("Assign playlists to the 3 mix slots. Use the configured shortcuts to quick-add the current track, or click the numbered buttons at the top of the window.")
  59. .font(.callout)
  60. .foregroundStyle(.secondary)
  61. VStack(spacing: 12) {
  62. ForEach(0..<3, id: \.self) { slot in
  63. HStack(spacing: 12) {
  64. // Colored number badge
  65. Text("\(slot + 1)")
  66. .font(.system(size: 14, weight: .bold, design: .rounded))
  67. .frame(width: 30, height: 30)
  68. .foregroundStyle(mixTargetColors[slot])
  69. .background(mixTargetColors[slot].opacity(0.15))
  70. .clipShape(RoundedRectangle(cornerRadius: 6))
  71. // Playlist picker — Picker with menu style for full-width clickability
  72. Picker(selection: Binding(
  73. get: { playlistVM.mixTargets[slot]?.id },
  74. set: { newID in
  75. if let newID, let playlist = playlists.first(where: { $0.id == newID }) {
  76. playlistVM.setMixTarget(slot, playlist: playlist)
  77. } else {
  78. playlistVM.setMixTarget(slot, playlist: nil)
  79. }
  80. }
  81. )) {
  82. Text("Not set").tag(UUID?.none)
  83. Divider()
  84. ForEach(playlists) { playlist in
  85. Text(playlist.name).tag(Optional(playlist.id))
  86. }
  87. } label: {
  88. EmptyView()
  89. }
  90. .labelsHidden()
  91. .frame(minWidth: 160)
  92. // Shortcut hint
  93. Text(shortcutConfig.binding(for: mixActions[slot]).displayString)
  94. .font(.system(size: 11, design: .monospaced))
  95. .foregroundStyle(.secondary)
  96. .frame(width: 50)
  97. }
  98. }
  99. }
  100. Divider()
  101. Text("Tip: You can also right-click a playlist in the sidebar to assign it to a mix slot.")
  102. .font(.callout)
  103. .foregroundStyle(.secondary)
  104. Spacer()
  105. }
  106. .padding(24)
  107. }
  108. }
  109. // MARK: - Appearance Settings (Theme/Skin picker)
  110. private struct AppearanceSettings: View {
  111. @EnvironmentObject private var theme: AppTheme
  112. @ObservedObject private var iconConfig = AppIconConfig.shared
  113. private let modernSkins: [AppTheme.Skin] = [.dark, .midnight, .forest, .ocean, .warm, .light]
  114. private let retroSkins: [AppTheme.Skin] = [.winampClassic, .winampModern, .foobarDark, .foobarLight, .win95, .win98, .xpLuna, .macOSClassic]
  115. var body: some View {
  116. ScrollView {
  117. VStack(alignment: .leading, spacing: 20) {
  118. // MARK: - App Icon Color
  119. Text("App Icon")
  120. .font(.title3.bold())
  121. Text("Choose an accent color for the Dock icon.")
  122. .font(.callout)
  123. .foregroundStyle(.secondary)
  124. ScrollView(.horizontal, showsIndicators: false) {
  125. HStack(spacing: 12) {
  126. ForEach(AppIconConfig.iconColors) { option in
  127. Button {
  128. iconConfig.selectedColorName = option.name
  129. } label: {
  130. VStack(spacing: 6) {
  131. RoundedRectangle(cornerRadius: 12)
  132. .fill(option.color)
  133. .frame(width: 50, height: 50)
  134. .overlay(
  135. RoundedRectangle(cornerRadius: 12)
  136. .stroke(
  137. iconConfig.selectedColorName == option.name
  138. ? Color.accentColor : Color.white.opacity(0.2),
  139. lineWidth: iconConfig.selectedColorName == option.name ? 2.5 : 1
  140. )
  141. )
  142. .overlay {
  143. if iconConfig.selectedColorName == option.name {
  144. Image(systemName: "checkmark")
  145. .font(.system(size: 16, weight: .bold))
  146. .foregroundStyle(.white)
  147. .shadow(radius: 2)
  148. }
  149. }
  150. Text(option.name)
  151. .font(.caption2)
  152. .foregroundStyle(.secondary)
  153. }
  154. }
  155. .buttonStyle(.plain)
  156. }
  157. }
  158. .padding(.vertical, 4)
  159. }
  160. Divider()
  161. // MARK: - Theme
  162. Text("Theme")
  163. .font(.title3.bold())
  164. Text("Choose a visual theme for MixBoard. Each theme enforces its own light/dark appearance.")
  165. .font(.callout)
  166. .foregroundStyle(.secondary)
  167. // Modern
  168. VStack(alignment: .leading, spacing: 8) {
  169. Text("Modern")
  170. .font(.headline)
  171. .foregroundStyle(.secondary)
  172. LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) {
  173. ForEach(modernSkins) { skin in
  174. SkinCard(skin: skin, isSelected: theme.currentSkin == skin) {
  175. withAnimation(.easeInOut(duration: 0.2)) {
  176. theme.currentSkin = skin
  177. }
  178. }
  179. }
  180. }
  181. }
  182. Divider()
  183. // Retro
  184. VStack(alignment: .leading, spacing: 8) {
  185. Text("Retro")
  186. .font(.headline)
  187. .foregroundStyle(.secondary)
  188. LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) {
  189. ForEach(retroSkins) { skin in
  190. SkinCard(skin: skin, isSelected: theme.currentSkin == skin) {
  191. withAnimation(.easeInOut(duration: 0.2)) {
  192. theme.currentSkin = skin
  193. }
  194. }
  195. }
  196. }
  197. }
  198. }
  199. .padding(24)
  200. }
  201. }
  202. }
  203. /// A compact card showing a skin preview swatch and name.
  204. private struct SkinCard: View {
  205. let skin: AppTheme.Skin
  206. let isSelected: Bool
  207. let action: () -> Void
  208. var body: some View {
  209. Button(action: action) {
  210. HStack(spacing: 10) {
  211. // Color swatch showing the skin's approximate palette
  212. RoundedRectangle(cornerRadius: 4)
  213. .fill(skin.previewColor)
  214. .frame(width: 28, height: 28)
  215. .overlay {
  216. if skin.colorScheme == .dark {
  217. Image(systemName: "moon.fill")
  218. .font(.system(size: 10))
  219. .foregroundStyle(.white.opacity(0.7))
  220. } else {
  221. Image(systemName: "sun.max.fill")
  222. .font(.system(size: 10))
  223. .foregroundStyle(.black.opacity(0.5))
  224. }
  225. }
  226. VStack(alignment: .leading, spacing: 1) {
  227. Text(skin.rawValue)
  228. .font(.system(size: 12, weight: .medium))
  229. .lineLimit(1)
  230. Text(skin.colorScheme == .dark ? "Dark" : "Light")
  231. .font(.system(size: 10))
  232. .foregroundStyle(.secondary)
  233. }
  234. Spacer(minLength: 0)
  235. if isSelected {
  236. Image(systemName: "checkmark.circle.fill")
  237. .foregroundStyle(.green)
  238. .font(.system(size: 14))
  239. }
  240. }
  241. .padding(8)
  242. .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
  243. .overlay(
  244. RoundedRectangle(cornerRadius: 8)
  245. .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
  246. )
  247. .clipShape(RoundedRectangle(cornerRadius: 8))
  248. .contentShape(Rectangle())
  249. }
  250. .buttonStyle(.plain)
  251. }
  252. }
  253. // MARK: - Playlist Settings (Columns, Artwork, Grouping)
  254. private struct PlaylistSettings: View {
  255. @ObservedObject private var viewConfig = PlaylistViewConfig.shared
  256. var body: some View {
  257. ScrollView {
  258. VStack(alignment: .leading, spacing: 20) {
  259. // Visible columns
  260. VStack(alignment: .leading, spacing: 8) {
  261. HStack {
  262. Text("Visible Columns")
  263. .font(.title3.bold())
  264. Spacer()
  265. Button("Reset to Defaults") {
  266. viewConfig.resetToDefaults()
  267. }
  268. .font(.caption)
  269. }
  270. Text("Select which columns appear in the playlist view.")
  271. .font(.callout)
  272. .foregroundStyle(.secondary)
  273. LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], spacing: 6) {
  274. ForEach(PlaylistViewConfig.Column.allCases) { column in
  275. Toggle(column.rawValue, isOn: Binding(
  276. get: { viewConfig.isColumnVisible(column) },
  277. set: { _ in viewConfig.toggleColumn(column) }
  278. ))
  279. .toggleStyle(.checkbox)
  280. .font(.system(size: 12))
  281. }
  282. }
  283. }
  284. Divider()
  285. // Artwork
  286. VStack(alignment: .leading, spacing: 8) {
  287. Text("Artwork")
  288. .font(.headline)
  289. Toggle("Show artwork thumbnails in playlist rows", isOn: $viewConfig.showArtwork)
  290. if viewConfig.showArtwork {
  291. Picker("Artwork size", selection: $viewConfig.artworkSize) {
  292. ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
  293. Text(size.rawValue).tag(size)
  294. }
  295. }
  296. .pickerStyle(.segmented)
  297. .frame(width: 250)
  298. }
  299. }
  300. Divider()
  301. }
  302. .padding(24)
  303. }
  304. }
  305. }
  306. // MARK: - Playback Settings
  307. private struct PlaybackSettings: View {
  308. @ObservedObject private var viewConfig = PlaylistViewConfig.shared
  309. var body: some View {
  310. VStack(alignment: .leading, spacing: 20) {
  311. Text("Cursor Behavior")
  312. .font(.title3.bold())
  313. VStack(alignment: .leading, spacing: 12) {
  314. Toggle("Cursor follows playback", isOn: Binding(
  315. get: { viewConfig.cursorFollowsPlayback },
  316. set: { newValue in
  317. if newValue {
  318. viewConfig.cursorFollowsPlayback = true
  319. viewConfig.playbackFollowsCursor = false
  320. } else {
  321. viewConfig.cursorFollowsPlayback = false
  322. viewConfig.playbackFollowsCursor = true
  323. }
  324. }
  325. ))
  326. Text("Auto-select and scroll to the currently playing track.")
  327. .font(.callout)
  328. .foregroundStyle(.secondary)
  329. .padding(.leading, 20)
  330. Toggle("Playback follows cursor", isOn: Binding(
  331. get: { viewConfig.playbackFollowsCursor },
  332. set: { newValue in
  333. if newValue {
  334. viewConfig.playbackFollowsCursor = true
  335. viewConfig.cursorFollowsPlayback = false
  336. } else {
  337. viewConfig.playbackFollowsCursor = false
  338. viewConfig.cursorFollowsPlayback = true
  339. }
  340. }
  341. ))
  342. Text("When a track finishes, play the currently selected track next (foobar2000 behavior).")
  343. .font(.callout)
  344. .foregroundStyle(.secondary)
  345. .padding(.leading, 20)
  346. }
  347. Spacer()
  348. }
  349. .padding(24)
  350. }
  351. }
  352. // MARK: - General Settings
  353. private struct GeneralSettings: View {
  354. @AppStorage("autoAnalyzeOnImport") private var autoAnalyzeOnImport = true
  355. var body: some View {
  356. VStack(alignment: .leading, spacing: 20) {
  357. Text("General")
  358. .font(.title3.bold())
  359. VStack(alignment: .leading, spacing: 12) {
  360. Toggle("Auto-analyze BPM & Key on import", isOn: $autoAnalyzeOnImport)
  361. Text("Automatically detect BPM and musical key when adding tracks.")
  362. .font(.callout)
  363. .foregroundStyle(.secondary)
  364. .padding(.leading, 20)
  365. }
  366. Spacer()
  367. }
  368. .padding(24)
  369. }
  370. }
  371. // MARK: - Keyboard Shortcut Settings
  372. private struct KeyboardShortcutSettings: View {
  373. @ObservedObject private var config = KeyboardShortcutConfig.shared
  374. @State private var recordingAction: ShortcutAction?
  375. @State private var keyMonitor: Any?
  376. var body: some View {
  377. ScrollView {
  378. VStack(alignment: .leading, spacing: 20) {
  379. HStack {
  380. Text("Keyboard Shortcuts")
  381. .font(.title3.bold())
  382. Spacer()
  383. Button("Reset to Defaults") {
  384. config.resetToDefaults()
  385. }
  386. .font(.caption)
  387. }
  388. Text("Click a shortcut to re-assign it. Press Escape to cancel.")
  389. .font(.callout)
  390. .foregroundStyle(.secondary)
  391. ForEach(ShortcutGroup.allCases, id: \.rawValue) { group in
  392. VStack(alignment: .leading, spacing: 6) {
  393. Text(group.rawValue)
  394. .font(.headline)
  395. .foregroundStyle(.secondary)
  396. ForEach(group.actions) { action in
  397. ShortcutRow(
  398. action: action,
  399. binding: config.binding(for: action),
  400. isRecording: recordingAction == action,
  401. onStartRecording: { startRecording(for: action) }
  402. )
  403. }
  404. }
  405. if group != .general {
  406. Divider()
  407. }
  408. }
  409. }
  410. .padding(24)
  411. }
  412. .onDisappear {
  413. stopRecording()
  414. }
  415. }
  416. private func startRecording(for action: ShortcutAction) {
  417. // Stop any existing recording
  418. stopRecording()
  419. recordingAction = action
  420. keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
  421. // Escape cancels
  422. if event.keyCode == 53 {
  423. self.stopRecording()
  424. return nil
  425. }
  426. let binding = ShortcutBinding.from(event)
  427. self.config.shortcuts[action] = binding
  428. self.stopRecording()
  429. return nil // consume the event
  430. }
  431. }
  432. private func stopRecording() {
  433. recordingAction = nil
  434. if let monitor = keyMonitor {
  435. NSEvent.removeMonitor(monitor)
  436. keyMonitor = nil
  437. }
  438. }
  439. }
  440. /// A single row: action name + clickable shortcut recorder button.
  441. private struct ShortcutRow: View {
  442. let action: ShortcutAction
  443. let binding: ShortcutBinding
  444. let isRecording: Bool
  445. let onStartRecording: () -> Void
  446. var body: some View {
  447. HStack {
  448. Text(action.rawValue)
  449. .font(.system(size: 12))
  450. .frame(maxWidth: .infinity, alignment: .leading)
  451. Button {
  452. onStartRecording()
  453. } label: {
  454. Text(isRecording ? "Press shortcut…" : binding.displayString)
  455. .font(.system(size: 12, design: isRecording ? .default : .monospaced))
  456. .frame(minWidth: 90)
  457. .padding(.horizontal, 8)
  458. .padding(.vertical, 4)
  459. .background(isRecording ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05))
  460. .clipShape(RoundedRectangle(cornerRadius: 6))
  461. .overlay(
  462. RoundedRectangle(cornerRadius: 6)
  463. .stroke(isRecording ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1)
  464. )
  465. }
  466. .buttonStyle(.plain)
  467. .animation(.easeInOut(duration: 0.15), value: isRecording)
  468. }
  469. .padding(.vertical, 2)
  470. }
  471. }
  472. // MARK: - Chad Music Settings
  473. private struct ChadMusicSettings: View {
  474. @AppStorage("chadMusic.serverURL") private var serverURL: String = ""
  475. @AppStorage("chadMusic.apiKey") private var apiKey: String = ""
  476. @State private var connectionStatus: ConnectionStatus = .unknown
  477. @State private var isTesting = false
  478. @State private var statsText: String = ""
  479. private enum ConnectionStatus {
  480. case unknown, testing, success, failed(String)
  481. }
  482. var body: some View {
  483. VStack(alignment: .leading, spacing: 20) {
  484. Text("Chad Music")
  485. .font(.title3.bold())
  486. Text("Connect to your Chad Music server to browse and stream your cloud library.")
  487. .font(.callout)
  488. .foregroundStyle(.secondary)
  489. // Server URL
  490. VStack(alignment: .leading, spacing: 6) {
  491. Text("Server URL")
  492. .font(.headline)
  493. TextField("https://music.tailnet.ts.net", text: $serverURL)
  494. .textFieldStyle(.roundedBorder)
  495. .onChange(of: serverURL) { _, _ in
  496. connectionStatus = .unknown
  497. }
  498. }
  499. // API Key
  500. VStack(alignment: .leading, spacing: 6) {
  501. Text("API Key")
  502. .font(.headline)
  503. SecureField("Enter API key", text: $apiKey)
  504. .textFieldStyle(.roundedBorder)
  505. .onChange(of: apiKey) { _, _ in
  506. connectionStatus = .unknown
  507. }
  508. }
  509. Divider()
  510. // Connection test
  511. HStack(spacing: 12) {
  512. Button("Test Connection") {
  513. testConnection()
  514. }
  515. .disabled(serverURL.isEmpty || apiKey.isEmpty || isTesting)
  516. switch connectionStatus {
  517. case .unknown:
  518. EmptyView()
  519. case .testing:
  520. ProgressView()
  521. .controlSize(.small)
  522. Text("Connecting...")
  523. .font(.callout)
  524. .foregroundStyle(.secondary)
  525. case .success:
  526. Image(systemName: "checkmark.circle.fill")
  527. .foregroundStyle(.green)
  528. Text(statsText)
  529. .font(.callout)
  530. .foregroundStyle(.secondary)
  531. case .failed(let message):
  532. Image(systemName: "xmark.circle.fill")
  533. .foregroundStyle(.red)
  534. Text(message)
  535. .font(.callout)
  536. .foregroundStyle(.red)
  537. }
  538. }
  539. Spacer()
  540. }
  541. .padding(24)
  542. }
  543. private func testConnection() {
  544. connectionStatus = .testing
  545. isTesting = true
  546. Task {
  547. let client = ChadMusicAPIClient.shared
  548. let result = await client.testConnection()
  549. switch result {
  550. case .success(let stats):
  551. let parts = [
  552. stats.tracks.map { "\($0) tracks" },
  553. stats.albums.map { "\($0) albums" },
  554. stats.artists.map { "\($0) artists" },
  555. ].compactMap { $0 }
  556. statsText = "Connected — " + parts.joined(separator: ", ")
  557. connectionStatus = .success
  558. case .failure(let error):
  559. connectionStatus = .failed(error.localizedDescription)
  560. }
  561. isTesting = false
  562. }
  563. }
  564. }
  565. // MARK: - Skin Preview Color
  566. extension AppTheme.Skin {
  567. /// Approximate preview swatch color for the skin card.
  568. var previewColor: Color {
  569. switch self {
  570. case .dark: return Color(red: 0.15, green: 0.15, blue: 0.17)
  571. case .midnight: return Color(red: 0.1, green: 0.1, blue: 0.2)
  572. case .forest: return Color(red: 0.1, green: 0.18, blue: 0.1)
  573. case .ocean: return Color(red: 0.1, green: 0.15, blue: 0.2)
  574. case .warm: return Color(red: 0.2, green: 0.15, blue: 0.1)
  575. case .light: return Color(red: 0.95, green: 0.95, blue: 0.96)
  576. case .winampClassic: return Color(red: 0.12, green: 0.12, blue: 0.14)
  577. case .winampModern: return Color(red: 0.13, green: 0.14, blue: 0.18)
  578. case .foobarDark: return Color(red: 0.14, green: 0.14, blue: 0.14)
  579. case .foobarLight: return Color(red: 0.94, green: 0.94, blue: 0.94)
  580. case .win95: return Color(red: 0.75, green: 0.75, blue: 0.75)
  581. case .win98: return Color(red: 0.83, green: 0.82, blue: 0.78)
  582. case .xpLuna: return Color(red: 0.85, green: 0.89, blue: 0.95)
  583. case .macOSClassic: return Color(red: 0.86, green: 0.86, blue: 0.86)
  584. }
  585. }
  586. }