2026-10-20 22:08:12 -07:00
|
|
|
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)
|
2026-10-21 01:06:45 -07:00
|
|
|
let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] server in
|
|
|
|
|
self?.send(to: server)
|
2026-10-20 22:08:12 -07:00
|
|
|
}, 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)
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 01:06:45 -07:00
|
|
|
private func send(to server: Server) {
|
|
|
|
|
guard !model.sending, !model.finished, let url = model.url else { return }
|
2026-10-20 22:08:12 -07:00
|
|
|
model.sending = true
|
|
|
|
|
model.message = nil
|
|
|
|
|
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
|
|
|
|
|
} 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()
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deinit {
|
|
|
|
|
inputTask?.cancel()
|
|
|
|
|
playbackTask?.cancel()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@MainActor @Observable
|
|
|
|
|
private final class ShareViewModel {
|
2026-10-21 01:06:45 -07:00
|
|
|
let configuredServers = Settings.fromSharedDefaults().configuredServers
|
2026-10-20 22:08:12 -07:00
|
|
|
var url: URL?
|
|
|
|
|
var loading = true
|
|
|
|
|
var sending = false
|
|
|
|
|
var finished = false
|
|
|
|
|
var succeeded = false
|
|
|
|
|
var message: String?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct ShareView: View {
|
|
|
|
|
@Bindable var model: ShareViewModel
|
2026-10-21 01:06:45 -07:00
|
|
|
let send: (Server) -> Void
|
2026-10-20 22:08:12 -07:00
|
|
|
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)
|
2026-10-21 01:06:45 -07:00
|
|
|
.lineLimit(1)
|
|
|
|
|
.truncationMode(.middle)
|
2026-10-20 22:08:12 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !model.finished {
|
|
|
|
|
Section("Choose player") {
|
|
|
|
|
if model.configuredServers.isEmpty {
|
2026-10-21 01:06:45 -07:00
|
|
|
Text("Open QueueCube to set up your players.")
|
2026-10-20 22:08:12 -07:00
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
ForEach(model.configuredServers) { server in
|
|
|
|
|
Button {
|
2026-10-21 01:06:45 -07:00
|
|
|
send(server)
|
2026-10-20 22:08:12 -07:00
|
|
|
} label: {
|
2026-10-21 01:06:45 -07:00
|
|
|
HStack {
|
|
|
|
|
Label(server.displayName, systemImage: "hifispeaker.fill")
|
|
|
|
|
Spacer()
|
|
|
|
|
}
|
|
|
|
|
.foregroundStyle(Color.primary)
|
|
|
|
|
.contentShape(Rectangle())
|
2026-10-20 22:08:12 -07:00
|
|
|
}
|
2026-10-21 01:06:45 -07:00
|
|
|
.tint(.primary)
|
|
|
|
|
.disabled(model.url == nil || model.sending)
|
2026-10-20 22:08:12 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-21 01:06:45 -07:00
|
|
|
}
|
|
|
|
|
if model.sending {
|
|
|
|
|
Section {
|
|
|
|
|
ProgressView("Queuing and starting playback…")
|
2026-10-20 22:08:12 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let message = model.message {
|
|
|
|
|
Section {
|
|
|
|
|
Label(message, systemImage: model.succeeded ? "checkmark.circle.fill" : "exclamationmark.circle")
|
|
|
|
|
.foregroundStyle(model.succeeded ? Color.green : Color.primary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.navigationTitle("QueueCube")
|
|
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
|
|
.toolbar {
|
|
|
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
|
|
|
Button(model.finished ? "Done" : "Cancel", action: close)
|
|
|
|
|
.disabled(model.sending)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|