Compare commits

...
Author SHA1 Message Date
buzzert cedd0c6aa2 ios: simplify share extension to saved-player tap-to-play
Show only configured players, enqueue and play immediately on row tap,
and keep the URL on one truncated line. Remove extension discovery,
selection state, and the bottom action while retaining progress and
existing duplicate/partial-failure guards. Preserve main-app discovery.

Update share-extension guidance to match the simplified flow.
2026-10-21 01:06:45 -07:00
buzzert 174a8f8255 Merge share extension for iOS release
TestFlight / testflight (push) Successful in 1m45s
* commit '87239557059e0bf8618647d066fe733188f89984':
  ios: add share extension to queue and play video links
2026-10-20 22:46:46 -07:00
buzzert 8723955705 ios: add share extension to queue and play video links
Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success.

Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits.
2026-10-20 22:08:12 -07:00
buzzert a846d9b4ec Optimize screen sharing latency and frame rate 2026-08-29 20:10:55 -07:00
buzzert ec99065cdf ios: hide progress controls for live streams
TestFlight / testflight (push) Successful in 1m16s
2026-07-19 13:14:38 -07:00
buzzert 366aa3b723 ios: advance playback progress from server syncs
TestFlight / testflight (push) Successful in 1m31s
2026-07-19 01:12:32 -07:00
buzzert 71142e99fc ios: fix media input sizing and playback progress
TestFlight / testflight (push) Successful in 1m24s
2026-07-19 00:22:04 -07:00
buzzert b47d60c17f ios: add playback progress and error reporting
TestFlight / testflight (push) Successful in 1m22s
2026-07-19 00:02:54 -07:00
buzzert d6d280338d Set up Gitea TestFlight deployment
TestFlight / testflight (push) Successful in 1m24s
2026-07-18 23:21:19 -07:00
buzzert e6e0b1d736 Improve mpv playback configuration 2026-06-14 16:48:23 -07:00
buzzert c9fe5ff272 add encryption compliance thing 2026-05-31 00:32:37 -07:00
43 changed files with 3160 additions and 364 deletions
+12
View File
@@ -0,0 +1,12 @@
FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1
# App Store Connect API key (the key content must be base64 encoded).
APP_STORE_CONNECT_KEY_ID=
APP_STORE_CONNECT_ISSUER_ID=
APP_STORE_CONNECT_KEY_CONTENT=
# Shared fastlane match repository.
MATCH_GIT_URL=
MATCH_PASSWORD=
MATCH_GIT_BASIC_AUTHORIZATION=
+43
View File
@@ -0,0 +1,43 @@
name: TestFlight
on:
workflow_dispatch:
push:
tags:
- "release/ios/v*"
- "v[0-9]*"
- "[0-9]*"
jobs:
testflight:
runs-on: macos-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.7"
bundler-cache: true
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
QUEUECUBE_VERSION_TAG: ${{ github.ref_name }}
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
# act_runner does not propagate setup-ruby's PATH changes into later
# steps, so put the selected Ruby back on PATH before running Bundler.
run: |
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
bundle exec fastlane ios beta
+1
View File
@@ -1,4 +1,5 @@
/.env
/.bundle/
ios/build/
fastlane/report.xml
fastlane/Preview.html
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
+231
View File
@@ -0,0 +1,231 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.2)
aws-partitions (1.1109.0)
aws-sdk-core (3.224.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.101.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.188.0)
aws-sdk-core (~> 3, >= 3.224.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.29.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.7.6)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
public_suffix (5.1.1)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
DEPENDENCIES
fastlane
BUNDLED WITH
2.5.23
+44 -1
View File
@@ -4,6 +4,34 @@ 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
workflow accepts the repository's existing numeric tags (for example `3.2`),
`v3.2`, and `release/ios/v3.2`; it can also be started manually.
Configure these Gitea Actions secrets before the first run:
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_KEY_CONTENT`
- `MATCH_GIT_URL`
- `MATCH_PASSWORD`
- `MATCH_GIT_BASIC_AUTHORIZATION`
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. 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.
![queuecube user interface](screenshots/queuecube.png)
## Running
@@ -36,6 +64,22 @@ Once running, you should be able to access the UI via http://localhost:8080.
QueueCube supports video as well. Just set the environment variable `ENABLE_VIDEO=1`.
When video is enabled, QueueCube starts a managed `mpv` process. The process
fullscreens by default; set `MPV_FULLSCREEN=0` to leave sizing and placement to
your window manager. These optional environment variables are also passed to
`mpv`:
| Environment variable | mpv option |
| --- | --- |
| `MPV_WAYLAND_APP_ID` | `--wayland-app-id=...` |
| `MPV_TITLE` | `--title=...` |
| `MPV_BORDER` | `--border=...` |
| `MPV_ONTOP=1` | `--ontop` |
| `MPV_YTDL_FORMAT` | `--ytdl-format=...` |
For less common options, `MPV_EXTRA_ARGS` can contain additional shell-style
arguments that will be appended to the generated `mpv` command line.
#### Video + Docker
When running in a Docker container, there are a few extra steps needed to make this work.
@@ -68,4 +112,3 @@ privileged: true
(or `--privileged`).
On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`).
+149 -15
View File
@@ -6,7 +6,13 @@ 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")
APP_ROOT = File.expand_path("..", File.expand_path(__dir__))
IOS_PROJECT_DIR = File.join(APP_ROOT, "ios")
XCODE_PROJECT = File.join(IOS_PROJECT_DIR, "QueueCube.xcodeproj")
@@ -33,18 +39,57 @@ def git_output(*args)
stdout.strip
end
def app_version
tag = git_output("describe", "--tags", "--abbrev=0")
version = tag.sub(/\Av/, "")
unless version.match?(/\A\d+(?:\.\d+){0,2}\z/)
UI.user_error!("Latest git tag #{tag.inspect} is not a valid App Store version. Use a tag like 1.5.2 or v1.5.2.")
def present?(value)
!value.to_s.strip.empty?
end
def ci?
present?(ENV["CI"])
end
def version_from_tag(tag)
patterns = [
%r{\Arelease/ios/v(\d+(?:\.\d+){0,2})\z},
%r{\Av?(\d+(?:\.\d+){0,2})\z}
]
patterns.each do |pattern|
match = tag.to_s.match(pattern)
return match[1] if match
end
version
nil
end
def app_version
candidates = [
ENV["QUEUECUBE_VERSION_TAG"],
ENV["GITHUB_REF_NAME"],
ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "")
]
candidates.each do |tag|
version = version_from_tag(tag)
return version if version
end
latest_tag = git_output("describe", "--tags", "--abbrev=0")
version = version_from_tag(latest_tag)
return version if version
candidates << latest_tag
UI.user_error!("Release tag must look like 3.2, v3.2, or release/ios/v3.2; got #{candidates.compact.inspect}")
end
def build_number
git_output("rev-list", "--count", "HEAD")
value = ENV["QUEUECUBE_BUILD_NUMBER"]
value = Time.now.utc.strftime("%Y%m%d%H%M%S") unless present?(value)
unless value.to_s.match?(/\A\d+\z/)
UI.user_error!("Build number must be numeric; got #{value.inspect}")
end
value.to_s
end
def upload_export_options
@@ -56,9 +101,18 @@ def upload_export_options
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>automatic</string>
<string>manual</string>
<key>teamID</key>
<string>#{DEVELOPMENT_TEAM}</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>provisioningProfiles</key>
<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/>
<key>stripSwiftSymbols</key>
@@ -71,12 +125,75 @@ def upload_export_options
end
platform :ios do
desc "Build QueueCube for iOS and upload the archive to TestFlight"
lane :beta do
private_lane :app_store_api_key do
app_store_connect_api_key(
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"),
is_key_content_base64: true
)
end
# CI signs headlessly, so match imports the distribution identity into a
# fresh keychain. Put it first because this shared runner may have locked
# keychains from other projects containing the same identity.
private_lane :prepare_ci_keychain do
next unless ci?
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH)
create_keychain(
name: CI_KEYCHAIN_NAME,
password: CI_KEYCHAIN_PASSWORD,
unlock: true,
timeout: 3600,
add_to_search_list: false
)
others = sh("security list-keychains -d user", log: false)
.scan(/"([^"]+)"/)
.flatten
.reject { |path| path.include?(CI_KEYCHAIN_NAME) }
sh("security list-keychains -d user -s #{([CI_KEYCHAIN_DB_PATH] + others).shelljoin}")
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
end
private_lane :sync_signing do |options|
{
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"
lane :setup_signing do
sync_signing(api_key: app_store_api_key, readonly: false)
end
private_lane :build_release do
prepare_ci_keychain
api_key = app_store_api_key
version = app_version
build = build_number
UI.message("Using QueueCube version #{version} (build #{build}) from git")
sync_signing(api_key: api_key, readonly: true)
FileUtils.rm_rf(archive_path)
FileUtils.rm_rf(export_path)
@@ -92,9 +209,12 @@ platform :ios do
"clean",
"archive",
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
"PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}",
"MARKETING_VERSION=#{version}",
"CURRENT_PROJECT_VERSION=#{build}"
"CURRENT_PROJECT_VERSION=#{build}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple Distribution",
"QUEUECUBE_APP_PROFILE=#{PROVISIONING_PROFILE_NAME}",
"QUEUECUBE_SHARE_PROFILE=#{SHARE_PROVISIONING_PROFILE_NAME}"
))
end
@@ -117,13 +237,27 @@ platform :ios do
ipa_path = Dir[File.join(export_path, "*.ipa")].first
UI.user_error!("No IPA found in #{export_path}") unless ipa_path
{ api_key: api_key, ipa_path: ipa_path }
ensure
export_options&.unlink
delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH)
end
desc "Build an App Store-signed QueueCube IPA without uploading it"
lane :build do
build_release
end
desc "Build QueueCube for iOS and upload the archive to TestFlight"
lane :beta do
release = build_release
upload_to_testflight(
api_key: release.fetch(:api_key),
app_identifier: BUNDLE_IDENTIFIER,
ipa: ipa_path,
ipa: release.fetch(:ipa_path),
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
ensure
export_options&.unlink
end
end
+16
View File
@@ -15,6 +15,22 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## iOS
### ios setup_signing
```sh
[bundle exec] fastlane ios setup_signing
```
Create or update match signing assets
### ios build
```sh
[bundle exec] fastlane ios build
```
Build an App Store-signed QueueCube IPA without uploading it
### ios beta
```sh
+12 -1
View File
@@ -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
@@ -91,3 +95,10 @@ QueueCubeApp
- **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
## 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.
+22
View File
@@ -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"])
]
)
+228
View File
@@ -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 */;
+6 -1
View File
@@ -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>
+2
View File
@@ -6,6 +6,8 @@
<array>
<string>_queuecube._tcp.</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSLocalNetworkUsageDescription</key>
<string>QueueCube needs access to your local network to discover nearby jukebox servers.</string>
<key>NSAppTransportSecurity</key>
+24 -3
View File
@@ -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 {
@@ -173,6 +178,13 @@ actor API
.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()
.pathString("/search?q=\(query.uriEncoded())")
@@ -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 {
+114
View File
@@ -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))"
}
}
}
}
}
+17 -4
View File
@@ -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
}
+168
View File
@@ -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)
}
}
+28 -6
View File
@@ -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)
}
+104
View File
@@ -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
}
}
+60
View File
@@ -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
}
}
+38
View File
@@ -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)
}
}
-76
View File
@@ -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
{
+3
View File
@@ -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()
+7 -2
View File
@@ -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
}
+8 -1
View File
@@ -78,6 +78,14 @@ class MainViewModel
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))
}
}
+13 -1
View File
@@ -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,6 +29,7 @@ struct NowPlayingMiniView: View {
onTap()
}
VStack(spacing: 0.0) {
HStack {
VStack(alignment: .leading) {
if let title = model.title, !title.isEmpty {
@@ -58,11 +60,21 @@ struct NowPlayingMiniView: View {
.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)
}
}
}
.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)
}
+108 -1
View File
@@ -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 {
@@ -124,6 +199,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
private enum Buttons: Int, CaseIterable, Identifiable {
@@ -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)
model.resolutionError = nil
defer { model.resolvingServers.remove(endpoint) }
do {
let server = try await endpoint.resolve()
onAddServer(server)
model.resolvingServers.remove(endpoint)
} 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,9 +150,11 @@ struct AddServerView: View
self.validationURL = self.serverURL
self.validationTimer?.invalidate()
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
Task { @MainActor [weak self] in
self?.validateSettings()
}
}
}
private func validateSettings() {
guard !validationURL.isEmpty else {
@@ -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
}
}
+10
View File
@@ -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>
+34
View File
@@ -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)
}
}
}
}
}
+102
View File
@@ -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([]))
}
}
+155 -30
View File
@@ -117,15 +117,86 @@ export class MediaPlayer {
}
}
private envEnabled(name: string, defaultValue: boolean = false): boolean {
const value = process.env[name];
if (value === undefined) {
return defaultValue;
}
switch (value.trim().toLowerCase()) {
case "1":
case "true":
case "yes":
case "on":
return true;
case "":
case "0":
case "false":
case "no":
case "off":
return false;
default:
return defaultValue;
}
}
private addMpvEnvOption(args: string[], envName: string, optionName: string) {
const value = process.env[envName];
if (value) {
args.push(`${optionName}=${value}`);
}
}
private parseMpvExtraArgs(): string[] {
const value = process.env.MPV_EXTRA_ARGS;
if (!value) {
return [];
}
const args: string[] = [];
let current = "";
let quote: string | null = null;
let escaped = false;
for (const char of value) {
if (escaped) {
current += char;
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
} else if (char === "\"" || char === "'") {
quote = char;
} else if (/\s/.test(char)) {
if (current) {
args.push(current);
current = "";
}
} else {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}
private tryRespawnPlayerProcess(): Promise<Socket> {
const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`;
const enableVideo = process.env.ENABLE_VIDEO || false;
const enableVideo = this.envEnabled("ENABLE_VIDEO");
const ytdlFormat = process.env.MPV_YTDL_FORMAT;
const logfilePath = `/tmp/mpv-logfile.txt`;
const playerArgs = [
"--video=" + (enableVideo ? "auto" : "no"),
"--fullscreen",
"--no-terminal",
"--idle=yes",
"--input-ipc-server=" + socketPath,
@@ -133,10 +204,24 @@ export class MediaPlayer {
"--msg-level=all=v"
];
if (this.envEnabled("MPV_FULLSCREEN", true)) {
playerArgs.push("--fullscreen");
}
this.addMpvEnvOption(playerArgs, "MPV_WAYLAND_APP_ID", "--wayland-app-id");
this.addMpvEnvOption(playerArgs, "MPV_TITLE", "--title");
this.addMpvEnvOption(playerArgs, "MPV_BORDER", "--border");
if (this.envEnabled("MPV_ONTOP")) {
playerArgs.push("--ontop");
}
if (ytdlFormat) {
playerArgs.push("--ytdl-format=" + ytdlFormat);
}
playerArgs.push(...this.parseMpvExtraArgs());
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
this.playerProcess = spawn("mpv", playerArgs);
@@ -290,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() {
@@ -381,22 +464,27 @@ export class MediaPlayer {
public async getFeatures(): Promise<Features> {
return {
video: !!process.env.ENABLE_VIDEO,
screenshare: !!process.env.ENABLE_SCREENSHARE,
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK
video: this.envEnabled("ENABLE_VIDEO"),
screenshare: this.envEnabled("ENABLE_SCREENSHARE"),
browserPlayback: this.envEnabled("ENABLE_BROWSER_PLAYBACK")
};
}
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) {
if (fetchMetadata && this.shouldFetchLinkPreview(url)) {
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, 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> {
@@ -446,7 +534,7 @@ export class MediaPlayer {
private async fetchMetadataAndNotify(url: string) {
try {
console.log("Fetching metadata for " + url);
console.log("Fetching metadata for " + this.loggableUrl(url));
const metadata = await getLinkPreview(url);
this.metadata.set(url, {
title: (metadata as any)?.title,
@@ -454,7 +542,7 @@ export class MediaPlayer {
siteName: (metadata as any)?.siteName,
});
console.log("Metadata fetched for " + url);
console.log("Metadata fetched for " + this.loggableUrl(url));
console.log(this.metadata.get(url));
// Notify clients that metadata has been updated
@@ -467,6 +555,43 @@ export class MediaPlayer {
}
}
private shouldFetchLinkPreview(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol === "file:") {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return false;
}
const pathname = parsed.pathname.toLowerCase();
if (/\.(ts|m3u8|mpd|mp4|m4v|mkv|webm|avi|mov|mpeg|mpg|mp3|aac|flac|wav|ogg|opus)$/.test(pathname)) {
return false;
}
if (/\/download\/?$/.test(pathname)) {
return false;
}
return true;
} catch {
return false;
}
}
private loggableUrl(url: string): string {
try {
const parsed = new URL(url);
const segments = parsed.pathname.split("/").filter(Boolean);
const basename = segments.length > 0 ? segments[segments.length - 1] : "";
return `${parsed.protocol}//${parsed.host}/${basename ? ".../" + basename : ""}${parsed.search ? "?..." : ""}`;
} catch {
return url;
}
}
private connectToSocket(path: string): Promise<Socket> {
const retryDelayMs = 100;
const maxAttempts = 50;
+94 -43
View File
@@ -26,17 +26,39 @@ 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());
expressWs(app);
const frontendDistPath = [
path.join(__dirname, "../dist/frontend"),
path.join(__dirname, "../../frontend/dist"),
].find((candidate) => fs.existsSync(path.join(candidate, "index.html")))
|| path.join(__dirname, "../dist/frontend");
const apiRouter = express.Router();
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) => {
@@ -152,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;
if (activeScreenshareStream !== screenshareStream) {
return;
}
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 (!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();
});
});
@@ -300,14 +351,14 @@ apiRouter.get("/features", withErrorHandling(async (req, res) => {
}));
// Serve static files for React app (after building)
app.use(express.static(path.join(__dirname, "../dist/frontend")));
app.use(express.static(frontendDistPath));
// Mount API routes under /api
app.use("/api", apiRouter);
// Serve React app for all other routes (client-side routing)
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "../dist/frontend/index.html"));
res.sendFile(path.join(frontendDistPath, "index.html"));
});
const port = process.env.PORT || 3000;
+3 -1
View File
@@ -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;
}
};
+215 -46
View File
@@ -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);
if (screenShareSocketRef.current === socket) {
stopScreenShare();
}
};
// Stop all tracks on error
mediaStream.getTracks().forEach(track => track.stop());
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) {
if (sessionGenerationRef.current === sessionGeneration) {
console.error('Error starting screen share:', error);
setIsScreenSharing(false);
stopScreenShare();
}
}, []);
}
}, [stopScreenShare]);
useEffect(() => {
return () => stopScreenShare();
}, [stopScreenShare]);
const toggleScreenShare = useCallback(async () => {
if (screenShareSocketRef.current) {
if (screenShareSocketRef.current || isStartingRef.current) {
stopScreenShare();
} else {
await startScreenShare();