| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- import SwiftData
- import SwiftUI
- /// Settings tab — skin selection, sync, about.
- struct SettingsView: View {
- @EnvironmentObject private var theme: AppTheme
- @EnvironmentObject private var syncManager: SyncManager
- @EnvironmentObject private var libraryManager: LibraryManager
- @Environment(PlaylistViewModel.self) private var playlistVM
- @Environment(\.modelContext) private var modelContext
- @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
- @Query private var tracks: [Track]
- @State private var showSyncImporter = false
- @State private var showSyncExportConfirm = false
- @State private var syncResult: String?
- @State private var showResetConfirm = false
- // Playback settings
- @AppStorage("trackTapAction") private var trackTapAction = "playNow"
- // Chad Music settings
- @State private var chadServerURL: String = UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? ""
- @State private var chadAPIKey: String = KeychainService.loadAPIKey() ?? ""
- @State private var chadTestResult: ChadTestState = .idle
- private enum ChadTestState {
- case idle
- case testing
- case success(ChadStats)
- case failed(String)
- }
- private static let mixColors: [Color] = [
- Color(red: 0.95, green: 0.3, blue: 0.3),
- Color(red: 0.3, green: 0.75, blue: 0.95),
- Color(red: 0.95, green: 0.75, blue: 0.2),
- ]
- var body: some View {
- NavigationStack {
- List {
- // MARK: - Mix Targets
- Section {
- ForEach(0..<3, id: \.self) { slot in
- Menu {
- ForEach(playlists) { playlist in
- Button(playlist.name) {
- playlistVM.setMixTarget(slot, playlist: playlist)
- }
- }
- if playlistVM.mixTargets[slot] != nil {
- Divider()
- Button("Clear", role: .destructive) {
- playlistVM.setMixTarget(slot, playlist: nil)
- }
- }
- } label: {
- HStack(spacing: 12) {
- Text("\(slot + 1)")
- .font(.system(size: 14, weight: .bold, design: .rounded))
- .frame(width: 28, height: 28)
- .foregroundStyle(Self.mixColors[slot])
- .background(Self.mixColors[slot].opacity(0.15))
- .clipShape(RoundedRectangle(cornerRadius: 6))
- if let target = playlistVM.mixTargets[slot] {
- Text(target.name)
- .foregroundStyle(theme.primaryText)
- } else {
- Text("Not set")
- .foregroundStyle(theme.tertiaryText)
- }
- Spacer()
- Image(systemName: "chevron.up.chevron.down")
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- .contentShape(Rectangle())
- }
- }
- } header: {
- Text("Mix Targets")
- } footer: {
- Text("Assign playlists to the 3 mix buttons shown on each track. Tap a number on a track to quick-add it.")
- }
- // MARK: - App Icon Color
- Section("App Icon") {
- let iconOptions: [(name: String, color: Color, iconName: String?)] = [
- ("Default", .green, nil),
- ("Green", Color(red: 0.35, green: 0.85, blue: 0.25), "AppIcon-Green"),
- ("Lime", Color(red: 0.55, green: 0.95, blue: 0.15), "AppIcon-Lime"),
- ("Cyan", Color(red: 0.15, green: 0.85, blue: 0.85), "AppIcon-Cyan"),
- ("Blue", Color(red: 0.25, green: 0.45, blue: 0.95), "AppIcon-Blue"),
- ("Purple", Color(red: 0.6, green: 0.3, blue: 0.9), "AppIcon-Purple"),
- ("Pink", Color(red: 0.95, green: 0.3, blue: 0.6), "AppIcon-Pink"),
- ("Red", Color(red: 0.95, green: 0.25, blue: 0.25), "AppIcon-Red"),
- ("Orange", Color(red: 0.95, green: 0.55, blue: 0.15), "AppIcon-Orange"),
- ("Gold", Color(red: 0.95, green: 0.8, blue: 0.15), "AppIcon-Gold"),
- ("White", Color(red: 0.9, green: 0.9, blue: 0.92), "AppIcon-White"),
- ]
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 12) {
- ForEach(iconOptions, id: \.name) { option in
- Button {
- UIApplication.shared.setAlternateIconName(option.iconName) { error in
- if let error { print("Icon change failed: \(error)") }
- }
- } label: {
- VStack(spacing: 6) {
- RoundedRectangle(cornerRadius: 12)
- .fill(option.color)
- .frame(width: 50, height: 50)
- .overlay(
- RoundedRectangle(cornerRadius: 12)
- .stroke(Color.white.opacity(0.2), lineWidth: 1)
- )
- Text(option.name)
- .font(.caption2)
- .foregroundStyle(theme.secondaryText)
- }
- }
- .buttonStyle(.plain)
- }
- }
- .padding(.vertical, 8)
- }
- .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
- }
- // MARK: - Playback
- Section("Playback") {
- Picker("Track Tap Action", selection: $trackTapAction) {
- Text("Play Now").tag("playNow")
- Text("Add to Queue").tag("addToQueue")
- }
- }
- // MARK: - Skin Selection
- Section("Skin") {
- ForEach(AppTheme.Skin.allCases) { skin in
- Button {
- withAnimation(.easeInOut(duration: 0.3)) {
- theme.currentSkin = skin
- }
- } label: {
- HStack(spacing: 12) {
- Image(systemName: skin.icon)
- .font(.title3)
- .foregroundStyle(theme.currentSkin == skin ? theme.accent : theme.secondaryText)
- .frame(width: 32)
- VStack(alignment: .leading, spacing: 2) {
- Text(skin.rawValue)
- .font(.headline)
- .foregroundStyle(theme.primaryText)
- Text(skin.description)
- .font(.caption)
- .foregroundStyle(theme.secondaryText)
- }
- Spacer()
- if theme.currentSkin == skin {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(theme.accent)
- }
- }
- }
- }
- }
- // MARK: - Chad Music
- Section {
- HStack {
- Text("Server URL")
- .foregroundStyle(theme.secondaryText)
- TextField("https://music.example.com", text: $chadServerURL)
- .textContentType(.URL)
- .keyboardType(.URL)
- .autocapitalization(.none)
- .disableAutocorrection(true)
- .multilineTextAlignment(.trailing)
- .onChange(of: chadServerURL) { _, newValue in
- ChadMusicAPIClient.shared.serverURL = newValue
- }
- }
- HStack {
- Text("API Key")
- .foregroundStyle(theme.secondaryText)
- SecureField("Enter API key", text: $chadAPIKey)
- .multilineTextAlignment(.trailing)
- .onChange(of: chadAPIKey) { _, newValue in
- if newValue.isEmpty {
- KeychainService.deleteAPIKey()
- } else {
- try? KeychainService.saveAPIKey(newValue)
- }
- }
- }
- Button {
- testChadConnection()
- } label: {
- HStack {
- Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right")
- Spacer()
- switch chadTestResult {
- case .idle:
- EmptyView()
- case .testing:
- ProgressView()
- case .success:
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- case .failed:
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- }
- }
- }
- .disabled(chadServerURL.isEmpty || chadAPIKey.isEmpty)
- switch chadTestResult {
- case .success(let stats):
- HStack(spacing: 16) {
- if let tracks = stats.tracks {
- VStack {
- Text("\(tracks)")
- .font(.headline)
- Text("Tracks")
- .font(.caption2)
- .foregroundStyle(theme.secondaryText)
- }
- }
- if let albums = stats.albums {
- VStack {
- Text("\(albums)")
- .font(.headline)
- Text("Albums")
- .font(.caption2)
- .foregroundStyle(theme.secondaryText)
- }
- }
- if let artists = stats.artists {
- VStack {
- Text("\(artists)")
- .font(.headline)
- Text("Artists")
- .font(.caption2)
- .foregroundStyle(theme.secondaryText)
- }
- }
- }
- .frame(maxWidth: .infinity)
- case .failed(let msg):
- Text(msg)
- .font(.caption)
- .foregroundStyle(.red)
- default:
- EmptyView()
- }
- } header: {
- Text("Chad Music")
- } footer: {
- Text("Connect to your Chad Music server to stream cloud music. The server URL and API key are stored securely in the Keychain.")
- }
- // MARK: - Sync
- Section {
- Button {
- syncManager.exportPlaylists(playlists)
- showSyncExportConfirm = true
- } label: {
- Label("Export Playlists for Mac", systemImage: "square.and.arrow.up")
- }
- .disabled(playlists.isEmpty)
- Button {
- showSyncImporter = true
- } label: {
- Label("Import Playlists from Mac", systemImage: "square.and.arrow.down")
- }
- if let date = syncManager.lastSyncDate {
- HStack {
- Text("Last export")
- .foregroundStyle(theme.secondaryText)
- Spacer()
- Text(date.formatted(date: .abbreviated, time: .shortened))
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- }
- if let result = syncResult {
- Text(result)
- .font(.caption)
- .foregroundStyle(theme.accent)
- }
- } header: {
- Text("Sync")
- } footer: {
- Text("Export creates a JSON file in Documents/Sync/ that you can share with the Mac app via AirDrop, iCloud Drive, or USB.")
- }
- // MARK: - Library Stats
- Section("Library") {
- HStack {
- Text("Tracks")
- Spacer()
- Text("\(tracks.count)")
- .foregroundStyle(theme.secondaryText)
- }
- HStack {
- Text("Playlists")
- Spacer()
- Text("\(playlists.count)")
- .foregroundStyle(theme.secondaryText)
- }
- HStack {
- Text("Analyzed")
- Spacer()
- Text("\(tracks.filter(\.isAnalyzed).count) / \(tracks.count)")
- .foregroundStyle(theme.secondaryText)
- }
- Button {
- Task { await libraryManager.rescanMetadata() }
- } label: {
- Label("Rescan Metadata", systemImage: "arrow.clockwise")
- }
- .disabled(libraryManager.isScanning)
- Button(role: .destructive) {
- showResetConfirm = true
- } label: {
- Label("Reset Library & Rescan", systemImage: "arrow.counterclockwise")
- .foregroundStyle(.red)
- }
- }
- // MARK: - About
- Section("About") {
- HStack {
- Text("MixBoard iOS")
- Spacer()
- Text("1.0.0")
- .foregroundStyle(theme.tertiaryText)
- }
- HStack {
- Text("Supported Formats")
- Spacer()
- Text("MP3, FLAC, WAV, AIFF, M4A, AAC, OGG, Opus")
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- }
- }
- .navigationTitle("Settings")
- .accessibilityIdentifier("settingsView")
- .fileImporter(
- isPresented: $showSyncImporter,
- allowedContentTypes: [.json],
- allowsMultipleSelection: false
- ) { result in
- switch result {
- case .success(let urls):
- guard let url = urls.first else { return }
- let accessing = url.startAccessingSecurityScopedResource()
- defer { if accessing { url.stopAccessingSecurityScopedResource() } }
- do {
- let imported = try syncManager.importPlaylists(from: url, context: modelContext)
- let result = syncManager.mergeImportedPlaylists(imported, existingTracks: tracks, context: modelContext)
- syncResult = "Imported \(result.created) playlists, \(result.matched) tracks matched, \(result.unmatched) not found"
- } catch {
- syncResult = "Import failed: \(error.localizedDescription)"
- }
- case .failure(let error):
- syncResult = "Error: \(error.localizedDescription)"
- }
- }
- .alert("Exported", isPresented: $showSyncExportConfirm) {
- Button("OK") {}
- } message: {
- Text("Playlists exported to Documents/Sync/mixboard-playlists.json\n\nShare this file with your Mac via AirDrop, iCloud Drive, or Files app.")
- }
- .alert("Reset Library?", isPresented: $showResetConfirm) {
- Button("Cancel", role: .cancel) {}
- Button("Reset & Rescan", role: .destructive) {
- // Delete all tracks from database
- for track in tracks {
- modelContext.delete(track)
- }
- try? modelContext.save()
- // Rescan
- Task {
- await libraryManager.scanMusicDirectory()
- }
- }
- } message: {
- Text("This will remove all tracks from the database and re-scan your music folder. Your music files are NOT deleted.")
- }
- }
- }
- private func testChadConnection() {
- chadTestResult = .testing
- Task {
- let result = await ChadMusicAPIClient.shared.testConnection()
- switch result {
- case .success(let stats):
- chadTestResult = .success(stats)
- case .failure(let error):
- chadTestResult = .failed(error.localizedDescription)
- }
- }
- }
- }
|