import SwiftData import SwiftUI /// Full settings window using macOS native TabView. /// Contains: Mix Targets, Appearance (themes), Playlist (columns, artwork, grouping), Playback, General. struct SettingsView: View { var body: some View { TabView { MixTargetSettings() .tabItem { Label("Mix Targets", systemImage: "target") } ChadMusicSettings() .tabItem { Label("Chad Music", systemImage: "cloud.fill") } AppearanceSettings() .tabItem { Label("Appearance", systemImage: "paintbrush") } PlaylistSettings() .tabItem { Label("Playlist", systemImage: "list.bullet") } PlaybackSettings() .tabItem { Label("Playback", systemImage: "play.circle") } KeyboardShortcutSettings() .tabItem { Label("Shortcuts", systemImage: "keyboard") } GeneralSettings() .tabItem { Label("General", systemImage: "gearshape") } } .frame(width: 580, height: 500) } } // MARK: - Mix Colors (shared across views) /// The 3 mix target colors: Red, Blue, Gold. let mixTargetColors: [Color] = [ Color(red: 0.95, green: 0.3, blue: 0.3), // Red Color(red: 0.3, green: 0.75, blue: 0.95), // Blue Color(red: 0.95, green: 0.75, blue: 0.2), // Yellow/Gold ] // MARK: - Mix Target Settings private struct MixTargetSettings: View { @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var theme: AppTheme @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist] private let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3] var body: some View { VStack(alignment: .leading, spacing: 20) { Text("Mix Targets") .font(.title3.bold()) 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.") .font(.callout) .foregroundStyle(.secondary) VStack(spacing: 12) { ForEach(0..<3, id: \.self) { slot in HStack(spacing: 12) { // Colored number badge Text("\(slot + 1)") .font(.system(size: 14, weight: .bold, design: .rounded)) .frame(width: 30, height: 30) .foregroundStyle(mixTargetColors[slot]) .background(mixTargetColors[slot].opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 6)) // Playlist picker — Picker with menu style for full-width clickability Picker(selection: Binding( get: { playlistVM.mixTargets[slot]?.id }, set: { newID in if let newID, let playlist = playlists.first(where: { $0.id == newID }) { playlistVM.setMixTarget(slot, playlist: playlist) } else { playlistVM.setMixTarget(slot, playlist: nil) } } )) { Text("Not set").tag(UUID?.none) Divider() ForEach(playlists) { playlist in Text(playlist.name).tag(Optional(playlist.id)) } } label: { EmptyView() } .labelsHidden() .frame(minWidth: 160) // Shortcut hint Text(shortcutConfig.binding(for: mixActions[slot]).displayString) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 50) } } } Divider() Text("Tip: You can also right-click a playlist in the sidebar to assign it to a mix slot.") .font(.callout) .foregroundStyle(.secondary) Spacer() } .padding(24) } } // MARK: - Appearance Settings (Theme/Skin picker) private struct AppearanceSettings: View { @EnvironmentObject private var theme: AppTheme @ObservedObject private var iconConfig = AppIconConfig.shared private let modernSkins: [AppTheme.Skin] = [.dark, .midnight, .forest, .ocean, .warm, .light] private let retroSkins: [AppTheme.Skin] = [.winampClassic, .winampModern, .foobarDark, .foobarLight, .win95, .win98, .xpLuna, .macOSClassic] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { // MARK: - App Icon Color Text("App Icon") .font(.title3.bold()) Text("Choose an accent color for the Dock icon.") .font(.callout) .foregroundStyle(.secondary) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(AppIconConfig.iconColors) { option in Button { iconConfig.selectedColorName = option.name } label: { VStack(spacing: 6) { RoundedRectangle(cornerRadius: 12) .fill(option.color) .frame(width: 50, height: 50) .overlay( RoundedRectangle(cornerRadius: 12) .stroke( iconConfig.selectedColorName == option.name ? Color.accentColor : Color.white.opacity(0.2), lineWidth: iconConfig.selectedColorName == option.name ? 2.5 : 1 ) ) .overlay { if iconConfig.selectedColorName == option.name { Image(systemName: "checkmark") .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) .shadow(radius: 2) } } Text(option.name) .font(.caption2) .foregroundStyle(.secondary) } } .buttonStyle(.plain) } } .padding(.vertical, 4) } Divider() // MARK: - Theme Text("Theme") .font(.title3.bold()) Text("Choose a visual theme for MixBoard. Each theme enforces its own light/dark appearance.") .font(.callout) .foregroundStyle(.secondary) // Modern VStack(alignment: .leading, spacing: 8) { Text("Modern") .font(.headline) .foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) { ForEach(modernSkins) { skin in SkinCard(skin: skin, isSelected: theme.currentSkin == skin) { withAnimation(.easeInOut(duration: 0.2)) { theme.currentSkin = skin } } } } } Divider() // Retro VStack(alignment: .leading, spacing: 8) { Text("Retro") .font(.headline) .foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) { ForEach(retroSkins) { skin in SkinCard(skin: skin, isSelected: theme.currentSkin == skin) { withAnimation(.easeInOut(duration: 0.2)) { theme.currentSkin = skin } } } } } } .padding(24) } } } /// A compact card showing a skin preview swatch and name. private struct SkinCard: View { let skin: AppTheme.Skin let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 10) { // Color swatch showing the skin's approximate palette RoundedRectangle(cornerRadius: 4) .fill(skin.previewColor) .frame(width: 28, height: 28) .overlay { if skin.colorScheme == .dark { Image(systemName: "moon.fill") .font(.system(size: 10)) .foregroundStyle(.white.opacity(0.7)) } else { Image(systemName: "sun.max.fill") .font(.system(size: 10)) .foregroundStyle(.black.opacity(0.5)) } } VStack(alignment: .leading, spacing: 1) { Text(skin.rawValue) .font(.system(size: 12, weight: .medium)) .lineLimit(1) Text(skin.colorScheme == .dark ? "Dark" : "Light") .font(.system(size: 10)) .foregroundStyle(.secondary) } Spacer(minLength: 0) if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) .font(.system(size: 14)) } } .padding(8) .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) ) .clipShape(RoundedRectangle(cornerRadius: 8)) .contentShape(Rectangle()) } .buttonStyle(.plain) } } // MARK: - Playlist Settings (Columns, Artwork, Grouping) private struct PlaylistSettings: View { @ObservedObject private var viewConfig = PlaylistViewConfig.shared var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { // Visible columns VStack(alignment: .leading, spacing: 8) { HStack { Text("Visible Columns") .font(.title3.bold()) Spacer() Button("Reset to Defaults") { viewConfig.resetToDefaults() } .font(.caption) } Text("Select which columns appear in the playlist view.") .font(.callout) .foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], spacing: 6) { ForEach(PlaylistViewConfig.Column.allCases) { column in Toggle(column.rawValue, isOn: Binding( get: { viewConfig.isColumnVisible(column) }, set: { _ in viewConfig.toggleColumn(column) } )) .toggleStyle(.checkbox) .font(.system(size: 12)) } } } Divider() // Artwork VStack(alignment: .leading, spacing: 8) { Text("Artwork") .font(.headline) Toggle("Show artwork thumbnails in playlist rows", isOn: $viewConfig.showArtwork) if viewConfig.showArtwork { Picker("Artwork size", selection: $viewConfig.artworkSize) { ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in Text(size.rawValue).tag(size) } } .pickerStyle(.segmented) .frame(width: 250) } } Divider() } .padding(24) } } } // MARK: - Playback Settings private struct PlaybackSettings: View { @ObservedObject private var viewConfig = PlaylistViewConfig.shared var body: some View { VStack(alignment: .leading, spacing: 20) { Text("Cursor Behavior") .font(.title3.bold()) VStack(alignment: .leading, spacing: 12) { Toggle("Cursor follows playback", isOn: Binding( get: { viewConfig.cursorFollowsPlayback }, set: { newValue in if newValue { viewConfig.cursorFollowsPlayback = true viewConfig.playbackFollowsCursor = false } else { viewConfig.cursorFollowsPlayback = false viewConfig.playbackFollowsCursor = true } } )) Text("Auto-select and scroll to the currently playing track.") .font(.callout) .foregroundStyle(.secondary) .padding(.leading, 20) Toggle("Playback follows cursor", isOn: Binding( get: { viewConfig.playbackFollowsCursor }, set: { newValue in if newValue { viewConfig.playbackFollowsCursor = true viewConfig.cursorFollowsPlayback = false } else { viewConfig.playbackFollowsCursor = false viewConfig.cursorFollowsPlayback = true } } )) Text("When a track finishes, play the currently selected track next (foobar2000 behavior).") .font(.callout) .foregroundStyle(.secondary) .padding(.leading, 20) } Spacer() } .padding(24) } } // MARK: - General Settings private struct GeneralSettings: View { @AppStorage("autoAnalyzeOnImport") private var autoAnalyzeOnImport = true var body: some View { VStack(alignment: .leading, spacing: 20) { Text("General") .font(.title3.bold()) VStack(alignment: .leading, spacing: 12) { Toggle("Auto-analyze BPM & Key on import", isOn: $autoAnalyzeOnImport) Text("Automatically detect BPM and musical key when adding tracks.") .font(.callout) .foregroundStyle(.secondary) .padding(.leading, 20) } Spacer() } .padding(24) } } // MARK: - Keyboard Shortcut Settings private struct KeyboardShortcutSettings: View { @ObservedObject private var config = KeyboardShortcutConfig.shared @State private var recordingAction: ShortcutAction? @State private var keyMonitor: Any? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { HStack { Text("Keyboard Shortcuts") .font(.title3.bold()) Spacer() Button("Reset to Defaults") { config.resetToDefaults() } .font(.caption) } Text("Click a shortcut to re-assign it. Press Escape to cancel.") .font(.callout) .foregroundStyle(.secondary) ForEach(ShortcutGroup.allCases, id: \.rawValue) { group in VStack(alignment: .leading, spacing: 6) { Text(group.rawValue) .font(.headline) .foregroundStyle(.secondary) ForEach(group.actions) { action in ShortcutRow( action: action, binding: config.binding(for: action), isRecording: recordingAction == action, onStartRecording: { startRecording(for: action) } ) } } if group != .general { Divider() } } } .padding(24) } .onDisappear { stopRecording() } } private func startRecording(for action: ShortcutAction) { // Stop any existing recording stopRecording() recordingAction = action keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in // Escape cancels if event.keyCode == 53 { self.stopRecording() return nil } let binding = ShortcutBinding.from(event) self.config.shortcuts[action] = binding self.stopRecording() return nil // consume the event } } private func stopRecording() { recordingAction = nil if let monitor = keyMonitor { NSEvent.removeMonitor(monitor) keyMonitor = nil } } } /// A single row: action name + clickable shortcut recorder button. private struct ShortcutRow: View { let action: ShortcutAction let binding: ShortcutBinding let isRecording: Bool let onStartRecording: () -> Void var body: some View { HStack { Text(action.rawValue) .font(.system(size: 12)) .frame(maxWidth: .infinity, alignment: .leading) Button { onStartRecording() } label: { Text(isRecording ? "Press shortcut…" : binding.displayString) .font(.system(size: 12, design: isRecording ? .default : .monospaced)) .frame(minWidth: 90) .padding(.horizontal, 8) .padding(.vertical, 4) .background(isRecording ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(isRecording ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1) ) } .buttonStyle(.plain) .animation(.easeInOut(duration: 0.15), value: isRecording) } .padding(.vertical, 2) } } // MARK: - Chad Music Settings private struct ChadMusicSettings: View { @AppStorage("chadMusic.serverURL") private var serverURL: String = "" @AppStorage("chadMusic.apiKey") private var apiKey: String = "" @State private var connectionStatus: ConnectionStatus = .unknown @State private var isTesting = false @State private var statsText: String = "" private enum ConnectionStatus { case unknown, testing, success, failed(String) } var body: some View { VStack(alignment: .leading, spacing: 20) { Text("Chad Music") .font(.title3.bold()) Text("Connect to your Chad Music server to browse and stream your cloud library.") .font(.callout) .foregroundStyle(.secondary) // Server URL VStack(alignment: .leading, spacing: 6) { Text("Server URL") .font(.headline) TextField("https://music.tailnet.ts.net", text: $serverURL) .textFieldStyle(.roundedBorder) .onChange(of: serverURL) { _, _ in connectionStatus = .unknown } } // API Key VStack(alignment: .leading, spacing: 6) { Text("API Key") .font(.headline) SecureField("Enter API key", text: $apiKey) .textFieldStyle(.roundedBorder) .onChange(of: apiKey) { _, _ in connectionStatus = .unknown } } Divider() // Connection test HStack(spacing: 12) { Button("Test Connection") { testConnection() } .disabled(serverURL.isEmpty || apiKey.isEmpty || isTesting) switch connectionStatus { case .unknown: EmptyView() case .testing: ProgressView() .controlSize(.small) Text("Connecting...") .font(.callout) .foregroundStyle(.secondary) case .success: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text(statsText) .font(.callout) .foregroundStyle(.secondary) case .failed(let message): Image(systemName: "xmark.circle.fill") .foregroundStyle(.red) Text(message) .font(.callout) .foregroundStyle(.red) } } Spacer() } .padding(24) } private func testConnection() { connectionStatus = .testing isTesting = true Task { let client = ChadMusicAPIClient.shared let result = await client.testConnection() switch result { case .success(let stats): let parts = [ stats.tracks.map { "\($0) tracks" }, stats.albums.map { "\($0) albums" }, stats.artists.map { "\($0) artists" }, ].compactMap { $0 } statsText = "Connected — " + parts.joined(separator: ", ") connectionStatus = .success case .failure(let error): connectionStatus = .failed(error.localizedDescription) } isTesting = false } } } // MARK: - Skin Preview Color extension AppTheme.Skin { /// Approximate preview swatch color for the skin card. var previewColor: Color { switch self { case .dark: return Color(red: 0.15, green: 0.15, blue: 0.17) case .midnight: return Color(red: 0.1, green: 0.1, blue: 0.2) case .forest: return Color(red: 0.1, green: 0.18, blue: 0.1) case .ocean: return Color(red: 0.1, green: 0.15, blue: 0.2) case .warm: return Color(red: 0.2, green: 0.15, blue: 0.1) case .light: return Color(red: 0.95, green: 0.95, blue: 0.96) case .winampClassic: return Color(red: 0.12, green: 0.12, blue: 0.14) case .winampModern: return Color(red: 0.13, green: 0.14, blue: 0.18) case .foobarDark: return Color(red: 0.14, green: 0.14, blue: 0.14) case .foobarLight: return Color(red: 0.94, green: 0.94, blue: 0.94) case .win95: return Color(red: 0.75, green: 0.75, blue: 0.75) case .win98: return Color(red: 0.83, green: 0.82, blue: 0.78) case .xpLuna: return Color(red: 0.85, green: 0.89, blue: 0.95) case .macOSClassic: return Color(red: 0.86, green: 0.86, blue: 0.86) } } }