| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005 |
- 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")
- }
- SlskdSettings()
- .tabItem {
- Label("Soulseek", systemImage: "arrow.down.circle.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, .djBoard]
- 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
- @AppStorage("playbackMode") private var playbackMode: String = "queue"
- var body: some View {
- VStack(alignment: .leading, spacing: 20) {
- Text("Playback Mode")
- .font(.title3.bold())
- Picker("Mode", selection: $playbackMode) {
- Text("Playlist Mode").tag("playlist")
- Text("Queue Mode").tag("queue")
- }
- .pickerStyle(.radioGroup)
- .labelsHidden()
- Group {
- if playbackMode == "playlist" {
- Text("Click a track to play it and continue through the playlist (foobar-style).")
- } else {
- Text("Manage a playback queue with Play Next and Add to Queue (Spotify-style).")
- }
- }
- .font(.callout)
- .foregroundStyle(.secondary)
- Divider()
- 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 = ""
- @State private var apiKey: String = ChadMusicCredentials.shared.apiKey ?? ""
- @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) { _, newValue in
- connectionStatus = .unknown
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty {
- ChadMusicCredentials.shared.delete()
- } else {
- try? ChadMusicCredentials.shared.save(trimmed)
- }
- }
- }
- 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: - Soulseek Settings
- private struct SlskdSettings: View {
- @AppStorage("slskd.serverMode") private var serverModeRaw: String = "managed"
- @AppStorage("slskd.serverURL") private var serverURL: String = ""
- @State private var username: String = SlskdCredentials.shared.username ?? ""
- @State private var password: String = SlskdCredentials.shared.password ?? ""
- @State private var soulseekUsername: String = SlskdCredentials.shared.username ?? ""
- @State private var soulseekPassword: String = SlskdCredentials.shared.password ?? ""
- @State private var connectionStatus: SlskdConnectionStatus = .unknown
- @State private var isTesting = false
- @AppStorage("slskd.qualityThreshold") private var qualityThreshold: Int = 80
- /// H-6: Track whether credentials have unsaved changes.
- @State private var hasUnsavedCredentials = false
- /// H-6: Debounce timer to avoid saving on every keystroke.
- @State private var saveDebounceTask: Task<Void, Never>?
- private var serverMode: SlskdServerMode {
- SlskdServerMode(rawValue: serverModeRaw) ?? .managed
- }
- private enum SlskdConnectionStatus {
- case unknown, testing, success, failed(String)
- }
- var body: some View {
- VStack(alignment: .leading, spacing: 20) {
- Text("Soulseek")
- .font(.title3.bold())
- Text("Search and download music from the Soulseek network. MixBoard can manage slskd automatically, or you can connect to your own server.")
- .font(.callout)
- .foregroundStyle(.secondary)
- // Mode picker
- Picker("Server Mode", selection: $serverModeRaw) {
- Text("Managed").tag("managed")
- Text("External").tag("external")
- }
- .pickerStyle(.segmented)
- .frame(maxWidth: 300)
- .onChange(of: serverModeRaw) { _, newValue in
- connectionStatus = .unknown
- SlskdAPIClient.shared.serverMode = SlskdServerMode(rawValue: newValue) ?? .managed
- }
- if serverMode == .managed {
- managedModeSection
- } else {
- externalModeSection
- }
- // Quality threshold (both modes)
- VStack(alignment: .leading, spacing: 6) {
- HStack {
- Text("Quality Threshold")
- .font(.headline)
- Spacer()
- Text("\(qualityThreshold)")
- .font(.system(.body, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- Slider(value: Binding(
- get: { Double(qualityThreshold) },
- set: { qualityThreshold = Int($0) }
- ), in: 30...150, step: 10)
- Text("Sources scoring below \(qualityThreshold) are grayed out. Higher = stricter (FLAC preferred). Lower = more results.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Divider()
- // Connection test (both modes)
- HStack(spacing: 12) {
- Button("Test Connection") {
- testConnection()
- }
- .disabled(isTesting || (serverMode == .external && (serverURL.isEmpty || username.isEmpty || password.isEmpty)))
- 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("Connected to slskd")
- .font(.callout)
- .foregroundStyle(.secondary)
- case .failed(let message):
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- Text(message)
- .font(.callout)
- .foregroundStyle(.red)
- }
- }
- Spacer()
- }
- .padding(24)
- .onDisappear {
- if hasUnsavedCredentials {
- saveCredentials()
- }
- saveDebounceTask?.cancel()
- }
- }
- // MARK: - Managed Mode
- @ViewBuilder
- private var managedModeSection: some View {
- // Status indicator
- HStack(spacing: 8) {
- switch SlskdProcessManager.shared.state {
- case .stopped:
- Image(systemName: "circle")
- .foregroundStyle(.secondary)
- Text("Stopped")
- .foregroundStyle(.secondary)
- case .downloading(let progress):
- ProgressView()
- .controlSize(.small)
- Text("Downloading slskd... \(Int(progress * 100))%")
- .foregroundStyle(.secondary)
- case .starting:
- ProgressView()
- .controlSize(.small)
- Text("Starting...")
- .foregroundStyle(.secondary)
- case .running:
- Image(systemName: "circle.fill")
- .foregroundStyle(.green)
- .font(.system(size: 8))
- Text("Running on localhost:\(SlskdProcessManager.port)")
- .foregroundStyle(.secondary)
- case .failed(let message):
- Image(systemName: "exclamationmark.circle.fill")
- .foregroundStyle(.red)
- Text(message)
- .foregroundStyle(.red)
- }
- }
- .font(.callout)
- // Start / Stop
- HStack(spacing: 12) {
- if SlskdProcessManager.shared.state == .running {
- Button("Stop") {
- SlskdProcessManager.shared.stop()
- }
- } else if case .downloading = SlskdProcessManager.shared.state {
- // Can't stop during download
- } else if SlskdProcessManager.shared.state == .starting {
- // Can't stop during startup
- } else {
- Button("Start") {
- Task { try? await SlskdProcessManager.shared.start() }
- }
- }
- }
- // Soulseek P2P credentials (needed for the network)
- VStack(alignment: .leading, spacing: 6) {
- Text("Soulseek Account")
- .font(.headline)
- Text("Your Soulseek network credentials (not the slskd API).")
- .font(.caption)
- .foregroundStyle(.secondary)
- TextField("Soulseek username", text: $soulseekUsername)
- .textFieldStyle(.roundedBorder)
- .onChange(of: soulseekUsername) { _, _ in
- scheduleDebouncedSave()
- }
- SecureField("Soulseek password", text: $soulseekPassword)
- .textFieldStyle(.roundedBorder)
- .onChange(of: soulseekPassword) { _, _ in
- scheduleDebouncedSave()
- }
- }
- if hasUnsavedCredentials {
- unsavedCredentialsIndicator
- }
- }
- // MARK: - External Mode
- @ViewBuilder
- private var externalModeSection: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("Server URL")
- .font(.headline)
- TextField("http://100.x.x.x:5030", text: $serverURL)
- .textFieldStyle(.roundedBorder)
- .onChange(of: serverURL) { _, _ in
- connectionStatus = .unknown
- }
- }
- VStack(alignment: .leading, spacing: 6) {
- Text("Username")
- .font(.headline)
- TextField("slskd username", text: $username)
- .textFieldStyle(.roundedBorder)
- .onChange(of: username) { _, _ in
- connectionStatus = .unknown
- scheduleDebouncedSave()
- }
- }
- VStack(alignment: .leading, spacing: 6) {
- Text("Password")
- .font(.headline)
- SecureField("slskd password", text: $password)
- .textFieldStyle(.roundedBorder)
- .onChange(of: password) { _, _ in
- connectionStatus = .unknown
- scheduleDebouncedSave()
- }
- }
- if hasUnsavedCredentials {
- unsavedCredentialsIndicator
- }
- }
- // MARK: - Shared Components
- private var unsavedCredentialsIndicator: some View {
- HStack(spacing: 6) {
- Image(systemName: "exclamationmark.circle.fill")
- .foregroundStyle(.orange)
- .font(.system(size: 12))
- Text("Unsaved changes")
- .font(.caption)
- .foregroundStyle(.orange)
- Spacer()
- Button("Save Credentials") {
- saveCredentials()
- }
- .controlSize(.small)
- }
- }
- // MARK: - Actions
- /// H-6: Schedule a debounced save (1.5s after last keystroke).
- private func scheduleDebouncedSave() {
- hasUnsavedCredentials = true
- saveDebounceTask?.cancel()
- saveDebounceTask = Task { @MainActor in
- try? await Task.sleep(for: .seconds(1.5))
- guard !Task.isCancelled else { return }
- saveCredentials()
- }
- }
- private func saveCredentials() {
- if serverMode == .managed {
- let u = soulseekUsername.trimmingCharacters(in: .whitespacesAndNewlines)
- let p = soulseekPassword.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !u.isEmpty, !p.isEmpty else { return }
- try? SlskdCredentials.shared.save(username: u, password: p)
- } else {
- let u = username.trimmingCharacters(in: .whitespacesAndNewlines)
- let p = password.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !u.isEmpty, !p.isEmpty else { return }
- try? SlskdCredentials.shared.save(username: u, password: p)
- }
- hasUnsavedCredentials = false
- }
- private func testConnection() {
- connectionStatus = .testing
- isTesting = true
- Task {
- let error = await SlskdAPIClient.shared.testConnection()
- if let error {
- connectionStatus = .failed(error.localizedDescription)
- } else {
- connectionStatus = .success
- }
- 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 .djBoard: return Color(red: 0.04, green: 0.04, blue: 0.06)
- 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)
- }
- }
- }
|