// // AddServerView.swift // QueueCube // // Created by James Magahern on 6/10/25. // import SwiftUI struct AddServerView: View { let onAddServer: (Server) -> Void @State var model = ViewModel() var body: some View { Form { // Manual Entry Section(.manual) { TextField(.serverURL, text: $model.serverURL) .autocapitalization(.none) .autocorrectionDisabled() .keyboardType(.URL) switch model.validationState { case .empty: EmptyView() case .validating: HStack { ProgressView() .progressViewStyle(.circular) Text(.validating) } case .notValid: HStack { Image(systemName: "x.circle.fill") Text(.unableToConnect) } .foregroundStyle(.red) case .valid: HStack { Image(systemName: "checkmark.circle.fill") Text(.serverIsOnline) } .foregroundStyle(.green) Button { // Force unwrap, since we validated it at this point. let server = Server(baseURL: URL(string: model.serverURL)!) onAddServer(server) } label: { HStack { Spacer() Text(.addServer) Spacer() } } } } // Discovered Section(.discovered) { if let message = model.resolutionError ?? model.discovery.errorMessage { Text(message) .foregroundStyle(.red) } if model.discovery.servers.isEmpty { HStack { ProgressView() .progressViewStyle(.circular) Text(.findingServers) } } else { List(model.discovery.servers) { (server: DiscoveredEndpoint) in Button { resolveEndpoint(server) } label: { HStack { Image(systemName: "network") Text("\(server.displayName)") .bold() Spacer() if model.resolvingServers.contains(server) { ProgressView() .progressViewStyle(.circular) } } } .tint(.primary) } } } } .task { model.discovery.start() } .onDisappear { model.discovery.stop() } } private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) { Task { model.resolvingServers.insert(endpoint) model.resolutionError = nil defer { model.resolvingServers.remove(endpoint) } do { let server = try await endpoint.resolve() onAddServer(server) } catch is CancellationError { // The view or its caller no longer needs this result. } catch { model.resolutionError = error.localizedDescription } } } // MARK: - Types @MainActor @Observable class ViewModel { var serverURL: String = "" var validationURL: String = "" var validationState: ValidationState = .empty let discovery = ServerDiscovery() var resolvingServers = Set() var resolutionError: String? private var validationTimer: Timer? = nil init() { observeForValidation() } private func observeForValidation() { withObservationTracking { _ = serverURL } onChange: { Task { @MainActor [weak self] in guard let self else { return } setNeedsValidation() observeForValidation() } } } private func setNeedsValidation() { self.validationURL = self.serverURL self.validationTimer?.invalidate() self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in self?.validateSettings() } } } private func validateSettings() { guard !validationURL.isEmpty else { validationState = .empty return } self.validationState = .validating Task { do { let url = try URL(string: validationURL).try_unwrap() let api = API(baseURL: url) _ = try await api.fetchNowPlayingInfo() self.validationState = .valid if validationURL != serverURL { self.serverURL = self.validationURL } } catch { print("Validation failed: \(error)") if !validationURL.hasSuffix("/api") { // Try adding /api and validating again. self.validationURL = serverURL.appending("/api") validateSettings() } else { self.validationState = .notValid } } } } // MARK: - Types enum ValidationState { case empty case validating case notValid case valid } } }