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.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.net.buzzert.QueueCube</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_queuecube._tcp.</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>QueueCube discovers nearby players so you can choose where to play this video.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,229 @@
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user