SettingsView.swift 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005
  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. SlskdSettings()
  17. .tabItem {
  18. Label("Soulseek", systemImage: "arrow.down.circle.fill")
  19. }
  20. AppearanceSettings()
  21. .tabItem {
  22. Label("Appearance", systemImage: "paintbrush")
  23. }
  24. PlaylistSettings()
  25. .tabItem {
  26. Label("Playlist", systemImage: "list.bullet")
  27. }
  28. PlaybackSettings()
  29. .tabItem {
  30. Label("Playback", systemImage: "play.circle")
  31. }
  32. KeyboardShortcutSettings()
  33. .tabItem {
  34. Label("Shortcuts", systemImage: "keyboard")
  35. }
  36. GeneralSettings()
  37. .tabItem {
  38. Label("General", systemImage: "gearshape")
  39. }
  40. }
  41. .frame(width: 580, height: 500)
  42. }
  43. }
  44. // MARK: - Mix Colors (shared across views)
  45. /// The 3 mix target colors: Red, Blue, Gold.
  46. let mixTargetColors: [Color] = [
  47. Color(red: 0.95, green: 0.3, blue: 0.3), // Red
  48. Color(red: 0.3, green: 0.75, blue: 0.95), // Blue
  49. Color(red: 0.95, green: 0.75, blue: 0.2), // Yellow/Gold
  50. ]
  51. // MARK: - Mix Target Settings
  52. private struct MixTargetSettings: View {
  53. @Environment(PlaylistViewModel.self) private var playlistVM
  54. @EnvironmentObject private var theme: AppTheme
  55. @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared
  56. @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
  57. private let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  58. var body: some View {
  59. VStack(alignment: .leading, spacing: 20) {
  60. Text("Mix Targets")
  61. .font(.title3.bold())
  62. 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.")
  63. .font(.callout)
  64. .foregroundStyle(.secondary)
  65. VStack(spacing: 12) {
  66. ForEach(0..<3, id: \.self) { slot in
  67. HStack(spacing: 12) {
  68. // Colored number badge
  69. Text("\(slot + 1)")
  70. .font(.system(size: 14, weight: .bold, design: .rounded))
  71. .frame(width: 30, height: 30)
  72. .foregroundStyle(mixTargetColors[slot])
  73. .background(mixTargetColors[slot].opacity(0.15))
  74. .clipShape(RoundedRectangle(cornerRadius: 6))
  75. // Playlist picker — Picker with menu style for full-width clickability
  76. Picker(selection: Binding(
  77. get: { playlistVM.mixTargets[slot]?.id },
  78. set: { newID in
  79. if let newID, let playlist = playlists.first(where: { $0.id == newID }) {
  80. playlistVM.setMixTarget(slot, playlist: playlist)
  81. } else {
  82. playlistVM.setMixTarget(slot, playlist: nil)
  83. }
  84. }
  85. )) {
  86. Text("Not set").tag(UUID?.none)
  87. Divider()
  88. ForEach(playlists) { playlist in
  89. Text(playlist.name).tag(Optional(playlist.id))
  90. }
  91. } label: {
  92. EmptyView()
  93. }
  94. .labelsHidden()
  95. .frame(minWidth: 160)
  96. // Shortcut hint
  97. Text(shortcutConfig.binding(for: mixActions[slot]).displayString)
  98. .font(.system(size: 11, design: .monospaced))
  99. .foregroundStyle(.secondary)
  100. .frame(width: 50)
  101. }
  102. }
  103. }
  104. Divider()
  105. Text("Tip: You can also right-click a playlist in the sidebar to assign it to a mix slot.")
  106. .font(.callout)
  107. .foregroundStyle(.secondary)
  108. Spacer()
  109. }
  110. .padding(24)
  111. }
  112. }
  113. // MARK: - Appearance Settings (Theme/Skin picker)
  114. private struct AppearanceSettings: View {
  115. @EnvironmentObject private var theme: AppTheme
  116. @ObservedObject private var iconConfig = AppIconConfig.shared
  117. private let modernSkins: [AppTheme.Skin] = [.dark, .midnight, .forest, .ocean, .warm, .light, .djBoard]
  118. private let retroSkins: [AppTheme.Skin] = [.winampClassic, .winampModern, .foobarDark, .foobarLight, .win95, .win98, .xpLuna, .macOSClassic]
  119. var body: some View {
  120. ScrollView {
  121. VStack(alignment: .leading, spacing: 20) {
  122. // MARK: - App Icon Color
  123. Text("App Icon")
  124. .font(.title3.bold())
  125. Text("Choose an accent color for the Dock icon.")
  126. .font(.callout)
  127. .foregroundStyle(.secondary)
  128. ScrollView(.horizontal, showsIndicators: false) {
  129. HStack(spacing: 12) {
  130. ForEach(AppIconConfig.iconColors) { option in
  131. Button {
  132. iconConfig.selectedColorName = option.name
  133. } label: {
  134. VStack(spacing: 6) {
  135. RoundedRectangle(cornerRadius: 12)
  136. .fill(option.color)
  137. .frame(width: 50, height: 50)
  138. .overlay(
  139. RoundedRectangle(cornerRadius: 12)
  140. .stroke(
  141. iconConfig.selectedColorName == option.name
  142. ? Color.accentColor : Color.white.opacity(0.2),
  143. lineWidth: iconConfig.selectedColorName == option.name ? 2.5 : 1
  144. )
  145. )
  146. .overlay {
  147. if iconConfig.selectedColorName == option.name {
  148. Image(systemName: "checkmark")
  149. .font(.system(size: 16, weight: .bold))
  150. .foregroundStyle(.white)
  151. .shadow(radius: 2)
  152. }
  153. }
  154. Text(option.name)
  155. .font(.caption2)
  156. .foregroundStyle(.secondary)
  157. }
  158. }
  159. .buttonStyle(.plain)
  160. }
  161. }
  162. .padding(.vertical, 4)
  163. }
  164. Divider()
  165. // MARK: - Theme
  166. Text("Theme")
  167. .font(.title3.bold())
  168. Text("Choose a visual theme for MixBoard. Each theme enforces its own light/dark appearance.")
  169. .font(.callout)
  170. .foregroundStyle(.secondary)
  171. // Modern
  172. VStack(alignment: .leading, spacing: 8) {
  173. Text("Modern")
  174. .font(.headline)
  175. .foregroundStyle(.secondary)
  176. LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) {
  177. ForEach(modernSkins) { skin in
  178. SkinCard(skin: skin, isSelected: theme.currentSkin == skin) {
  179. withAnimation(.easeInOut(duration: 0.2)) {
  180. theme.currentSkin = skin
  181. }
  182. }
  183. }
  184. }
  185. }
  186. Divider()
  187. // Retro
  188. VStack(alignment: .leading, spacing: 8) {
  189. Text("Retro")
  190. .font(.headline)
  191. .foregroundStyle(.secondary)
  192. LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) {
  193. ForEach(retroSkins) { skin in
  194. SkinCard(skin: skin, isSelected: theme.currentSkin == skin) {
  195. withAnimation(.easeInOut(duration: 0.2)) {
  196. theme.currentSkin = skin
  197. }
  198. }
  199. }
  200. }
  201. }
  202. }
  203. .padding(24)
  204. }
  205. }
  206. }
  207. /// A compact card showing a skin preview swatch and name.
  208. private struct SkinCard: View {
  209. let skin: AppTheme.Skin
  210. let isSelected: Bool
  211. let action: () -> Void
  212. var body: some View {
  213. Button(action: action) {
  214. HStack(spacing: 10) {
  215. // Color swatch showing the skin's approximate palette
  216. RoundedRectangle(cornerRadius: 4)
  217. .fill(skin.previewColor)
  218. .frame(width: 28, height: 28)
  219. .overlay {
  220. if skin.colorScheme == .dark {
  221. Image(systemName: "moon.fill")
  222. .font(.system(size: 10))
  223. .foregroundStyle(.white.opacity(0.7))
  224. } else {
  225. Image(systemName: "sun.max.fill")
  226. .font(.system(size: 10))
  227. .foregroundStyle(.black.opacity(0.5))
  228. }
  229. }
  230. VStack(alignment: .leading, spacing: 1) {
  231. Text(skin.rawValue)
  232. .font(.system(size: 12, weight: .medium))
  233. .lineLimit(1)
  234. Text(skin.colorScheme == .dark ? "Dark" : "Light")
  235. .font(.system(size: 10))
  236. .foregroundStyle(.secondary)
  237. }
  238. Spacer(minLength: 0)
  239. if isSelected {
  240. Image(systemName: "checkmark.circle.fill")
  241. .foregroundStyle(.green)
  242. .font(.system(size: 14))
  243. }
  244. }
  245. .padding(8)
  246. .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
  247. .overlay(
  248. RoundedRectangle(cornerRadius: 8)
  249. .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
  250. )
  251. .clipShape(RoundedRectangle(cornerRadius: 8))
  252. .contentShape(Rectangle())
  253. }
  254. .buttonStyle(.plain)
  255. }
  256. }
  257. // MARK: - Playlist Settings (Columns, Artwork, Grouping)
  258. private struct PlaylistSettings: View {
  259. @ObservedObject private var viewConfig = PlaylistViewConfig.shared
  260. var body: some View {
  261. ScrollView {
  262. VStack(alignment: .leading, spacing: 20) {
  263. // Visible columns
  264. VStack(alignment: .leading, spacing: 8) {
  265. HStack {
  266. Text("Visible Columns")
  267. .font(.title3.bold())
  268. Spacer()
  269. Button("Reset to Defaults") {
  270. viewConfig.resetToDefaults()
  271. }
  272. .font(.caption)
  273. }
  274. Text("Select which columns appear in the playlist view.")
  275. .font(.callout)
  276. .foregroundStyle(.secondary)
  277. LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], spacing: 6) {
  278. ForEach(PlaylistViewConfig.Column.allCases) { column in
  279. Toggle(column.rawValue, isOn: Binding(
  280. get: { viewConfig.isColumnVisible(column) },
  281. set: { _ in viewConfig.toggleColumn(column) }
  282. ))
  283. .toggleStyle(.checkbox)
  284. .font(.system(size: 12))
  285. }
  286. }
  287. }
  288. Divider()
  289. // Artwork
  290. VStack(alignment: .leading, spacing: 8) {
  291. Text("Artwork")
  292. .font(.headline)
  293. Toggle("Show artwork thumbnails in playlist rows", isOn: $viewConfig.showArtwork)
  294. if viewConfig.showArtwork {
  295. Picker("Artwork size", selection: $viewConfig.artworkSize) {
  296. ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
  297. Text(size.rawValue).tag(size)
  298. }
  299. }
  300. .pickerStyle(.segmented)
  301. .frame(width: 250)
  302. }
  303. }
  304. Divider()
  305. }
  306. .padding(24)
  307. }
  308. }
  309. }
  310. // MARK: - Playback Settings
  311. private struct PlaybackSettings: View {
  312. @ObservedObject private var viewConfig = PlaylistViewConfig.shared
  313. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  314. var body: some View {
  315. VStack(alignment: .leading, spacing: 20) {
  316. Text("Playback Mode")
  317. .font(.title3.bold())
  318. Picker("Mode", selection: $playbackMode) {
  319. Text("Playlist Mode").tag("playlist")
  320. Text("Queue Mode").tag("queue")
  321. }
  322. .pickerStyle(.radioGroup)
  323. .labelsHidden()
  324. Group {
  325. if playbackMode == "playlist" {
  326. Text("Click a track to play it and continue through the playlist (foobar-style).")
  327. } else {
  328. Text("Manage a playback queue with Play Next and Add to Queue (Spotify-style).")
  329. }
  330. }
  331. .font(.callout)
  332. .foregroundStyle(.secondary)
  333. Divider()
  334. Text("Cursor Behavior")
  335. .font(.title3.bold())
  336. VStack(alignment: .leading, spacing: 12) {
  337. Toggle("Cursor follows playback", isOn: Binding(
  338. get: { viewConfig.cursorFollowsPlayback },
  339. set: { newValue in
  340. if newValue {
  341. viewConfig.cursorFollowsPlayback = true
  342. viewConfig.playbackFollowsCursor = false
  343. } else {
  344. viewConfig.cursorFollowsPlayback = false
  345. viewConfig.playbackFollowsCursor = true
  346. }
  347. }
  348. ))
  349. Text("Auto-select and scroll to the currently playing track.")
  350. .font(.callout)
  351. .foregroundStyle(.secondary)
  352. .padding(.leading, 20)
  353. Toggle("Playback follows cursor", isOn: Binding(
  354. get: { viewConfig.playbackFollowsCursor },
  355. set: { newValue in
  356. if newValue {
  357. viewConfig.playbackFollowsCursor = true
  358. viewConfig.cursorFollowsPlayback = false
  359. } else {
  360. viewConfig.playbackFollowsCursor = false
  361. viewConfig.cursorFollowsPlayback = true
  362. }
  363. }
  364. ))
  365. Text("When a track finishes, play the currently selected track next (foobar2000 behavior).")
  366. .font(.callout)
  367. .foregroundStyle(.secondary)
  368. .padding(.leading, 20)
  369. }
  370. Spacer()
  371. }
  372. .padding(24)
  373. }
  374. }
  375. // MARK: - General Settings
  376. private struct GeneralSettings: View {
  377. @AppStorage("autoAnalyzeOnImport") private var autoAnalyzeOnImport = true
  378. var body: some View {
  379. VStack(alignment: .leading, spacing: 20) {
  380. Text("General")
  381. .font(.title3.bold())
  382. VStack(alignment: .leading, spacing: 12) {
  383. Toggle("Auto-analyze BPM & Key on import", isOn: $autoAnalyzeOnImport)
  384. Text("Automatically detect BPM and musical key when adding tracks.")
  385. .font(.callout)
  386. .foregroundStyle(.secondary)
  387. .padding(.leading, 20)
  388. }
  389. Spacer()
  390. }
  391. .padding(24)
  392. }
  393. }
  394. // MARK: - Keyboard Shortcut Settings
  395. private struct KeyboardShortcutSettings: View {
  396. @ObservedObject private var config = KeyboardShortcutConfig.shared
  397. @State private var recordingAction: ShortcutAction?
  398. @State private var keyMonitor: Any?
  399. var body: some View {
  400. ScrollView {
  401. VStack(alignment: .leading, spacing: 20) {
  402. HStack {
  403. Text("Keyboard Shortcuts")
  404. .font(.title3.bold())
  405. Spacer()
  406. Button("Reset to Defaults") {
  407. config.resetToDefaults()
  408. }
  409. .font(.caption)
  410. }
  411. Text("Click a shortcut to re-assign it. Press Escape to cancel.")
  412. .font(.callout)
  413. .foregroundStyle(.secondary)
  414. ForEach(ShortcutGroup.allCases, id: \.rawValue) { group in
  415. VStack(alignment: .leading, spacing: 6) {
  416. Text(group.rawValue)
  417. .font(.headline)
  418. .foregroundStyle(.secondary)
  419. ForEach(group.actions) { action in
  420. ShortcutRow(
  421. action: action,
  422. binding: config.binding(for: action),
  423. isRecording: recordingAction == action,
  424. onStartRecording: { startRecording(for: action) }
  425. )
  426. }
  427. }
  428. if group != .general {
  429. Divider()
  430. }
  431. }
  432. }
  433. .padding(24)
  434. }
  435. .onDisappear {
  436. stopRecording()
  437. }
  438. }
  439. private func startRecording(for action: ShortcutAction) {
  440. // Stop any existing recording
  441. stopRecording()
  442. recordingAction = action
  443. keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
  444. // Escape cancels
  445. if event.keyCode == 53 {
  446. self.stopRecording()
  447. return nil
  448. }
  449. let binding = ShortcutBinding.from(event)
  450. self.config.shortcuts[action] = binding
  451. self.stopRecording()
  452. return nil // consume the event
  453. }
  454. }
  455. private func stopRecording() {
  456. recordingAction = nil
  457. if let monitor = keyMonitor {
  458. NSEvent.removeMonitor(monitor)
  459. keyMonitor = nil
  460. }
  461. }
  462. }
  463. /// A single row: action name + clickable shortcut recorder button.
  464. private struct ShortcutRow: View {
  465. let action: ShortcutAction
  466. let binding: ShortcutBinding
  467. let isRecording: Bool
  468. let onStartRecording: () -> Void
  469. var body: some View {
  470. HStack {
  471. Text(action.rawValue)
  472. .font(.system(size: 12))
  473. .frame(maxWidth: .infinity, alignment: .leading)
  474. Button {
  475. onStartRecording()
  476. } label: {
  477. Text(isRecording ? "Press shortcut…" : binding.displayString)
  478. .font(.system(size: 12, design: isRecording ? .default : .monospaced))
  479. .frame(minWidth: 90)
  480. .padding(.horizontal, 8)
  481. .padding(.vertical, 4)
  482. .background(isRecording ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05))
  483. .clipShape(RoundedRectangle(cornerRadius: 6))
  484. .overlay(
  485. RoundedRectangle(cornerRadius: 6)
  486. .stroke(isRecording ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1)
  487. )
  488. }
  489. .buttonStyle(.plain)
  490. .animation(.easeInOut(duration: 0.15), value: isRecording)
  491. }
  492. .padding(.vertical, 2)
  493. }
  494. }
  495. // MARK: - Chad Music Settings
  496. private struct ChadMusicSettings: View {
  497. @AppStorage("chadMusic.serverURL") private var serverURL: String = ""
  498. @State private var apiKey: String = ChadMusicCredentials.shared.apiKey ?? ""
  499. @State private var connectionStatus: ConnectionStatus = .unknown
  500. @State private var isTesting = false
  501. @State private var statsText: String = ""
  502. private enum ConnectionStatus {
  503. case unknown, testing, success, failed(String)
  504. }
  505. var body: some View {
  506. VStack(alignment: .leading, spacing: 20) {
  507. Text("Chad Music")
  508. .font(.title3.bold())
  509. Text("Connect to your Chad Music server to browse and stream your cloud library.")
  510. .font(.callout)
  511. .foregroundStyle(.secondary)
  512. // Server URL
  513. VStack(alignment: .leading, spacing: 6) {
  514. Text("Server URL")
  515. .font(.headline)
  516. TextField("https://music.tailnet.ts.net", text: $serverURL)
  517. .textFieldStyle(.roundedBorder)
  518. .onChange(of: serverURL) { _, _ in
  519. connectionStatus = .unknown
  520. }
  521. }
  522. // API Key
  523. VStack(alignment: .leading, spacing: 6) {
  524. Text("API Key")
  525. .font(.headline)
  526. SecureField("Enter API key", text: $apiKey)
  527. .textFieldStyle(.roundedBorder)
  528. .onChange(of: apiKey) { _, newValue in
  529. connectionStatus = .unknown
  530. let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
  531. if trimmed.isEmpty {
  532. ChadMusicCredentials.shared.delete()
  533. } else {
  534. try? ChadMusicCredentials.shared.save(trimmed)
  535. }
  536. }
  537. }
  538. Divider()
  539. // Connection test
  540. HStack(spacing: 12) {
  541. Button("Test Connection") {
  542. testConnection()
  543. }
  544. .disabled(serverURL.isEmpty || apiKey.isEmpty || isTesting)
  545. switch connectionStatus {
  546. case .unknown:
  547. EmptyView()
  548. case .testing:
  549. ProgressView()
  550. .controlSize(.small)
  551. Text("Connecting...")
  552. .font(.callout)
  553. .foregroundStyle(.secondary)
  554. case .success:
  555. Image(systemName: "checkmark.circle.fill")
  556. .foregroundStyle(.green)
  557. Text(statsText)
  558. .font(.callout)
  559. .foregroundStyle(.secondary)
  560. case .failed(let message):
  561. Image(systemName: "xmark.circle.fill")
  562. .foregroundStyle(.red)
  563. Text(message)
  564. .font(.callout)
  565. .foregroundStyle(.red)
  566. }
  567. }
  568. Spacer()
  569. }
  570. .padding(24)
  571. }
  572. private func testConnection() {
  573. connectionStatus = .testing
  574. isTesting = true
  575. Task {
  576. let client = ChadMusicAPIClient.shared
  577. let result = await client.testConnection()
  578. switch result {
  579. case .success(let stats):
  580. let parts = [
  581. stats.tracks.map { "\($0) tracks" },
  582. stats.albums.map { "\($0) albums" },
  583. stats.artists.map { "\($0) artists" },
  584. ].compactMap { $0 }
  585. statsText = "Connected — " + parts.joined(separator: ", ")
  586. connectionStatus = .success
  587. case .failure(let error):
  588. connectionStatus = .failed(error.localizedDescription)
  589. }
  590. isTesting = false
  591. }
  592. }
  593. }
  594. // MARK: - Soulseek Settings
  595. private struct SlskdSettings: View {
  596. @AppStorage("slskd.serverMode") private var serverModeRaw: String = "managed"
  597. @AppStorage("slskd.serverURL") private var serverURL: String = ""
  598. @State private var username: String = SlskdCredentials.shared.username ?? ""
  599. @State private var password: String = SlskdCredentials.shared.password ?? ""
  600. @State private var soulseekUsername: String = SlskdCredentials.shared.username ?? ""
  601. @State private var soulseekPassword: String = SlskdCredentials.shared.password ?? ""
  602. @State private var connectionStatus: SlskdConnectionStatus = .unknown
  603. @State private var isTesting = false
  604. @AppStorage("slskd.qualityThreshold") private var qualityThreshold: Int = 80
  605. /// H-6: Track whether credentials have unsaved changes.
  606. @State private var hasUnsavedCredentials = false
  607. /// H-6: Debounce timer to avoid saving on every keystroke.
  608. @State private var saveDebounceTask: Task<Void, Never>?
  609. private var serverMode: SlskdServerMode {
  610. SlskdServerMode(rawValue: serverModeRaw) ?? .managed
  611. }
  612. private enum SlskdConnectionStatus {
  613. case unknown, testing, success, failed(String)
  614. }
  615. var body: some View {
  616. VStack(alignment: .leading, spacing: 20) {
  617. Text("Soulseek")
  618. .font(.title3.bold())
  619. Text("Search and download music from the Soulseek network. MixBoard can manage slskd automatically, or you can connect to your own server.")
  620. .font(.callout)
  621. .foregroundStyle(.secondary)
  622. // Mode picker
  623. Picker("Server Mode", selection: $serverModeRaw) {
  624. Text("Managed").tag("managed")
  625. Text("External").tag("external")
  626. }
  627. .pickerStyle(.segmented)
  628. .frame(maxWidth: 300)
  629. .onChange(of: serverModeRaw) { _, newValue in
  630. connectionStatus = .unknown
  631. SlskdAPIClient.shared.serverMode = SlskdServerMode(rawValue: newValue) ?? .managed
  632. }
  633. if serverMode == .managed {
  634. managedModeSection
  635. } else {
  636. externalModeSection
  637. }
  638. // Quality threshold (both modes)
  639. VStack(alignment: .leading, spacing: 6) {
  640. HStack {
  641. Text("Quality Threshold")
  642. .font(.headline)
  643. Spacer()
  644. Text("\(qualityThreshold)")
  645. .font(.system(.body, design: .monospaced))
  646. .foregroundStyle(.secondary)
  647. }
  648. Slider(value: Binding(
  649. get: { Double(qualityThreshold) },
  650. set: { qualityThreshold = Int($0) }
  651. ), in: 30...150, step: 10)
  652. Text("Sources scoring below \(qualityThreshold) are grayed out. Higher = stricter (FLAC preferred). Lower = more results.")
  653. .font(.caption)
  654. .foregroundStyle(.secondary)
  655. }
  656. Divider()
  657. // Connection test (both modes)
  658. HStack(spacing: 12) {
  659. Button("Test Connection") {
  660. testConnection()
  661. }
  662. .disabled(isTesting || (serverMode == .external && (serverURL.isEmpty || username.isEmpty || password.isEmpty)))
  663. switch connectionStatus {
  664. case .unknown:
  665. EmptyView()
  666. case .testing:
  667. ProgressView()
  668. .controlSize(.small)
  669. Text("Connecting...")
  670. .font(.callout)
  671. .foregroundStyle(.secondary)
  672. case .success:
  673. Image(systemName: "checkmark.circle.fill")
  674. .foregroundStyle(.green)
  675. Text("Connected to slskd")
  676. .font(.callout)
  677. .foregroundStyle(.secondary)
  678. case .failed(let message):
  679. Image(systemName: "xmark.circle.fill")
  680. .foregroundStyle(.red)
  681. Text(message)
  682. .font(.callout)
  683. .foregroundStyle(.red)
  684. }
  685. }
  686. Spacer()
  687. }
  688. .padding(24)
  689. .onDisappear {
  690. if hasUnsavedCredentials {
  691. saveCredentials()
  692. }
  693. saveDebounceTask?.cancel()
  694. }
  695. }
  696. // MARK: - Managed Mode
  697. @ViewBuilder
  698. private var managedModeSection: some View {
  699. // Status indicator
  700. HStack(spacing: 8) {
  701. switch SlskdProcessManager.shared.state {
  702. case .stopped:
  703. Image(systemName: "circle")
  704. .foregroundStyle(.secondary)
  705. Text("Stopped")
  706. .foregroundStyle(.secondary)
  707. case .downloading(let progress):
  708. ProgressView()
  709. .controlSize(.small)
  710. Text("Downloading slskd... \(Int(progress * 100))%")
  711. .foregroundStyle(.secondary)
  712. case .starting:
  713. ProgressView()
  714. .controlSize(.small)
  715. Text("Starting...")
  716. .foregroundStyle(.secondary)
  717. case .running:
  718. Image(systemName: "circle.fill")
  719. .foregroundStyle(.green)
  720. .font(.system(size: 8))
  721. Text("Running on localhost:\(SlskdProcessManager.port)")
  722. .foregroundStyle(.secondary)
  723. case .failed(let message):
  724. Image(systemName: "exclamationmark.circle.fill")
  725. .foregroundStyle(.red)
  726. Text(message)
  727. .foregroundStyle(.red)
  728. }
  729. }
  730. .font(.callout)
  731. // Start / Stop
  732. HStack(spacing: 12) {
  733. if SlskdProcessManager.shared.state == .running {
  734. Button("Stop") {
  735. SlskdProcessManager.shared.stop()
  736. }
  737. } else if case .downloading = SlskdProcessManager.shared.state {
  738. // Can't stop during download
  739. } else if SlskdProcessManager.shared.state == .starting {
  740. // Can't stop during startup
  741. } else {
  742. Button("Start") {
  743. Task { try? await SlskdProcessManager.shared.start() }
  744. }
  745. }
  746. }
  747. // Soulseek P2P credentials (needed for the network)
  748. VStack(alignment: .leading, spacing: 6) {
  749. Text("Soulseek Account")
  750. .font(.headline)
  751. Text("Your Soulseek network credentials (not the slskd API).")
  752. .font(.caption)
  753. .foregroundStyle(.secondary)
  754. TextField("Soulseek username", text: $soulseekUsername)
  755. .textFieldStyle(.roundedBorder)
  756. .onChange(of: soulseekUsername) { _, _ in
  757. scheduleDebouncedSave()
  758. }
  759. SecureField("Soulseek password", text: $soulseekPassword)
  760. .textFieldStyle(.roundedBorder)
  761. .onChange(of: soulseekPassword) { _, _ in
  762. scheduleDebouncedSave()
  763. }
  764. }
  765. if hasUnsavedCredentials {
  766. unsavedCredentialsIndicator
  767. }
  768. }
  769. // MARK: - External Mode
  770. @ViewBuilder
  771. private var externalModeSection: some View {
  772. VStack(alignment: .leading, spacing: 6) {
  773. Text("Server URL")
  774. .font(.headline)
  775. TextField("http://100.x.x.x:5030", text: $serverURL)
  776. .textFieldStyle(.roundedBorder)
  777. .onChange(of: serverURL) { _, _ in
  778. connectionStatus = .unknown
  779. }
  780. }
  781. VStack(alignment: .leading, spacing: 6) {
  782. Text("Username")
  783. .font(.headline)
  784. TextField("slskd username", text: $username)
  785. .textFieldStyle(.roundedBorder)
  786. .onChange(of: username) { _, _ in
  787. connectionStatus = .unknown
  788. scheduleDebouncedSave()
  789. }
  790. }
  791. VStack(alignment: .leading, spacing: 6) {
  792. Text("Password")
  793. .font(.headline)
  794. SecureField("slskd password", text: $password)
  795. .textFieldStyle(.roundedBorder)
  796. .onChange(of: password) { _, _ in
  797. connectionStatus = .unknown
  798. scheduleDebouncedSave()
  799. }
  800. }
  801. if hasUnsavedCredentials {
  802. unsavedCredentialsIndicator
  803. }
  804. }
  805. // MARK: - Shared Components
  806. private var unsavedCredentialsIndicator: some View {
  807. HStack(spacing: 6) {
  808. Image(systemName: "exclamationmark.circle.fill")
  809. .foregroundStyle(.orange)
  810. .font(.system(size: 12))
  811. Text("Unsaved changes")
  812. .font(.caption)
  813. .foregroundStyle(.orange)
  814. Spacer()
  815. Button("Save Credentials") {
  816. saveCredentials()
  817. }
  818. .controlSize(.small)
  819. }
  820. }
  821. // MARK: - Actions
  822. /// H-6: Schedule a debounced save (1.5s after last keystroke).
  823. private func scheduleDebouncedSave() {
  824. hasUnsavedCredentials = true
  825. saveDebounceTask?.cancel()
  826. saveDebounceTask = Task { @MainActor in
  827. try? await Task.sleep(for: .seconds(1.5))
  828. guard !Task.isCancelled else { return }
  829. saveCredentials()
  830. }
  831. }
  832. private func saveCredentials() {
  833. if serverMode == .managed {
  834. let u = soulseekUsername.trimmingCharacters(in: .whitespacesAndNewlines)
  835. let p = soulseekPassword.trimmingCharacters(in: .whitespacesAndNewlines)
  836. guard !u.isEmpty, !p.isEmpty else { return }
  837. try? SlskdCredentials.shared.save(username: u, password: p)
  838. } else {
  839. let u = username.trimmingCharacters(in: .whitespacesAndNewlines)
  840. let p = password.trimmingCharacters(in: .whitespacesAndNewlines)
  841. guard !u.isEmpty, !p.isEmpty else { return }
  842. try? SlskdCredentials.shared.save(username: u, password: p)
  843. }
  844. hasUnsavedCredentials = false
  845. }
  846. private func testConnection() {
  847. connectionStatus = .testing
  848. isTesting = true
  849. Task {
  850. let error = await SlskdAPIClient.shared.testConnection()
  851. if let error {
  852. connectionStatus = .failed(error.localizedDescription)
  853. } else {
  854. connectionStatus = .success
  855. }
  856. isTesting = false
  857. }
  858. }
  859. }
  860. // MARK: - Skin Preview Color
  861. extension AppTheme.Skin {
  862. /// Approximate preview swatch color for the skin card.
  863. var previewColor: Color {
  864. switch self {
  865. case .dark: return Color(red: 0.15, green: 0.15, blue: 0.17)
  866. case .midnight: return Color(red: 0.1, green: 0.1, blue: 0.2)
  867. case .forest: return Color(red: 0.1, green: 0.18, blue: 0.1)
  868. case .ocean: return Color(red: 0.1, green: 0.15, blue: 0.2)
  869. case .warm: return Color(red: 0.2, green: 0.15, blue: 0.1)
  870. case .light: return Color(red: 0.95, green: 0.95, blue: 0.96)
  871. case .djBoard: return Color(red: 0.04, green: 0.04, blue: 0.06)
  872. case .winampClassic: return Color(red: 0.12, green: 0.12, blue: 0.14)
  873. case .winampModern: return Color(red: 0.13, green: 0.14, blue: 0.18)
  874. case .foobarDark: return Color(red: 0.14, green: 0.14, blue: 0.14)
  875. case .foobarLight: return Color(red: 0.94, green: 0.94, blue: 0.94)
  876. case .win95: return Color(red: 0.75, green: 0.75, blue: 0.75)
  877. case .win98: return Color(red: 0.83, green: 0.82, blue: 0.78)
  878. case .xpLuna: return Color(red: 0.85, green: 0.89, blue: 0.95)
  879. case .macOSClassic: return Color(red: 0.86, green: 0.86, blue: 0.86)
  880. }
  881. }
  882. }