Files
QueueCube/ios/QueueCubeShare/ShareViewController.swift
T

230 lines
8.8 KiB
Swift
Raw Permalink Normal View History

import SwiftUI
import UIKit
final class ShareViewController: UIViewController {
private let model = ShareViewModel()
private var inputTask: Task<Void, Never>?
private var playbackTask: Task<Void, Never>?
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<Void, Never>?
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") }
}
}
}