Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success. Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits.
203 lines
6.5 KiB
Swift
203 lines
6.5 KiB
Swift
//
|
|
// 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<DiscoveredEndpoint>()
|
|
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
|
|
}
|
|
}
|
|
}
|