ios: simplify share extension to saved-player tap-to-play

Show only configured players, enqueue and play immediately on row tap,
and keep the URL on one truncated line. Remove extension discovery,
selection state, and the bottom action while retaining progress and
existing duplicate/partial-failure guards. Preserve main-app discovery.

Update share-extension guidance to match the simplified flow.
This commit is contained in:
2026-10-21 01:06:45 -07:00
parent 174a8f8255
commit cedd0c6aa2
7 changed files with 36 additions and 113 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ It allows you to remotely manage mpv's playqueue as well as the ability to searc
## Sharing video links ## Sharing video links
The iOS and Mac Catalyst app includes a QueueCube share extension. Share one The iOS and Mac Catalyst app includes a QueueCube share extension. Share one
video link, choose a player, and tap **Queue & Play** to append and play it while video link and tap a saved player to append and play it while
keeping the existing queue. See [share extension setup, API behavior, and validation](ios/SHARE_EXTENSION.md). keeping the existing queue. See [share extension setup, API behavior, and validation](ios/SHARE_EXTENSION.md).
## iOS TestFlight releases ## iOS TestFlight releases
+3 -2
View File
@@ -13,7 +13,7 @@ QueueCube is a SwiftUI-based jukebox client application for iOS and macOS (via M
- **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates. - **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates.
- **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow. - **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow.
- **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery. - **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery.
- **ServerDiscovery.swift**: Shared Bonjour browsing and bounded, cancellation-safe endpoint resolution for the app and share extension. - **ServerDiscovery.swift**: Main-app Bonjour browsing and bounded, cancellation-safe endpoint resolution for Add Server.
- **RequestBuilder.swift**: Shared extension-safe HTTP builder and error handling. - **RequestBuilder.swift**: Shared extension-safe HTTP builder and error handling.
- **SharePlayback.swift**: Append once, select the new queue item, unpause, and verify playback using existing endpoints. - **SharePlayback.swift**: Append once, select the new queue item, unpause, and verify playback using existing endpoints.
- **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes. - **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes.
@@ -99,5 +99,6 @@ QueueCubeApp
## Share extension ## Share extension
`QueueCubeShare` is embedded in iOS and Mac Catalyst builds. It reads an App Group `QueueCubeShare` is embedded in iOS and Mac Catalyst builds. It reads an App Group
settings snapshot and reuses the API/discovery code. See [SHARE_EXTENSION.md](SHARE_EXTENSION.md) settings snapshot and reuses the API code. Tapping a saved player queues and plays
the shared link; discovery remains in the main app. See [SHARE_EXTENSION.md](SHARE_EXTENSION.md)
for authentication limitations, queue/play failure semantics, signing setup, and focused tests. for authentication limitations, queue/play failure semantics, signing setup, and focused tests.
-4
View File
@@ -11,7 +11,6 @@
CD2026102000000000000201 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000101 /* RequestBuilder.swift */; }; CD2026102000000000000201 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000101 /* RequestBuilder.swift */; };
CD2026102000000000000202 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000102 /* Server.swift */; }; CD2026102000000000000202 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000102 /* Server.swift */; };
CD2026102000000000000203 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000103 /* Settings.swift */; }; CD2026102000000000000203 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000103 /* Settings.swift */; };
CD2026102000000000000204 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000104 /* ServerDiscovery.swift */; };
CD2026102000000000000205 /* SharedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000105 /* SharedVideo.swift */; }; CD2026102000000000000205 /* SharedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000105 /* SharedVideo.swift */; };
CD2026102000000000000206 /* SharePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000106 /* SharePlayback.swift */; }; CD2026102000000000000206 /* SharePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000106 /* SharePlayback.swift */; };
CD2026102000000000000207 /* SharedInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000107 /* SharedInput.swift */; }; CD2026102000000000000207 /* SharedInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000107 /* SharedInput.swift */; };
@@ -49,7 +48,6 @@
CD2026102000000000000101 /* RequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = "<group>"; }; CD2026102000000000000101 /* RequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = "<group>"; };
CD2026102000000000000102 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; }; CD2026102000000000000102 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; };
CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; }; CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
CD2026102000000000000104 /* ServerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = "<group>"; }; CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = "<group>"; };
CD2026102000000000000106 /* SharePlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayback.swift; sourceTree = "<group>"; }; CD2026102000000000000106 /* SharePlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayback.swift; sourceTree = "<group>"; };
CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = "<group>"; }; CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = "<group>"; };
@@ -137,7 +135,6 @@
CD2026102000000000000101 /* RequestBuilder.swift */, CD2026102000000000000101 /* RequestBuilder.swift */,
CD2026102000000000000102 /* Server.swift */, CD2026102000000000000102 /* Server.swift */,
CD2026102000000000000103 /* Settings.swift */, CD2026102000000000000103 /* Settings.swift */,
CD2026102000000000000104 /* ServerDiscovery.swift */,
CD2026102000000000000105 /* SharedVideo.swift */, CD2026102000000000000105 /* SharedVideo.swift */,
CD2026102000000000000106 /* SharePlayback.swift */, CD2026102000000000000106 /* SharePlayback.swift */,
CD2026102000000000000107 /* SharedInput.swift */, CD2026102000000000000107 /* SharedInput.swift */,
@@ -272,7 +269,6 @@
CD2026102000000000000201 /* RequestBuilder.swift in Sources */, CD2026102000000000000201 /* RequestBuilder.swift in Sources */,
CD2026102000000000000202 /* Server.swift in Sources */, CD2026102000000000000202 /* Server.swift in Sources */,
CD2026102000000000000203 /* Settings.swift in Sources */, CD2026102000000000000203 /* Settings.swift in Sources */,
CD2026102000000000000204 /* ServerDiscovery.swift in Sources */,
CD2026102000000000000205 /* SharedVideo.swift in Sources */, CD2026102000000000000205 /* SharedVideo.swift in Sources */,
CD2026102000000000000206 /* SharePlayback.swift in Sources */, CD2026102000000000000206 /* SharePlayback.swift in Sources */,
CD2026102000000000000207 /* SharedInput.swift in Sources */, CD2026102000000000000207 /* SharedInput.swift in Sources */,
+1 -1
View File
@@ -1,4 +1,4 @@
// Shared Bonjour discovery for the app and share extension. // Bonjour discovery for the main app's Add Server view.
import Foundation import Foundation
import Network import Network
+1 -5
View File
@@ -4,12 +4,8 @@
<dict> <dict>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>NSBonjourServices</key>
<array>
<string>_queuecube._tcp.</string>
</array>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>QueueCube discovers nearby players so you can choose where to play this video.</string> <string>QueueCube connects to your saved players to queue and play this video.</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
+20 -90
View File
@@ -9,8 +9,8 @@ final class ShareViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
preferredContentSize = CGSize(width: 480, height: 600) preferredContentSize = CGSize(width: 480, height: 600)
let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] in let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] server in
self?.send() self?.send(to: server)
}, close: { [weak self] in }, close: { [weak self] in
self?.close() self?.close()
})) }))
@@ -24,7 +24,6 @@ final class ShareViewController: UIViewController {
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
]) ])
host.didMove(toParent: self) host.didMove(toParent: self)
model.discovery.start()
inputTask = Task { [weak self] in inputTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
do { do {
@@ -37,12 +36,10 @@ final class ShareViewController: UIViewController {
} }
} }
private func send() { private func send(to server: Server) {
guard !model.sending, !model.finished, let url = model.url, guard !model.sending, !model.finished, let url = model.url else { return }
let server = model.selectedServer else { return }
model.sending = true model.sending = true
model.message = nil model.message = nil
model.discovery.stop()
playbackTask = Task { [weak self] in playbackTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
do { do {
@@ -55,7 +52,6 @@ final class ShareViewController: UIViewController {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
model.message = error.localizedDescription model.message = error.localizedDescription
model.finished = !error.canRetry model.finished = !error.canRetry
if error.canRetry { model.discovery.start() }
} catch { } catch {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
model.message = "The request could not be confirmed. Check the player in QueueCube before sharing again." model.message = "The request could not be confirmed. Check the player in QueueCube before sharing again."
@@ -68,7 +64,6 @@ final class ShareViewController: UIViewController {
private func close() { private func close() {
inputTask?.cancel() inputTask?.cancel()
playbackTask?.cancel() playbackTask?.cancel()
model.stop()
if model.finished { if model.finished {
extensionContext?.completeRequest(returningItems: nil) extensionContext?.completeRequest(returningItems: nil)
} else { } else {
@@ -80,7 +75,6 @@ final class ShareViewController: UIViewController {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
inputTask?.cancel() inputTask?.cancel()
playbackTask?.cancel() playbackTask?.cancel()
model.stop()
} }
deinit { deinit {
@@ -91,53 +85,18 @@ final class ShareViewController: UIViewController {
@MainActor @Observable @MainActor @Observable
private final class ShareViewModel { private final class ShareViewModel {
let discovery = ServerDiscovery() let configuredServers = Settings.fromSharedDefaults().configuredServers
let configuredServers: [Server]
var selectedServer: Server?
var url: URL? var url: URL?
var loading = true var loading = true
var sending = false var sending = false
var finished = false var finished = false
var succeeded = false var succeeded = false
var resolving = false
var message: String? 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 { private struct ShareView: View {
@Bindable var model: ShareViewModel @Bindable var model: ShareViewModel
let send: () -> Void let send: (Server) -> Void
let close: () -> Void let close: () -> Void
var body: some View { var body: some View {
@@ -149,40 +108,35 @@ private struct ShareView: View {
} else if let url = model.url { } else if let url = model.url {
Text(url.absoluteString) Text(url.absoluteString)
.font(.callout) .font(.callout)
.lineLimit(3) .lineLimit(1)
.truncationMode(.middle)
} }
} }
if !model.finished { if !model.finished {
Section("Choose player") { Section("Choose player") {
if model.configuredServers.isEmpty { 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.") Text("Open QueueCube to set up your players.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
ForEach(model.configuredServers) { server in ForEach(model.configuredServers) { server in
Button { Button {
model.selectedServer = server send(server)
} label: { } label: {
playerLabel(server.displayName, selected: model.selectedServer?.id == server.id) HStack {
Label(server.displayName, systemImage: "hifispeaker.fill")
Spacer()
} }
.disabled(model.sending || model.resolving) .foregroundStyle(Color.primary)
.contentShape(Rectangle())
} }
if let selected = model.selectedServer, .tint(.primary)
!model.configuredServers.contains(where: { $0.id == selected.id }) { .disabled(model.url == nil || model.sending)
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 model.sending {
Section {
ProgressView("Queuing and starting playback…")
} }
} }
if let message = model.message { if let message = model.message {
@@ -191,22 +145,6 @@ private struct ShareView: View {
.foregroundStyle(model.succeeded ? Color.green : Color.primary) .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") .navigationTitle("QueueCube")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -218,12 +156,4 @@ private struct ShareView: View {
} }
} }
} }
private func playerLabel(_ name: String, selected: Bool) -> some View {
HStack {
Label(name, systemImage: "hifispeaker.fill")
Spacer()
if selected { Image(systemName: "checkmark").accessibilityLabel("Selected") }
}
}
} }
+8 -8
View File
@@ -2,15 +2,15 @@
Open QueueCube once after updating to make saved players available to the share Open QueueCube once after updating to make saved players available to the share
extension. In Safari or a video app, share one HTTP(S) link, choose QueueCube, extension. In Safari or a video app, share one HTTP(S) link, choose QueueCube,
select a saved or nearby player, and tap **Queue & Play**. The link is appended and tap a saved player. The link is appended
and played immediately; existing queue entries remain. A successful request and played immediately; existing queue entries remain. A successful request
shows the selected player and a Done button. Video support depends on the shows the selected player and a Done button. Video support depends on the
player's existing mpv/yt-dlp support. player's existing mpv/yt-dlp support.
Nearby players use the same `_queuecube._tcp.` Bonjour discovery as Add Server. The extension shows only saved players and does not browse for nearby players.
Resolved IPv4 interface suffixes are removed; scoped IPv6 addresses remain valid. Add players manually or with Bonjour discovery in QueueCube Settings first.
Allow local network access in the containing app first. Players can also be Allow local network access in the containing app to reach local players.
configured manually in QueueCube Settings. The extension handles URL attachments The extension handles URL attachments
(including UTF-8 data and the absolute-URL property-list form observed from (including UTF-8 data and the absolute-URL property-list form observed from
UIKit) and plain text containing one link. URL data keeps the same single-link UIKit) and plain text containing one link. URL data keeps the same single-link
validation; property lists with a nonempty base or properties dictionary and validation; property lists with a nonempty base or properties dictionary and
@@ -25,7 +25,7 @@ The app and `net.buzzert.QueueCube.Share` extension share
publishes a single versioned settings snapshot on read/save. The extension reads publishes a single versioned settings snapshot on read/save. The extension reads
that snapshot without changing the app's selected player or server list. No that snapshot without changing the app's selected player or server list. No
queued link, credentials store, background transfer, or keychain group is added. queued link, credentials store, background transfer, or keychain group is added.
The extension has Bonjour/local network declarations and the same HTTP transport The extension has a local network usage declaration and the same HTTP transport
compatibility as the existing app; Catalyst adds only app sandbox and outgoing compatibility as the existing app; Catalyst adds only app sandbox and outgoing
network access. network access.
@@ -94,8 +94,8 @@ app/extension versions synchronized. Do not apply a single global bundle ID or
provisioning profile override to both targets. provisioning profile override to both targets.
For manual UI validation, use a dedicated simulator or test installation: For manual UI validation, use a dedicated simulator or test installation:
configure a test player in QueueCube, share a URL from Safari, verify saved and configure a test player in QueueCube, share a URL from Safari, verify saved-player
nearby player selection, Queue & Play success, cancellation, invalid text, and tap-to-play, the empty saved-player state, cancellation, invalid text, and
an offline/partially failing player. Confirm the old queue is retained and the an offline/partially failing player. Confirm the old queue is retained and the
new item is current and unpaused. On macOS also confirm QueueCube appears in new item is current and unpaused. On macOS also confirm QueueCube appears in
Share extensions and can be invoked from a host app. Record signed-device and Share extensions and can be invoked from a host app. Record signed-device and