import SwiftUI import UIKit final class ShareViewController: UIViewController { private let model = ShareViewModel() private var inputTask: Task? private var playbackTask: Task? override func viewDidLoad() { super.viewDidLoad() preferredContentSize = CGSize(width: 480, height: 600) let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] in self?.send() }, close: { [weak self] in self?.close() })) addChild(host) host.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(host.view) NSLayoutConstraint.activate([ host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), host.view.topAnchor.constraint(equalTo: view.topAnchor), host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) host.didMove(toParent: self) model.discovery.start() inputTask = Task { [weak self] in guard let self else { return } do { model.url = try await SharedInput.load(extensionContext?.inputItems ?? []) } catch { guard !Task.isCancelled else { return } model.message = error.localizedDescription } model.loading = false } } private func send() { guard !model.sending, !model.finished, let url = model.url, let server = model.selectedServer else { return } model.sending = true model.message = nil model.discovery.stop() playbackTask = Task { [weak self] in guard let self else { return } do { try await SharePlayback.enqueueAndPlay(url, using: API(baseURL: server.baseURL, requestTimeout: 8)) try Task.checkCancellation() model.succeeded = true model.finished = true model.message = "Queued and playing on \(server.displayName)." } catch let error as SharePlayback.Failure { guard !Task.isCancelled else { return } model.message = error.localizedDescription model.finished = !error.canRetry if error.canRetry { model.discovery.start() } } catch { guard !Task.isCancelled else { return } model.message = "The request could not be confirmed. Check the player in QueueCube before sharing again." model.finished = true } model.sending = false } } private func close() { inputTask?.cancel() playbackTask?.cancel() model.stop() if model.finished { extensionContext?.completeRequest(returningItems: nil) } else { extensionContext?.cancelRequest(withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) inputTask?.cancel() playbackTask?.cancel() model.stop() } deinit { inputTask?.cancel() playbackTask?.cancel() } } @MainActor @Observable private final class ShareViewModel { let discovery = ServerDiscovery() let configuredServers: [Server] var selectedServer: Server? var url: URL? var loading = true var sending = false var finished = false var succeeded = false var resolving = false var message: String? private var resolutionTask: Task? init() { let settings = Settings.fromSharedDefaults() configuredServers = settings.configuredServers selectedServer = settings.selectedServer.flatMap { selected in settings.configuredServers.first { $0.id == selected.id } } ?? settings.configuredServers.first } func select(_ endpoint: DiscoveredEndpoint) { resolutionTask?.cancel() resolving = true message = nil resolutionTask = Task { do { let server = try await endpoint.resolve() try Task.checkCancellation() selectedServer = server } catch { if !Task.isCancelled { message = "Could not connect to that player. Check its connection and try again." } } if !Task.isCancelled { resolving = false } } } func stop() { resolutionTask?.cancel() discovery.stop() } } private struct ShareView: View { @Bindable var model: ShareViewModel let send: () -> Void let close: () -> Void var body: some View { NavigationStack { Form { Section("Video link") { if model.loading { ProgressView("Reading link…") } else if let url = model.url { Text(url.absoluteString) .font(.callout) .lineLimit(3) } } if !model.finished { Section("Choose player") { if model.configuredServers.isEmpty { Text("Open QueueCube once to share your saved players, or choose a nearby player below. Allow local network access in QueueCube to reach nearby players.") .foregroundStyle(.secondary) } ForEach(model.configuredServers) { server in Button { model.selectedServer = server } label: { playerLabel(server.displayName, selected: model.selectedServer?.id == server.id) } .disabled(model.sending || model.resolving) } if let selected = model.selectedServer, !model.configuredServers.contains(where: { $0.id == selected.id }) { playerLabel(selected.displayName, selected: true) } } if !model.sending { Section("Nearby players") { if model.discovery.servers.isEmpty { Text(model.discovery.errorMessage ?? "Looking for QueueCube players on your local network…") .foregroundStyle(.secondary) } ForEach(model.discovery.servers) { endpoint in Button(endpoint.displayName) { model.select(endpoint) } .disabled(model.resolving) } if model.resolving { ProgressView("Connecting…") } } } } if let message = model.message { Section { Label(message, systemImage: model.succeeded ? "checkmark.circle.fill" : "exclamationmark.circle") .foregroundStyle(model.succeeded ? Color.green : Color.primary) } } if !model.finished { Section { Button(action: send) { HStack { Spacer() if model.sending { ProgressView() } Text(model.sending ? "Queuing and starting playback…" : "Queue & Play") Spacer() } } .disabled(model.url == nil || model.selectedServer == nil || model.sending || model.resolving) Text("Adds this link to the queue and plays it now. Existing queued items are kept.") .font(.caption) .foregroundStyle(.secondary) } } } .navigationTitle("QueueCube") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.finished ? "Done" : "Cancel", action: close) .disabled(model.sending) } } } } private func playerLabel(_ name: String, selected: Bool) -> some View { HStack { Label(name, systemImage: "hifispeaker.fill") Spacer() if selected { Image(systemName: "checkmark").accessibilityLabel("Selected") } } } }