SettingsView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import SwiftData
  2. import SwiftUI
  3. /// Settings tab — skin selection, sync, about.
  4. struct SettingsView: View {
  5. @EnvironmentObject private var theme: AppTheme
  6. @EnvironmentObject private var syncManager: SyncManager
  7. @EnvironmentObject private var libraryManager: LibraryManager
  8. @Environment(PlaylistViewModel.self) private var playlistVM
  9. @Environment(\.modelContext) private var modelContext
  10. @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
  11. @Query private var tracks: [Track]
  12. @State private var showSyncImporter = false
  13. @State private var showSyncExportConfirm = false
  14. @State private var syncResult: String?
  15. @State private var showResetConfirm = false
  16. // Playback settings
  17. @AppStorage("trackTapAction") private var trackTapAction = "playNow"
  18. // Chad Music settings
  19. @State private var chadServerURL: String = UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? ""
  20. @State private var chadAPIKey: String = KeychainService.loadAPIKey() ?? ""
  21. @State private var chadTestResult: ChadTestState = .idle
  22. private enum ChadTestState {
  23. case idle
  24. case testing
  25. case success(ChadStats)
  26. case failed(String)
  27. }
  28. private static let mixColors: [Color] = [
  29. Color(red: 0.95, green: 0.3, blue: 0.3),
  30. Color(red: 0.3, green: 0.75, blue: 0.95),
  31. Color(red: 0.95, green: 0.75, blue: 0.2),
  32. ]
  33. var body: some View {
  34. NavigationStack {
  35. List {
  36. // MARK: - Mix Targets
  37. Section {
  38. ForEach(0..<3, id: \.self) { slot in
  39. Menu {
  40. ForEach(playlists) { playlist in
  41. Button(playlist.name) {
  42. playlistVM.setMixTarget(slot, playlist: playlist)
  43. }
  44. }
  45. if playlistVM.mixTargets[slot] != nil {
  46. Divider()
  47. Button("Clear", role: .destructive) {
  48. playlistVM.setMixTarget(slot, playlist: nil)
  49. }
  50. }
  51. } label: {
  52. HStack(spacing: 12) {
  53. Text("\(slot + 1)")
  54. .font(.system(size: 14, weight: .bold, design: .rounded))
  55. .frame(width: 28, height: 28)
  56. .foregroundStyle(Self.mixColors[slot])
  57. .background(Self.mixColors[slot].opacity(0.15))
  58. .clipShape(RoundedRectangle(cornerRadius: 6))
  59. if let target = playlistVM.mixTargets[slot] {
  60. Text(target.name)
  61. .foregroundStyle(theme.primaryText)
  62. } else {
  63. Text("Not set")
  64. .foregroundStyle(theme.tertiaryText)
  65. }
  66. Spacer()
  67. Image(systemName: "chevron.up.chevron.down")
  68. .font(.caption)
  69. .foregroundStyle(theme.tertiaryText)
  70. }
  71. .contentShape(Rectangle())
  72. }
  73. }
  74. } header: {
  75. Text("Mix Targets")
  76. } footer: {
  77. Text("Assign playlists to the 3 mix buttons shown on each track. Tap a number on a track to quick-add it.")
  78. }
  79. // MARK: - App Icon Color
  80. Section("App Icon") {
  81. let iconOptions: [(name: String, color: Color, iconName: String?)] = [
  82. ("Default", .green, nil),
  83. ("Green", Color(red: 0.35, green: 0.85, blue: 0.25), "AppIcon-Green"),
  84. ("Lime", Color(red: 0.55, green: 0.95, blue: 0.15), "AppIcon-Lime"),
  85. ("Cyan", Color(red: 0.15, green: 0.85, blue: 0.85), "AppIcon-Cyan"),
  86. ("Blue", Color(red: 0.25, green: 0.45, blue: 0.95), "AppIcon-Blue"),
  87. ("Purple", Color(red: 0.6, green: 0.3, blue: 0.9), "AppIcon-Purple"),
  88. ("Pink", Color(red: 0.95, green: 0.3, blue: 0.6), "AppIcon-Pink"),
  89. ("Red", Color(red: 0.95, green: 0.25, blue: 0.25), "AppIcon-Red"),
  90. ("Orange", Color(red: 0.95, green: 0.55, blue: 0.15), "AppIcon-Orange"),
  91. ("Gold", Color(red: 0.95, green: 0.8, blue: 0.15), "AppIcon-Gold"),
  92. ("White", Color(red: 0.9, green: 0.9, blue: 0.92), "AppIcon-White"),
  93. ]
  94. ScrollView(.horizontal, showsIndicators: false) {
  95. HStack(spacing: 12) {
  96. ForEach(iconOptions, id: \.name) { option in
  97. Button {
  98. UIApplication.shared.setAlternateIconName(option.iconName) { error in
  99. if let error { print("Icon change failed: \(error)") }
  100. }
  101. } label: {
  102. VStack(spacing: 6) {
  103. RoundedRectangle(cornerRadius: 12)
  104. .fill(option.color)
  105. .frame(width: 50, height: 50)
  106. .overlay(
  107. RoundedRectangle(cornerRadius: 12)
  108. .stroke(Color.white.opacity(0.2), lineWidth: 1)
  109. )
  110. Text(option.name)
  111. .font(.caption2)
  112. .foregroundStyle(theme.secondaryText)
  113. }
  114. }
  115. .buttonStyle(.plain)
  116. }
  117. }
  118. .padding(.vertical, 8)
  119. }
  120. .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
  121. }
  122. // MARK: - Playback
  123. Section("Playback") {
  124. Picker("Track Tap Action", selection: $trackTapAction) {
  125. Text("Play Now").tag("playNow")
  126. Text("Add to Queue").tag("addToQueue")
  127. }
  128. }
  129. // MARK: - Skin Selection
  130. Section("Skin") {
  131. ForEach(AppTheme.Skin.allCases) { skin in
  132. Button {
  133. withAnimation(.easeInOut(duration: 0.3)) {
  134. theme.currentSkin = skin
  135. }
  136. } label: {
  137. HStack(spacing: 12) {
  138. Image(systemName: skin.icon)
  139. .font(.title3)
  140. .foregroundStyle(theme.currentSkin == skin ? theme.accent : theme.secondaryText)
  141. .frame(width: 32)
  142. VStack(alignment: .leading, spacing: 2) {
  143. Text(skin.rawValue)
  144. .font(.headline)
  145. .foregroundStyle(theme.primaryText)
  146. Text(skin.description)
  147. .font(.caption)
  148. .foregroundStyle(theme.secondaryText)
  149. }
  150. Spacer()
  151. if theme.currentSkin == skin {
  152. Image(systemName: "checkmark.circle.fill")
  153. .foregroundStyle(theme.accent)
  154. }
  155. }
  156. }
  157. }
  158. }
  159. // MARK: - Chad Music
  160. Section {
  161. HStack {
  162. Text("Server URL")
  163. .foregroundStyle(theme.secondaryText)
  164. TextField("https://music.example.com", text: $chadServerURL)
  165. .textContentType(.URL)
  166. .keyboardType(.URL)
  167. .autocapitalization(.none)
  168. .disableAutocorrection(true)
  169. .multilineTextAlignment(.trailing)
  170. .onChange(of: chadServerURL) { _, newValue in
  171. ChadMusicAPIClient.shared.serverURL = newValue
  172. }
  173. }
  174. HStack {
  175. Text("API Key")
  176. .foregroundStyle(theme.secondaryText)
  177. SecureField("Enter API key", text: $chadAPIKey)
  178. .multilineTextAlignment(.trailing)
  179. .onChange(of: chadAPIKey) { _, newValue in
  180. if newValue.isEmpty {
  181. KeychainService.deleteAPIKey()
  182. } else {
  183. try? KeychainService.saveAPIKey(newValue)
  184. }
  185. }
  186. }
  187. Button {
  188. testChadConnection()
  189. } label: {
  190. HStack {
  191. Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right")
  192. Spacer()
  193. switch chadTestResult {
  194. case .idle:
  195. EmptyView()
  196. case .testing:
  197. ProgressView()
  198. case .success:
  199. Image(systemName: "checkmark.circle.fill")
  200. .foregroundStyle(.green)
  201. case .failed:
  202. Image(systemName: "xmark.circle.fill")
  203. .foregroundStyle(.red)
  204. }
  205. }
  206. }
  207. .disabled(chadServerURL.isEmpty || chadAPIKey.isEmpty)
  208. switch chadTestResult {
  209. case .success(let stats):
  210. HStack(spacing: 16) {
  211. if let tracks = stats.tracks {
  212. VStack {
  213. Text("\(tracks)")
  214. .font(.headline)
  215. Text("Tracks")
  216. .font(.caption2)
  217. .foregroundStyle(theme.secondaryText)
  218. }
  219. }
  220. if let albums = stats.albums {
  221. VStack {
  222. Text("\(albums)")
  223. .font(.headline)
  224. Text("Albums")
  225. .font(.caption2)
  226. .foregroundStyle(theme.secondaryText)
  227. }
  228. }
  229. if let artists = stats.artists {
  230. VStack {
  231. Text("\(artists)")
  232. .font(.headline)
  233. Text("Artists")
  234. .font(.caption2)
  235. .foregroundStyle(theme.secondaryText)
  236. }
  237. }
  238. }
  239. .frame(maxWidth: .infinity)
  240. case .failed(let msg):
  241. Text(msg)
  242. .font(.caption)
  243. .foregroundStyle(.red)
  244. default:
  245. EmptyView()
  246. }
  247. } header: {
  248. Text("Chad Music")
  249. } footer: {
  250. Text("Connect to your Chad Music server to stream cloud music. The server URL and API key are stored securely in the Keychain.")
  251. }
  252. // MARK: - Sync
  253. Section {
  254. Button {
  255. syncManager.exportPlaylists(playlists)
  256. showSyncExportConfirm = true
  257. } label: {
  258. Label("Export Playlists for Mac", systemImage: "square.and.arrow.up")
  259. }
  260. .disabled(playlists.isEmpty)
  261. Button {
  262. showSyncImporter = true
  263. } label: {
  264. Label("Import Playlists from Mac", systemImage: "square.and.arrow.down")
  265. }
  266. if let date = syncManager.lastSyncDate {
  267. HStack {
  268. Text("Last export")
  269. .foregroundStyle(theme.secondaryText)
  270. Spacer()
  271. Text(date.formatted(date: .abbreviated, time: .shortened))
  272. .font(.caption)
  273. .foregroundStyle(theme.tertiaryText)
  274. }
  275. }
  276. if let result = syncResult {
  277. Text(result)
  278. .font(.caption)
  279. .foregroundStyle(theme.accent)
  280. }
  281. } header: {
  282. Text("Sync")
  283. } footer: {
  284. Text("Export creates a JSON file in Documents/Sync/ that you can share with the Mac app via AirDrop, iCloud Drive, or USB.")
  285. }
  286. // MARK: - Library Stats
  287. Section("Library") {
  288. HStack {
  289. Text("Tracks")
  290. Spacer()
  291. Text("\(tracks.count)")
  292. .foregroundStyle(theme.secondaryText)
  293. }
  294. HStack {
  295. Text("Playlists")
  296. Spacer()
  297. Text("\(playlists.count)")
  298. .foregroundStyle(theme.secondaryText)
  299. }
  300. HStack {
  301. Text("Analyzed")
  302. Spacer()
  303. Text("\(tracks.filter(\.isAnalyzed).count) / \(tracks.count)")
  304. .foregroundStyle(theme.secondaryText)
  305. }
  306. Button {
  307. Task { await libraryManager.rescanMetadata() }
  308. } label: {
  309. Label("Rescan Metadata", systemImage: "arrow.clockwise")
  310. }
  311. .disabled(libraryManager.isScanning)
  312. Button(role: .destructive) {
  313. showResetConfirm = true
  314. } label: {
  315. Label("Reset Library & Rescan", systemImage: "arrow.counterclockwise")
  316. .foregroundStyle(.red)
  317. }
  318. }
  319. // MARK: - About
  320. Section("About") {
  321. HStack {
  322. Text("MixBoard iOS")
  323. Spacer()
  324. Text("1.0.0")
  325. .foregroundStyle(theme.tertiaryText)
  326. }
  327. HStack {
  328. Text("Supported Formats")
  329. Spacer()
  330. Text("MP3, FLAC, WAV, AIFF, M4A, AAC, OGG, Opus")
  331. .font(.caption)
  332. .foregroundStyle(theme.tertiaryText)
  333. }
  334. }
  335. }
  336. .navigationTitle("Settings")
  337. .accessibilityIdentifier("settingsView")
  338. .fileImporter(
  339. isPresented: $showSyncImporter,
  340. allowedContentTypes: [.json],
  341. allowsMultipleSelection: false
  342. ) { result in
  343. switch result {
  344. case .success(let urls):
  345. guard let url = urls.first else { return }
  346. let accessing = url.startAccessingSecurityScopedResource()
  347. defer { if accessing { url.stopAccessingSecurityScopedResource() } }
  348. do {
  349. let imported = try syncManager.importPlaylists(from: url, context: modelContext)
  350. let result = syncManager.mergeImportedPlaylists(imported, existingTracks: tracks, context: modelContext)
  351. syncResult = "Imported \(result.created) playlists, \(result.matched) tracks matched, \(result.unmatched) not found"
  352. } catch {
  353. syncResult = "Import failed: \(error.localizedDescription)"
  354. }
  355. case .failure(let error):
  356. syncResult = "Error: \(error.localizedDescription)"
  357. }
  358. }
  359. .alert("Exported", isPresented: $showSyncExportConfirm) {
  360. Button("OK") {}
  361. } message: {
  362. Text("Playlists exported to Documents/Sync/mixboard-playlists.json\n\nShare this file with your Mac via AirDrop, iCloud Drive, or Files app.")
  363. }
  364. .alert("Reset Library?", isPresented: $showResetConfirm) {
  365. Button("Cancel", role: .cancel) {}
  366. Button("Reset & Rescan", role: .destructive) {
  367. // Delete all tracks from database
  368. for track in tracks {
  369. modelContext.delete(track)
  370. }
  371. try? modelContext.save()
  372. // Rescan
  373. Task {
  374. await libraryManager.scanMusicDirectory()
  375. }
  376. }
  377. } message: {
  378. Text("This will remove all tracks from the database and re-scan your music folder. Your music files are NOT deleted.")
  379. }
  380. }
  381. }
  382. private func testChadConnection() {
  383. chadTestResult = .testing
  384. Task {
  385. let result = await ChadMusicAPIClient.shared.testConnection()
  386. switch result {
  387. case .success(let stats):
  388. chadTestResult = .success(stats)
  389. case .failure(let error):
  390. chadTestResult = .failed(error.localizedDescription)
  391. }
  392. }
  393. }
  394. }