Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cedd0c6aa2 | ||
|
|
174a8f8255 | ||
|
|
8723955705 | ||
|
|
a846d9b4ec | ||
|
|
ec99065cdf | ||
|
|
366aa3b723 | ||
|
|
71142e99fc | ||
|
|
b47d60c17f |
@@ -4,6 +4,12 @@ QueueCube is a web frontend for [mpv](https://github.com/mpv-player/mpv).
|
||||
|
||||
It allows you to remotely manage mpv's playqueue as well as the ability to search YouTube via a locally running [Invidious](https://github.com/iv-org/invidious) instance.
|
||||
|
||||
## Sharing video links
|
||||
|
||||
The iOS and Mac Catalyst app includes a QueueCube share extension. Share one
|
||||
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
|
||||
|
||||
Gitea Actions builds and uploads the iOS app when a version tag is pushed. The
|
||||
@@ -21,7 +27,8 @@ Configure these Gitea Actions secrets before the first run:
|
||||
|
||||
For local Fastlane use, copy `.env.example` to `.env` and fill in the same
|
||||
values. Run `bundle exec fastlane ios setup_signing` only when the match signing
|
||||
assets need to be created or updated. `bundle exec fastlane ios build` creates
|
||||
assets need to be created or updated. The app and share extension require separate
|
||||
profiles with the shared App Group enabled; see the share extension guide. `bundle exec fastlane ios build` creates
|
||||
a signed IPA without uploading it, while `bundle exec fastlane ios beta`
|
||||
performs the release.
|
||||
|
||||
|
||||
+23
-14
@@ -6,8 +6,10 @@ require "tempfile"
|
||||
default_platform(:ios)
|
||||
|
||||
BUNDLE_IDENTIFIER = "net.buzzert.QueueCube"
|
||||
SHARE_BUNDLE_IDENTIFIER = "net.buzzert.QueueCube.Share"
|
||||
DEVELOPMENT_TEAM = "DQQH5H6GBD"
|
||||
PROVISIONING_PROFILE_NAME = "QueueCube AppStore CI"
|
||||
SHARE_PROVISIONING_PROFILE_NAME = "QueueCube Share AppStore CI"
|
||||
CI_KEYCHAIN_NAME = "queuecube_ci_keychain"
|
||||
CI_KEYCHAIN_PASSWORD = "queuecube-ci-keychain-password"
|
||||
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
|
||||
@@ -108,6 +110,8 @@ def upload_export_options
|
||||
<dict>
|
||||
<key>#{BUNDLE_IDENTIFIER}</key>
|
||||
<string>#{PROVISIONING_PROFILE_NAME}</string>
|
||||
<key>#{SHARE_BUNDLE_IDENTIFIER}</key>
|
||||
<string>#{SHARE_PROVISIONING_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
@@ -156,18 +160,23 @@ platform :ios do
|
||||
end
|
||||
|
||||
private_lane :sync_signing do |options|
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: options.fetch(:readonly),
|
||||
app_identifier: BUNDLE_IDENTIFIER,
|
||||
team_id: DEVELOPMENT_TEAM,
|
||||
profile_name: PROVISIONING_PROFILE_NAME,
|
||||
git_url: ENV.fetch("MATCH_GIT_URL"),
|
||||
git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"),
|
||||
git_full_name: "QueueCube Release Bot",
|
||||
git_user_email: "james.magahern@me.com",
|
||||
api_key: options.fetch(:api_key)
|
||||
)
|
||||
{
|
||||
BUNDLE_IDENTIFIER => PROVISIONING_PROFILE_NAME,
|
||||
SHARE_BUNDLE_IDENTIFIER => SHARE_PROVISIONING_PROFILE_NAME
|
||||
}.each do |identifier, profile_name|
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: options.fetch(:readonly),
|
||||
app_identifier: identifier,
|
||||
team_id: DEVELOPMENT_TEAM,
|
||||
profile_name: profile_name,
|
||||
git_url: ENV.fetch("MATCH_GIT_URL"),
|
||||
git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"),
|
||||
git_full_name: "QueueCube Release Bot",
|
||||
git_user_email: "james.magahern@me.com",
|
||||
api_key: options.fetch(:api_key)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
desc "Create or update match signing assets"
|
||||
@@ -200,12 +209,12 @@ platform :ios do
|
||||
"clean",
|
||||
"archive",
|
||||
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
|
||||
"PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}",
|
||||
"MARKETING_VERSION=#{version}",
|
||||
"CURRENT_PROJECT_VERSION=#{build}",
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY=Apple Distribution",
|
||||
"PROVISIONING_PROFILE_SPECIFIER=#{PROVISIONING_PROFILE_NAME}"
|
||||
"QUEUECUBE_APP_PROFILE=#{PROVISIONING_PROFILE_NAME}",
|
||||
"QUEUECUBE_SHARE_PROFILE=#{SHARE_PROVISIONING_PROFILE_NAME}"
|
||||
))
|
||||
end
|
||||
|
||||
|
||||
+13
-2
@@ -13,6 +13,9 @@ 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**: 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.
|
||||
|
||||
### Data Flow
|
||||
@@ -64,7 +67,8 @@ The server API includes these endpoints:
|
||||
- `GET /playlist` - Current playlist items
|
||||
- `GET /favorites` - User favorites
|
||||
- `POST /play`, `/pause`, `/skip`, `/previous` - Playback controls
|
||||
- `POST /playlist` - Add media URL to playlist
|
||||
- `POST /playlist` - Append media URL (append-play starts only when idle)
|
||||
- `POST /skip/{index}` - Select and play a zero-based playlist index; existing queue remains
|
||||
- `DELETE /playlist/{index}` - Remove playlist item
|
||||
- `POST /volume` - Set volume level
|
||||
- `WS /events` - WebSocket for real-time updates
|
||||
@@ -90,4 +94,11 @@ QueueCubeApp
|
||||
- **PlaylistView**: Scrollable list of queued media with reorder/delete actions, includes embedded NowPlayingView
|
||||
- **NowPlayingView**: Playback controls and current track display
|
||||
- **AddMediaBarView**: Input field for adding new media URLs to playlist
|
||||
- **SettingsView**: Multi-server configuration with live validation and service discovery
|
||||
- **SettingsView**: Multi-server configuration with live validation and service discovery
|
||||
|
||||
## Share extension
|
||||
|
||||
`QueueCubeShare` is embedded in iOS and Mac Catalyst builds. It reads an App Group
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// A small macOS test harness for the production networking and share logic.
|
||||
// The app and extension continue to build through QueueCube.xcodeproj.
|
||||
let package = Package(
|
||||
name: "QueueCubeShareCore",
|
||||
platforms: [.macOS(.v14)],
|
||||
targets: [
|
||||
.target(
|
||||
name: "QueueCubeShareCore",
|
||||
path: "QueueCube/Backend",
|
||||
exclude: ["Utilities.swift", "ServerDiscovery.swift"],
|
||||
sources: [
|
||||
"API.swift", "RequestBuilder.swift", "Server.swift", "Settings.swift",
|
||||
"SharedVideo.swift", "SharePlayback.swift", "SharedInput.swift"
|
||||
],
|
||||
swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")]
|
||||
),
|
||||
.testTarget(name: "QueueCubeShareCoreTests", dependencies: ["QueueCubeShareCore"])
|
||||
]
|
||||
)
|
||||
@@ -6,8 +6,51 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
CD2026102000000000000200 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000100 /* API.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
CD2026102000000000000001 /* QueueCubeShare.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000002 /* QueueCubeShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
CD2026102000000000000003 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = CD4E9B8F2D7691C20066FC17 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = CD2026102000000000000004;
|
||||
remoteInfo = QueueCubeShare;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
CD2026102000000000000005 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
CD2026102000000000000001 /* QueueCubeShare.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CD2026102000000000000002 /* QueueCubeShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QueueCubeShare.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CD2026102000000000000100 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.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>"; };
|
||||
CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.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>"; };
|
||||
CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -19,6 +62,14 @@
|
||||
);
|
||||
target = CD4E9B962D7691C20066FC17 /* QueueCube */;
|
||||
};
|
||||
CD2026102000000000000006 /* Exceptions for "QueueCubeShare" folder in "QueueCubeShare" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Entitlements.plist,
|
||||
Info.plist,
|
||||
);
|
||||
target = CD2026102000000000000004 /* QueueCubeShare */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -30,6 +81,14 @@
|
||||
path = QueueCube;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CD2026102000000000000007 /* QueueCubeShare */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CD2026102000000000000006 /* Exceptions for "QueueCubeShare" folder in "QueueCubeShare" target */,
|
||||
);
|
||||
path = QueueCubeShare;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -40,6 +99,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CD2026102000000000000008 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -47,6 +113,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||
CD2026102000000000000007 /* QueueCubeShare */,
|
||||
CD2026102000000000000009 /* Shared Extension Sources */,
|
||||
CD4E9B982D7691C20066FC17 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -55,10 +123,26 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */,
|
||||
CD2026102000000000000002 /* QueueCubeShare.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CD2026102000000000000009 /* Shared Extension Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD2026102000000000000100 /* API.swift */,
|
||||
CD2026102000000000000101 /* RequestBuilder.swift */,
|
||||
CD2026102000000000000102 /* Server.swift */,
|
||||
CD2026102000000000000103 /* Settings.swift */,
|
||||
CD2026102000000000000105 /* SharedVideo.swift */,
|
||||
CD2026102000000000000106 /* SharePlayback.swift */,
|
||||
CD2026102000000000000107 /* SharedInput.swift */,
|
||||
);
|
||||
name = "Shared Extension Sources";
|
||||
path = QueueCube/Backend;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -69,10 +153,12 @@
|
||||
CD4E9B932D7691C20066FC17 /* Sources */,
|
||||
CD4E9B942D7691C20066FC17 /* Frameworks */,
|
||||
CD4E9B952D7691C20066FC17 /* Resources */,
|
||||
CD2026102000000000000005 /* Embed App Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
CD202610200000000000000A /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||
@@ -84,6 +170,28 @@
|
||||
productReference = CD4E9B972D7691C20066FC17 /* QueueCube.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
CD2026102000000000000004 /* QueueCubeShare */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CD202610200000000000000B /* Build configuration list for PBXNativeTarget "QueueCubeShare" */;
|
||||
buildPhases = (
|
||||
CD202610200000000000000C /* Sources */,
|
||||
CD2026102000000000000008 /* Frameworks */,
|
||||
CD202610200000000000000D /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
CD2026102000000000000007 /* QueueCubeShare */,
|
||||
);
|
||||
name = QueueCubeShare;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = QueueCubeShare;
|
||||
productReference = CD2026102000000000000002 /* QueueCubeShare.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -94,8 +202,17 @@
|
||||
LastSwiftUpdateCheck = 1700;
|
||||
LastUpgradeCheck = 1700;
|
||||
TargetAttributes = {
|
||||
CD2026102000000000000004 = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
SystemCapabilities = {
|
||||
com.apple.ApplicationGroups.iOS = {enabled = 1; };
|
||||
};
|
||||
};
|
||||
CD4E9B962D7691C20066FC17 = {
|
||||
CreatedOnToolsVersion = 17.0;
|
||||
SystemCapabilities = {
|
||||
com.apple.ApplicationGroups.iOS = {enabled = 1; };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -114,6 +231,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
CD4E9B962D7691C20066FC17 /* QueueCube */,
|
||||
CD2026102000000000000004 /* QueueCubeShare */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -126,6 +244,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CD202610200000000000000D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -136,8 +261,30 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CD202610200000000000000C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CD2026102000000000000200 /* API.swift in Sources */,
|
||||
CD2026102000000000000201 /* RequestBuilder.swift in Sources */,
|
||||
CD2026102000000000000202 /* Server.swift in Sources */,
|
||||
CD2026102000000000000203 /* Settings.swift in Sources */,
|
||||
CD2026102000000000000205 /* SharedVideo.swift in Sources */,
|
||||
CD2026102000000000000206 /* SharePlayback.swift in Sources */,
|
||||
CD2026102000000000000207 /* SharedInput.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
CD202610200000000000000A /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = CD2026102000000000000004 /* QueueCubeShare */;
|
||||
targetProxy = CD2026102000000000000003 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
CD4E9BA02D7691C40066FC17 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -286,6 +433,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_APP_PROFILE)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
@@ -322,6 +470,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_APP_PROFILE)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
@@ -332,6 +481,76 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CD202610200000000000000E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = QueueCubeShare/Entitlements.plist;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = QueueCubeShare/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = QueueCube;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube.Share;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_SHARE_PROFILE)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CD202610200000000000000F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = QueueCubeShare/Entitlements.plist;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = QueueCubeShare/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = QueueCube;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube.Share;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_SHARE_PROFILE)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -353,6 +572,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CD202610200000000000000B /* Build configuration list for PBXNativeTarget "QueueCubeShare" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CD202610200000000000000E /* Debug */,
|
||||
CD202610200000000000000F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = CD4E9B8F2D7691C20066FC17 /* Project object */;
|
||||
|
||||
@@ -1,5 +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/>
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.net.buzzert.QueueCube</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -62,16 +62,21 @@ struct NowPlayingInfo: Codable
|
||||
let playingItem: MediaItem?
|
||||
let isPaused: Bool
|
||||
let volume: Int
|
||||
let timePosition: Double?
|
||||
let duration: Double?
|
||||
let seekable: Bool?
|
||||
}
|
||||
|
||||
actor API
|
||||
{
|
||||
let baseURL: URL
|
||||
private let requestTimeout: TimeInterval
|
||||
|
||||
private var pingTask: Task<(), any Swift.Error>? = nil
|
||||
|
||||
init(baseURL: URL) {
|
||||
init(baseURL: URL, requestTimeout: TimeInterval = 60) {
|
||||
self.baseURL = baseURL
|
||||
self.requestTimeout = requestTimeout
|
||||
}
|
||||
|
||||
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
||||
@@ -172,6 +177,13 @@ actor API
|
||||
.body([ "volume" : Int(value * 100) ])
|
||||
.post()
|
||||
}
|
||||
|
||||
public func seek(to time: Double) async throws {
|
||||
try await request()
|
||||
.path("/player/seek")
|
||||
.body([ "time" : time ])
|
||||
.post()
|
||||
}
|
||||
|
||||
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
|
||||
try await request()
|
||||
@@ -250,15 +262,24 @@ actor API
|
||||
}
|
||||
|
||||
private func request() -> RequestBuilder {
|
||||
RequestBuilder(url: baseURL)
|
||||
RequestBuilder(url: baseURL, timeout: requestTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Error: Swift.Error
|
||||
enum Error: Swift.Error, LocalizedError
|
||||
{
|
||||
case apiNotConfigured
|
||||
case websocketError(Swift.Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .apiNotConfigured:
|
||||
"No server is configured."
|
||||
case .websocketError(let error):
|
||||
error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamEvent {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
|
||||
struct RequestBuilder
|
||||
{
|
||||
let url: URL
|
||||
private var timeout: TimeInterval
|
||||
private var httpMethod: HTTPMethod = .get
|
||||
private var body: Data? = nil
|
||||
|
||||
init(url: URL, timeout: TimeInterval = 60) {
|
||||
self.url = url
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
public func method(_ method: HTTPMethod) -> Self {
|
||||
var copy = self
|
||||
copy.httpMethod = method
|
||||
return copy
|
||||
}
|
||||
|
||||
public func path(_ path: any StringProtocol) -> Self {
|
||||
return RequestBuilder(url: self.url.appending(path: path), timeout: timeout)
|
||||
}
|
||||
|
||||
public func pathString(_ pathString: any StringProtocol) -> Self {
|
||||
// xxx: should just fix DELETE /favorites/:filename: instead.
|
||||
return RequestBuilder(url: URL(string: self.url.absoluteString + pathString)!, timeout: timeout)
|
||||
}
|
||||
|
||||
public func body(_ data: Codable) -> Self {
|
||||
var copy = self
|
||||
copy.body = try! JSONEncoder().encode(data)
|
||||
return copy
|
||||
}
|
||||
|
||||
public func build() -> URLRequest {
|
||||
var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
request.timeoutInterval = timeout
|
||||
request.httpMethod = self.httpMethod.rawValue
|
||||
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
public func json<T: Decodable>() async throws -> T {
|
||||
let data = try await responseData()
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
public func post() async throws {
|
||||
try await self.method(.post).execute()
|
||||
}
|
||||
|
||||
public func execute() async throws {
|
||||
_ = try await responseData()
|
||||
}
|
||||
|
||||
private func responseData() async throws -> Data {
|
||||
let (data, response) = try await URLSession.shared.data(for: build())
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw RequestError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
let serverError = try? JSONDecoder().decode(ServerErrorResponse.self, from: data)
|
||||
throw RequestError.httpError(
|
||||
statusCode: httpResponse.statusCode,
|
||||
message: serverError?.error
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
public func websocket() -> URL {
|
||||
guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() }
|
||||
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
||||
components.host = components.host!.replacing(/\%(.*)$/, with: "")
|
||||
return components.url!
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case put = "PUT"
|
||||
case post = "POST"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
private struct ServerErrorResponse: Decodable {
|
||||
let error: String?
|
||||
}
|
||||
|
||||
enum RequestError: Swift.Error, LocalizedError {
|
||||
case invalidResponse
|
||||
case httpError(statusCode: Int, message: String?)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
"The server returned an invalid response."
|
||||
case .httpError(let statusCode, let message):
|
||||
if let message, !message.isEmpty {
|
||||
"Server error (\(statusCode)): \(message)"
|
||||
} else {
|
||||
"Server error (\(statusCode)): \(HTTPURLResponse.localizedString(forStatusCode: statusCode))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
struct Server: Identifiable, Codable, Equatable
|
||||
{
|
||||
@@ -28,10 +29,22 @@ struct Server: Identifiable, Codable, Equatable
|
||||
init?(serviceName: String?, host: String, port: UInt16) {
|
||||
self.serviceName = serviceName
|
||||
|
||||
// Assumes this is the local service discovery path, which is http
|
||||
// Bounjour gives us the interface sometimes, which we can handle, but need to percent encode.
|
||||
let host = host.replacingOccurrences(of: "%", with: "%25")
|
||||
guard let url = URL(string: "http://\(host):\(port)/api") else {
|
||||
// Network.framework can append an interface to IPv4 and IPv6 addresses.
|
||||
// URL hosts only support that scope on IPv6 literals.
|
||||
var host = host
|
||||
if let scope = host.firstIndex(of: "%"),
|
||||
IPv4Address(String(host[..<scope])) != nil {
|
||||
host = String(host[..<scope])
|
||||
}
|
||||
if host.contains(":") {
|
||||
if !host.hasPrefix("[") { host = "[\(host)]" }
|
||||
}
|
||||
var components = URLComponents()
|
||||
components.scheme = "http"
|
||||
components.host = host
|
||||
components.port = Int(port)
|
||||
components.path = "/api"
|
||||
guard let url = components.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Bonjour discovery for the main app's Add Server view.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ServerDiscovery
|
||||
{
|
||||
private(set) var servers: [DiscoveredEndpoint] = []
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
private var browser: NWBrowser?
|
||||
|
||||
func start() {
|
||||
guard browser == nil else { return }
|
||||
errorMessage = nil
|
||||
let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
|
||||
self.browser = browser
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self, weak browser] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self, let browser, self.browser === browser else { return }
|
||||
self.servers = results.map(DiscoveredEndpoint.init(result:)).sorted {
|
||||
$0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { [weak self, weak browser] state in
|
||||
Task { @MainActor in
|
||||
guard let self, let browser, self.browser === browser else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.errorMessage = nil
|
||||
case .failed(let error), .waiting(let error):
|
||||
self.errorMessage = "Unable to discover players: \(error.localizedDescription)"
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
servers = []
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredEndpoint: Identifiable, Hashable
|
||||
{
|
||||
let endpoint: NWEndpoint
|
||||
let serviceName: String
|
||||
|
||||
var displayName: String { serviceName.queueCubeServiceName }
|
||||
var id: NWEndpoint { endpoint }
|
||||
|
||||
init(result: NWBrowser.Result) {
|
||||
endpoint = result.endpoint
|
||||
if case .service(name: let name, type: _, domain: _, interface: _) = result.endpoint {
|
||||
serviceName = name
|
||||
} else {
|
||||
serviceName = "(Unknown)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resolve() async throws -> Server {
|
||||
try await EndpointResolver(endpoint: endpoint, serviceName: serviceName).resolve()
|
||||
}
|
||||
|
||||
enum Error: Swift.Error, LocalizedError {
|
||||
case timedOut
|
||||
case endpointIncorrect
|
||||
case urlError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .timedOut:
|
||||
"The player did not respond. Check that it is online and try again."
|
||||
case .endpointIncorrect, .urlError:
|
||||
"The player did not provide a usable network address."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep connection callbacks, cancellation, and timeout completion on one actor so
|
||||
// a connection that becomes ready during cancellation never resumes twice.
|
||||
@MainActor
|
||||
private final class EndpointResolver
|
||||
{
|
||||
private let connection: NWConnection
|
||||
private let serviceName: String
|
||||
private var continuation: CheckedContinuation<Server, any Swift.Error>?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
init(endpoint: NWEndpoint, serviceName: String) {
|
||||
connection = NWConnection(to: endpoint, using: .tcp)
|
||||
self.serviceName = serviceName
|
||||
}
|
||||
|
||||
func resolve() async throws -> Server {
|
||||
try Task.checkCancellation()
|
||||
return try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in self?.handle(state) }
|
||||
}
|
||||
timeoutTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(8))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
self?.finish(.failure(DiscoveredEndpoint.Error.timedOut))
|
||||
}
|
||||
connection.start(queue: .main)
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor in self.finish(.failure(CancellationError())) }
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ state: NWConnection.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
guard case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint else {
|
||||
finish(.failure(DiscoveredEndpoint.Error.endpointIncorrect))
|
||||
return
|
||||
}
|
||||
let address: String
|
||||
switch host {
|
||||
case .name(let name, _): address = name
|
||||
case .ipv4(let ip): address = ip.debugDescription
|
||||
case .ipv6(let ip): address = ip.debugDescription
|
||||
@unknown default:
|
||||
finish(.failure(DiscoveredEndpoint.Error.endpointIncorrect))
|
||||
return
|
||||
}
|
||||
guard let server = Server(serviceName: serviceName, host: address, port: port.rawValue) else {
|
||||
finish(.failure(DiscoveredEndpoint.Error.urlError))
|
||||
return
|
||||
}
|
||||
finish(.success(server))
|
||||
case .failed(let error):
|
||||
finish(.failure(error))
|
||||
case .cancelled:
|
||||
finish(.failure(CancellationError()))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<Server, any Swift.Error>) {
|
||||
guard let continuation else { return }
|
||||
self.continuation = nil
|
||||
timeoutTask?.cancel()
|
||||
timeoutTask = nil
|
||||
connection.stateUpdateHandler = nil
|
||||
connection.cancel()
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Settings
|
||||
struct Settings: Codable
|
||||
{
|
||||
var selectedServer: Server?
|
||||
|
||||
@@ -27,22 +27,44 @@ struct Settings
|
||||
|
||||
var isConfigured: Bool { !configuredServers.isEmpty }
|
||||
|
||||
static func fromDefaults() -> Settings {
|
||||
let defaults = UserDefaults.standard
|
||||
return Settings(
|
||||
static func fromDefaults(defaults: UserDefaults = .standard,
|
||||
sharedDefaults: UserDefaults? = UserDefaults(suiteName: appGroupIdentifier)) -> Settings {
|
||||
let settings = Settings(
|
||||
selectedServer: defaults[SelectedServerKey.self],
|
||||
configuredServers: defaults[ConfiguredServersKey.self]
|
||||
)
|
||||
settings.publishForSharing(defaults: sharedDefaults)
|
||||
return settings
|
||||
}
|
||||
|
||||
func save() {
|
||||
let defaults = UserDefaults.standard
|
||||
func save(defaults: UserDefaults = .standard,
|
||||
sharedDefaults: UserDefaults? = UserDefaults(suiteName: Self.appGroupIdentifier)) {
|
||||
defaults[ConfiguredServersKey.self] = configuredServers
|
||||
defaults[SelectedServerKey.self] = selectedServer
|
||||
|
||||
publishForSharing(defaults: sharedDefaults)
|
||||
postSettingsChanged()
|
||||
}
|
||||
|
||||
// The containing app remains the owner of settings. Publish one snapshot so
|
||||
// the extension never observes a selected player from a different revision.
|
||||
static let appGroupIdentifier = "group.net.buzzert.QueueCube"
|
||||
private static let sharedSettingsKey = "shareSettings.v1"
|
||||
|
||||
static func fromSharedDefaults(defaults: UserDefaults? = UserDefaults(suiteName: appGroupIdentifier)) -> Settings {
|
||||
guard let defaults,
|
||||
let data = defaults.data(forKey: sharedSettingsKey),
|
||||
let settings = try? PropertyListDecoder().decode(Settings.self, from: data)
|
||||
else { return Settings(selectedServer: nil, configuredServers: []) }
|
||||
return settings
|
||||
}
|
||||
|
||||
private func publishForSharing(defaults: UserDefaults?) {
|
||||
guard let defaults,
|
||||
let data = try? PropertyListEncoder().encode(self) else { return }
|
||||
defaults.set(data, forKey: Self.sharedSettingsKey)
|
||||
}
|
||||
|
||||
func postSettingsChanged() {
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
|
||||
protocol SharePlayerAPI: Sendable {
|
||||
func fetchPlaylist() async throws -> [MediaItem]
|
||||
func add(mediaURL: String) async throws
|
||||
func skip(_ to: Int?) async throws
|
||||
func play() async throws
|
||||
func fetchNowPlayingInfo() async throws -> NowPlayingInfo
|
||||
}
|
||||
|
||||
extension API: SharePlayerAPI {}
|
||||
|
||||
enum SharePlayback {
|
||||
struct Failure: Swift.Error, LocalizedError {
|
||||
let message: String
|
||||
let canRetry: Bool
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
// Existing servers can acknowledge append before mpv has updated its queue.
|
||||
// Poll reads only; never repeat a mutation after an ambiguous response.
|
||||
static func enqueueAndPlay(_ url: URL, using api: any SharePlayerAPI,
|
||||
attempts: Int = 80,
|
||||
pollInterval: Duration = .milliseconds(250)) async throws {
|
||||
let previousIDs: Set<Int>
|
||||
do {
|
||||
_ = try SharedVideo.validate(url)
|
||||
previousIDs = Set(try await api.fetchPlaylist().map(\.id))
|
||||
try Task.checkCancellation()
|
||||
} catch {
|
||||
throw Failure(message: "Could not connect to the player. No link was added. Check that QueueCube has local network access and the player is online.", canRetry: true)
|
||||
}
|
||||
|
||||
var queued = false
|
||||
let deadline = ContinuousClock.now + .seconds(20)
|
||||
do {
|
||||
try await api.add(mediaURL: url.absoluteString)
|
||||
var addedID: Int?
|
||||
for attempt in 0..<attempts {
|
||||
try Task.checkCancellation()
|
||||
guard ContinuousClock.now < deadline else { throw VerificationError.itemMissing }
|
||||
let candidates = try await api.fetchPlaylist().filter {
|
||||
!previousIDs.contains($0.id) && $0.filename == url.absoluteString
|
||||
}
|
||||
// Concurrent identical shares cannot be safely disambiguated.
|
||||
guard candidates.count <= 1 else { throw VerificationError.ambiguousItem }
|
||||
if let item = candidates.first {
|
||||
addedID = item.id
|
||||
queued = true
|
||||
break
|
||||
}
|
||||
if attempt + 1 < attempts { try await Task.sleep(for: pollInterval) }
|
||||
}
|
||||
guard let addedID else { throw VerificationError.itemMissing }
|
||||
|
||||
// Use the latest index, not the queue length from before append.
|
||||
let playlist = try await api.fetchPlaylist()
|
||||
let finalCandidates = playlist.filter {
|
||||
!previousIDs.contains($0.id) && $0.filename == url.absoluteString
|
||||
}
|
||||
guard finalCandidates.count == 1, finalCandidates.first?.id == addedID else {
|
||||
throw VerificationError.ambiguousItem
|
||||
}
|
||||
guard let index = playlist.firstIndex(where: { $0.id == addedID }) else {
|
||||
throw VerificationError.itemMissing
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard ContinuousClock.now < deadline else { throw VerificationError.playbackFailed }
|
||||
try await api.skip(index)
|
||||
try Task.checkCancellation()
|
||||
try await api.play()
|
||||
|
||||
var previousPosition: Double?
|
||||
for attempt in 0..<attempts {
|
||||
try Task.checkCancellation()
|
||||
guard ContinuousClock.now < deadline else { throw VerificationError.playbackFailed }
|
||||
let info = try await api.fetchNowPlayingInfo()
|
||||
if info.playingItem?.id == addedID, info.playingItem?.playbackError != nil {
|
||||
throw VerificationError.playbackFailed
|
||||
}
|
||||
// A current, unpaused item can still be loading. Require time
|
||||
// progression for this ID before claiming playback started.
|
||||
if info.playingItem?.id == addedID, !info.isPaused,
|
||||
let position = info.timePosition, position.isFinite, position >= 0 {
|
||||
if let previousPosition, position > previousPosition { return }
|
||||
previousPosition = position
|
||||
} else {
|
||||
previousPosition = nil
|
||||
}
|
||||
if attempt + 1 < attempts { try await Task.sleep(for: pollInterval) }
|
||||
}
|
||||
throw VerificationError.playbackFailed
|
||||
} catch {
|
||||
let message = queued
|
||||
? "The link was queued, but playback could not be confirmed. Open QueueCube to check the player before sharing again."
|
||||
: "The player did not confirm the request. The link may already be queued. Open QueueCube to check before sharing again."
|
||||
throw Failure(message: message, canRetry: false)
|
||||
}
|
||||
}
|
||||
|
||||
private enum VerificationError: Swift.Error {
|
||||
case itemMissing, ambiguousItem, playbackFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SharedInput {
|
||||
static func load(_ items: [Any]) async throws -> URL {
|
||||
let extensionItems = items.compactMap { $0 as? NSExtensionItem }
|
||||
let providers = extensionItems.flatMap { $0.attachments ?? [] }
|
||||
// Safari and video apps often also provide a title as text. Prefer their
|
||||
// explicit URL attachments, so a title isn't mistaken for another link.
|
||||
let urlProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) }
|
||||
var urls: [URL] = []
|
||||
if !urlProviders.isEmpty {
|
||||
for provider in urlProviders {
|
||||
let item = try await provider.loadItem(forTypeIdentifier: UTType.url.identifier)
|
||||
try Task.checkCancellation()
|
||||
if let url = item as? URL { urls.append(try SharedVideo.validate(url)) }
|
||||
else if let text = (item as? String) ?? (item as? Data).flatMap(urlText) {
|
||||
urls.append(try SharedVideo.extract(from: text))
|
||||
}
|
||||
else { throw SharedVideo.Error.noLink }
|
||||
}
|
||||
} else {
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
let item = try await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier)
|
||||
try Task.checkCancellation()
|
||||
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) }
|
||||
if let text {
|
||||
do { urls.append(try SharedVideo.extract(from: text)) }
|
||||
catch SharedVideo.Error.noLink { continue }
|
||||
}
|
||||
}
|
||||
if urls.isEmpty {
|
||||
for item in extensionItems {
|
||||
if let text = item.attributedContentText?.string {
|
||||
do { urls.append(try SharedVideo.extract(from: text)) }
|
||||
catch SharedVideo.Error.noLink { continue }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return try SharedVideo.single(urls)
|
||||
}
|
||||
|
||||
private static func urlText(_ data: Data) -> String? {
|
||||
// Some binary plists also happen to be valid UTF-8. Recognize their
|
||||
// header first so malformed structured data cannot become plain text.
|
||||
if !data.starts(with: Data("bplist00".utf8)) {
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
// UIKit can transport public.url as an NSURL property list:
|
||||
// [relative string, base string, properties]. Accept only the observed
|
||||
// absolute-URL form; never read a file or decode an arbitrary archive.
|
||||
guard let parts = (try? PropertyListSerialization.propertyList(from: data, options: [], format: nil)) as? [Any],
|
||||
parts.count == 3,
|
||||
let text = parts[0] as? String,
|
||||
let base = parts[1] as? String, base.isEmpty,
|
||||
let properties = parts[2] as? [String: Any], properties.isEmpty else { return nil }
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
enum SharedVideo {
|
||||
enum Error: Swift.Error, LocalizedError {
|
||||
case noLink
|
||||
case multipleLinks
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noLink: "Share a single http or https video link with QueueCube."
|
||||
case .multipleLinks: "Share one video link at a time."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func validate(_ url: URL) throws -> URL {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
["http", "https"].contains(components.scheme?.lowercased() ?? ""),
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil else { throw Error.noLink }
|
||||
return url
|
||||
}
|
||||
|
||||
static func extract(from text: String) throws -> URL {
|
||||
let detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let urls = detector.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
||||
.compactMap { $0.url }
|
||||
.compactMap { try? validate($0) }
|
||||
return try single(urls)
|
||||
}
|
||||
|
||||
static func single(_ urls: [URL]) throws -> URL {
|
||||
let unique = Set(urls)
|
||||
guard unique.count <= 1 else { throw Error.multipleLinks }
|
||||
guard let url = unique.first else { throw Error.noLink }
|
||||
return try validate(url)
|
||||
}
|
||||
}
|
||||
@@ -18,82 +18,6 @@ extension Optional
|
||||
struct UnwrapError: Swift.Error {}
|
||||
}
|
||||
|
||||
struct RequestBuilder
|
||||
{
|
||||
let url: URL
|
||||
private var httpMethod: HTTPMethod = .get
|
||||
private var body: Data? = nil
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
public func method(_ method: HTTPMethod) -> Self {
|
||||
var copy = self
|
||||
copy.httpMethod = method
|
||||
return copy
|
||||
}
|
||||
|
||||
public func path(_ path: any StringProtocol) -> Self {
|
||||
return RequestBuilder(url: self.url.appending(path: path))
|
||||
}
|
||||
|
||||
public func pathString(_ pathString: any StringProtocol) -> Self {
|
||||
// xxx: should just fix DELETE /favorites/:filename: instead.
|
||||
return RequestBuilder(url: URL(string: self.url.absoluteString + pathString)!)
|
||||
}
|
||||
|
||||
public func body(_ data: Codable) -> Self {
|
||||
var copy = self
|
||||
copy.body = try! JSONEncoder().encode(data)
|
||||
return copy
|
||||
}
|
||||
|
||||
public func build() -> URLRequest {
|
||||
var request = URLRequest(url: self.url)
|
||||
request.httpMethod = self.httpMethod.rawValue
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
public func json<T: Decodable>() async throws -> T {
|
||||
let urlRequest = self.build()
|
||||
let (data, _) = try await URLSession.shared.data(for: urlRequest)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
public func post() async throws {
|
||||
try await self.method(.post).execute()
|
||||
}
|
||||
|
||||
public func execute() async throws {
|
||||
let urlRequest = self.build()
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
if httpResponse.statusCode != 200 {
|
||||
print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func websocket() -> URL {
|
||||
guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() }
|
||||
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
||||
components.host = components.host!.replacing(/\%(.*)$/, with: "")
|
||||
return components.url!
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case put = "PUT"
|
||||
case post = "POST"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
}
|
||||
|
||||
extension Color
|
||||
{
|
||||
|
||||
@@ -20,12 +20,14 @@ struct AddMediaView: View
|
||||
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.frame(minWidth: 0.0, maxWidth: .infinity)
|
||||
|
||||
PasteButton(payloadType: String.self) { payload in
|
||||
guard let contents = payload.first else { return }
|
||||
model.fieldContents = contents
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,7 @@ struct SearchMediaView: View
|
||||
|
||||
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
|
||||
.focused($searchFieldFocused)
|
||||
.frame(minWidth: 0.0, maxWidth: .infinity)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
|
||||
@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
|
||||
tf.delegate = context.coordinator
|
||||
tf.returnKeyType = .done
|
||||
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
tf.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
tf.autocorrectionType = .no
|
||||
tf.autocapitalizationType = .none
|
||||
return tf
|
||||
@@ -64,6 +66,12 @@ struct AutofocusingTextField: UIViewRepresentable
|
||||
}
|
||||
|
||||
final class FirstResponderTextField: UITextField {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width = UIView.noIntrinsicMetric
|
||||
return size
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
becomeFirstResponder()
|
||||
|
||||
@@ -24,7 +24,7 @@ struct ContentView: View
|
||||
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
||||
NowPlayingView(model: model.nowPlayingViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents([ .height(320.0) ])
|
||||
.presentationDetents([ .height(390.0) ])
|
||||
}
|
||||
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
||||
AddMediaView(model: $model.addMediaViewModel)
|
||||
@@ -73,8 +73,13 @@ extension ContentView
|
||||
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
|
||||
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
|
||||
|
||||
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
||||
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
|
||||
model.nowPlayingViewModel.syncPlaybackPosition(
|
||||
nowPlaying.timePosition,
|
||||
duration: nowPlaying.duration,
|
||||
isPlaying: !nowPlaying.isPaused,
|
||||
isSeekable: nowPlaying.seekable ?? false
|
||||
)
|
||||
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||
model.favoritesModel.isPlaying = !nowPlaying.isPaused
|
||||
}
|
||||
@@ -108,7 +113,7 @@ extension ContentView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func watchWebsocket() async {
|
||||
guard let api = model.selectedServer?.api else { return }
|
||||
|
||||
|
||||
@@ -77,6 +77,14 @@ class MainViewModel
|
||||
nowPlayingViewModel.onPrev = apiCallback { _, api in
|
||||
try await api.previous()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onSeek = { [weak self] model, time in
|
||||
Task {
|
||||
await self?.withModificationsViaAPI { api in
|
||||
try await api.seek(to: time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
|
||||
self?.isNowPlayingSheetPresented = false
|
||||
@@ -411,4 +419,3 @@ extension View {
|
||||
modifier(ErrorDisplayModifier(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ struct NowPlayingMiniView: View {
|
||||
|
||||
var body: some View {
|
||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||
let containerShape = RoundedRectangle(cornerRadius: 12.0, style: .continuous)
|
||||
let tapGesture = DragGesture(minimumDistance: 0)
|
||||
.updating($tapGestureState) { _, state, _ in
|
||||
state = true
|
||||
@@ -28,41 +29,52 @@ struct NowPlayingMiniView: View {
|
||||
onTap()
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
if let title = model.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
VStack(spacing: 0.0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
if let title = model.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
}
|
||||
|
||||
if let subtitle = model.subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if nothingQueued {
|
||||
Text(.notPlaying)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let subtitle = model.subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
if nothingQueued {
|
||||
Text(.notPlaying)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
||||
.imageScale(.large)
|
||||
.padding(12.0)
|
||||
}
|
||||
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
|
||||
|
||||
if model.seekablePlaybackDuration != nil {
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||
ProgressView(value: model.playbackProgress(at: context.date) ?? 0.0)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
||||
.imageScale(.large)
|
||||
.padding(12.0)
|
||||
}
|
||||
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
containerShape
|
||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||
.stroke(.ultraThinMaterial, lineWidth: 1.0)
|
||||
)
|
||||
.clipShape(containerShape)
|
||||
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
|
||||
.gesture(tapGesture)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@@ -14,17 +15,67 @@ class NowPlayingViewModel
|
||||
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onSeek: (NowPlayingViewModel, Double) -> Void = { _, _ in }
|
||||
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
||||
|
||||
var isPlaying: Bool = false
|
||||
var title: String? = ""
|
||||
var subtitle: String? = ""
|
||||
var volume: Double = 0.5
|
||||
var timePosition: Double?
|
||||
var duration: Double?
|
||||
var isSeekable: Bool = false
|
||||
|
||||
fileprivate var isSettingVolume: Bool = false
|
||||
fileprivate var settingVolume: Double = 0.0 {
|
||||
didSet { volume = settingVolume }
|
||||
}
|
||||
|
||||
fileprivate var isSettingPlaybackPosition: Bool = false
|
||||
fileprivate var settingPlaybackPosition: Double = 0.0
|
||||
private var playbackPositionSyncedAt = Date.now
|
||||
|
||||
var playbackDuration: Double? {
|
||||
guard let duration, duration.isFinite, duration > 0 else { return nil }
|
||||
return duration
|
||||
}
|
||||
|
||||
var seekablePlaybackDuration: Double? {
|
||||
guard isSeekable else { return nil }
|
||||
return playbackDuration
|
||||
}
|
||||
|
||||
var displayedPlaybackPosition: Double {
|
||||
displayedPlaybackPosition(at: .now)
|
||||
}
|
||||
|
||||
func displayedPlaybackPosition(at date: Date) -> Double {
|
||||
var position = isSettingPlaybackPosition ? settingPlaybackPosition : (timePosition ?? 0.0)
|
||||
if isPlaying && !isSettingPlaybackPosition {
|
||||
position += max(date.timeIntervalSince(playbackPositionSyncedAt), 0.0)
|
||||
}
|
||||
|
||||
guard position.isFinite else { return 0.0 }
|
||||
return min(max(position, 0.0), playbackDuration ?? position)
|
||||
}
|
||||
|
||||
func playbackProgress(at date: Date) -> Double? {
|
||||
guard let seekablePlaybackDuration else { return nil }
|
||||
return displayedPlaybackPosition(at: date) / seekablePlaybackDuration
|
||||
}
|
||||
|
||||
func syncPlaybackPosition(_ position: Double?, duration: Double?, isPlaying: Bool, isSeekable: Bool) {
|
||||
timePosition = position
|
||||
self.duration = duration
|
||||
self.isPlaying = isPlaying
|
||||
self.isSeekable = isSeekable
|
||||
playbackPositionSyncedAt = .now
|
||||
}
|
||||
|
||||
fileprivate func seekOptimistically(to position: Double) {
|
||||
timePosition = position
|
||||
playbackPositionSyncedAt = .now
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingView: View
|
||||
@@ -60,7 +111,31 @@ struct NowPlayingView: View
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 24.0)
|
||||
Spacer(minLength: 20.0)
|
||||
|
||||
if let duration = model.seekablePlaybackDuration {
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||
VStack(spacing: 2.0) {
|
||||
Slider(
|
||||
value: playbackPositionBinding(at: context.date),
|
||||
in: 0.0...duration,
|
||||
onEditingChanged: playbackPositionEditingChanged
|
||||
)
|
||||
.disabled(nothingQueued)
|
||||
|
||||
HStack {
|
||||
Text(formatTime(model.displayedPlaybackPosition(at: context.date)))
|
||||
Spacer()
|
||||
Text(formatTime(duration))
|
||||
}
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4.0)
|
||||
|
||||
Spacer(minLength: 20.0)
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
@@ -123,6 +198,38 @@ struct NowPlayingView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playbackPositionBinding(at date: Date) -> Binding<Double> {
|
||||
Binding(
|
||||
get: { model.displayedPlaybackPosition(at: date) },
|
||||
set: { model.settingPlaybackPosition = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func playbackPositionEditingChanged(_ editing: Bool) {
|
||||
if editing {
|
||||
model.settingPlaybackPosition = model.displayedPlaybackPosition
|
||||
model.isSettingPlaybackPosition = true
|
||||
} else if model.isSettingPlaybackPosition {
|
||||
let position = model.settingPlaybackPosition
|
||||
model.seekOptimistically(to: position)
|
||||
model.isSettingPlaybackPosition = false
|
||||
model.onSeek(model, position)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ time: Double) -> String {
|
||||
let totalSeconds = max(Int(time), 0)
|
||||
let hours = totalSeconds / 3_600
|
||||
let minutes = (totalSeconds % 3_600) / 60
|
||||
let seconds = totalSeconds % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import Network
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View
|
||||
@@ -60,14 +59,18 @@ struct AddServerView: View
|
||||
|
||||
// Discovered
|
||||
Section(.discovered) {
|
||||
if model.discoveredServers.isEmpty {
|
||||
if let message = model.resolutionError ?? model.discovery.errorMessage {
|
||||
Text(message)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if model.discovery.servers.isEmpty {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text(.findingServers)
|
||||
}
|
||||
} else {
|
||||
List(model.discoveredServers) { (server: DiscoveredEndpoint) in
|
||||
List(model.discovery.servers) { (server: DiscoveredEndpoint) in
|
||||
Button {
|
||||
resolveEndpoint(server)
|
||||
} label: {
|
||||
@@ -90,23 +93,30 @@ struct AddServerView: View
|
||||
}
|
||||
|
||||
.task {
|
||||
model.startDiscovery()
|
||||
model.discovery.start()
|
||||
}
|
||||
.onDisappear { model.discovery.stop() }
|
||||
}
|
||||
|
||||
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
|
||||
Task {
|
||||
model.resolvingServers.insert(endpoint)
|
||||
|
||||
let server = try await endpoint.resolve()
|
||||
onAddServer(server)
|
||||
|
||||
model.resolvingServers.remove(endpoint)
|
||||
model.resolutionError = nil
|
||||
defer { model.resolvingServers.remove(endpoint) }
|
||||
do {
|
||||
let server = try await endpoint.resolve()
|
||||
onAddServer(server)
|
||||
} catch is CancellationError {
|
||||
// The view or its caller no longer needs this result.
|
||||
} catch {
|
||||
model.resolutionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
@@ -114,10 +124,9 @@ struct AddServerView: View
|
||||
var validationURL: String = ""
|
||||
var validationState: ValidationState = .empty
|
||||
|
||||
var discoveredServers: [DiscoveredEndpoint] = []
|
||||
let discovery = ServerDiscovery()
|
||||
var resolvingServers = Set<DiscoveredEndpoint>()
|
||||
|
||||
private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
|
||||
var resolutionError: String?
|
||||
|
||||
private var validationTimer: Timer? = nil
|
||||
|
||||
@@ -125,21 +134,6 @@ struct AddServerView: View
|
||||
observeForValidation()
|
||||
}
|
||||
|
||||
public func startDiscovery() {
|
||||
browser.browseResultsChangedHandler = { [weak self] results, changes in
|
||||
guard let self else { return }
|
||||
self.discoveredServers = results.map { DiscoveredEndpoint(result: $0) }
|
||||
}
|
||||
|
||||
browser.stateUpdateHandler = { state in
|
||||
if case .failed(let error) = state {
|
||||
print("Discovery error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
browser.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
private func observeForValidation() {
|
||||
withObservationTracking {
|
||||
_ = serverURL
|
||||
@@ -156,7 +150,9 @@ struct AddServerView: View
|
||||
self.validationURL = self.serverURL
|
||||
self.validationTimer?.invalidate()
|
||||
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||
self?.validateSettings()
|
||||
Task { @MainActor [weak self] in
|
||||
self?.validateSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,77 +200,3 @@ struct AddServerView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredEndpoint: Identifiable, Hashable
|
||||
{
|
||||
let endpoint: NWEndpoint
|
||||
let serviceName: String
|
||||
|
||||
var displayName: String {
|
||||
serviceName.queueCubeServiceName
|
||||
}
|
||||
|
||||
var id: String { serviceName }
|
||||
|
||||
init(result: NWBrowser.Result) {
|
||||
self.endpoint = result.endpoint
|
||||
|
||||
switch result.endpoint {
|
||||
case .service(name: let name, type: _, domain: _, interface: _):
|
||||
self.serviceName = name
|
||||
default:
|
||||
self.serviceName = "(Unknown)"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func resolve() async throws -> Server {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .preparing: break
|
||||
case .ready:
|
||||
// xxx: is this really the right way to do this? Maybe we should not try to turn this into a URL.
|
||||
if case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint {
|
||||
let address = switch host {
|
||||
case .name(let string, _): string
|
||||
case .ipv4(let iPv4Address): iPv4Address.debugDescription
|
||||
case .ipv6(let iPv6Address): iPv6Address.debugDescription
|
||||
default: "unknown"
|
||||
}
|
||||
|
||||
if let server = Server(serviceName: serviceName, host: address, port: port.rawValue) {
|
||||
continuation.resume(returning: server)
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.urlError)
|
||||
}
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.endpointIncorrect)
|
||||
}
|
||||
|
||||
connection.cancel()
|
||||
case .cancelled:
|
||||
// expected
|
||||
break
|
||||
case .failed(let error):
|
||||
continuation.resume(throwing: error)
|
||||
connection.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Error: Swift.Error
|
||||
{
|
||||
case cancelledConnection
|
||||
case endpointIncorrect
|
||||
case urlError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,34 @@
|
||||
<?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>NSLocalNetworkUsageDescription</key>
|
||||
<string>QueueCube connects to your saved players to queue and 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,159 @@
|
||||
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] server in
|
||||
self?.send(to: server)
|
||||
}, 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
|
||||
}
|
||||
}
|
||||
|
||||
private func send(to server: Server) {
|
||||
guard !model.sending, !model.finished, let url = model.url else { return }
|
||||
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 {
|
||||
let configuredServers = Settings.fromSharedDefaults().configuredServers
|
||||
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
|
||||
let send: (Server) -> 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(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
if !model.finished {
|
||||
Section("Choose player") {
|
||||
if model.configuredServers.isEmpty {
|
||||
Text("Open QueueCube to set up your players.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(model.configuredServers) { server in
|
||||
Button {
|
||||
send(server)
|
||||
} label: {
|
||||
HStack {
|
||||
Label(server.displayName, systemImage: "hifispeaker.fill")
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(Color.primary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.tint(.primary)
|
||||
.disabled(model.url == nil || model.sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
if model.sending {
|
||||
Section {
|
||||
ProgressView("Queuing and starting playback…")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Share a video to QueueCube
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
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
|
||||
unrecognized binary forms are rejected.
|
||||
Multiple different links, files, and links
|
||||
with embedded user/password information are rejected.
|
||||
|
||||
## Data and permissions
|
||||
|
||||
The app and `net.buzzert.QueueCube.Share` extension share
|
||||
`group.net.buzzert.QueueCube`. The app retains its original UserDefaults and
|
||||
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 a local network usage declaration and the same HTTP transport
|
||||
compatibility as the existing app; Catalyst adds only app sandbox and outgoing
|
||||
network access.
|
||||
|
||||
The repository has no application authentication, login flow, or token store.
|
||||
Requests reuse the existing URLSession client and configured server URLs.
|
||||
Browser login cookies and credentials cached in another process are not shared.
|
||||
Servers behind an authenticated reverse proxy may fail; this change does not
|
||||
introduce a new authentication protocol or bypass access controls.
|
||||
|
||||
## Existing API sequence and failure behavior
|
||||
|
||||
All paths below are relative to the configured API base URL (usually `/api`).
|
||||
No backend deployment is required.
|
||||
|
||||
| Request | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /playlist` | Snapshot existing mpv item IDs before append. |
|
||||
| `POST /playlist` with `{"url":"https://…"}` | Append once using the existing append-play operation. Starts only if idle. |
|
||||
| `GET /playlist` | Poll for exactly one new ID matching the URL, then obtain its current zero-based index. |
|
||||
| `POST /skip/{index}` | Select the new entry while retaining the queue. |
|
||||
| `POST /play` | Clear pause, including on an already paused player. |
|
||||
| `GET /nowplaying` | Confirm the new ID is current, is not paused, has no reported playback error, and its playback position advances across reads. |
|
||||
|
||||
The current server may acknowledge mutations before mpv finishes and may return
|
||||
HTTP success even for an mpv failure. The extension verifies reads instead of
|
||||
assuming a 2xx response means playback started. A selected but still loading
|
||||
item does not count as success; playback position must advance for the intended
|
||||
item. Verification polling checks a shared 20-second deadline between operations; each
|
||||
HTTP request has an 8-second timeout. In-flight or sequential mutation requests
|
||||
can extend completion beyond that window. Slow media may remain queued without
|
||||
confirmed playback. This is a point-in-time
|
||||
confirmation; media loading or decoding can fail later.
|
||||
|
||||
If preflight fails, no link has been added and the user can retry. Once append
|
||||
has been attempted, any failure is treated as potentially partially completed:
|
||||
the UI says either the link may already be queued or it was queued but playback
|
||||
was not confirmed. It disables repeat submission for that share session and
|
||||
asks the user to check QueueCube before sharing again. It never clears the queue,
|
||||
rolls back another client's changes, or automatically retries a mutation.
|
||||
|
||||
Identical URLs already in the queue are distinguished using mpv IDs. Concurrent
|
||||
identical new entries are ambiguous and are not selected. The existing skip API
|
||||
uses an index, so another client can reorder the queue between lookup and skip;
|
||||
verification catches a different resulting item, but cannot make these existing
|
||||
endpoints atomic. No new backend contract is assumed.
|
||||
|
||||
## Building and validation
|
||||
|
||||
Build the QueueCube scheme to embed the extension on iOS and Mac Catalyst:
|
||||
|
||||
```sh
|
||||
xcodebuild -project ios/QueueCube.xcodeproj -scheme QueueCube -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/queuecube-build-simulator CODE_SIGNING_ALLOWED=NO build
|
||||
xcodebuild -project ios/QueueCube.xcodeproj -scheme QueueCube -configuration Debug -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath /tmp/queuecube-build-catalyst CODE_SIGNING_ALLOWED=NO build
|
||||
swift test --package-path ios --scratch-path /tmp/queuecube-share-tests
|
||||
```
|
||||
|
||||
Unsigned builds validate compilation and embedding; they do not validate App
|
||||
Group provisioning. For simulator UI tests of saved players, use normal local
|
||||
ad-hoc signing (`CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM=`)
|
||||
so Simulator registers the App Group container. This does not use an Apple
|
||||
signing identity or provision a physical device. Before a future signed distribution,
|
||||
register the extension ID and associate **both** app IDs with the App Group,
|
||||
then update their profiles through the existing signing workflow. Fastlane maps
|
||||
`QueueCube AppStore CI` and `QueueCube Share AppStore CI` separately and keeps
|
||||
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-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
|
||||
live-player gaps explicitly; unit mocks cannot establish those behaviors.
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class RequestBuilderTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
URLProtocol.registerClass(StubURLProtocol.self)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
StubURLProtocol.setResponse(status: 200, body: Data())
|
||||
URLProtocol.unregisterClass(StubURLProtocol.self)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testConfiguredTimeoutAndBodySurviveEndpointBuilding() throws {
|
||||
let baseURL = URL(string: "https://queuecube-test.invalid/api")!
|
||||
let request = RequestBuilder(url: baseURL, timeout: 7)
|
||||
.path("/playlist")
|
||||
.body(["url": "https://video.example/watch?v=one"])
|
||||
.method(.post)
|
||||
.build()
|
||||
|
||||
XCTAssertEqual(request.url?.path, "/api/playlist")
|
||||
XCTAssertEqual(request.timeoutInterval, 7)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Cache-Control"), "no-cache")
|
||||
XCTAssertEqual(try JSONDecoder().decode([String: String].self, from: XCTUnwrap(request.httpBody)), ["url": "https://video.example/watch?v=one"])
|
||||
XCTAssertEqual(RequestBuilder(url: baseURL, timeout: 7).pathString("/search?q=one").build().timeoutInterval, 7)
|
||||
}
|
||||
|
||||
func testPOSTRejectsAuthenticationFailureInsteadOfReportingSuccess() async throws {
|
||||
StubURLProtocol.setResponse(status: 401, body: Data(#"{"error":"Authentication required"}"#.utf8))
|
||||
do {
|
||||
try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/playlist")!).post()
|
||||
XCTFail("A non-2xx mutation must fail")
|
||||
} catch RequestBuilder.RequestError.httpError(let status, let message) {
|
||||
XCTAssertEqual(status, 401)
|
||||
XCTAssertEqual(message, "Authentication required")
|
||||
}
|
||||
}
|
||||
|
||||
func testNonJSONServerFailureStillReportsItsStatus() async throws {
|
||||
StubURLProtocol.setResponse(status: 502, body: Data("upstream unavailable".utf8))
|
||||
do {
|
||||
try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/play")!).post()
|
||||
XCTFail("A non-JSON server failure must fail")
|
||||
} catch RequestBuilder.RequestError.httpError(let status, let message) {
|
||||
XCTAssertEqual(status, 502)
|
||||
XCTAssertNil(message)
|
||||
}
|
||||
}
|
||||
|
||||
func testSuccessfulEmptyMutationResponseIsAccepted() async throws {
|
||||
StubURLProtocol.setResponse(status: 204, body: Data())
|
||||
try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/play")!).post()
|
||||
}
|
||||
|
||||
func testAPIReadsExistingPlaylistEndpointAndDecodesIDs() async throws {
|
||||
StubURLProtocol.setResponse(status: 200, body: Data(#"[{"id":42,"filename":"https://video.example/one"}]"#.utf8))
|
||||
let api = API(baseURL: URL(string: "https://queuecube-test.invalid/api")!, requestTimeout: 7)
|
||||
let playlist = try await api.fetchPlaylist()
|
||||
XCTAssertEqual(playlist.map(\.id), [42])
|
||||
let request = try XCTUnwrap(StubURLProtocol.lastRequest())
|
||||
XCTAssertEqual(request.url?.path, "/api/playlist")
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.timeoutInterval, 7)
|
||||
}
|
||||
}
|
||||
|
||||
// Intercepts only this test's reserved host. No network or real player is used.
|
||||
private final class StubURLProtocol: URLProtocol {
|
||||
private static let lock = NSLock()
|
||||
private static var status = 200
|
||||
private static var body = Data()
|
||||
private static var request: URLRequest?
|
||||
|
||||
static func setResponse(status: Int, body: Data) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
Self.status = status
|
||||
Self.body = body
|
||||
Self.request = nil
|
||||
}
|
||||
|
||||
static func lastRequest() -> URLRequest? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return request
|
||||
}
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
request.url?.host == "queuecube-test.invalid"
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
Self.lock.lock()
|
||||
let status = Self.status
|
||||
let body = Self.body
|
||||
Self.request = request
|
||||
Self.lock.unlock()
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: [:])!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: body)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class ServerTests: XCTestCase {
|
||||
func testScopedIPv4FromNetworkFrameworkUsesResolvableHost() throws {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: "192.168.1.153%en1", port: 18731))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, "http://192.168.1.153:18731/api")
|
||||
XCTAssertEqual(server.baseURL.host, "192.168.1.153")
|
||||
}
|
||||
|
||||
func testUnscopedIPv4AndHostnameKeepHostAndPort() throws {
|
||||
for host in ["192.168.1.153", "queuecube.local"] {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: host, port: 3000))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, "http://\(host):3000/api")
|
||||
XCTAssertEqual(server.baseURL.host, host)
|
||||
}
|
||||
}
|
||||
|
||||
func testIPv6LiteralsAreBracketedAndPreserveEncodedScope() throws {
|
||||
let cases = [
|
||||
("2001:db8::1", "http://[2001:db8::1]:3000/api"),
|
||||
("fe80::1%en1", "http://[fe80::1%25en1]:3000/api"),
|
||||
("[2001:db8::1]", "http://[2001:db8::1]:3000/api"),
|
||||
("[fe80::1%en1]", "http://[fe80::1%25en1]:3000/api")
|
||||
]
|
||||
for (host, expected) in cases {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: host, port: 3000))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func testHostnameUsesURLHostEncoding() throws {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: "café.local", port: 3000))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, "http://xn--caf-dma.local:3000/api")
|
||||
XCTAssertNil(Server(serviceName: nil, host: "queuecube.local/path", port: 3000))
|
||||
}
|
||||
|
||||
func testPlayerDisplayNameUsesBonjourNameAndFallsBackToHost() throws {
|
||||
let advertised = try XCTUnwrap(Server(serviceName: "QueueCube Media Server (Living Room)", host: "192.168.1.153%en1", port: 3000))
|
||||
XCTAssertEqual(advertised.displayName, "Living Room")
|
||||
let custom = try XCTUnwrap(Server(serviceName: "Kitchen", host: "queuecube.local", port: 3000))
|
||||
XCTAssertEqual(custom.displayName, "Kitchen")
|
||||
let unnamed = try XCTUnwrap(Server(serviceName: nil, host: "queuecube.local", port: 3000))
|
||||
XCTAssertEqual(unnamed.displayName, "queuecube.local")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SettingsTests: XCTestCase {
|
||||
func testExistingSettingsArePublishedOnReadWithoutChangingLegacyStorage() throws {
|
||||
try withDefaults { legacy, shared, legacyName, _ in
|
||||
let first = Server(baseURL: URL(string: "http://living-room.invalid:8080/api")!)
|
||||
let selected = Server(baseURL: URL(string: "https://bedroom.invalid/api")!)
|
||||
let original = Settings(selectedServer: selected, configuredServers: [first, selected])
|
||||
original.save(defaults: legacy, sharedDefaults: nil)
|
||||
let legacyBefore = legacy.persistentDomain(forName: legacyName)! as NSDictionary
|
||||
|
||||
XCTAssertFalse(Settings.fromSharedDefaults(defaults: shared).isConfigured)
|
||||
let loaded = Settings.fromDefaults(defaults: legacy, sharedDefaults: shared)
|
||||
let snapshot = Settings.fromSharedDefaults(defaults: shared)
|
||||
|
||||
XCTAssertEqual(loaded.configuredServers, original.configuredServers)
|
||||
XCTAssertEqual(loaded.selectedServer, selected)
|
||||
XCTAssertEqual(snapshot.configuredServers, original.configuredServers)
|
||||
XCTAssertEqual(snapshot.selectedServer, selected)
|
||||
XCTAssertEqual(legacy.persistentDomain(forName: legacyName)! as NSDictionary, legacyBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func testSavePublishesOneSnapshotAndExtensionReadsLeaveItUnchanged() throws {
|
||||
try withDefaults { legacy, shared, _, sharedName in
|
||||
let first = Server(baseURL: URL(string: "http://living-room.invalid:8080/api")!)
|
||||
let second = Server(baseURL: URL(string: "http://bedroom.invalid:8080/api")!)
|
||||
let settings = Settings(selectedServer: first, configuredServers: [first, second])
|
||||
settings.save(defaults: legacy, sharedDefaults: shared)
|
||||
settings.selectedServer(second).save(defaults: legacy, sharedDefaults: shared)
|
||||
let before = shared.persistentDomain(forName: sharedName)!
|
||||
XCTAssertEqual(before.count, 1, "Player list and selection must be published atomically")
|
||||
|
||||
let snapshot = Settings.fromSharedDefaults(defaults: shared)
|
||||
|
||||
XCTAssertEqual(snapshot.selectedServer, second)
|
||||
XCTAssertEqual(snapshot.configuredServers, [first, second])
|
||||
XCTAssertEqual(shared.persistentDomain(forName: sharedName)! as NSDictionary, before as NSDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
func testMissingOrInvalidSharedSnapshotIsAnUnconfiguredState() throws {
|
||||
XCTAssertFalse(Settings.fromSharedDefaults(defaults: nil).isConfigured)
|
||||
try withDefaults { _, shared, _, _ in
|
||||
XCTAssertNil(Settings.fromSharedDefaults(defaults: shared).selectedServer)
|
||||
shared.set(Data("not a property list".utf8), forKey: "shareSettings.v1")
|
||||
let invalid = Settings.fromSharedDefaults(defaults: shared)
|
||||
XCTAssertTrue(invalid.configuredServers.isEmpty)
|
||||
XCTAssertNil(invalid.selectedServer)
|
||||
}
|
||||
}
|
||||
|
||||
func testSelectionTracksFirstAddedAndRemovedConfiguredPlayers() {
|
||||
let first = Server(baseURL: URL(string: "http://living-room.invalid/api")!)
|
||||
let second = Server(baseURL: URL(string: "http://bedroom.invalid/api")!)
|
||||
var settings = Settings(selectedServer: nil, configuredServers: [])
|
||||
settings.configuredServers = [first, second]
|
||||
XCTAssertEqual(settings.selectedServer, first)
|
||||
settings.configuredServers = [second]
|
||||
XCTAssertEqual(settings.selectedServer, second)
|
||||
settings.configuredServers = []
|
||||
XCTAssertNil(settings.selectedServer)
|
||||
}
|
||||
|
||||
private func withDefaults(_ body: (UserDefaults, UserDefaults, String, String) throws -> Void) throws {
|
||||
let prefix = "QueueCubeShareTests.\(UUID().uuidString)"
|
||||
let legacyName = "\(prefix).legacy"
|
||||
let sharedName = "\(prefix).shared"
|
||||
let legacy = try XCTUnwrap(UserDefaults(suiteName: legacyName))
|
||||
let shared = try XCTUnwrap(UserDefaults(suiteName: sharedName))
|
||||
defer {
|
||||
legacy.removePersistentDomain(forName: legacyName)
|
||||
shared.removePersistentDomain(forName: sharedName)
|
||||
}
|
||||
try body(legacy, shared, legacyName, sharedName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SharePlaybackTests: XCTestCase {
|
||||
private let url = URL(string: "https://video.example/watch?v=shared")!
|
||||
|
||||
func testSelectsNewDuplicateByIDAtItsLatestQueueIndex() async throws {
|
||||
let existing = item(10)
|
||||
let other = item(11, filename: "https://video.example/other")
|
||||
let appended = item(12)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([existing, other]), .success([existing, other, appended]), .success([other, appended, existing])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended, paused: true)),
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(nowPlaying(appended, position: 0.25))
|
||||
]
|
||||
)
|
||||
|
||||
try await enqueue(using: api)
|
||||
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||||
XCTAssertEqual(calls.skippedIndices, [1])
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
XCTAssertEqual(calls.nowPlayingReads, 3, "A paused player or a single position sample cannot prove playback")
|
||||
}
|
||||
|
||||
func testEmptyQueueWaitsForAsynchronousAppendThenStartsPlayer() async throws {
|
||||
let appended = item(42)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([]), .success([]), .success([appended]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(nil)),
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(nowPlaying(appended, position: 0.25))
|
||||
]
|
||||
)
|
||||
|
||||
try await enqueue(using: api)
|
||||
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertEqual(calls.skippedIndices, [0])
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
XCTAssertEqual(calls.playlistReads, 5)
|
||||
}
|
||||
|
||||
func testPreflightFailureAllowsRetryAndDoesNotMutatePlayer() async throws {
|
||||
let api = FakePlayer(playlists: [.failure(.connection)], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertTrue(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("No link was added"))
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.addedURLs.isEmpty)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testUncertainAppendResponseDoesNotRepeatMutationOrAllowRetry() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], addError: .connection)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("may already be queued"))
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testAppendNeverAppearsDoesNotRepeatMutation() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testConcurrentIdenticalAppendsFailWithoutSelectingAnArbitraryItem() async throws {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1), item(2)])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testDuplicateAppearingInFinalSnapshotDoesNotSelectAnArbitraryItem() async throws {
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([item(1)]), .success([item(1), item(2)])],
|
||||
nowPlaying: []
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testDifferentCandidateInFinalSnapshotDoesNotSelectAnotherClientsAppend() async throws {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([item(2)])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testAppendedItemRemovedBeforeSelectionDoesNotSkipAnotherItem() async throws {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("was queued"))
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testPlayRequestFailureReportsPartialSuccessWithoutRetry() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(playlists: [.success([]), .success([appended])], nowPlaying: [], playError: .connection)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("was queued, but playback could not be confirmed"))
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertEqual(calls.skippedIndices, [0])
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
|
||||
func testPlaybackErrorIsNotReportedAsSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let errored = item(1, playbackError: "Unsupported video")
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [.success(nowPlaying(errored))]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 1)
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
}
|
||||
|
||||
func testWrongPlayingItemAndPausedPlaybackDoNotReportSuccess() async throws {
|
||||
for info in [nowPlaying(item(8)), nowPlaying(item(1), paused: true)] {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1)])], nowPlaying: [.success(info)])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testLoadingItemFollowedByLoadErrorDoesNotReportSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let errored = item(1, playbackError: "Failed to load video")
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [.success(nowPlaying(appended)), .success(nowPlaying(errored))]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 2)
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
|
||||
func testLoadingItemFollowedByDifferentCurrentItemDoesNotReportSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended)),
|
||||
.success(nowPlaying(item(2), position: 0)),
|
||||
.success(nowPlaying(item(2), position: 0.25))
|
||||
]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
|
||||
func testLoadingItemFollowedByMatchingPlaybackProgressSucceeds() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended)),
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(nowPlaying(appended, position: 0.25))
|
||||
]
|
||||
)
|
||||
try await enqueue(using: api)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 3)
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
|
||||
func testFrozenPlaybackPositionDoesNotReportSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [.success(nowPlaying(appended, position: 0))]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
|
||||
func testInvalidPositionCannotEstablishPlaybackProgress() async throws {
|
||||
let appended = item(1)
|
||||
for invalidPosition in [-1, Double.infinity, -Double.infinity, Double.nan] {
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended, position: invalidPosition)),
|
||||
.success(nowPlaying(appended, position: 1))
|
||||
]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
}
|
||||
|
||||
func testInterruptedPlaybackObservationsResetProgressBaseline() async throws {
|
||||
let appended = item(1)
|
||||
let interruptions = [
|
||||
nowPlaying(appended),
|
||||
nowPlaying(appended, paused: true, position: 0.25),
|
||||
nowPlaying(item(2), position: 0.25)
|
||||
]
|
||||
for interruption in interruptions {
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(interruption),
|
||||
.success(nowPlaying(appended, position: 1))
|
||||
]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
}
|
||||
|
||||
func testCancellationBeforeAppendDoesNotMutateAndAllowsRetry() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .preflight)
|
||||
let failure = await cancelledFailure(using: api)
|
||||
XCTAssertTrue(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("No link was added"))
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.addedURLs.isEmpty)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testCancellationAfterAppendDoesNotRepeatMutationOrAllowRetry() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .afterAppend)
|
||||
let failure = await cancelledFailure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("may already be queued"))
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
private func cancelledFailure(using api: FakePlayer) async -> SharePlayback.Failure {
|
||||
// Cancel only this child operation, leaving XCTest's task unaffected.
|
||||
await Task { await failure(using: api) }.value
|
||||
}
|
||||
|
||||
private func enqueue(using api: FakePlayer) async throws {
|
||||
try await SharePlayback.enqueueAndPlay(url, using: api, attempts: 4, pollInterval: .zero)
|
||||
}
|
||||
|
||||
private func failure(using api: FakePlayer, file: StaticString = #filePath, line: UInt = #line) async -> SharePlayback.Failure {
|
||||
do {
|
||||
try await enqueue(using: api)
|
||||
XCTFail("Expected playback to fail", file: file, line: line)
|
||||
} catch let failure as SharePlayback.Failure {
|
||||
return failure
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)", file: file, line: line)
|
||||
}
|
||||
return .init(message: "Test did not receive the expected failure", canRetry: true)
|
||||
}
|
||||
|
||||
private func item(_ id: Int, filename: String? = nil, playbackError: String? = nil) -> MediaItem {
|
||||
MediaItem(filename: filename ?? url.absoluteString, title: nil, id: id, current: nil, playing: nil, metadata: nil, playbackError: playbackError)
|
||||
}
|
||||
|
||||
private func nowPlaying(_ item: MediaItem?, paused: Bool = false, position: Double? = nil) -> NowPlayingInfo {
|
||||
NowPlayingInfo(playingItem: item, isPaused: paused, volume: 50, timePosition: position, duration: nil, seekable: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error { case connection }
|
||||
|
||||
private actor FakePlayer: SharePlayerAPI {
|
||||
enum Cancellation { case preflight, afterAppend }
|
||||
|
||||
struct Calls {
|
||||
var addedURLs: [String] = []
|
||||
var skippedIndices: [Int?] = []
|
||||
var playCount = 0
|
||||
var playlistReads = 0
|
||||
var nowPlayingReads = 0
|
||||
}
|
||||
|
||||
private var playlists: [Result<[MediaItem], TestError>]
|
||||
private var nowPlaying: [Result<NowPlayingInfo, TestError>]
|
||||
private let addError: TestError?
|
||||
private let playError: TestError?
|
||||
private let cancellation: Cancellation?
|
||||
private(set) var calls = Calls()
|
||||
|
||||
init(playlists: [Result<[MediaItem], TestError>], nowPlaying: [Result<NowPlayingInfo, TestError>], addError: TestError? = nil, playError: TestError? = nil, cancellation: Cancellation? = nil) {
|
||||
self.playlists = playlists
|
||||
self.nowPlaying = nowPlaying
|
||||
self.addError = addError
|
||||
self.playError = playError
|
||||
self.cancellation = cancellation
|
||||
}
|
||||
|
||||
func fetchPlaylist() async throws -> [MediaItem] {
|
||||
calls.playlistReads += 1
|
||||
if cancellation == .preflight, calls.playlistReads == 1 {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
}
|
||||
guard let next = playlists.first else { throw TestError.connection }
|
||||
if playlists.count > 1 { playlists.removeFirst() }
|
||||
return try next.get()
|
||||
}
|
||||
|
||||
func add(mediaURL: String) async throws {
|
||||
calls.addedURLs.append(mediaURL)
|
||||
if cancellation == .afterAppend { withUnsafeCurrentTask { $0?.cancel() } }
|
||||
if let addError { throw addError }
|
||||
}
|
||||
|
||||
func skip(_ to: Int?) async throws { calls.skippedIndices.append(to) }
|
||||
|
||||
func play() async throws {
|
||||
calls.playCount += 1
|
||||
if let playError { throw playError }
|
||||
}
|
||||
|
||||
func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
||||
calls.nowPlayingReads += 1
|
||||
guard let next = nowPlaying.first else { throw TestError.connection }
|
||||
if nowPlaying.count > 1 { nowPlaying.removeFirst() }
|
||||
return try next.get()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SharedInputTests: XCTestCase {
|
||||
private let url = URL(string: "https://video.example/watch?v=shared")!
|
||||
|
||||
func testURLDataRepresentationLoadsWithTitleOnlyCompanion() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url),
|
||||
textProvider("A video worth watching")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testURLObjectRepresentationLoads() async throws {
|
||||
let provider = NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier)
|
||||
let result = try await SharedInput.load([extensionItem([provider])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testModernNSURLObjectProviderRoundTrips() async throws {
|
||||
let provider = NSItemProvider(object: url as NSURL)
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
provider, textProvider("A video worth watching")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testTypedFileAndCredentialURLsDoNotFallBackToCompanionText() async throws {
|
||||
for invalid in [
|
||||
URL(fileURLWithPath: "/tmp/video.mp4"),
|
||||
URL(string: "https://user:synthetic@video.example/video")!
|
||||
] {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
NSItemProvider(object: invalid as NSURL),
|
||||
textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testLegacyURLStringProviderPreservesTitleAndLinkFallback() async throws {
|
||||
let provider = NSItemProvider(item: "Watch this video: \(url.absoluteString)" as NSString, typeIdentifier: UTType.url.identifier)
|
||||
let result = try await SharedInput.load([extensionItem([provider])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testMultipleLinksInsideOneURLDataProviderAreRejected() async throws {
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([
|
||||
dataProvider(Data("\(url.absoluteString) https://other.example/video".utf8), type: .url)
|
||||
])])
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListRepresentationLoadsWithTitleOnlyCompanion() async throws {
|
||||
let data = try propertyList([url.absoluteString, "", [:]] as [Any])
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
dataProvider(data, type: .url), textProvider("A video worth watching")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListRejectsNonmatchingShapes() async throws {
|
||||
let shapes: [Any] = [
|
||||
url.absoluteString,
|
||||
["URL": url.absoluteString],
|
||||
[url.absoluteString],
|
||||
[url.absoluteString, ""],
|
||||
[url.absoluteString, "", [:], "extra"] as [Any],
|
||||
[Data(url.absoluteString.utf8), "", [:]] as [Any],
|
||||
[url.absoluteString, 0, [:]] as [Any],
|
||||
[url.absoluteString, "", []] as [Any],
|
||||
[url.absoluteString, "https://base.example/", [:]] as [Any],
|
||||
[url.absoluteString, "", ["unexpected": "value"]] as [Any]
|
||||
]
|
||||
for (index, shape) in shapes.enumerated() {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(try propertyList(shape), type: .url),
|
||||
textProvider(url.absoluteString)
|
||||
])], context: "plist shape \(index)")
|
||||
}
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListRejectsFilesAndCredentials() async throws {
|
||||
for value in ["file:///tmp/video.mp4", "https://user:synthetic@video.example/video"] {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(try propertyList([value, "", [:]] as [Any]), type: .url),
|
||||
textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListPreservesExactOneLinkRequirement() async throws {
|
||||
let another = "https://other.example/video"
|
||||
let combined = try propertyList(["\(url.absoluteString) \(another)", "", [:]] as [Any])
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([dataProvider(combined, type: .url)])])
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([
|
||||
dataProvider(try propertyList([url.absoluteString, "", [:]] as [Any]), type: .url),
|
||||
dataProvider(try propertyList([another, "", [:]] as [Any]), type: .url)
|
||||
])])
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListIsNotDecodedForPlainTextProviders() async throws {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(try propertyList([url.absoluteString, "", [:]] as [Any]), type: .plainText)
|
||||
])])
|
||||
}
|
||||
|
||||
func testArbitraryKeyedURLArchivesAreNotDecoded() async throws {
|
||||
for value in [url as NSURL, url.absoluteString as NSString, Data(url.absoluteString.utf8) as NSData] as [Any] {
|
||||
let archive = try NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: true)
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(archive, type: .url), textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testTruncatedUIKitURLPropertyListIsRejected() async throws {
|
||||
let data = try propertyList([url.absoluteString, "", [:]] as [Any])
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(data.dropLast(5), type: .url), textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
|
||||
func testExplicitURLTakesPrecedenceOverLinksInCompanionText() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url),
|
||||
textProvider("Visit https://other.example/channel for more")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testMultipleDistinctURLDataProvidersAreRejected() async throws {
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url),
|
||||
dataProvider(Data("https://video.example/another".utf8), type: .url)
|
||||
])])
|
||||
}
|
||||
|
||||
func testRepeatedObjectAndDataURLRepresentationsAreOneLink() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier),
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url)
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testInvalidURLDataDoesNotFallBackToCompanionText() async throws {
|
||||
for data in [Data([0xff, 0xfe, 0x80]), Data("not a URL".utf8), Data("file:///tmp/video.mp4".utf8)] {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(data, type: .url),
|
||||
textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testPlainTextDataFallbackExtractsLinkAndIgnoresTitleOnlyProvider() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
textProvider("A video worth watching"),
|
||||
dataProvider(Data("Watch this: \(url.absoluteString)".utf8), type: .plainText)
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testAttributedTextFallbackAndEmptyInput() async throws {
|
||||
let item = NSExtensionItem()
|
||||
item.attributedContentText = NSAttributedString(string: "Watch this: \(url.absoluteString)")
|
||||
let result = try await SharedInput.load([item])
|
||||
XCTAssertEqual(result, url)
|
||||
await assertFailure(.noLink, items: [])
|
||||
}
|
||||
|
||||
private func assertFailure(_ expected: SharedVideo.Error, items: [Any], context: String = "attachment", file: StaticString = #filePath, line: UInt = #line) async {
|
||||
do {
|
||||
_ = try await SharedInput.load(items)
|
||||
XCTFail("Expected \(context) loading to fail", file: file, line: line)
|
||||
} catch let error as SharedVideo.Error {
|
||||
switch (expected, error) {
|
||||
case (.noLink, .noLink), (.multipleLinks, .multipleLinks): break
|
||||
default: XCTFail("Unexpected error: \(error)", file: file, line: line)
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Unexpected provider error: \(error)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
private func extensionItem(_ providers: [NSItemProvider]) -> NSExtensionItem {
|
||||
let item = NSExtensionItem()
|
||||
item.attachments = providers
|
||||
return item
|
||||
}
|
||||
|
||||
private func textProvider(_ text: String) -> NSItemProvider {
|
||||
NSItemProvider(item: text as NSString, typeIdentifier: UTType.plainText.identifier)
|
||||
}
|
||||
|
||||
private func dataProvider(_ data: Data, type: UTType) -> NSItemProvider {
|
||||
let provider = NSItemProvider()
|
||||
provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in
|
||||
completion(data, nil)
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
private func propertyList(_ value: Any) throws -> Data {
|
||||
try PropertyListSerialization.data(fromPropertyList: value, format: .binary, options: 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SharedVideoTests: XCTestCase {
|
||||
func testAcceptsHTTPAndHTTPSURLsWithoutChangingTheirContents() throws {
|
||||
for value in ["https://video.example/watch?v=one&t=12", "http://video.example/a%20b"] {
|
||||
let url = try XCTUnwrap(URL(string: value))
|
||||
XCTAssertEqual(try SharedVideo.validate(url), url)
|
||||
}
|
||||
}
|
||||
|
||||
func testExtractsLinkFromSharedTitleAndText() throws {
|
||||
let url = try SharedVideo.extract(from: "A video to watch: https://video.example/watch?v=one\nSent from my phone")
|
||||
XCTAssertEqual(url.absoluteString, "https://video.example/watch?v=one")
|
||||
}
|
||||
|
||||
func testRepeatedURLInTextAndAttachmentsIsOneLink() throws {
|
||||
let url = try XCTUnwrap(URL(string: "https://video.example/watch?v=one"))
|
||||
XCTAssertEqual(try SharedVideo.extract(from: "\(url)\nWatch it: \(url)"), url)
|
||||
XCTAssertEqual(try SharedVideo.single([url, url]), url)
|
||||
}
|
||||
|
||||
func testRejectsMultipleDistinctLinks() throws {
|
||||
XCTAssertThrowsError(try SharedVideo.extract(from: "https://video.example/one https://video.example/two")) {
|
||||
guard case SharedVideo.Error.multipleLinks = $0 else { return XCTFail("Unexpected error: \($0)") }
|
||||
}
|
||||
XCTAssertThrowsError(try SharedVideo.single([
|
||||
XCTUnwrap(URL(string: "https://video.example/one")),
|
||||
XCTUnwrap(URL(string: "https://video.example/two"))
|
||||
]))
|
||||
}
|
||||
|
||||
func testRejectsFilesOtherSchemesCredentialsAndMissingHosts() throws {
|
||||
for value in [
|
||||
"file:///tmp/video.mp4", "ftp://video.example/video.mp4", "javascript:alert(1)",
|
||||
"https://user:password@video.example/video", "https://user@video.example/video",
|
||||
"https:///video", "/relative/video"
|
||||
] {
|
||||
let url = try XCTUnwrap(URL(string: value))
|
||||
XCTAssertThrowsError(try SharedVideo.validate(url), "Accepted \(value)") {
|
||||
guard case SharedVideo.Error.noLink = $0 else { return XCTFail("Unexpected error: \($0)") }
|
||||
}
|
||||
}
|
||||
XCTAssertThrowsError(try SharedVideo.extract(from: "This title contains no video link"))
|
||||
XCTAssertThrowsError(try SharedVideo.single([]))
|
||||
}
|
||||
}
|
||||
@@ -375,29 +375,27 @@ export class MediaPlayer {
|
||||
siteName: "Screen Sharing",
|
||||
});
|
||||
|
||||
// Special options for mpv to better handle screen sharing (AI recommended...)
|
||||
// Start from mpv's maintained low-latency profile, then keep only a
|
||||
// couple of frames of demuxed data ready for smooth 60 fps playback.
|
||||
await this.loadFile(url, "replace", false, [
|
||||
"demuxer-lavf-o=fflags=+nobuffer+discardcorrupt", // Reduce buffering and discard corrupt frames
|
||||
"demuxer-lavf-o=analyzeduration=100000", // Reduce analyze duration
|
||||
"demuxer-lavf-o=probesize=1000000", // Reduce probe size
|
||||
"untimed=yes", // Ignore timing info
|
||||
"cache=no", // Disable cache
|
||||
"force-seekable=yes", // Force seekable
|
||||
"no-cache=yes", // Disable cache
|
||||
"demuxer-max-bytes=500K", // Limit demuxer buffer
|
||||
"demuxer-readahead-secs=0.1", // Reduce readahead
|
||||
"hr-seek=no", // Disable high-res seeking
|
||||
"video-sync=display-resample", // Better sync mode
|
||||
"video-latency-hacks=yes", // Enable latency hacks
|
||||
"audio-sync=yes", // Enable audio sync
|
||||
"audio-buffer=0.1", // Reduce audio buffer
|
||||
"audio-channels=stereo", // Force stereo audio
|
||||
"audio-samplerate=44100", // Match sample rate
|
||||
"audio-format=s16", // Use 16-bit audio
|
||||
"profile=low-latency",
|
||||
"cache=no",
|
||||
"force-seekable=no",
|
||||
"demuxer-readahead-secs=0.05",
|
||||
"demuxer-max-bytes=256K",
|
||||
"demuxer-mkv-probe-start-time=no",
|
||||
"vd-lavc-threads=2",
|
||||
"vd-lavc-fast=yes",
|
||||
"audio-buffer=0",
|
||||
"pulse-buffer=20",
|
||||
"pipewire-buffer=20",
|
||||
"alsa-buffer-time=20000",
|
||||
"initial-audio-sync=no",
|
||||
]);
|
||||
|
||||
// Make sure it's playing
|
||||
setTimeout(() => this.play(), 100);
|
||||
// loadfile normally unpauses, but make that explicit without adding the
|
||||
// previous fixed 100 ms startup delay.
|
||||
await this.play();
|
||||
}
|
||||
|
||||
public async play() {
|
||||
@@ -475,13 +473,18 @@ export class MediaPlayer {
|
||||
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
||||
this.lastLoadCandidate = url;
|
||||
this.playbackErrors.delete(url);
|
||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||
const loadPromise = this.modify(
|
||||
UserEvent.PlaylistUpdate,
|
||||
() => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]),
|
||||
);
|
||||
|
||||
if (fetchMetadata && this.shouldFetchLinkPreview(url)) {
|
||||
this.fetchMetadataAndNotify(url).catch(error => {
|
||||
console.warn(`Failed to fetch metadata for ${this.loggableUrl(url)}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
await loadPromise;
|
||||
}
|
||||
|
||||
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||
|
||||
+87
-42
@@ -26,6 +26,7 @@ import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||
import { PlaylistItem } from './types';
|
||||
import { PassThrough } from "stream";
|
||||
import { AddressInfo } from "net";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -43,6 +44,21 @@ const mediaPlayer = new MediaPlayer();
|
||||
// Create a shared stream that both endpoints can access
|
||||
let activeScreenshareStream: PassThrough | null = null;
|
||||
let activeScreenshareMimeType: string | null = null;
|
||||
let activeScreenshareSocket: WebSocket | null = null;
|
||||
const screenshareStreamsClosedByViewer = new WeakSet<PassThrough>();
|
||||
|
||||
const SCREENSHARE_STREAM_HIGH_WATER_MARK = 32 * 1024;
|
||||
|
||||
const getScreenshareContentType = (mimeType: unknown): string => {
|
||||
if (typeof mimeType !== "string") {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
const contentType = mimeType.split(";", 1)[0].trim().toLowerCase();
|
||||
return contentType === "video/webm" || contentType === "video/mp4"
|
||||
? contentType
|
||||
: "application/octet-stream";
|
||||
};
|
||||
|
||||
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
||||
return async (req: any, res: any) => {
|
||||
@@ -158,75 +174,104 @@ apiRouter.ws("/events", (ws, req) => {
|
||||
// using the fact that QueueCube/MPV is based all around streaming URLs, so the active
|
||||
// screenshare stream manifests as just another URL to play.
|
||||
apiRouter.get("/screenshareStream", withErrorHandling(async (req, res) => {
|
||||
res.setHeader("Content-Type", activeScreenshareMimeType || "video/mp4");
|
||||
const screenshareStream = activeScreenshareStream;
|
||||
if (!screenshareStream) {
|
||||
res.status(503).send("No active screen sharing session");
|
||||
return;
|
||||
}
|
||||
|
||||
req.socket.setNoDelay(true);
|
||||
res.setHeader("Content-Type", activeScreenshareMimeType || "application/octet-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("Transfer-Encoding", "chunked");
|
||||
res.setHeader("Accept-Ranges", "none");
|
||||
res.flushHeaders();
|
||||
|
||||
if (!activeScreenshareStream) {
|
||||
res.status(503).send("No active screen sharing session");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle client disconnection
|
||||
req.on('close', () => {
|
||||
res.on('close', () => {
|
||||
screenshareStream.unpipe(res);
|
||||
console.log("Screenshare viewer disconnected");
|
||||
|
||||
// If mpv goes away unexpectedly, stop capture instead of leaving an
|
||||
// unconsumed stream to build backpressure forever.
|
||||
if (activeScreenshareStream === screenshareStream && !screenshareStream.writableEnded) {
|
||||
screenshareStreamsClosedByViewer.add(screenshareStream);
|
||||
activeScreenshareSocket?.close(1011, "Screen sharing viewer disconnected");
|
||||
}
|
||||
});
|
||||
|
||||
// Configure stream for low latency
|
||||
activeScreenshareStream.setMaxListeners(0);
|
||||
|
||||
// Pipe with immediate flush
|
||||
activeScreenshareStream.pipe(res, { end: false });
|
||||
screenshareStream.pipe(res);
|
||||
}));
|
||||
|
||||
apiRouter.ws("/screenshare", (ws, req) => {
|
||||
const mimeType = req.query.mimeType as string;
|
||||
const mimeType = getScreenshareContentType(req.query.mimeType);
|
||||
console.log("Screen sharing client connected with mimeType: " + mimeType);
|
||||
ws.binaryType = "arraybuffer";
|
||||
req.socket.setNoDelay(true);
|
||||
|
||||
let firstChunk = false;
|
||||
// Supersede an old sender without allowing its close handler to tear down
|
||||
// the new session.
|
||||
const previousSocket = activeScreenshareSocket;
|
||||
const previousStream = activeScreenshareStream;
|
||||
activeScreenshareSocket = ws;
|
||||
activeScreenshareMimeType = mimeType;
|
||||
activeScreenshareStream = new PassThrough({
|
||||
highWaterMark: SCREENSHARE_STREAM_HIGH_WATER_MARK,
|
||||
allowHalfOpen: false,
|
||||
});
|
||||
const screenshareStream = activeScreenshareStream;
|
||||
let waitingForDrain = false;
|
||||
|
||||
// Configure WebSocket for low latency
|
||||
ws.setMaxListeners(0);
|
||||
ws.binaryType = "arraybuffer";
|
||||
previousStream?.end();
|
||||
if (previousSocket && previousSocket.readyState === WebSocket.OPEN) {
|
||||
previousSocket.close(1000, "A newer screen sharing session started");
|
||||
}
|
||||
|
||||
// Start mpv while the browser starts its recorder, instead of waiting for a
|
||||
// whole encoded chunk before beginning the HTTP connection and demux setup.
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const url = `http://127.0.0.1:${port}/api/screenshareStream`;
|
||||
console.log(`Starting screen share stream at ${url}`);
|
||||
mediaPlayer.initiateScreenSharing(url).catch((error: unknown) => {
|
||||
console.error("Failed to start screen sharing:", error);
|
||||
if (activeScreenshareSocket === ws) {
|
||||
ws.close(1011, "Unable to start the media player");
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (data: any) => {
|
||||
const buffer = data instanceof Buffer ? data : Buffer.from(data);
|
||||
|
||||
if (!firstChunk) {
|
||||
firstChunk = true;
|
||||
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const url = `http://localhost:${port}/api/screenshareStream`;
|
||||
console.log(`Starting screen share stream at ${url}`);
|
||||
|
||||
// Create new shared stream with immediate flush
|
||||
activeScreenshareStream = new PassThrough({
|
||||
highWaterMark: 1024 * 1024, // 1MB buffer
|
||||
allowHalfOpen: false
|
||||
});
|
||||
|
||||
activeScreenshareStream.write(buffer);
|
||||
mediaPlayer.initiateScreenSharing(url);
|
||||
} else if (activeScreenshareStream) {
|
||||
// Write with immediate flush
|
||||
activeScreenshareStream.write(buffer, () => {
|
||||
activeScreenshareStream?.cork();
|
||||
activeScreenshareStream?.uncork();
|
||||
if (activeScreenshareStream !== screenshareStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!screenshareStream.write(buffer) && !waitingForDrain) {
|
||||
waitingForDrain = true;
|
||||
ws.pause();
|
||||
screenshareStream.once('drain', () => {
|
||||
waitingForDrain = false;
|
||||
if (activeScreenshareStream === screenshareStream && ws.readyState === WebSocket.OPEN) {
|
||||
ws.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log("Screen sharing client disconnected");
|
||||
if (activeScreenshareStream) {
|
||||
activeScreenshareStream.end();
|
||||
if (activeScreenshareStream === screenshareStream) {
|
||||
screenshareStream.end();
|
||||
activeScreenshareStream = null;
|
||||
activeScreenshareMimeType = null;
|
||||
activeScreenshareSocket = null;
|
||||
if (!screenshareStreamsClosedByViewer.has(screenshareStream)) {
|
||||
mediaPlayer.stop().catch((error: unknown) => {
|
||||
console.error("Failed to stop screen sharing:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
mediaPlayer.stop();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -203,7 +203,9 @@ export const API = {
|
||||
|
||||
startScreenShare(mimeType: string): WebSocket {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/screenshare?mimeType=${mimeType}`);
|
||||
const ws = new WebSocket(
|
||||
`${protocol}://${window.location.host}/api/screenshare?mimeType=${encodeURIComponent(mimeType)}`,
|
||||
);
|
||||
return ws;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { API } from '../api/player';
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const MAX_CAPTURE_WIDTH = 1920;
|
||||
const MAX_CAPTURE_HEIGHT = 1080;
|
||||
const RECORDER_TIMESLICE_MS = 16;
|
||||
const MIN_VIDEO_BIT_RATE = 2_500_000;
|
||||
const MAX_VIDEO_BIT_RATE = 8_000_000;
|
||||
const VIDEO_BITS_PER_PIXEL = 0.065;
|
||||
const SOCKET_BUFFER_TARGET_MS = 75;
|
||||
const MIN_SOCKET_BUFFER_BYTES = 32 * 1024;
|
||||
const MIN_RESUME_BUFFER_BYTES = 8 * 1024;
|
||||
|
||||
interface UseScreenShareResult {
|
||||
isScreenSharing: boolean;
|
||||
isScreenSharingSupported: boolean;
|
||||
@@ -8,15 +19,21 @@ interface UseScreenShareResult {
|
||||
stopScreenShare: () => void;
|
||||
}
|
||||
|
||||
function getBestSupportedMimeType() {
|
||||
// Ordered by preference (best first) - all of these include audio+video
|
||||
const mimeTypes = [
|
||||
'video/webm;codecs=vp9,opus', // Best quality, good compression
|
||||
'video/webm;codecs=vp8,opus', // Good fallback, well supported
|
||||
'video/webm;codecs=h264,opus', // Better compatibility with some systems
|
||||
'video/mp4;codecs=h264,aac', // Good for Safari but may not be supported for MediaRecorder
|
||||
'video/webm', // Generic fallback (browser will choose codecs)
|
||||
'video/mp4' // Last resort
|
||||
function getBestSupportedMimeType(hasAudio: boolean): string | undefined {
|
||||
// VP8 is substantially cheaper to encode than VP9 on most systems. Prefer it
|
||||
// for real-time capture, then use hardware-friendly H.264 where available.
|
||||
const mimeTypes = hasAudio ? [
|
||||
'video/webm;codecs=vp8,opus',
|
||||
'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
|
||||
'video/webm;codecs=vp9,opus',
|
||||
'video/webm',
|
||||
'video/mp4',
|
||||
] : [
|
||||
'video/webm;codecs=vp8',
|
||||
'video/mp4;codecs=avc1.42E01E',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm',
|
||||
'video/mp4',
|
||||
];
|
||||
|
||||
// Find the first supported mimetype
|
||||
@@ -27,15 +44,42 @@ function getBestSupportedMimeType() {
|
||||
}
|
||||
}
|
||||
|
||||
// If none are supported, return null or a basic fallback
|
||||
console.warn('No preferred mime types supported by this browser');
|
||||
return 'video/webm'; // Most basic fallback
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getTargetVideoBitRate(track: MediaStreamTrack): number {
|
||||
const settings = track.getSettings();
|
||||
const width = settings.width ?? MAX_CAPTURE_WIDTH;
|
||||
const height = settings.height ?? MAX_CAPTURE_HEIGHT;
|
||||
const frameRate = Math.min(settings.frameRate ?? TARGET_FRAME_RATE, TARGET_FRAME_RATE);
|
||||
const target = width * height * frameRate * VIDEO_BITS_PER_PIXEL;
|
||||
|
||||
return Math.round(Math.min(MAX_VIDEO_BIT_RATE, Math.max(MIN_VIDEO_BIT_RATE, target)));
|
||||
}
|
||||
|
||||
function getSocketBufferLimits(recorder: MediaRecorder) {
|
||||
const bytesPerSecond = (recorder.videoBitsPerSecond + recorder.audioBitsPerSecond) / 8;
|
||||
const maximum = Math.max(
|
||||
MIN_SOCKET_BUFFER_BYTES,
|
||||
Math.ceil(bytesPerSecond * SOCKET_BUFFER_TARGET_MS / 1000),
|
||||
);
|
||||
|
||||
return {
|
||||
maximum,
|
||||
resumeAt: Math.max(MIN_RESUME_BUFFER_BYTES, Math.floor(maximum / 4)),
|
||||
};
|
||||
}
|
||||
|
||||
export const useScreenShare = (): UseScreenShareResult => {
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false);
|
||||
const screenShareSocketRef = useRef<WebSocket | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const backpressureTimerRef = useRef<number | null>(null);
|
||||
const isStartingRef = useRef(false);
|
||||
const sessionGenerationRef = useRef(0);
|
||||
|
||||
// Check if screen sharing is supported
|
||||
useEffect(() => {
|
||||
@@ -47,80 +91,205 @@ export const useScreenShare = (): UseScreenShareResult => {
|
||||
}, []);
|
||||
|
||||
const stopScreenShare = useCallback(() => {
|
||||
if (screenShareSocketRef.current) {
|
||||
screenShareSocketRef.current.close();
|
||||
screenShareSocketRef.current = null;
|
||||
sessionGenerationRef.current += 1;
|
||||
isStartingRef.current = false;
|
||||
|
||||
if (backpressureTimerRef.current !== null) {
|
||||
window.clearInterval(backpressureTimerRef.current);
|
||||
backpressureTimerRef.current = null;
|
||||
}
|
||||
|
||||
const recorder = mediaRecorderRef.current;
|
||||
mediaRecorderRef.current = null;
|
||||
if (recorder) {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onerror = null;
|
||||
if (recorder.state !== 'inactive') {
|
||||
recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
const mediaStream = mediaStreamRef.current;
|
||||
mediaStreamRef.current = null;
|
||||
mediaStream?.getTracks().forEach((track) => {
|
||||
track.onended = null;
|
||||
track.stop();
|
||||
});
|
||||
|
||||
const socket = screenShareSocketRef.current;
|
||||
screenShareSocketRef.current = null;
|
||||
if (socket) {
|
||||
socket.onopen = null;
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
setIsScreenSharing(false);
|
||||
}, []);
|
||||
|
||||
const startScreenShare = useCallback(async () => {
|
||||
if (isStartingRef.current || screenShareSocketRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isStartingRef.current = true;
|
||||
const sessionGeneration = ++sessionGenerationRef.current;
|
||||
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
video: {
|
||||
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
||||
width: { max: MAX_CAPTURE_WIDTH },
|
||||
height: { max: MAX_CAPTURE_HEIGHT },
|
||||
},
|
||||
audio: true,
|
||||
});
|
||||
|
||||
let mimeType = getBestSupportedMimeType();
|
||||
console.log('Using MIME type:', mimeType);
|
||||
// getDisplayMedia cannot be programmatically dismissed. If the user
|
||||
// stopped or restarted while its picker was open, discard this result.
|
||||
if (sessionGenerationRef.current !== sessionGeneration) {
|
||||
mediaStream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaRecorder = new MediaRecorder(mediaStream, {
|
||||
mimeType: mimeType,
|
||||
videoBitsPerSecond: 2500000, // 2.5 Mbps
|
||||
audioBitsPerSecond: 128000, // 128 kbps
|
||||
mediaStreamRef.current = mediaStream;
|
||||
|
||||
const videoTrack = mediaStream.getVideoTracks()[0];
|
||||
if (!videoTrack) {
|
||||
throw new Error('The selected source did not provide a video track');
|
||||
}
|
||||
|
||||
// Favor smooth motion over static-text quality when the browser needs to
|
||||
// trade capture resolution for frame rate.
|
||||
videoTrack.contentHint = 'motion';
|
||||
mediaStream.getAudioTracks().forEach((track) => {
|
||||
track.contentHint = 'music';
|
||||
});
|
||||
|
||||
const hasAudio = mediaStream.getAudioTracks().length > 0;
|
||||
const preferredMimeType = getBestSupportedMimeType(hasAudio);
|
||||
const recorderOptions: MediaRecorderOptions = {
|
||||
videoBitsPerSecond: getTargetVideoBitRate(videoTrack),
|
||||
};
|
||||
|
||||
if (preferredMimeType) {
|
||||
recorderOptions.mimeType = preferredMimeType;
|
||||
}
|
||||
if (hasAudio) {
|
||||
recorderOptions.audioBitsPerSecond = 128_000;
|
||||
}
|
||||
|
||||
const mediaRecorder = new MediaRecorder(mediaStream, recorderOptions);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
const mimeType = mediaRecorder.mimeType || preferredMimeType || 'application/octet-stream';
|
||||
const socketBufferLimits = getSocketBufferLimits(mediaRecorder);
|
||||
console.log(
|
||||
`Screen sharing at up to ${TARGET_FRAME_RATE} fps, ${Math.round(mediaRecorder.videoBitsPerSecond / 1000)} kbps, using ${mimeType}`,
|
||||
);
|
||||
|
||||
// Connect to WebSocket
|
||||
screenShareSocketRef.current = API.startScreenShare(mimeType);
|
||||
const socket = API.startScreenShare(mimeType);
|
||||
screenShareSocketRef.current = socket;
|
||||
isStartingRef.current = false;
|
||||
|
||||
const resumeRecorderWhenDrained = () => {
|
||||
if (screenShareSocketRef.current !== socket || mediaRecorderRef.current !== mediaRecorder) {
|
||||
if (backpressureTimerRef.current !== null) {
|
||||
window.clearInterval(backpressureTimerRef.current);
|
||||
backpressureTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket.bufferedAmount <= socketBufferLimits.resumeAt && mediaRecorder.state === 'paused') {
|
||||
mediaRecorder.resume();
|
||||
if (backpressureTimerRef.current !== null) {
|
||||
window.clearInterval(backpressureTimerRef.current);
|
||||
backpressureTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pauseRecorderForBackpressure = () => {
|
||||
if (mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.pause();
|
||||
}
|
||||
|
||||
if (backpressureTimerRef.current === null) {
|
||||
backpressureTimerRef.current = window.setInterval(
|
||||
resumeRecorderWhenDrained,
|
||||
RECORDER_TIMESLICE_MS,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up WebSocket event handlers
|
||||
screenShareSocketRef.current.onopen = () => {
|
||||
socket.onopen = () => {
|
||||
if (screenShareSocketRef.current !== socket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Screen sharing WebSocket connected');
|
||||
setIsScreenSharing(true);
|
||||
|
||||
mediaRecorder.start(100);
|
||||
mediaRecorder.start(RECORDER_TIMESLICE_MS);
|
||||
};
|
||||
|
||||
screenShareSocketRef.current.onclose = () => {
|
||||
socket.onclose = () => {
|
||||
console.log('Screen sharing WebSocket closed');
|
||||
setIsScreenSharing(false);
|
||||
|
||||
// Stop all tracks when WebSocket is closed
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
if (screenShareSocketRef.current === socket) {
|
||||
stopScreenShare();
|
||||
}
|
||||
};
|
||||
|
||||
screenShareSocketRef.current.onerror = (error) => {
|
||||
socket.onerror = (error) => {
|
||||
console.error('Screen sharing WebSocket error:', error);
|
||||
setIsScreenSharing(false);
|
||||
|
||||
// Stop all tracks on error
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
if (screenShareSocketRef.current === socket) {
|
||||
stopScreenShare();
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = (error) => {
|
||||
console.error('Screen sharing recorder error:', error);
|
||||
if (mediaRecorderRef.current === mediaRecorder) {
|
||||
stopScreenShare();
|
||||
}
|
||||
};
|
||||
|
||||
// Send data over WebSocket when available
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0 && screenShareSocketRef.current && screenShareSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
screenShareSocketRef.current.send(event.data);
|
||||
if (event.data.size > 0 && screenShareSocketRef.current === socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(event.data);
|
||||
|
||||
// Browser WebSockets otherwise queue without a limit, turning a brief
|
||||
// network slowdown into permanently stale playback. Pausing capture
|
||||
// bounds that queue and resumes at the live edge once it drains.
|
||||
if (socket.bufferedAmount >= socketBufferLimits.maximum) {
|
||||
pauseRecorderForBackpressure();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle stream ending (user clicks "Stop sharing")
|
||||
mediaStream.getVideoTracks()[0].onended = () => {
|
||||
if (screenShareSocketRef.current) {
|
||||
screenShareSocketRef.current.close();
|
||||
screenShareSocketRef.current = null;
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
};
|
||||
videoTrack.onended = stopScreenShare;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting screen share:', error);
|
||||
setIsScreenSharing(false);
|
||||
if (sessionGenerationRef.current === sessionGeneration) {
|
||||
console.error('Error starting screen share:', error);
|
||||
stopScreenShare();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [stopScreenShare]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => stopScreenShare();
|
||||
}, [stopScreenShare]);
|
||||
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (screenShareSocketRef.current) {
|
||||
if (screenShareSocketRef.current || isStartingRef.current) {
|
||||
stopScreenShare();
|
||||
} else {
|
||||
await startScreenShare();
|
||||
@@ -133,4 +302,4 @@ export const useScreenShare = (): UseScreenShareResult => {
|
||||
toggleScreenShare,
|
||||
stopScreenShare
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user