Files
QueueCube/ios/QueueCube/Views/Settings View/AddServerView.swift
T
buzzert 8723955705 ios: add share extension to queue and play video links
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.
2026-10-20 22:08:12 -07:00

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
}
}
}