diff --git a/README.md b/README.md index c5f3342..4aff1cf 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ It allows you to remotely manage mpv's playqueue as well as the ability to searc ## Sharing video links 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). ## iOS TestFlight releases diff --git a/ios/CLAUDE.md b/ios/CLAUDE.md index ef364a6..a158b64 100644 --- a/ios/CLAUDE.md +++ b/ios/CLAUDE.md @@ -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. - **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. -- **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. - **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. @@ -99,5 +99,6 @@ QueueCubeApp ## Share extension `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. diff --git a/ios/QueueCube.xcodeproj/project.pbxproj b/ios/QueueCube.xcodeproj/project.pbxproj index 52799d2..62f8581 100644 --- a/ios/QueueCube.xcodeproj/project.pbxproj +++ b/ios/QueueCube.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ CD2026102000000000000201 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000101 /* RequestBuilder.swift */; }; CD2026102000000000000202 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000102 /* Server.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 */; }; CD2026102000000000000206 /* SharePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000106 /* SharePlayback.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 = ""; }; CD2026102000000000000102 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; - CD2026102000000000000104 /* ServerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = ""; }; CD2026102000000000000106 /* SharePlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayback.swift; sourceTree = ""; }; CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = ""; }; @@ -137,7 +135,6 @@ CD2026102000000000000101 /* RequestBuilder.swift */, CD2026102000000000000102 /* Server.swift */, CD2026102000000000000103 /* Settings.swift */, - CD2026102000000000000104 /* ServerDiscovery.swift */, CD2026102000000000000105 /* SharedVideo.swift */, CD2026102000000000000106 /* SharePlayback.swift */, CD2026102000000000000107 /* SharedInput.swift */, @@ -272,7 +269,6 @@ CD2026102000000000000201 /* RequestBuilder.swift in Sources */, CD2026102000000000000202 /* Server.swift in Sources */, CD2026102000000000000203 /* Settings.swift in Sources */, - CD2026102000000000000204 /* ServerDiscovery.swift in Sources */, CD2026102000000000000205 /* SharedVideo.swift in Sources */, CD2026102000000000000206 /* SharePlayback.swift in Sources */, CD2026102000000000000207 /* SharedInput.swift in Sources */, diff --git a/ios/QueueCube/Backend/ServerDiscovery.swift b/ios/QueueCube/Backend/ServerDiscovery.swift index 69c8e59..681ec61 100644 --- a/ios/QueueCube/Backend/ServerDiscovery.swift +++ b/ios/QueueCube/Backend/ServerDiscovery.swift @@ -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 Network diff --git a/ios/QueueCubeShare/Info.plist b/ios/QueueCubeShare/Info.plist index 3428e9c..fc486e9 100644 --- a/ios/QueueCubeShare/Info.plist +++ b/ios/QueueCubeShare/Info.plist @@ -4,12 +4,8 @@ ITSAppUsesNonExemptEncryption - NSBonjourServices - - _queuecube._tcp. - NSLocalNetworkUsageDescription - QueueCube discovers nearby players so you can choose where to play this video. + QueueCube connects to your saved players to queue and play this video. NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/ios/QueueCubeShare/ShareViewController.swift b/ios/QueueCubeShare/ShareViewController.swift index 7eb91b3..b9362e4 100644 --- a/ios/QueueCubeShare/ShareViewController.swift +++ b/ios/QueueCubeShare/ShareViewController.swift @@ -9,8 +9,8 @@ final class ShareViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() preferredContentSize = CGSize(width: 480, height: 600) - let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] in - self?.send() + let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] server in + self?.send(to: server) }, close: { [weak self] in self?.close() })) @@ -24,7 +24,6 @@ final class ShareViewController: UIViewController { 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 { @@ -37,12 +36,10 @@ final class ShareViewController: UIViewController { } } - private func send() { - guard !model.sending, !model.finished, let url = model.url, - let server = model.selectedServer else { return } + private func send(to server: Server) { + guard !model.sending, !model.finished, let url = model.url else { return } model.sending = true model.message = nil - model.discovery.stop() playbackTask = Task { [weak self] in guard let self else { return } do { @@ -55,7 +52,6 @@ final class ShareViewController: UIViewController { 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." @@ -68,7 +64,6 @@ final class ShareViewController: UIViewController { private func close() { inputTask?.cancel() playbackTask?.cancel() - model.stop() if model.finished { extensionContext?.completeRequest(returningItems: nil) } else { @@ -80,7 +75,6 @@ final class ShareViewController: UIViewController { super.viewDidDisappear(animated) inputTask?.cancel() playbackTask?.cancel() - model.stop() } deinit { @@ -91,53 +85,18 @@ final class ShareViewController: UIViewController { @MainActor @Observable private final class ShareViewModel { - let discovery = ServerDiscovery() - let configuredServers: [Server] - var selectedServer: Server? + let configuredServers = Settings.fromSharedDefaults().configuredServers 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? - - 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 send: (Server) -> Void let close: () -> Void var body: some View { @@ -149,40 +108,35 @@ private struct ShareView: View { } else if let url = model.url { Text(url.absoluteString) .font(.callout) - .lineLimit(3) + .lineLimit(1) + .truncationMode(.middle) } } 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.") + Text("Open QueueCube to set up your players.") .foregroundStyle(.secondary) } ForEach(model.configuredServers) { server in Button { - model.selectedServer = server + send(server) } label: { - playerLabel(server.displayName, selected: model.selectedServer?.id == server.id) + HStack { + Label(server.displayName, systemImage: "hifispeaker.fill") + Spacer() + } + .foregroundStyle(Color.primary) + .contentShape(Rectangle()) } - .disabled(model.sending || model.resolving) - } - if let selected = model.selectedServer, - !model.configuredServers.contains(where: { $0.id == selected.id }) { - playerLabel(selected.displayName, selected: true) + .tint(.primary) + .disabled(model.url == nil || model.sending) } } - 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 { @@ -191,22 +145,6 @@ private struct ShareView: View { .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) @@ -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") } - } - } } diff --git a/ios/SHARE_EXTENSION.md b/ios/SHARE_EXTENSION.md index 9f49c6a..507785b 100644 --- a/ios/SHARE_EXTENSION.md +++ b/ios/SHARE_EXTENSION.md @@ -2,15 +2,15 @@ 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, -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 shows the selected player and a Done button. Video support depends on the player's existing mpv/yt-dlp support. -Nearby players use the same `_queuecube._tcp.` Bonjour discovery as Add Server. -Resolved IPv4 interface suffixes are removed; scoped IPv6 addresses remain valid. -Allow local network access in the containing app first. Players can also be -configured manually in QueueCube Settings. The extension handles URL attachments +The extension shows only saved players and does not browse for nearby players. +Add players manually or with Bonjour discovery in QueueCube Settings first. +Allow local network access in the containing app to reach local players. +The extension handles URL attachments (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 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 that snapshot without changing the app's selected player or server list. No 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 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. 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 -nearby player selection, Queue & Play success, cancellation, invalid text, and +configure a test player in QueueCube, share a URL from Safari, verify saved-player +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 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