230 lines
8.8 KiB
Swift
230 lines
8.8 KiB
Swift
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") }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|