Compare commits

..
80 Commits
Author SHA1 Message Date
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
buzzert 1c9916c69d Regenerate Fastlane docs 2026-05-30 20:47:17 -07:00
buzzert cfd023be69 Let Xcode manage release signing 2026-05-30 20:45:52 -07:00
buzzert 719359b940 Add Fastlane TestFlight release lane 2026-05-30 20:44:45 -07:00
buzzert f4d0bd7ca0 pin yt-dlp version 2026-05-28 23:00:10 -07:00
buzzert 26a13c27d6 update flake.lock 2026-05-28 22:05:06 -07:00
buzzert cfe5063828 backend: observe metadata/title changes 2026-05-01 08:51:38 -07:00
buzzert 69742ce2d9 build bump to 13 2026-02-20 00:08:46 -08:00
buzzert 5b3787f19f retry logic for when mpv doesnt start right away 2026-02-18 17:57:42 -08:00
buzzert 9b0f6e2123 Nix: add screen share option 2026-01-31 22:34:55 -08:00
buzzert 2d4eae9676 version bump, 1.5.2 2025-11-15 18:25:44 -08:00
buzzert 848c4c2b55 ios: minheight on mediaitemcell 2025-11-15 18:25:01 -08:00
buzzert a6ca763730 version bump and warning fix 2025-11-15 18:20:37 -08:00
buzzert 0916de60f3 error alert cleanup 2025-11-15 18:20:03 -08:00
buzzert cfc6e6c411 ios: add error handling 2025-11-15 18:16:03 -08:00
buzzert bc54735d1f web: add error surfacing 2025-11-15 17:58:29 -08:00
buzzert 04d23bec1e ios: bump version 2025-11-15 17:47:13 -08:00
buzzert 3aa819eccc ios: Fixes for add media view
- Adds paste button
- Fix autofocus behavior (using UIKit)
- Remove pointless sheet detents
2025-11-15 17:42:00 -08:00
buzzert 718518c3f2 ios: tighten fonts / list styles 2025-11-15 17:13:11 -08:00
buzzert f3053c1db1 ios: fix backgrounding error 2025-11-15 16:15:53 -08:00
buzzert 58a43a617e MediaPlayer: remove config line for reconnect rules 2025-11-15 15:45:34 -08:00
buzzert 85fd112a1a web: update nix flake lock 2025-11-15 15:35:55 -08:00
buzzert 8ac494049d dont mess with these 2025-11-02 11:40:14 -08:00
buzzert 4e3bcb406a flake: try and remove restrictions to improve mpv reliability 2025-11-02 11:35:11 -08:00
buzzert ec1ee508b3 backend: better timeouts/stream reconnect options for mpv 2025-11-02 10:52:58 -08:00
buzzert 4e8cd11d8f fix screenshots 2025-10-10 23:15:44 -07:00
buzzert ac4c22c2fb Add 'ios/' from commit '2220a0d4f2bb0f0ebca509581c21d6c90359bd14'
git-subtree-dir: ios
git-subtree-mainline: 52968df567
git-subtree-split: 2220a0d4f2
2025-10-10 23:13:50 -07:00
buzzert 52968df567 move web components to web/ 2025-10-10 23:13:42 -07:00
buzzert 2220a0d4f2 unconditional active scene phase change 2025-10-10 23:10:10 -07:00
buzzertandClaude 6110f712bd Fix WebSocket reconnection after app backgrounding
- Add scenePhase monitoring to ContentView to detect app lifecycle changes
- Implement handleScenePhaseChange() to force WebSocket reconnection and full UI refresh when app returns to foreground from background
- Update error handling in watchWebsocket() to suppress UI errors for backgrounding (error code 53) while still triggering reconnection
- Simplify API.notifyError() to always report errors, letting UI layer decide what to display

This fixes the issue where WebSocket connections would permanently disconnect after extended backgrounding, as iOS terminates background network connections after ~30 seconds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 11:42:13 -07:00
buzzert 3a5c285511 version 1.2 2025-10-05 18:23:16 -07:00
buzzert 4021881f11 Resolve various connection issues 2025-10-05 18:19:51 -07:00
buzzert 623b562e8d NowPlaying: appearance fixes for timestamp indicator 2025-06-27 01:13:45 -07:00
buzzert 08619255c7 update typescript 2025-06-26 01:47:07 -07:00
buzzert 472860f426 nix: update npm deps hash 2025-06-26 01:41:29 -07:00
buzzert 6d0c52b96f Implements seek/time bar 2025-06-26 01:38:12 -07:00
buzzert 5e9842f02d Adds feature flags, and the ability to do browser playback 2025-06-24 12:50:15 -07:00
buzzert 480b30d909 Fix package-lock issues when building with Docker 2025-06-23 11:53:35 -07:00
buzzert 839ec53c17 fix volume slider 2025-06-20 19:03:33 -07:00
buzzert 6e5e587998 implements editing items in favorites 2025-06-20 18:50:06 -07:00
buzzert d87d6e038e Appearance tweaks 2025-06-20 18:22:31 -07:00
buzzert 0d2eb229cf Resolves on-device Bonjour service discovery issue 2025-06-20 15:16:58 -07:00
buzzert 82b5c886cb More granular websocket error handling 2025-06-20 14:55:55 -07:00
buzzert 751261ffc4 Bonjour services to info.plist 2025-06-11 21:24:25 -07:00
buzzert 0e7305baa4 implements youtube search 2025-06-11 21:16:59 -07:00
buzzert 484d08d3d4 bleh... 2025-06-11 20:27:42 -07:00
buzzert 663125aa0e flake: fix error in flake config for INVIDIOUS_BASE_URL 2025-06-11 20:17:20 -07:00
buzzert 937a061cdd Implements add media page 2025-06-11 20:13:37 -07:00
buzzert 601ffc4a75 Implements updated nowplaying view 2025-06-11 19:33:20 -07:00
buzzert bde29e7e98 better error handling and server switching 2025-06-11 18:41:39 -07:00
buzzert afe985661a add server: show progress bar when resolving 2025-06-11 17:42:26 -07:00
buzzert ce8ece23a5 implements favorites/playlist deletion 2025-06-11 15:08:17 -07:00
buzzert 9aa55864f8 Tweaks to now playing view 2025-06-11 13:48:31 -07:00
buzzert a98bcd5b66 Started working on NowPlaying mini 2025-06-11 13:32:34 -07:00
buzzert ca829dde4c Implements server selection UI 2025-06-11 13:00:09 -07:00
buzzert 51048678bb Unify playlist/favorites views 2025-06-11 12:12:53 -07:00
buzzert 7e6d449c52 Selected server and better settings 2025-06-10 23:10:13 -07:00
buzzert 0cdbecc031 finish implementing server configuration 2025-06-10 22:40:51 -07:00
buzzert f4f3ef543f Started working on multiple server configuration 2025-06-10 18:45:34 -07:00
buzzert c775fa0def Implements UI for adding servers in settings, moves to tab model on Phone 2025-06-10 14:16:47 -07:00
buzzert 13b27a2a1a project reorg 2025-06-10 11:09:44 -07:00
buzzert 63094f7e49 zeroconf: add hostname to service name 2025-06-07 11:39:56 -07:00
buzzert d34363c650 nix: update deps hash 2025-05-31 15:21:35 -07:00
buzzert 1bde92b974 Adds support for zeroconf (Bonjour) 2025-05-30 20:12:53 -07:00
buzzert 6c183aea03 adds CLAUDE.md 2025-05-30 17:17:18 -07:00
buzzert 3775f2dc7c Better connection handling, favorites support 2025-05-30 17:06:52 -07:00
buzzert 8807d6e621 better handling of connection errors 2025-05-30 16:45:09 -07:00
buzzert 3552c9c476 Better socket handling, loadfile api update for mpv 0.38.0 2025-05-30 16:40:20 -07:00
buzzert 45f1f521e2 implements settings 2025-05-02 21:27:46 -07:00
buzzert 74c0227ec7 Implements a few more api endpoints 2025-03-03 21:08:47 -08:00
buzzert fb9a6fcb9b Initial commit 2025-03-03 20:21:30 -08:00
105 changed files with 9312 additions and 7320 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
+7 -4
View File
@@ -1,4 +1,7 @@
build/
result
node_modules/
tsconfig.tsbuildinfo
/.env
/.bundle/
ios/build/
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/
+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, choose a player, and tap **Queue & Play** 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`).
-2121
View File
File diff suppressed because it is too large Load Diff
-359
View File
@@ -1,359 +0,0 @@
/*
* MediaPlayer.ts
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ChildProcess, spawn } from "child_process";
import { Socket } from "net";
import { WebSocket } from "ws";
import { getLinkPreview } from "link-preview-js";
import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore";
interface PendingCommand {
resolve: (value: any) => void;
reject: (reason: any) => void;
}
enum UserEvent {
PlaylistUpdate = "playlist_update",
NowPlayingUpdate = "now_playing_update",
VolumeUpdate = "volume_update",
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
}
export class MediaPlayer {
private playerProcess: ChildProcess;
private socket: Socket;
private eventSubscribers: WebSocket[] = [];
private favoritesStore: FavoritesStore;
private pendingCommands: Map<number, PendingCommand> = new Map();
private requestId: number = 1;
private dataBuffer: string = '';
private metadata: Map<string, LinkMetadata> = new Map();
constructor() {
const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`;
const enableVideo = process.env.ENABLE_VIDEO || false;
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
this.playerProcess = spawn("mpv", [
"--video=" + (enableVideo ? "auto" : "no"),
"--fullscreen",
"--no-terminal",
"--idle=yes",
"--input-ipc-server=" + socketPath
]);
this.socket = new Socket();
this.playerProcess.on("spawn", () => {
console.log(`Player process spawned, opening socket @ ${socketPath}`);
setTimeout(() => {
this.connectToSocket(socketPath);
}, 500);
});
this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => {
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
};
}
public async getPlaylist(): Promise<PlaylistItem[]> {
return this.writeCommand("get_property", ["playlist"])
.then((response) => {
// Enhance playlist items with metadata
const playlist = response.data as PlaylistItem[];
return playlist.map((item: PlaylistItem) => ({
...item,
metadata: this.metadata.get(item.filename) || {}
}));
});
}
public async getNowPlaying(): Promise<PlaylistItem> {
const playlist = await this.getPlaylist();
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
const fetchMediaTitle = async (): Promise<string> => {
return (await this.writeCommand("get_property", ["media-title"])).data;
};
if (currentlyPlayingSong !== undefined) {
// Use media title if we don't have a title
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
return {
...currentlyPlayingSong,
title: await fetchMediaTitle()
};
}
return currentlyPlayingSong;
}
const mediaTitle = await fetchMediaTitle();
return {
id: 0,
filename: mediaTitle,
title: mediaTitle
};
}
public async getCurrentFile(): Promise<string> {
return this.writeCommand("get_property", ["stream-open-filename"])
.then((response) => {
return response.data;
});
}
public async getPauseState(): Promise<boolean> {
return this.writeCommand("get_property", ["pause"])
.then((response) => {
return response.data;
});
}
public async getVolume(): Promise<number> {
return this.writeCommand("get_property", ["volume"])
.then((response) => {
return response.data;
});
}
public async getIdle(): Promise<boolean> {
return this.writeCommand("get_property", ["idle"])
.then((response) => {
return response.data;
});
}
public async append(url: string) {
await this.loadFile(url, "append-play");
}
public async replace(url: string) {
await this.loadFile(url, "replace");
}
public async initiateScreenSharing(url: string) {
console.log(`Initiating screen sharing with file: ${url}`);
this.metadata.set(url, {
title: "Screen Sharing",
description: "Screen Sharing",
siteName: "Screen Sharing",
});
// Special options for mpv to better handle screen sharing (AI recommended...)
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
]);
// Make sure it's playing
setTimeout(() => this.play(), 100);
}
public async play() {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
}
public async pause() {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
}
public async stop() {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
}
public async skip() {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
}
public async skipTo(index: number) {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
}
public async previous() {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
}
public async deletePlaylistItem(index: number) {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
}
public async setVolume(volume: number) {
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
}
public subscribe(ws: WebSocket) {
this.eventSubscribers.push(ws);
}
public unsubscribe(ws: WebSocket) {
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
}
public async getFavorites(): Promise<PlaylistItem[]> {
return this.favoritesStore.getFavorites();
}
public async addFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
}
public async removeFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
}
public async clearFavorites() {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
}
public async updateFavoriteTitle(filename: string, title: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
}
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, options.join(',')]));
if (fetchMetadata) {
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, error);
});
}
}
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
return func()
.then((result) => {
// Notify all subscribers
this.handleEvent(event, {});
return result;
});
}
private async writeCommand(command: string, args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
const id = this.requestId++;
const commandObject = JSON.stringify({
command: [command, ...args],
request_id: id
});
this.pendingCommands.set(id, { resolve, reject });
this.socket.write(commandObject + '\n');
// Add timeout to prevent hanging promises
setTimeout(() => {
if (this.pendingCommands.has(id)) {
const pending = this.pendingCommands.get(id);
if (pending) {
pending.reject(new Error('Command timed out'));
this.pendingCommands.delete(id);
}
}
}, 5000);
});
}
private async fetchMetadataAndNotify(url: string) {
try {
console.log("Fetching metadata for " + url);
const metadata = await getLinkPreview(url);
this.metadata.set(url, {
title: (metadata as any)?.title,
description: (metadata as any)?.description,
siteName: (metadata as any)?.siteName,
});
console.log("Metadata fetched for " + url);
console.log(this.metadata.get(url));
// Notify clients that metadata has been updated
this.handleEvent(UserEvent.MetadataUpdate, {
url,
metadata: this.metadata.get(url)
});
} catch (error) {
throw error;
}
}
private connectToSocket(path: string) {
this.socket.connect(path);
this.socket.on("data", data => this.receiveData(data.toString()));
}
private handleEvent(event: string, data: any) {
console.log("Event [" + event + "]: ", data);
// Notify all subscribers
this.eventSubscribers.forEach(subscriber => {
subscriber.send(JSON.stringify({ event, data }));
});
}
private receiveData(data: string) {
this.dataBuffer += data;
const lines = this.dataBuffer.split('\n');
// Keep last incomplete line in the buffer
this.dataBuffer = lines.pop() || '';
for (const line of lines) {
if (line.trim().length > 0) {
try {
const response = JSON.parse(line);
if (response.request_id) {
const pending = this.pendingCommands.get(response.request_id);
if (pending) {
pending.resolve(response);
this.pendingCommands.delete(response.request_id);
}
} else if (response.event) {
this.handleEvent(UserEvent.MPDUpdate, response);
} else {
console.log(response);
}
} catch (error) {
console.error('Error parsing JSON:', error);
}
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
apple_id("james.magahern@mac.com")
app_identifier("net.buzzert.QueueCube")
team_id("DQQH5H6GBD")
team_name("James Magahern")
itc_team_id("127764897")
itc_team_name("James Magahern")
+263
View File
@@ -0,0 +1,263 @@
require "fileutils"
require "open3"
require "shellwords"
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")
SCHEME = "QueueCube"
ARCHIVE_PATH = File.join(IOS_PROJECT_DIR, "build", "#{SCHEME}.xcarchive")
EXPORT_PATH = File.join(IOS_PROJECT_DIR, "build", "upload")
def shell_command(*parts)
parts.flatten.map { |part| part.to_s.shellescape }.join(" ")
end
def archive_path
ARCHIVE_PATH
end
def export_path
EXPORT_PATH
end
def git_output(*args)
stdout, stderr, status = Open3.capture3("git", *args, chdir: APP_ROOT)
UI.user_error!("git #{args.join(' ')} failed: #{stderr.strip}") unless status.success?
stdout.strip
end
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
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
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
<<~PLIST
<?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>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<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>
<true/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
PLIST
end
platform :ios 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)
Dir.chdir(IOS_PROJECT_DIR) do
sh(shell_command(
"xcodebuild",
"-project", XCODE_PROJECT,
"-scheme", SCHEME,
"-configuration", "Release",
"-destination", "generic/platform=iOS",
"-archivePath", archive_path,
"-allowProvisioningUpdates",
"clean",
"archive",
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
"MARKETING_VERSION=#{version}",
"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
export_options = Tempfile.new(["queuecube-export-options", ".plist"])
export_options.write(upload_export_options)
export_options.close
FileUtils.rm_rf(export_path)
Dir.chdir(IOS_PROJECT_DIR) do
sh(shell_command(
"xcodebuild",
"-exportArchive",
"-archivePath", archive_path,
"-exportPath", export_path,
"-exportOptionsPlist", export_options.path,
"-allowProvisioningUpdates"
))
end
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: release.fetch(:ipa_path),
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
end
end
+48
View File
@@ -0,0 +1,48 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## 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
[bundle exec] fastlane ios beta
```
Build QueueCube for iOS and upload the archive to TestFlight
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
-196
View File
@@ -1,196 +0,0 @@
{
description = "NodeJS application with mpv, yt-dlp, and pulseaudio dependencies";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
let
# Define the NixOS module for the systemd service
nixosModule = { config, lib, pkgs, ... }:
let
cfg = config.services.queuecube;
in {
options.services.queuecube = {
enable = lib.mkEnableOption "QueueCube media player service";
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port on which QueueCube will listen";
};
enable_video = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable video playback";
};
store_path = lib.mkOption {
type = lib.types.str;
default = "/var/tmp/queuecube";
description = "Path to the store for QueueCube";
};
invidious = lib.mkOption {
type = lib.types.submodule {
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Invidious";
};
url = lib.mkOption {
type = lib.types.str;
default = "http://invidious.nor";
description = "URL of the Invidious instance to use";
};
};
};
default = {
enable = false;
url = "http://invidious.nor";
};
};
user = lib.mkOption {
type = lib.types.str;
description = "User account under which QueueCube runs (required)";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = {
packages = [ self.packages.${pkgs.system}.queuecube ];
};
systemd.user.services.queuecube = {
description = "QueueCube media player service";
wantedBy = [ "default.target" ];
after = [ "pipewire.service" "pipewire-pulse.service" ];
serviceConfig = {
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube";
Restart = "on-failure";
RestartSec = 5;
# Allow access to X11 for mpv
Environment = [ "DISPLAY=:0" ];
};
environment = {
PORT = toString cfg.port;
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
INVIDIOUS_URL = cfg.invidious.url;
STORE_PATH = cfg.store_path;
};
};
};
};
in
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
# Define the package using buildNpmPackage
queuecube = pkgs.buildNpmPackage {
pname = "queuecube";
version = "0.1.0";
src = ./.;
# Skip the standard buildPhase and provide our own
dontNpmBuild = true;
buildPhase = ''
# First install all dependencies
npm install
# Then run the build with workspaces flag
npm run build --workspaces
'';
# Runtime dependencies
buildInputs = with pkgs; [
mpv
yt-dlp
pulseaudio
];
# Create a wrapper script to ensure runtime deps are available
postInstall = ''
# Create the necessary directories
mkdir -p $out/lib/node_modules/queuecube
# Copy the entire project with built files
cp -r . $out/lib/node_modules/queuecube
# Install the frontend build to the backend dist directory
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
# Create bin directory if it doesn't exist
mkdir -p $out/bin
# Create executable script
cat > $out/bin/queuecube <<EOF
#!/bin/sh
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
EOF
# Make it executable
chmod +x $out/bin/queuecube
# Wrap the program to include runtime deps in PATH
wrapProgram $out/bin/queuecube \
--prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv
pkgs.yt-dlp
pkgs.pulseaudio
]}
'';
# Let buildNpmPackage handle npm package hash
npmDepsHash = "sha256-BqjJ4CxTPc14Od88sAm/ASwsLszkvcHHeNoZupotlFw=";
meta = with pkgs.lib; {
description = "NodeJS application with media playback capabilities";
platforms = platforms.linux;
};
};
in {
packages = {
default = queuecube;
queuecube = queuecube;
};
apps.default = {
type = "app";
program = "${queuecube}/bin/queuecube";
};
# Development environment
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
nodePackages.npm
mpv
yt-dlp
pulseaudio
];
};
# Add a basic check to verify the package builds
checks.queuecube = queuecube;
}
) // {
# Export the NixOS module
nixosModules.default = nixosModule;
nixosModules.queuecube = nixosModule;
};
}
-3673
View File
File diff suppressed because it is too large Load Diff
-99
View File
@@ -1,99 +0,0 @@
import React, { HTMLAttributes } from 'react';
import classNames from 'classnames';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
songName: string;
fileName: string;
isPlaying: boolean;
isIdle: boolean;
volume: number;
onPlayPause: () => void;
onStop: () => void;
onSkip: () => void;
onPrevious: () => void;
onScreenShare: () => void;
isScreenSharingSupported: boolean;
isScreenSharing: boolean;
// Sent when the volume setting actually changes value
onVolumeSettingChange: (volume: number) => void;
// Sent when the volume is about to start changing
onVolumeWillChange: (volume: number) => void;
// Sent when the volume has changed
onVolumeDidChange: (volume: number) => void;
}
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
const titleArea = props.isScreenSharing ? (
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
<FaDesktop size={24} />
<div className="text-lg font-bold truncate">Screen Sharing</div>
</div>
) : (
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100')}>
<div className="text-lg font-bold truncate">{props.songName}</div>
<div className="text-sm truncate">{props.fileName}</div>
</div>
);
return (
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg p-5 gap-4">
<div className="flex-grow min-w-0 w-full text-white text-left">
{titleArea}
</div>
<div className="flex flex-row items-center gap-4 w-full">
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
<FaVolumeUp size={20} />
<input
type="range"
min="0"
max="100"
value={props.volume}
onMouseDown={() => props.onVolumeWillChange(props.volume)}
onMouseUp={() => props.onVolumeDidChange(props.volume)}
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
className="fancy-slider h-2 w-full"
/>
</div>
<div className="flex-grow"></div>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
<FaStepBackward size={24} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
<FaStepForward size={24} />
</button>
{props.isScreenSharingSupported && (
<button
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
onClick={props.onScreenShare}
title="Share your screen"
>
<FaDesktop size={24} />
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default NowPlaying;
-136
View File
@@ -1,136 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { API } from '../api/player';
interface UseScreenShareResult {
isScreenSharing: boolean;
isScreenSharingSupported: boolean;
toggleScreenShare: () => Promise<void>;
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
];
// Find the first supported mimetype
for (const type of mimeTypes) {
if (MediaRecorder.isTypeSupported(type)) {
console.log(`Using mime type: ${type}`);
return type;
}
}
// 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
}
export const useScreenShare = (): UseScreenShareResult => {
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false);
const screenShareSocketRef = useRef<WebSocket | null>(null);
// Check if screen sharing is supported
useEffect(() => {
setIsScreenSharingSupported(
typeof navigator !== 'undefined' &&
navigator.mediaDevices !== undefined &&
typeof navigator.mediaDevices.getDisplayMedia === 'function'
);
}, []);
const stopScreenShare = useCallback(() => {
if (screenShareSocketRef.current) {
screenShareSocketRef.current.close();
screenShareSocketRef.current = null;
}
setIsScreenSharing(false);
}, []);
const startScreenShare = useCallback(async () => {
try {
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
let mimeType = getBestSupportedMimeType();
console.log('Using MIME type:', mimeType);
const mediaRecorder = new MediaRecorder(mediaStream, {
mimeType: mimeType,
videoBitsPerSecond: 2500000, // 2.5 Mbps
audioBitsPerSecond: 128000, // 128 kbps
});
// Connect to WebSocket
screenShareSocketRef.current = API.startScreenShare(mimeType);
// Set up WebSocket event handlers
screenShareSocketRef.current.onopen = () => {
console.log('Screen sharing WebSocket connected');
setIsScreenSharing(true);
mediaRecorder.start(100);
};
screenShareSocketRef.current.onclose = () => {
console.log('Screen sharing WebSocket closed');
setIsScreenSharing(false);
// Stop all tracks when WebSocket is closed
mediaStream.getTracks().forEach(track => track.stop());
};
screenShareSocketRef.current.onerror = (error) => {
console.error('Screen sharing WebSocket error:', error);
setIsScreenSharing(false);
// Stop all tracks on error
mediaStream.getTracks().forEach(track => track.stop());
};
// 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);
}
};
// Handle stream ending (user clicks "Stop sharing")
mediaStream.getVideoTracks()[0].onended = () => {
if (screenShareSocketRef.current) {
screenShareSocketRef.current.close();
screenShareSocketRef.current = null;
}
setIsScreenSharing(false);
};
} catch (error) {
console.error('Error starting screen share:', error);
setIsScreenSharing(false);
}
}, []);
const toggleScreenShare = useCallback(async () => {
if (screenShareSocketRef.current) {
stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
return {
isScreenSharing,
isScreenSharingSupported,
toggleScreenShare,
stopScreenShare
};
};
+103
View File
@@ -0,0 +1,103 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
QueueCube is a SwiftUI-based jukebox client application for iOS and macOS (via Mac Catalyst). It provides a frontend for controlling a server-based jukebox system that supports playlist management, favorites, and playback controls.
## Architecture
### Core Components
- **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates.
- **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow.
- **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery.
- **ServerDiscovery.swift**: Shared Bonjour browsing and bounded, cancellation-safe endpoint resolution for the app and share extension.
- **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
1. **Multiple Server Support**: Array of `Server` objects stored in UserDefaults with selected server tracking
2. **Settings**: Server configurations validated asynchronously via API calls with live connectivity testing
3. **Real-time Updates**: WebSocket connection provides live updates for playlist changes, playback state, and volume
4. **API Integration**: All server communication goes through the `API` struct using a fluent `RequestBuilder` pattern
5. **State Management**: Uses SwiftUI's `@Observable` pattern for reactive UI updates
### Request Builder Pattern
The API layer uses a fluent builder pattern for HTTP requests:
```swift
try await request()
.path("/nowplaying")
.json()
```
This provides type-safe, composable API calls with automatic error handling and connection state management.
### Key Features
- **Real-time sync**: WebSocket events automatically refresh UI when server state changes
- **Cross-platform**: Supports iOS, iPadOS, and macOS via Mac Catalyst
- **Settings validation**: Live server connectivity testing with visual feedback
- **Error handling**: Connection state management with user-friendly error displays
## Development Commands
### Building
```bash
# Build for iOS Simulator
xcodebuild -project QueueCube.xcodeproj -scheme QueueCube -destination 'platform=iOS Simulator,name=iPhone 15' build
# Build for Mac Catalyst
xcodebuild -project QueueCube.xcodeproj -scheme QueueCube -destination 'platform=macOS,variant=Mac Catalyst' build
```
### Running
- Open `QueueCube.xcodeproj` in Xcode
- Select target device (iOS Simulator or Mac)
- Run with Cmd+R
## API Endpoints Reference
The server API includes these endpoints:
- `GET /nowplaying` - Current playback status
- `GET /playlist` - Current playlist items
- `GET /favorites` - User favorites
- `POST /play`, `/pause`, `/skip`, `/previous` - Playback controls
- `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
## UI Structure
### View Hierarchy
```
QueueCubeApp
└── ContentView (coordination layer)
└── MainView (tab management)
├── PlaylistView (with embedded NowPlayingView)
├── FavoritesView (favorites management)
└── SettingsView (server configuration)
├── ServerListSettingsView
├── AddServerView
└── GeneralSettingsView
```
### Key Views
- **ContentView**: Main coordinator that manages API instances and global state
- **MainView**: Tab-based navigation container with platform-specific adaptations
- **PlaylistView**: Scrollable list of queued media with reorder/delete actions, includes embedded NowPlayingView
- **NowPlayingView**: Playback controls and current track display
- **AddMediaBarView**: Input field for adding new media URLs to playlist
- **SettingsView**: Multi-server configuration with live validation and service discovery
## Share extension
`QueueCubeShare` is embedded in iOS and Mac Catalyst builds. It reads an App Group
settings snapshot and reuses the API/discovery code. See [SHARE_EXTENSION.md](SHARE_EXTENSION.md)
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"])
]
)
+591
View File
@@ -0,0 +1,591 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
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 */; };
CD2026102000000000000204 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000104 /* ServerDiscovery.swift */; };
CD2026102000000000000205 /* SharedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000105 /* SharedVideo.swift */; };
CD2026102000000000000206 /* SharePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000106 /* SharePlayback.swift */; };
CD2026102000000000000207 /* SharedInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000107 /* SharedInput.swift */; };
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>"; };
CD2026102000000000000104 /* ServerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = "<group>"; };
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 */
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/Entitlements.plist,
App/Info.plist,
);
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 */
CD4E9B992D7691C20066FC17 /* QueueCube */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */,
);
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 */
CD4E9B942D7691C20066FC17 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
CD2026102000000000000008 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
CD4E9B8E2D7691C20066FC17 = {
isa = PBXGroup;
children = (
CD4E9B992D7691C20066FC17 /* QueueCube */,
CD2026102000000000000007 /* QueueCubeShare */,
CD2026102000000000000009 /* Shared Extension Sources */,
CD4E9B982D7691C20066FC17 /* Products */,
);
sourceTree = "<group>";
};
CD4E9B982D7691C20066FC17 /* Products */ = {
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 */,
CD2026102000000000000104 /* ServerDiscovery.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 */
CD4E9B962D7691C20066FC17 /* QueueCube */ = {
isa = PBXNativeTarget;
buildConfigurationList = CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */;
buildPhases = (
CD4E9B932D7691C20066FC17 /* Sources */,
CD4E9B942D7691C20066FC17 /* Frameworks */,
CD4E9B952D7691C20066FC17 /* Resources */,
CD2026102000000000000005 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
CD202610200000000000000A /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
CD4E9B992D7691C20066FC17 /* QueueCube */,
);
name = QueueCube;
packageProductDependencies = (
);
productName = QueueCube;
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 */
CD4E9B8F2D7691C20066FC17 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
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; };
};
};
};
};
buildConfigurationList = CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = CD4E9B8E2D7691C20066FC17;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = CD4E9B982D7691C20066FC17 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
CD4E9B962D7691C20066FC17 /* QueueCube */,
CD2026102000000000000004 /* QueueCubeShare */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
CD4E9B952D7691C20066FC17 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
CD202610200000000000000D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
CD4E9B932D7691C20066FC17 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
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 */,
CD2026102000000000000204 /* ServerDiscovery.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;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 19.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
CD4E9BA12D7691C40066FC17 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 19.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
CD4E9BA32D7691C40066FC17 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = QueueCube/App/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_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = QueueCube/App/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
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;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Debug;
};
CD4E9BA42D7691C40066FC17 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = QueueCube/App/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_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = QueueCube/App/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
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;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
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 */
CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CD4E9BA02D7691C40066FC17 /* Debug */,
CD4E9BA12D7691C40066FC17 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CD4E9BA32D7691C40066FC17 /* Debug */,
CD4E9BA42D7691C40066FC17 /* Release */,
);
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 */;
}
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
BuildableName = "QueueCube.app"
BlueprintName = "QueueCube"
ReferencedContainer = "container:QueueCube.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
BuildableName = "QueueCube.app"
BlueprintName = "QueueCube"
ReferencedContainer = "container:QueueCube.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
BuildableName = "QueueCube.app"
BlueprintName = "QueueCube"
ReferencedContainer = "container:QueueCube.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
<InstallAction
buildConfiguration = "Release">
</InstallAction>
</Scheme>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

@@ -0,0 +1,36 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+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>
+19
View File
@@ -0,0 +1,19 @@
<?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>NSBonjourServices</key>
<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>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+44
View File
@@ -0,0 +1,44 @@
//
// QueueCubeApp.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import SwiftUI
@main
struct QueueCubeApp: App {
@Environment(\.openWindow) private var openWindow
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
#if targetEnvironment(macCatalyst)
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
windowScene.titlebar?.titleVisibility = .hidden
windowScene.titlebar?.separatorStyle = .none
#endif
}
}.commands {
CommandGroup(replacing: .appSettings) {
Button(.settings_) {
openWindow(id: .settingsWindowID)
}
.keyboardShortcut(",", modifiers: .command)
}
}
.defaultSize(width: 640.0, height: 800.0)
WindowGroup(id: .settingsWindowID) {
SettingsView(onDone: {})
}
.defaultSize(width: 480.0, height: 400.0)
}
}
fileprivate extension String
{
static let settingsWindowID = "settings"
}
+319
View File
@@ -0,0 +1,319 @@
//
// API.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import Foundation
struct MediaItem: Codable
{
let filename: String?
let title: String?
let id: Int
let current: Bool?
let playing: Bool?
let metadata: Metadata?
let playbackError: String?
var displayTitle: String {
metadata?.title ?? title ?? displayFilename ?? "item \(id)"
}
private var displayFilename: String? {
guard let filename else { return nil }
if let url = URL(string: filename) {
return url.lastPathComponent
}
return filename
}
// MARK: - Types
struct Metadata: Codable
{
let title: String?
let description: String?
let siteName: String?
}
}
struct SearchResultItem: Codable
{
var type: String
var title: String
var author: String
var mediaUrl: String
var thumbnailUrl: String
}
struct FetchResult<T: Codable>: Codable
{
let success: Bool
let results: T?
let error: String?
}
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, requestTimeout: TimeInterval = 60) {
self.baseURL = baseURL
self.requestTimeout = requestTimeout
}
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
try await request()
.path("/nowplaying")
.json()
}
public func fetchPlaylist() async throws -> [MediaItem] {
try await request()
.path("/playlist")
.json()
}
public func fetchFavorites() async throws -> [MediaItem] {
try await request()
.path("/favorites")
.json()
}
public func play() async throws {
try await request()
.path("/play")
.post()
}
public func pause() async throws {
try await request()
.path("/pause")
.post()
}
public func stop() async throws {
try await request()
.path("/stop")
.post()
}
public func skip(_ to: Int? = nil) async throws {
let path = if let to { "/skip/\(to)" } else { "/skip" }
try await request()
.path(path)
.post()
}
public func previous() async throws {
try await request()
.path("/previous")
.post()
}
public func add(mediaURL: String) async throws {
try await request()
.path("/playlist")
.body([ "url" : mediaURL ])
.post()
}
public func replace(mediaURL: String) async throws {
try await request()
.path("/playlist/replace")
.body([ "url" : mediaURL ])
.post()
}
public func addFavorite(mediaURL: String) async throws {
try await request()
.path("/favorites")
.body([ "filename" : mediaURL ])
.post()
}
public func deleteFavorite(mediaURL: String) async throws {
try await request()
.pathString("/favorites/\(mediaURL.uriEncoded())")
.method(.delete)
.execute()
}
public func renameFavorite(mediaURL: String, title: String) async throws {
try await request()
.pathString("/favorites/\(mediaURL.uriEncoded())/title")
.body([ "title": title ])
.method(.put)
.execute()
}
public func delete(index: Int) async throws {
try await request()
.path("/playlist/\(index)")
.method(.delete)
.execute()
}
public func setVolume(_ value: Double) async throws {
try await request()
.path("/volume")
.body([ "volume" : Int(value * 100) ])
.post()
}
public func seek(to time: Double) async throws {
try await request()
.path("/player/seek")
.body([ "time" : time ])
.post()
}
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
try await request()
.pathString("/search?q=\(query.uriEncoded())")
.json()
}
public func events() async throws -> AsyncStream<StreamEvent> {
let requestBuilder: () -> RequestBuilder = request
return AsyncStream { continuation in
let websocketTask: URLSessionWebSocketTask = API.spawnWebsocketTask(requestBuilder: requestBuilder, with: continuation)
Task {
var pingLoopEnabled = true
while pingLoopEnabled {
try await Task.sleep(for: .seconds(5))
websocketTask.sendPing { error in
if let error {
API.notifyError(error, continuation: continuation)
pingLoopEnabled = false
} else {
continuation.yield(.event(Event(type: .receivedWebsocketPong)))
}
}
}
}
}
}
private static func spawnWebsocketTask(
requestBuilder: () -> RequestBuilder,
with continuation: AsyncStream<StreamEvent>.Continuation
) -> URLSessionWebSocketTask
{
let url = requestBuilder()
.path("/events")
.websocket()
let websocketTask = URLSession.shared.webSocketTask(with: url)
websocketTask.resume()
Task {
do {
let event = { (data: Data) in
try JSONDecoder().decode(Event.self, from: data)
}
while websocketTask.state == .running {
switch try await websocketTask.receive() {
case .string(let string):
let event = try event(string.data(using: .utf8)!)
continuation.yield(.event(event))
case .data(let data):
let event = try event(data)
continuation.yield(.event(event))
default:
break
}
}
} catch {
notifyError(error, continuation: continuation)
}
}
return websocketTask
}
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
print("Websocket Error: \(error)")
// Always notify observers of WebSocket errors so reconnection can happen
// The UI layer can decide whether to show the error to the user
continuation.yield(.error(.websocketError(error)))
}
private func request() -> RequestBuilder {
RequestBuilder(url: baseURL, timeout: requestTimeout)
}
// MARK: - Types
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 {
case event(Event)
case error(API.Error)
}
struct Event: Decodable
{
let type: EventType
enum CodingKeys: String, CodingKey {
case type = "event"
}
enum EventType: String, Decodable {
case playlistUpdate = "playlist_update"
case nowPlayingUpdate = "now_playing_update"
case volumeUpdate = "volume_update"
case favoritesUpdate = "favorites_update"
case metadataUpdate = "metadata_update"
case mpdUpdate = "mpd_update"
case playbackError = "playback_error"
// Private UI events
case receivedWebsocketPong
case websocketReconnected
}
}
}
extension String
{
func uriEncoded() -> Self {
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
}
}
+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))"
}
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
//
// Server.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import Foundation
import Network
struct Server: Identifiable, Codable, Equatable
{
let serviceName: String?
let baseURL: URL
var id: String { baseURL.absoluteString }
var api: API { API(baseURL: baseURL) }
var displayName: String {
if let serviceName {
return serviceName.queueCubeServiceName
}
let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
return components.host ?? baseURL.absoluteString
}
init?(serviceName: String?, host: String, port: UInt16) {
self.serviceName = serviceName
// 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
}
self.baseURL = url
}
init(baseURL: URL) {
self.serviceName = nil
self.baseURL = baseURL
}
}
extension String
{
var queueCubeServiceName: String {
let regex = /.* \((.*)\)/
if let match = try? regex.firstMatch(in: self) {
return String(match.output.1)
}
return self
}
}
+168
View File
@@ -0,0 +1,168 @@
// Shared Bonjour discovery for the app and share extension.
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)
}
}
+139
View File
@@ -0,0 +1,139 @@
//
// Settings.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import Foundation
struct Settings: Codable
{
var selectedServer: Server?
var configuredServers: [Server] {
willSet {
// Set selected server to whatever the first server is, if we're adding the first one.
if configuredServers.isEmpty && !newValue.isEmpty && selectedServer == nil {
selectedServer = newValue.first
}
// If the selected server is being removed, set it to something else
if !newValue.contains(where: { $0 == selectedServer }) {
selectedServer = newValue.first // nil if empty
}
}
}
var isConfigured: Bool { !configuredServers.isEmpty }
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(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)
}
// MARK: - Modifiers
func selectedServer(_ server: Server?) -> Self {
var copy = self
copy.selectedServer = server
return copy
}
func configuredServers(_ servers: [Server]) -> Self {
var copy = self
copy.configuredServers = servers
return copy
}
// MARK: - Types
enum Keys: String
{
case selectedServer
case configuredServers
}
fileprivate protocol Key
{
associatedtype Value: Codable
static var defaultValue: Value { get }
static var key: String { get }
}
private struct ConfiguredServersKey: Key {
static var defaultValue: [Server] { [] }
}
private struct SelectedServerKey: Key {
static var defaultValue: Server? { nil }
}
}
extension UserDefaults
{
fileprivate subscript<T: Settings.Key>(_ type: T.Type) -> T.Value {
get {
guard let data = data(forKey: type.key)
else { return type.defaultValue }
guard let value = try? PropertyListDecoder().decode(type.Value, from: data)
else { return type.defaultValue }
return value
}
set {
let data = try? PropertyListEncoder().encode(newValue)
set(data, forKey: type.key)
}
}
}
extension Settings.Key
{
static var key: String { Mirror(reflecting: Self.self).description }
}
extension Notification.Name
{
static let settingsChanged = Notification.Name("settingsChanged")
}
+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)
}
}
+25
View File
@@ -0,0 +1,25 @@
//
// Utilities.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import Foundation
import SwiftUI
extension Optional
{
func try_unwrap() throws -> Wrapped {
guard let self else { throw UnwrapError() }
return self
}
struct UnwrapError: Swift.Error {}
}
extension Color
{
static let label = Color(uiColor: .label)
}
@@ -0,0 +1,415 @@
{
"sourceLanguage" : "en",
"strings" : {
"%@" : {
},
"ADD" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add"
}
}
}
},
"ADD_ANY_URL" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add any URL…"
}
}
}
},
"ADD_MEDIA" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add Media"
}
}
}
},
"ADD_SERVER" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add Server"
}
}
}
},
"ADD_TO_QUEUE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add to Queue"
}
}
}
},
"CANCEL" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
}
}
},
"CONFIGURATION" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configuration"
}
}
}
},
"CONNECTION_ERROR" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connection Error"
}
}
}
},
"COPY_TITLE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copy Title"
}
}
}
},
"COPY_URL" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copy URL"
}
}
}
},
"DELETE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete"
}
}
}
},
"DISCOVERED" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Discovered"
}
}
}
},
"DONE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done"
}
}
}
},
"EDIT" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit…"
}
}
}
},
"EDIT_ITEM" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit Item"
}
}
}
},
"ENTER_MANUALLY" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enter Manually"
}
}
}
},
"FAVORITE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Favorite"
}
}
}
},
"FAVORITES" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Favorites"
}
}
}
},
"FAVORITES_IS_EMPTY" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Favorites is empty"
}
}
}
},
"FINDING_SERVERS" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Finding Servers…"
}
}
}
},
"GENERAL" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "General"
}
}
}
},
"NO_RESULTS_FOUND" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No Results Found"
}
}
}
},
"NO_SERVERS_CONFIGURED" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No Servers Configured"
}
}
}
},
"NOT_CONFIGURED" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Not Configured"
}
}
}
},
"NOT_PLAYING" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Not Playing"
}
}
}
},
"Nothing here yet." : {
},
"PLAYBACK_ERROR" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Playback Error"
}
}
}
},
"PLAYLIST" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Playlist"
}
}
}
},
"PLAYLIST_IS_EMPTY" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Playlist is empty"
}
}
}
},
"SEARCH_FOR_MEDIA" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Search YouTube for Media…"
}
}
}
},
"SEARCHING_" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Searching…"
}
}
}
},
"SERVER_IS_ONLINE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Server is online"
}
}
}
},
"SERVER_URL" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Server URL"
}
}
}
},
"SERVERS" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Servers"
}
}
}
},
"SETTINGS" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Settings"
}
}
}
},
"SETTINGS_ELLIPSES" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Settings…"
}
}
}
},
"TITLE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Title"
}
}
}
},
"UNABLE_TO_CONNECT" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unable to connect"
}
}
}
},
"Unknown error" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unknown Error"
}
}
}
},
"URL" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL"
}
}
}
},
"VALIDATING" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Validating…"
}
}
}
}
},
"version" : "1.0"
}
+51
View File
@@ -0,0 +1,51 @@
//
// Strings.swift
// QueueCube
//
// Created by James Magahern on 5/2/25.
//
import SwiftUI
extension LocalizedStringKey
{
static let serverURL = LocalizedStringKey("SERVER_URL")
static let settings = LocalizedStringKey("SETTINGS")
static let settings_ = LocalizedStringKey("SETTINGS_ELLIPSES")
static let done = LocalizedStringKey("DONE")
static let notConfigured = LocalizedStringKey("NOT_CONFIGURED")
static let add = LocalizedStringKey("ADD")
static let addAnyURL = LocalizedStringKey("ADD_ANY_URL")
static let serverIsOnline = LocalizedStringKey("SERVER_IS_ONLINE")
static let unableToConnect = LocalizedStringKey("UNABLE_TO_CONNECT")
static let configuration = LocalizedStringKey("CONFIGURATION")
static let validating = LocalizedStringKey("VALIDATING")
static let general = LocalizedStringKey("GENERAL")
static let connectionError = LocalizedStringKey("CONNECTION_ERROR")
static let playlist = LocalizedStringKey("PLAYLIST")
static let favorites = LocalizedStringKey("FAVORITES")
static let favorite = LocalizedStringKey("FAVORITE")
static let servers = LocalizedStringKey("SERVERS")
static let addServer = LocalizedStringKey("ADD_SERVER")
static let cancel = LocalizedStringKey("CANCEL")
static let manual = LocalizedStringKey("ENTER_MANUALLY")
static let discovered = LocalizedStringKey("DISCOVERED")
static let findingServers = LocalizedStringKey("FINDING_SERVERS")
static let noServersConfigured = LocalizedStringKey("NO_SERVERS_CONFIGURED")
static let playlistEmpty = LocalizedStringKey("PLAYLIST_IS_EMPTY")
static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY")
static let addMedia = LocalizedStringKey("ADD_MEDIA")
static let searchForMedia = LocalizedStringKey("SEARCH_FOR_MEDIA")
static let searching = LocalizedStringKey("SEARCHING_")
static let noResultsFound = LocalizedStringKey("NO_RESULTS_FOUND")
static let copyTitle = LocalizedStringKey("COPY_TITLE")
static let copyURL = LocalizedStringKey("COPY_URL")
static let edit = LocalizedStringKey("EDIT")
static let editItem = LocalizedStringKey("EDIT_ITEM")
static let addToQueue = LocalizedStringKey("ADD_TO_QUEUE")
static let notPlaying = LocalizedStringKey("NOT_PLAYING")
static let url = LocalizedStringKey("URL")
static let title = LocalizedStringKey("TITLE")
static let playbackError = LocalizedStringKey("PLAYBACK_ERROR")
static let delete = LocalizedStringKey("DELETE")
}
+272
View File
@@ -0,0 +1,272 @@
//
// AddMediaView.swift
// QueueCube
//
// Created by James Magahern on 6/11/25.
//
import SwiftUI
struct AddMediaView: View
{
@Binding var model: ViewModel
var body: some View {
NavigationStack {
Form {
// Add URL
Section {
HStack {
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()
}
}
if model.supportsSearch {
Section {
NavigationLink {
SearchMediaView(model: $model)
} label: {
Image(systemName: "magnifyingglass")
Button(.searchForMedia, action: model.onSearch)
}
.tint(.label)
}
}
}
.navigationTitle(.addMedia)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button(.add, action: model.addButtonTapped)
.disabled(model.fieldContents.isEmpty)
.bold()
}
ToolbarItemGroup(placement: .topBarLeading) {
Button(.cancel, action: model.onCancel)
}
}
}
}
// MARK: - Types
enum Page: String, Identifiable
{
case addURL
case searchMedia
var id: String { rawValue }
}
@Observable
class ViewModel
{
var fieldContents: String = ""
var onAdd: (String) -> Void = { _ in }
var onCancel: () -> Void = { }
var onSearch: () -> Void = { }
var supportsSearch: Bool = true
fileprivate func addButtonTapped() {
onAdd(fieldContents)
}
}
}
struct SearchMediaView: View
{
@Binding var model: AddMediaView.ViewModel
@State private var searchModel = SearchModel()
@State private var searchText = ""
@FocusState private var searchFieldFocused: Bool
var body: some View {
VStack(spacing: 0) {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
.focused($searchFieldFocused)
.frame(minWidth: 0.0, maxWidth: .infinity)
if !searchText.isEmpty {
Button {
searchText = ""
searchModel.displayedResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding()
.background(Color(.systemGray6))
if searchModel.isLoading {
VStack {
Spacer()
ProgressView(.searching)
.progressViewStyle(CircularProgressViewStyle())
Spacer()
}
} else if searchModel.displayedResults.isEmpty && !searchText.isEmpty && searchModel.lastSearchedQuery == searchText {
VStack {
Spacer()
Text(.noResultsFound)
.foregroundColor(.secondary)
Spacer()
}
} else {
// Results list
List(searchModel.displayedResults, id: \.mediaUrl) { item in
SearchResultRow(item: item) {
model.onAdd(item.mediaUrl)
}
}
.listStyle(PlainListStyle())
}
}
.navigationTitle(.searchForMedia)
.presentationBackground(.regularMaterial)
.onAppear {
searchFieldFocused = true
}
}
private func performSearch() {
guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
searchModel.performSearch(query: searchText)
}
}
struct SearchResultRow: View
{
let item: SearchResultItem
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Thumbnail
AsyncImage(url: URL(string: item.thumbnailUrl)) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 80, height: 60)
.overlay {
ProgressView()
.scaleEffect(0.8)
}
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
case .failure(_):
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 80, height: 60)
.overlay {
Image(systemName: "photo")
.foregroundColor(.secondary)
}
@unknown default:
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 80, height: 60)
}
}
// Content
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
.lineLimit(2)
Text(item.author)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
Text(item.type.capitalized)
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color(.systemGray6))
.clipShape(Capsule())
}
Spacer()
Image(systemName: "plus.circle.fill")
.foregroundColor(.accentColor)
.font(.title2)
}
.padding(.vertical, 8)
}
.buttonStyle(PlainButtonStyle())
}
}
extension SearchMediaView
{
// MARK: - Types
@Observable
class SearchModel
{
var displayedResults: [SearchResultItem] = []
var isLoading: Bool = false
var lastSearchedQuery: String? = nil
func performSearch(query: String) {
guard let api = Settings.fromDefaults().selectedServer?.api else { return }
isLoading = true
lastSearchedQuery = query
Task {
do {
let fetchResult = try await api.search(query: query)
if let results = fetchResult.results {
await MainActor.run {
self.displayedResults = results
.map { item in
// Convert relative thumbnail urls to absolute for loading by AsyncImage
var copy = item
copy.thumbnailUrl = api.baseURL.absoluteString
.replacingOccurrences(of: "/api", with: "") + item.thumbnailUrl // xxx: ugh...
return copy
}
self.isLoading = false
}
}
} catch {
await MainActor.run {
self.displayedResults = []
self.isLoading = false
}
}
}
}
}
}
@@ -0,0 +1,79 @@
//
// AutofocusingTextField.swift
// QueueCube
//
// Created by James Magahern on 11/15/25.
//
import SwiftUI
import UIKit
/// Stupid: it appears to be impossible to make it so SwiftUI's `.focused(_:)` modifier takes place during the
/// presentation of a sheet, so this needs to exist just to make sure it's made first responder as soon as it moves to
/// the view hierarchy.
struct AutofocusingTextField: UIViewRepresentable
{
let placeholder: String
@Binding var text: String
var onSubmit: () -> Void = {}
init(_ placeholder: String, text: Binding<String>, onSubmit: @escaping () -> Void = {}) {
self.placeholder = placeholder
self._text = text
self.onSubmit = onSubmit
}
func makeUIView(context: Context) -> UITextField {
let tf = FirstResponderTextField()
tf.placeholder = placeholder
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
}
func updateUIView(_ uiView: UITextField, context: Context) {
if uiView.text != text {
uiView.text = text
}
context.coordinator.parent = self // keep latest onSubmit/text binding
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class Coordinator: NSObject, UITextFieldDelegate {
var parent: AutofocusingTextField
init(parent: AutofocusingTextField) {
self.parent = parent
}
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parent.onSubmit()
return true
}
}
}
final class FirstResponderTextField: UITextField {
override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
size.width = UIView.noIntrinsicMetric
return size
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
becomeFirstResponder()
}
}
@@ -0,0 +1,64 @@
//
// ContentPlaceholderView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import SwiftUI
struct ContentPlaceholderView<Label, Actions>: View
where Label: View, Actions: View
{
let label: Label
let actions: Actions
init(@ViewBuilder label: () -> Label, @ViewBuilder actions: () -> Actions = { EmptyView() }) {
self.label = label()
self.actions = actions()
}
var body: some View {
Spacer()
ContentUnavailableView {
label
.imageScale(.large)
.tint(.secondary)
} actions: { actions }
Spacer()
}
}
func contentPlaceholderView<Actions>(
title: LocalizedStringKey,
subtitle: (any StringProtocol)? = nil,
systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() })
-> ContentPlaceholderView<AnyView, Actions>
{
ContentPlaceholderView(label: {
AnyView(erasing: VStack(spacing: 16.0) {
Image(systemName: systemImage)
.resizable()
.scaledToFit()
.frame(width: 50.0, height: 50.0)
.foregroundStyle(.secondary)
.imageScale(.large)
Text(title)
.foregroundStyle(.tint)
.bold()
if let subtitle {
Text(subtitle)
.foregroundStyle(.tint.opacity(0.5))
}
Spacer()
.frame(height: 14.0)
})
}, actions: actions)
}
+209
View File
@@ -0,0 +1,209 @@
//
// ContentView.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import SwiftUI
struct ContentView: View
{
@State var model = MainViewModel()
@State private var websocketRestartTrigger = 0
@Environment(\.scenePhase) private var scenePhase
var body: some View {
MainView(model: $model)
.task(id: websocketRestartTrigger) { await watchWebsocket() }
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
.task { await watchForSettingsChanges() }
.onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase)
}
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
NowPlayingView(model: model.nowPlayingViewModel)
.presentationBackground(.regularMaterial)
.presentationDetents([ .height(390.0) ])
}
.sheet(isPresented: $model.isAddMediaSheetPresented) {
AddMediaView(model: $model.addMediaViewModel)
.presentationBackground(.regularMaterial)
}
.sheet(isPresented: $model.isEditSheetPresented) {
EditItemView(model: $model.editMediaViewModel)
.presentationBackground(.regularMaterial)
}
}
// MARK: - Types
struct RefreshType: OptionSet
{
let rawValue: Int
static let nowPlaying = RefreshType(rawValue: 1 << 0)
static let playlist = RefreshType(rawValue: 1 << 1)
static let favorites = RefreshType(rawValue: 1 << 2)
}
}
extension ContentView
{
private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
// When app returns to active state from background, force reconnect and refresh
if newPhase == .active {
Task {
// Force WebSocket reconnection
websocketRestartTrigger += 1
// Give the WebSocket a moment to reconnect
try? await Task.sleep(for: .milliseconds(100))
// Full UI refresh
await refresh([.nowPlaying, .playlist, .favorites])
}
}
}
private func refresh(_ what: RefreshType) async {
await model.withModificationsViaAPI { api in
if what.contains(.nowPlaying) {
let nowPlaying = try await api.fetchNowPlayingInfo()
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
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
}
if what.contains(.playlist) {
let playlist = try await api.fetchPlaylist()
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
MediaListItem(
id: String(mediaItem.id),
title: mediaItem.displayTitle,
filename: mediaItem.filename ?? "<null>",
index: idx,
isCurrent: mediaItem.current ?? false,
playbackError: mediaItem.playbackError
)
}
}
if what.contains(.favorites) {
let favorites = try await api.fetchFavorites()
let nowPlaying = try await api.fetchNowPlayingInfo()
model.favoritesModel.items = favorites.map { mediaItem in
MediaListItem(
id: String(mediaItem.id),
title: mediaItem.displayTitle,
filename: mediaItem.filename ?? "<null>",
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename,
playbackError: mediaItem.playbackError
)
}
}
}
}
private func watchWebsocket() async {
guard let api = model.selectedServer?.api else { return }
do {
for await streamEvent in try await api.events() {
switch streamEvent {
case .event(let event):
await clearConnectionErrorIfNecessary()
await handle(event: event)
case .error(let error):
// Ignore if we're in the bg
guard scenePhase == .active else { break }
// Check if this is a backgrounding error (connection abort)
var isBackgroundingError = false
if case let .websocketError(wsError) = error {
let nsError = wsError as NSError
isBackgroundingError = nsError.code == 53
}
// Only show connection error to user if it's not a backgrounding error
if !isBackgroundingError {
model.connectionError = error
}
// Always attempt reconnection after a delay
Task { @MainActor in
try await Task.sleep(for: .seconds(1.0))
websocketRestartTrigger += 1
}
break
}
}
} catch {
print("Events error: \(error)")
}
}
private func handle(event: API.Event) async {
switch event.type {
case .volumeUpdate: fallthrough
case .nowPlayingUpdate:
await refresh(.nowPlaying)
case .playlistUpdate:
await refresh(.playlist)
case .favoritesUpdate:
await refresh(.favorites)
case .websocketReconnected: fallthrough
case .metadataUpdate: fallthrough
case .mpdUpdate: fallthrough
case .playbackError:
await refresh([.playlist, .nowPlaying, .favorites])
case .receivedWebsocketPong:
// This means we're online.
await clearConnectionErrorIfNecessary()
}
}
private func clearConnectionErrorIfNecessary() async {
if model.connectionError != nil {
model.connectionError = nil
await refresh([.playlist, .nowPlaying, .favorites])
}
}
private func watchForSettingsChanges() async {
let settingsChangedNotifications = NotificationCenter.default.notifications(named: .settingsChanged)
.map({ _ in Optional.none })
for await _ in settingsChangedNotifications {
let newSelectedServer = Settings.fromDefaults().selectedServer
if newSelectedServer != model.selectedServer {
model.selectedServer = newSelectedServer
// Reset view model to defaults
await model.reset()
// Restart WebSocket connection for new server
websocketRestartTrigger += 1
await refresh([.playlist, .nowPlaying, .favorites])
}
// Always reset this
model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
}
}
}
+60
View File
@@ -0,0 +1,60 @@
//
// EditItemView.swift
// QueueCube
//
// Created by James Magahern on 6/20/25.
//
import SwiftUI
@Observable
class EditItemViewModel
{
var mediaURL: String = ""
var title: String = ""
var onDone: (EditItemViewModel) -> Void = { _ in }
var onCancel: (EditItemViewModel) -> Void = { _ in }
}
struct EditItemView: View
{
@Binding var model: EditItemViewModel
var body: some View {
NavigationStack {
Form {
Section(.url) {
TextField(.url, text: $model.mediaURL)
.foregroundStyle(.secondary)
.disabled(true) // editing URL not yet supported by server
.contextMenu {
Button(.copyURL) {
UIPasteboard.general.string = model.mediaURL
}
}
}
Section(.title) {
TextField(.title, text: $model.title)
}
}
.navigationTitle(.editItem)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
Button(.cancel, role: .cancel) {
model.onCancel(model)
}
}
ToolbarItemGroup(placement: .topBarTrailing) {
Button(.done, role: .destructive) {
model.onDone(model)
}
.bold()
}
}
}
}
}
+421
View File
@@ -0,0 +1,421 @@
//
// MainView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import SwiftUI
@Observable
class MainViewModel
{
var selectedServer: Server? = Settings.fromDefaults().selectedServer
var connectionError: Error? = nil
var selectedTab: Tab = .playlist
var isNowPlayingSheetPresented: Bool = false
var isAddMediaSheetPresented: Bool = false
var isEditSheetPresented: Bool = false
var playlistModel = MediaListViewModel(mode: .playlist)
var favoritesModel = MediaListViewModel(mode: .favorites)
var nowPlayingViewModel = NowPlayingViewModel()
var addMediaViewModel = AddMediaView.ViewModel()
var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
var editMediaViewModel = EditItemViewModel()
private var refreshingFromAPIDepth: UInt8 = 0
private var isRefreshingFromAPI: Bool { refreshingFromAPIDepth > 0 }
enum Tab: String, CaseIterable
{
case playlist
case favorites
case settings
}
init() {
observePlaylistChanges()
observeNowPlayingModel()
configureViewModelCallbacks()
}
func onAddButtonTapped() {
isAddMediaSheetPresented = true
}
func onNowPlayingMiniTapped() {
isNowPlayingSheetPresented = true
}
func reset() async {
await withModificationsViaAPI { _ in
playlistModel = MediaListViewModel(mode: .playlist)
favoritesModel = MediaListViewModel(mode: .favorites)
nowPlayingViewModel = NowPlayingViewModel()
}
configureViewModelCallbacks()
}
func configureViewModelCallbacks() {
// Now Playing
nowPlayingViewModel.onPlayPause = apiCallback { model, api in
model.isPlaying ? try await api.pause() : try await api.play()
}
nowPlayingViewModel.onStop = apiCallback { model, api in
try await api.stop()
}
nowPlayingViewModel.onNext = apiCallback { _, api in
try await api.skip()
}
nowPlayingViewModel.onPrev = apiCallback { _, api in
try await api.previous()
}
nowPlayingViewModel.onSeek = { [weak self] model, time in
Task {
await self?.withModificationsViaAPI { api in
try await api.seek(to: time)
}
}
}
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
self?.isNowPlayingSheetPresented = false
}
// Playlist
playlistModel.onSeek = apiCallback { item, api in
if let index = item.index {
try await api.skip(index)
}
}
playlistModel.onFavorite = apiCallback { item, api in
try await api.addFavorite(mediaURL: item.filename)
}
// Favorites
favoritesModel.onPlay = apiCallback { item, api in
try await api.replace(mediaURL: item.filename)
try await api.play()
}
favoritesModel.onEdit = { [weak self] item in
guard let self else { return }
editMediaViewModel.mediaURL = item.filename
editMediaViewModel.title = item.title
isEditSheetPresented = true
}
favoritesModel.onQueue = apiCallback { item, api in
try await api.add(mediaURL: item.filename)
}
// Edit
editMediaViewModel.onCancel = { [weak self] _ in
self?.isEditSheetPresented = false
}
editMediaViewModel.onDone = apiCallback { [weak self] model, api in
self?.isEditSheetPresented = false
try await api.renameFavorite(mediaURL: model.mediaURL, title: model.title)
}
// Add Media
addMediaViewModel.onAdd = apiCallback { [weak self] mediaURL, api in
guard let self else { return }
let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !strippedURL.isEmpty {
addMediaViewModel.fieldContents = ""
isAddMediaSheetPresented = false
switch selectedTab {
case .playlist:
try await api.add(mediaURL: strippedURL)
case .favorites:
try await api.addFavorite(mediaURL: strippedURL)
case .settings:
break
}
}
}
addMediaViewModel.onCancel = { [weak self] in
self?.isAddMediaSheetPresented = false
}
}
func observeNowPlayingModel() {
withObservationTracking {
_ = nowPlayingViewModel.volume
} onChange: { [weak self] in
guard let self else { return }
let isRefreshing = isRefreshingFromAPI
Task {
if !isRefreshing {
await self.withModificationsViaAPI { api in
try await api.setVolume(self.nowPlayingViewModel.volume)
}
}
await MainActor.run { self.observeNowPlayingModel() }
}
}
}
func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async {
guard let api = selectedServer?.api else { return }
refreshingFromAPIDepth += 1
do {
try await modificationBlock(api)
connectionError = nil
} catch {
print("Error refreshing content: \(error)")
connectionError = error
}
refreshingFromAPIDepth -= 1
}
private func apiCallback<T>(_ f: @escaping (T, API) async throws -> Void) -> (T) -> Void {
return { t in
Task {
await self.withModificationsViaAPI { try await f(t, $0) }
}
}
}
private func observePlaylistChanges() {
withObservationTracking {
_ = playlistModel.items
_ = favoritesModel.items
} onChange: { [weak self] in
guard let self else { return }
let isRefreshing = isRefreshingFromAPI
let oldPlaylist = playlistModel.items
let oldFavorites = favoritesModel.items
Task { @MainActor [weak self] in
guard let self else { return }
if !isRefreshing {
// Notify server of removals
let playlistDiff = playlistModel.items.difference(from: oldPlaylist) { $0.id == $1.id }
await withModificationsViaAPI { api in
for removal in playlistDiff.removals {
switch removal {
case .remove(let offset, _, _):
try await api.delete(index: offset)
default: break
}
}
}
let favoritesDiff = favoritesModel.items.difference(from: oldFavorites) { $0.id == $1.id }
await withModificationsViaAPI { api in
for removal in favoritesDiff.removals {
switch removal {
case .remove(_, let favorite, _):
try await api.deleteFavorite(mediaURL: favorite.filename)
default: break
}
}
}
}
observePlaylistChanges()
}
}
}
}
struct MainView: View
{
@Binding var model: MainViewModel
@State var isSettingsVisible: Bool = false
init(model: Binding<MainViewModel>) {
self._model = model
// If no servers are configured, make Settings the default tab.
if !Settings.fromDefaults().isConfigured {
model.wrappedValue.selectedTab = .settings
}
}
var body: some View {
TabView(selection: $model.selectedTab) {
Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
NavigationStack {
MediaListView(model: $model.playlistModel)
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
.displayingError(model.connectionError)
.withAddButton { model.onAddButtonTapped() }
.navigationTitle(.playlist)
}
}
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
NavigationStack {
MediaListView(model: $model.favoritesModel)
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
.displayingError(model.connectionError)
.withAddButton { model.onAddButtonTapped() }
.navigationTitle(.favorites)
}
}
Tab(.settings, systemImage: "gear", value: .settings) {
SettingsView(onDone: {})
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
struct NowPlayingMiniPlayerModifier: ViewModifier
{
let onTap: () -> Void
@Binding var model: NowPlayingViewModel
@State var nowPlayingHeight: CGFloat = 0.0
func body(content: Content) -> some View {
ZStack {
content
.safeAreaPadding(.bottom, nowPlayingHeight)
VStack {
Spacer()
NowPlayingMiniView(model: $model, onTap: onTap)
.padding()
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 800.0)
.onGeometryChange(for: CGSize.self) { $0.size }
action: { nowPlayingHeight = $0.height }
}
}
}
}
struct ServerSelectionToolbarModifier: ViewModifier
{
@Binding var model: ViewModel
func body(content: Content) -> some View {
content
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
Menu {
Section {
ForEach(model.selectableServers) { server in
Button {
model.selectedServer = server
} label: {
Text(server.displayName)
if model.selectedServer == server {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
.labelStyle(.titleOnly)
}
.buttonBorderShape(.capsule)
.buttonStyle(.bordered)
.menuStyle(.button)
}
}
}
// MARK: - Types
@Observable
class ViewModel
{
var selectableServers: [Server] = Settings.fromDefaults().configuredServers
var selectedServer: Server? = Settings.fromDefaults().selectedServer {
didSet {
Settings
.fromDefaults()
.selectedServer(selectedServer)
.save()
}
}
}
}
struct AddButtonToolbarModifier: ViewModifier
{
let onAdd: () -> Void
func body(content: Content) -> some View {
content
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
onAdd()
} label: {
Image(systemName: "plus")
}
}
}
}
}
struct ErrorDisplayModifier: ViewModifier
{
let error: Error?
func body(content: Content) -> some View {
content
.overlay {
if error != nil {
ZStack {
Rectangle()
.fill(.background)
contentPlaceholderView(
title: .connectionError,
subtitle: error?.localizedDescription,
systemImage: "exclamationmark.triangle.fill"
).tint(.label)
}
}
}
}
}
extension View {
func displayingServerSelectionToolbar(model: Binding<ServerSelectionToolbarModifier.ViewModel>) -> some View {
modifier(ServerSelectionToolbarModifier(model: model))
}
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>, onTap: @escaping () -> Void) -> some View {
modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model))
}
func withAddButton(onAdd: @escaping () -> Void) -> some View {
modifier(AddButtonToolbarModifier(onAdd: onAdd))
}
func displayingError(_ error: Error?) -> some View {
modifier(ErrorDisplayModifier(error: error))
}
}
@@ -0,0 +1,81 @@
//
// NowPlayingMiniView.swift
// QueueCube
//
// Created by James Magahern on 6/11/25.
//
import SwiftUI
struct NowPlayingMiniView: View {
@Binding var model: NowPlayingViewModel
let onTap: () -> Void
@GestureState private var tapGestureState = false
private var nothingQueued: Bool {
guard let title = model.title, let subtitle = model.subtitle else { return true }
return title.isEmpty && subtitle.isEmpty
}
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
}
.onEnded { _ in
onTap()
}
VStack(spacing: 0.0) {
HStack {
VStack(alignment: .leading) {
if let title = model.title, !title.isEmpty {
Text(title)
.font(.caption)
.lineLimit(1)
.bold()
}
if let subtitle = model.subtitle, !subtitle.isEmpty {
Text(subtitle)
.lineLimit(1)
.font(.caption)
.foregroundStyle(.secondary)
}
if nothingQueued {
Text(.notPlaying)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
.imageScale(.large)
.padding(12.0)
}
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
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(
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)
}
}
+280
View File
@@ -0,0 +1,280 @@
//
// NowPlayingView.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import Foundation
import SwiftUI
@Observable
class NowPlayingViewModel
{
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
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
{
@State var model: NowPlayingViewModel
private var nothingQueued: Bool { model.title == nil && model.subtitle == nil }
var body: some View {
NavigationStack {
VStack {
Spacer()
.frame(height: 1.0)
VStack {
if let title = model.title {
Text(title)
.font(.title3)
.lineLimit(1)
.bold()
}
if let subtitle = model.subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
if nothingQueued {
Text(.notPlaying)
.font(.title2)
.foregroundStyle(.secondary)
}
}
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 {
ForEach(Buttons.allCases) { button in
Spacer()
Button(action: button.action(model: model)) {
Image(systemName: button.imageName(isPlaying: model.isPlaying))
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.scaleEffect(button.scale, anchor: .center)
.tint(button.tintColor)
}
.disabled(nothingQueued)
Spacer()
}
}
.imageScale(.large)
.frame(height: 34.0)
.tint(.label)
Spacer()
Slider(
value: model.isSettingVolume ? $model.settingVolume : $model.volume,
in: 0.0...1.0,
onEditingChanged: { editing in
if model.isSettingVolume != editing {
model.settingVolume = model.volume
model.isSettingVolume = editing
}
}
)
.padding(.horizontal, 18.0)
.padding(.bottom, -12.0) // intrinsic sizing bug workaround?
}
.padding(.vertical, 44.0)
.padding(.horizontal, 12.0)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 14.0)
.fill(.ultraThinMaterial)
.stroke(Color.label.opacity(0.08))
)
}
.padding(.horizontal, 15.0)
.padding(.bottom, 10.0)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
model.onSheetDismiss(model)
} label: {
Image(systemName: "xmark.circle.fill")
.tint(.secondary)
}
}
}
}
}
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 {
case backward
case stop
case playPause
case forward
var id: Int { rawValue }
var scale: Double {
switch self {
case .backward: 0.7
case .forward: 0.7
case .playPause: 1.0
case .stop: 0.8
}
}
var tintColor: Color {
switch self {
case .backward: .label.mix(with: .gray, by: 0.5)
case .forward: .label.mix(with: .gray, by: 0.5)
case .playPause: .label
case .stop: .label
}
}
func imageName(isPlaying: Bool) -> String {
switch self {
case .backward: "backward.fill"
case .stop: "stop.fill"
case .playPause: isPlaying ? "pause.fill" : "play.fill"
case .forward: "forward.fill"
}
}
func action(model: NowPlayingViewModel) -> () -> Void {
switch self {
case .backward: { model.onPrev(model) }
case .stop: { model.onStop(model) }
case .playPause: { model.onPlayPause(model) }
case .forward: { model.onNext(model) }
}
}
}
}
+212
View File
@@ -0,0 +1,212 @@
//
// PlaylistView.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import SwiftUI
struct MediaListItem: Identifiable
{
let _id: String
let title: String
let filename: String
let index: Int?
let isCurrent: Bool
let playbackError: String?
var id: String {
_id + filename // temporary: we get duplicate ids from the server sometimes...
}
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false, playbackError: String? = nil) {
self._id = id
self.title = title
self.filename = filename
self.index = index
self.isCurrent = isCurrent
self.playbackError = playbackError
}
}
enum MediaListMode {
case playlist
case favorites
}
@Observable
class MediaListViewModel
{
let mode: MediaListMode
var isPlaying: Bool = false
var items: [MediaListItem] = []
var onSeek: (MediaListItem) -> Void = { _ in }
var onPlay: (MediaListItem) -> Void = { _ in }
var onQueue: (MediaListItem) -> Void = { _ in }
var onEdit: (MediaListItem) -> Void = { _ in }
var onFavorite: (MediaListItem) -> Void = { _ in }
var onDelete: (MediaListItem) -> Void = { _ in }
init(mode: MediaListMode) {
self.mode = mode
}
}
struct MediaListView: View
{
@Binding var model: MediaListViewModel
@State private var errorAlertItem: MediaListItem? = nil
@State private var isShowingErrorAlert: Bool = false
var body: some View {
VStack {
if model.items.isEmpty {
let title: LocalizedStringKey = switch model.mode {
case .playlist: .playlistEmpty
case .favorites: .favoritesEmpty
}
contentPlaceholderView(title: title, systemImage: "list.bullet")
} else {
List($model.items, editActions: .delete) { item in
let item = item.wrappedValue
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
Button {
if let _ = item.playbackError {
errorAlertItem = item
isShowingErrorAlert = true
} else {
switch model.mode {
case .playlist:
model.onSeek(item)
case .favorites:
model.onPlay(item)
}
}
} label: {
MediaItemCell(
title: item.title,
subtitle: item.filename,
state: state,
playbackError: item.playbackError
)
}
.listRowBackground(
item.playbackError != nil ? Color.red.opacity(0.15) :
(model.mode == .playlist && state != .queued ? Color.accentColor.opacity(0.10) : nil)
)
.contextMenu {
Button(.copyTitle) {
UIPasteboard.general.string = item.title
}
Button(.copyURL) {
if let url = URL(string: item.filename) {
UIPasteboard.general.url = url
} else {
UIPasteboard.general.string = item.filename
}
}
if model.mode == .favorites {
Button(.edit) {
model.onEdit(item)
}
}
}
.swipeActions(edge: .leading) {
if model.mode == .favorites {
Button {
model.onQueue(item)
} label: {
Image(systemName: "plus.square.on.square")
Text(.addToQueue)
}
.tint(.blue)
} else if model.mode == .playlist {
Button {
model.onFavorite(item)
} label: {
Image(systemName: "star")
Text(.favorite)
}
.tint(.yellow)
}
}
}
.alert(.playbackError, isPresented: $isShowingErrorAlert, presenting: errorAlertItem) { item in
Button(.cancel, role: .cancel) {
errorAlertItem = nil
isShowingErrorAlert = false
}
Button(.delete, role: .destructive) {
model.items.removeAll { $0.id == item.id }
model.onDelete(item)
errorAlertItem = nil
isShowingErrorAlert = false
}
} message: { item in
Text(item.playbackError ?? "Unknown error")
}
}
}
}
}
struct MediaItemCell: View
{
let title: String
let subtitle: String
let state: State
let playbackError: String?
var body: some View {
HStack {
Image(systemName: iconName)
.tint(playbackError == nil ? Color.primary : Color.orange)
.frame(width: 15.0)
.padding(.trailing, 10.0)
VStack(alignment: .leading) {
Text(title)
.bold()
.font(.subheadline)
.tint(.primary)
.lineLimit(1)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
}
.padding([.top, .bottom], 4.0)
.frame(minHeight: 44.0)
}
private var iconName: String {
if playbackError != nil {
return "exclamationmark.triangle.fill"
}
switch state {
case .queued: return "play.fill"
case .playing: return "speaker.wave.3.fill"
case .paused: return "speaker.fill"
}
}
// MARK: - Types
enum State {
case queued
case playing
case paused
}
}
@@ -0,0 +1,202 @@
//
// AddServerView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import SwiftUI
struct AddServerView: View
{
let onAddServer: (Server) -> Void
@State var model = ViewModel()
var body: some View {
Form {
// Manual Entry
Section(.manual) {
TextField(.serverURL, text: $model.serverURL)
.autocapitalization(.none)
.autocorrectionDisabled()
.keyboardType(.URL)
switch model.validationState {
case .empty:
EmptyView()
case .validating:
HStack {
ProgressView()
.progressViewStyle(.circular)
Text(.validating)
}
case .notValid:
HStack {
Image(systemName: "x.circle.fill")
Text(.unableToConnect)
}
.foregroundStyle(.red)
case .valid:
HStack {
Image(systemName: "checkmark.circle.fill")
Text(.serverIsOnline)
}
.foregroundStyle(.green)
Button {
// Force unwrap, since we validated it at this point.
let server = Server(baseURL: URL(string: model.serverURL)!)
onAddServer(server)
} label: {
HStack {
Spacer()
Text(.addServer)
Spacer()
}
}
}
}
// Discovered
Section(.discovered) {
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.discovery.servers) { (server: DiscoveredEndpoint) in
Button {
resolveEndpoint(server)
} label: {
HStack {
Image(systemName: "network")
Text("\(server.displayName)")
.bold()
Spacer()
if model.resolvingServers.contains(server) {
ProgressView()
.progressViewStyle(.circular)
}
}
}
.tint(.primary)
}
}
}
}
.task {
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)
} catch is CancellationError {
// The view or its caller no longer needs this result.
} catch {
model.resolutionError = error.localizedDescription
}
}
}
// MARK: - Types
@MainActor
@Observable
class ViewModel
{
var serverURL: String = ""
var validationURL: String = ""
var validationState: ValidationState = .empty
let discovery = ServerDiscovery()
var resolvingServers = Set<DiscoveredEndpoint>()
var resolutionError: String?
private var validationTimer: Timer? = nil
init() {
observeForValidation()
}
private func observeForValidation() {
withObservationTracking {
_ = serverURL
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
setNeedsValidation()
observeForValidation()
}
}
}
private func setNeedsValidation() {
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 {
validationState = .empty
return
}
self.validationState = .validating
Task {
do {
let url = try URL(string: validationURL).try_unwrap()
let api = API(baseURL: url)
_ = try await api.fetchNowPlayingInfo()
self.validationState = .valid
if validationURL != serverURL {
self.serverURL = self.validationURL
}
} catch {
print("Validation failed: \(error)")
if !validationURL.hasSuffix("/api") {
// Try adding /api and validating again.
self.validationURL = serverURL.appending("/api")
validateSettings()
} else {
self.validationState = .notValid
}
}
}
}
// MARK: - Types
enum ValidationState
{
case empty
case validating
case notValid
case valid
}
}
}
@@ -0,0 +1,16 @@
//
// GeneralSettingsView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import SwiftUI
struct GeneralSettingsView: View
{
var body: some View {
Text("Nothing here yet.")
}
}
@@ -0,0 +1,120 @@
//
// ServerListSettingsView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import SwiftUI
struct ServerListSettingsView: View
{
@State var model = ViewModel()
var body: some View {
VStack {
if model.configuredServers.isEmpty {
contentPlaceholderView(title: .noServersConfigured, systemImage: "server.rack") {
Button {
model.isAddServerPresented = true
} label: {
Text(.addServer)
}
}
} else {
Form {
List($model.configuredServers, editActions: [.delete]) { server in
serverListItem(server.wrappedValue)
.tag(server.id)
}
}
}
}
.navigationTitle(.servers)
.toolbar {
Button {
model.isAddServerPresented = true
} label: {
Image(systemName: "plus")
}
}
.sheet(isPresented: $model.isAddServerPresented) {
NavigationView {
AddServerView(onAddServer: { model.onAddServer(server: $0) })
.navigationTitle(.addServer)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .cancellationAction) {
Button(.cancel) { model.isAddServerPresented = false }
}
}
}
}
}
@ViewBuilder
func serverListItem(_ server: Server) -> some View {
HStack {
Image(systemName: "hifispeaker.fill")
VStack(alignment: .leading) {
Text(server.displayName)
.lineLimit(1)
.bold()
Text(server.baseURL.absoluteString)
.foregroundStyle(.secondary)
.font(.caption)
}
Spacer()
}
}
// MARK: - Types
@Observable
class ViewModel
{
var configuredServers: [Server]
var isAddServerPresented = false
var selectedItems: [Server.ID] = []
init() {
self.configuredServers = Settings
.fromDefaults()
.configuredServers
observeForChanges()
}
func observeForChanges() {
withObservationTracking {
_ = configuredServers
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
saveToSettings()
observeForChanges()
}
}
}
func onAddServer(server: Server) {
isAddServerPresented = false
configuredServers = configuredServers + [ server ]
saveToSettings()
}
func saveToSettings() {
Settings
.fromDefaults()
.configuredServers(configuredServers)
.save()
}
}
}
@@ -0,0 +1,61 @@
//
// SettingsView.swift
// QueueCube
//
// Created by James Magahern on 5/2/25.
//
import SwiftUI
struct SettingsView: View
{
let onDone: () -> Void
@State private var navigationPath: [SettingsPage]
init(onDone: @escaping () -> Void) {
self.onDone = onDone
self.navigationPath = if !Settings.fromDefaults().isConfigured {
// Show server settings if not configured.
[ .servers ]
} else {
[]
}
}
var body: some View {
NavigationStack(path: $navigationPath) {
List {
NavigationLink(value: SettingsPage.general) {
Image(systemName: "gear")
Text(.general)
}
NavigationLink(value: SettingsPage.servers) {
Image(systemName: "server.rack")
Text(.servers)
}
}
.navigationDestination(for: SettingsPage.self, destination: { page in
Group {
switch page {
case .general: GeneralSettingsView()
case .servers: ServerListSettingsView()
}
}
.navigationBarTitleDisplayMode(.inline)
})
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(.settings)
}
}
// MARK: - Types
enum SettingsPage: String, Identifiable
{
var id: String { rawValue }
case general
case servers
}
}
+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>
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSBonjourServices</key>
<array>
<string>_queuecube._tcp.</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>QueueCube discovers nearby players so you can choose where to play this video.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,229 @@
import SwiftUI
import UIKit
final class ShareViewController: UIViewController {
private let model = ShareViewModel()
private var inputTask: Task<Void, Never>?
private var playbackTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
preferredContentSize = CGSize(width: 480, height: 600)
let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] in
self?.send()
}, close: { [weak self] in
self?.close()
}))
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
host.view.topAnchor.constraint(equalTo: view.topAnchor),
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
host.didMove(toParent: self)
model.discovery.start()
inputTask = Task { [weak self] in
guard let self else { return }
do {
model.url = try await SharedInput.load(extensionContext?.inputItems ?? [])
} catch {
guard !Task.isCancelled else { return }
model.message = error.localizedDescription
}
model.loading = false
}
}
private func send() {
guard !model.sending, !model.finished, let url = model.url,
let server = model.selectedServer else { return }
model.sending = true
model.message = nil
model.discovery.stop()
playbackTask = Task { [weak self] in
guard let self else { return }
do {
try await SharePlayback.enqueueAndPlay(url, using: API(baseURL: server.baseURL, requestTimeout: 8))
try Task.checkCancellation()
model.succeeded = true
model.finished = true
model.message = "Queued and playing on \(server.displayName)."
} catch let error as SharePlayback.Failure {
guard !Task.isCancelled else { return }
model.message = error.localizedDescription
model.finished = !error.canRetry
if error.canRetry { model.discovery.start() }
} catch {
guard !Task.isCancelled else { return }
model.message = "The request could not be confirmed. Check the player in QueueCube before sharing again."
model.finished = true
}
model.sending = false
}
}
private func close() {
inputTask?.cancel()
playbackTask?.cancel()
model.stop()
if model.finished {
extensionContext?.completeRequest(returningItems: nil)
} else {
extensionContext?.cancelRequest(withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError))
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
inputTask?.cancel()
playbackTask?.cancel()
model.stop()
}
deinit {
inputTask?.cancel()
playbackTask?.cancel()
}
}
@MainActor @Observable
private final class ShareViewModel {
let discovery = ServerDiscovery()
let configuredServers: [Server]
var selectedServer: Server?
var url: URL?
var loading = true
var sending = false
var finished = false
var succeeded = false
var resolving = false
var message: String?
private var resolutionTask: Task<Void, Never>?
init() {
let settings = Settings.fromSharedDefaults()
configuredServers = settings.configuredServers
selectedServer = settings.selectedServer.flatMap { selected in
settings.configuredServers.first { $0.id == selected.id }
} ?? settings.configuredServers.first
}
func select(_ endpoint: DiscoveredEndpoint) {
resolutionTask?.cancel()
resolving = true
message = nil
resolutionTask = Task {
do {
let server = try await endpoint.resolve()
try Task.checkCancellation()
selectedServer = server
} catch {
if !Task.isCancelled {
message = "Could not connect to that player. Check its connection and try again."
}
}
if !Task.isCancelled { resolving = false }
}
}
func stop() {
resolutionTask?.cancel()
discovery.stop()
}
}
private struct ShareView: View {
@Bindable var model: ShareViewModel
let send: () -> Void
let close: () -> Void
var body: some View {
NavigationStack {
Form {
Section("Video link") {
if model.loading {
ProgressView("Reading link…")
} else if let url = model.url {
Text(url.absoluteString)
.font(.callout)
.lineLimit(3)
}
}
if !model.finished {
Section("Choose player") {
if model.configuredServers.isEmpty {
Text("Open QueueCube once to share your saved players, or choose a nearby player below. Allow local network access in QueueCube to reach nearby players.")
.foregroundStyle(.secondary)
}
ForEach(model.configuredServers) { server in
Button {
model.selectedServer = server
} label: {
playerLabel(server.displayName, selected: model.selectedServer?.id == server.id)
}
.disabled(model.sending || model.resolving)
}
if let selected = model.selectedServer,
!model.configuredServers.contains(where: { $0.id == selected.id }) {
playerLabel(selected.displayName, selected: true)
}
}
if !model.sending {
Section("Nearby players") {
if model.discovery.servers.isEmpty {
Text(model.discovery.errorMessage ?? "Looking for QueueCube players on your local network…")
.foregroundStyle(.secondary)
}
ForEach(model.discovery.servers) { endpoint in
Button(endpoint.displayName) { model.select(endpoint) }
.disabled(model.resolving)
}
if model.resolving { ProgressView("Connecting…") }
}
}
}
if let message = model.message {
Section {
Label(message, systemImage: model.succeeded ? "checkmark.circle.fill" : "exclamationmark.circle")
.foregroundStyle(model.succeeded ? Color.green : Color.primary)
}
}
if !model.finished {
Section {
Button(action: send) {
HStack {
Spacer()
if model.sending { ProgressView() }
Text(model.sending ? "Queuing and starting playback…" : "Queue & Play")
Spacer()
}
}
.disabled(model.url == nil || model.selectedServer == nil || model.sending || model.resolving)
Text("Adds this link to the queue and plays it now. Existing queued items are kept.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("QueueCube")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(model.finished ? "Done" : "Cancel", action: close)
.disabled(model.sending)
}
}
}
}
private func playerLabel(_ name: String, selected: Bool) -> some View {
HStack {
Label(name, systemImage: "hifispeaker.fill")
Spacer()
if selected { Image(systemName: "checkmark").accessibilityLabel("Selected") }
}
}
}
+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,
select a saved or nearby player, and tap **Queue & Play**. The link is appended
and played immediately; existing queue entries remain. A successful request
shows the selected player and a Done button. Video support depends on the
player's existing mpv/yt-dlp support.
Nearby players use the same `_queuecube._tcp.` Bonjour discovery as Add Server.
Resolved IPv4 interface suffixes are removed; scoped IPv6 addresses remain valid.
Allow local network access in the containing app first. Players can also be
configured manually in QueueCube Settings. The extension handles URL attachments
(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 Bonjour/local network declarations 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 and
nearby player selection, Queue & Play success, 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([]))
}
}
+4
View File
@@ -0,0 +1,4 @@
build/
result
node_modules/
tsconfig.tsbuildinfo
+9 -2
View File
@@ -18,18 +18,25 @@ RUN npm run build --workspaces
FROM --platform=$TARGETPLATFORM debian:testing-20250203
RUN apt-get update && apt-get install -y \
mpv npm yt-dlp pulseaudio pulseaudio-utils \
mpv npm yt-dlp pulseaudio pulseaudio-utils ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install only production dependencies
COPY backend/package*.json ./
COPY package-lock.json ./
RUN rm -rf node_modules/ # need to do a clean build
RUN npm ci --production
# Copy built files
COPY --from=builder /app/backend/build ./build
COPY --from=builder /app/frontend/dist ./dist/frontend
# Copy entrypoint script
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
EXPOSE 3000
CMD ["node", "build/server.js"]
CMD ["./entrypoint.sh"]
View File
View File
@@ -21,6 +21,7 @@
},
"dependencies": {
"@types/node-fetch": "^2.6.12",
"bonjour-service": "^1.3.0",
"classnames": "^2.5.1",
"express": "^4.21.2",
"express-ws": "^5.0.2",
@@ -48,7 +48,7 @@ export interface ThumbnailResponse {
}
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || 'http://invidious.nor';
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || process.env.INVIDIOUS_URL || 'http://invidious.nor';
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
export const getInvidiousSearchURL = (query: string): string =>
+848
View File
@@ -0,0 +1,848 @@
/*
* MediaPlayer.ts
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ChildProcess, spawn } from "child_process";
import { Socket } from "net";
import { WebSocket } from "ws";
import { getLinkPreview } from "link-preview-js";
import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore";
import { Bonjour } from "bonjour-service";
import os from 'os';
interface PendingCommand {
resolve: (value: any) => void;
reject: (reason: any) => void;
}
enum ObservedProperty {
MediaTitle = 1,
Metadata = 2,
}
enum UserEvent {
PlaylistUpdate = "playlist_update",
NowPlayingUpdate = "now_playing_update",
VolumeUpdate = "volume_update",
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
PlaybackError = "playback_error",
}
export interface Features {
video: boolean;
screenshare: boolean;
browserPlayback: boolean;
}
export class MediaPlayer {
private playerProcess: ChildProcess | null = null;
private socket: Promise<Socket>;
private eventSubscribers: WebSocket[] = [];
private favoritesStore: FavoritesStore;
private pendingCommands: Map<number, PendingCommand> = new Map();
private requestId: number = 1;
private dataBuffer: string = '';
private metadata: Map<string, LinkMetadata> = new Map();
private bonjourInstance: Bonjour | null = null;
private playbackErrors: Map<string, string> = new Map();
private currentFile: string | null = null;
private lastLoadCandidate: string | null = null;
private currentMediaTitle: string | null = null;
private currentPlaybackMetadata: Record<string, string> = {};
constructor() {
this.socket = this.tryRespawnPlayerProcess();
this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => {
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
};
this.getFeatures().then(features => {
console.log("Features: ", features);
});
}
public startZeroconfService(port: number) {
if (this.bonjourInstance) {
console.log("Zeroconf service already running");
return;
}
this.bonjourInstance = new Bonjour();
const service = this.bonjourInstance.publish({
name: `QueueCube Media Server (${os.hostname()})`,
type: 'queuecube',
port: port,
txt: {
version: '1.0.0',
features: 'playlist,favorites,screenshare'
}
});
service.on('up', () => {
console.log(`Zeroconf service advertised: ${service.name} on port ${port}`);
});
service.on('error', (err: Error) => {
console.error('Zeroconf service error:', err);
});
}
public stopZeroconfService() {
if (this.bonjourInstance) {
this.bonjourInstance.destroy();
this.bonjourInstance = null;
console.log("Zeroconf service stopped");
}
}
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 = this.envEnabled("ENABLE_VIDEO");
const ytdlFormat = process.env.MPV_YTDL_FORMAT;
const logfilePath = `/tmp/mpv-logfile.txt`;
const playerArgs = [
"--video=" + (enableVideo ? "auto" : "no"),
"--no-terminal",
"--idle=yes",
"--input-ipc-server=" + socketPath,
"--log-file=" + logfilePath,
"--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);
let socketReady!: (s: Socket) => void;
let socketFailed!: (reason?: unknown) => void;
let socketPromise = new Promise<Socket>((resolve, reject) => {
socketReady = resolve;
socketFailed = reject;
});
this.playerProcess.on("spawn", () => {
console.log(`Player process spawned, opening socket @ ${socketPath}`);
this.connectToSocket(socketPath)
.then(socket => socketReady(socket))
.catch((error: unknown) => {
console.error(`Failed to connect to mpv socket @ ${socketPath}:`, error);
console.log("Continuing without mpv player...");
socketFailed(error);
});
});
this.playerProcess.on("error", (error: unknown) => {
console.error("Player process error:", error);
console.log("Continuing without mpv player...");
socketFailed(error);
});
return socketPromise;
}
public async getPlaylist(): Promise<PlaylistItem[]> {
return this.writeCommand("get_property", ["playlist"])
.then((response) => {
// Enhance playlist items with metadata
const playlist = response.data as PlaylistItem[];
return playlist.map((item: PlaylistItem) => {
const enhancedItem = {
...item,
metadata: this.metadata.get(item.filename) || {},
playbackError: this.playbackErrors.get(item.filename)
};
return item.current ? this.withCurrentDynamicTitle(enhancedItem) : enhancedItem;
});
});
}
public async getNowPlaying(): Promise<PlaylistItem> {
const playlist = await this.getPlaylist();
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
const fetchMediaTitle = async (): Promise<string | null> => {
try {
const mediaTitle = (await this.writeCommand("get_property", ["media-title"])).data;
this.currentMediaTitle = typeof mediaTitle === "string" ? mediaTitle : null;
return this.currentMediaTitle;
} catch (err) {
return null;
}
};
const mediaTitle = await fetchMediaTitle();
if (currentlyPlayingSong !== undefined) {
const dynamicTitle = this.getCurrentDynamicTitle(currentlyPlayingSong);
if (dynamicTitle) {
return this.withCurrentDynamicTitle(currentlyPlayingSong, dynamicTitle);
}
// Use media title if we don't have a title
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
return {
...currentlyPlayingSong,
title: mediaTitle || currentlyPlayingSong.filename
};
}
return currentlyPlayingSong;
}
return {
id: 0,
filename: mediaTitle || "",
title: mediaTitle || ""
};
}
public async getCurrentFile(): Promise<string | null> {
return this.writeCommand("get_property", ["stream-open-filename"])
.then((response) => {
return response.data;
}, (reject) => { return null; });
}
public async getPauseState(): Promise<boolean> {
return this.writeCommand("get_property", ["pause"])
.then((response) => {
return response.data;
});
}
public async getVolume(): Promise<number> {
return this.writeCommand("get_property", ["volume"])
.then((response) => {
return response.data;
});
}
public async getTimePosition(): Promise<number | null> {
return this.writeCommand("get_property", ["time-pos"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getDuration(): Promise<number | null> {
return this.writeCommand("get_property", ["duration"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getSeekable(): Promise<boolean | null> {
return this.writeCommand("get_property", ["seekable"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getIdle(): Promise<boolean> {
return this.writeCommand("get_property", ["idle"])
.then((response) => {
return response.data;
});
}
public async append(url: string) {
await this.loadFile(url, "append-play");
}
public async replace(url: string) {
await this.loadFile(url, "replace");
}
public async initiateScreenSharing(url: string) {
console.log(`Initiating screen sharing with file: ${url}`);
this.metadata.set(url, {
title: "Screen Sharing",
description: "Screen Sharing",
siteName: "Screen Sharing",
});
// 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, [
"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",
]);
// loadfile normally unpauses, but make that explicit without adding the
// previous fixed 100 ms startup delay.
await this.play();
}
public async play() {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
}
public async pause() {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
}
public async stop() {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
}
public async skip() {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
}
public async skipTo(index: number) {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
}
public async previous() {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
}
public async deletePlaylistItem(index: number) {
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
}
public async setVolume(volume: number) {
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
}
public async seek(time: number) {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"]));
}
public subscribe(ws: WebSocket) {
this.eventSubscribers.push(ws);
}
public unsubscribe(ws: WebSocket) {
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
}
public async getFavorites(): Promise<PlaylistItem[]> {
return this.favoritesStore.getFavorites();
}
public async addFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
}
public async removeFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
}
public async clearFavorites() {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
}
public async updateFavoriteTitle(filename: string, title: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
}
public async getFeatures(): Promise<Features> {
return {
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);
const loadPromise = this.modify(
UserEvent.PlaylistUpdate,
() => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]),
);
if (fetchMetadata && this.shouldFetchLinkPreview(url)) {
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${this.loggableUrl(url)}:`, error);
});
}
await loadPromise;
}
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
return func()
.then((result) => {
// Notify all subscribers
this.handleEvent(event, {});
return result;
}, (reject) => {
console.log("Error modifying playlist: " + reject);
return reject;
});
}
private async writeCommand(command: string, args: any[]): Promise<any> {
// Wait for socket to become available.
let socket = await this.socket;
return new Promise((resolve, reject) => {
const id = this.requestId++;
const commandObject = JSON.stringify({
command: [command, ...args],
request_id: id
});
try {
this.pendingCommands.set(id, { resolve, reject });
socket.write(commandObject + '\n');
} catch (e: any) {
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
this.socket = this.tryRespawnPlayerProcess();
}
// Add timeout to prevent hanging promises
setTimeout(() => {
if (this.pendingCommands.has(id)) {
const pending = this.pendingCommands.get(id);
if (pending) {
pending.reject(new Error('Command timed out'));
this.pendingCommands.delete(id);
}
}
}, 5000);
});
}
private async fetchMetadataAndNotify(url: string) {
try {
console.log("Fetching metadata for " + this.loggableUrl(url));
const metadata = await getLinkPreview(url);
this.metadata.set(url, {
title: (metadata as any)?.title,
description: (metadata as any)?.description,
siteName: (metadata as any)?.siteName,
});
console.log("Metadata fetched for " + this.loggableUrl(url));
console.log(this.metadata.get(url));
// Notify clients that metadata has been updated
this.handleEvent(UserEvent.MetadataUpdate, {
url,
metadata: this.metadata.get(url)
});
} catch (error) {
throw error;
}
}
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;
return new Promise((resolve, reject) => {
type SocketError = Error & { code?: string };
const attemptConnection = (attempt: number) => {
const socket = new Socket();
const onError = (error: SocketError) => {
socket.removeAllListeners();
socket.destroy();
const shouldRetry = (error.code === "ENOENT" || error.code === "ECONNREFUSED") && attempt < maxAttempts;
if (shouldRetry) {
setTimeout(() => attemptConnection(attempt + 1), retryDelayMs);
return;
}
reject(error);
};
socket.once("connect", () => {
socket.removeListener("error", onError);
socket.on("data", (data: unknown) => this.receiveData(String(data)));
socket.on("error", (error: unknown) => {
console.error("MPV socket error:", error);
});
resolve(socket);
this.registerMpvObservers().catch((error: unknown) => {
console.error("Failed to register mpv observers:", error);
});
});
socket.once("error", onError);
socket.connect(path);
};
attemptConnection(1);
});
}
private async registerMpvObservers() {
await this.writeCommand("observe_property", [ObservedProperty.MediaTitle, "media-title"]);
await this.writeCommand("observe_property", [ObservedProperty.Metadata, "metadata"]);
}
private handleEvent(event: string, data: any) {
console.log("Event [" + event + "]: ", data);
// Notify all subscribers
this.eventSubscribers.forEach(subscriber => {
subscriber.send(JSON.stringify({ event, data }));
});
}
private receiveData(data: string) {
this.dataBuffer += data;
const lines = this.dataBuffer.split('\n');
// Keep last incomplete line in the buffer
this.dataBuffer = lines.pop() || '';
for (const line of lines) {
if (line.trim().length > 0) {
try {
const response = JSON.parse(line);
if (response.request_id) {
const pending = this.pendingCommands.get(response.request_id);
if (pending) {
if (response.error == "success") {
pending.resolve(response);
} else {
pending.reject(response.error);
}
this.pendingCommands.delete(response.request_id);
}
} else if (response.event) {
if (response.event === "start-file") {
// Clear any previous error for the file that is starting
const file = response.file || this.lastLoadCandidate;
if (file) {
this.currentFile = file;
this.playbackErrors.delete(file);
}
this.currentMediaTitle = null;
this.currentPlaybackMetadata = {};
} else if (response.event === "property-change") {
this.handlePropertyChange(response);
} else if (response.event === "end-file" && response.reason === "error") {
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
this.playbackErrors.set(file, errorMessage);
this.handleEvent(UserEvent.PlaybackError, {
filename: file,
error: errorMessage
});
}
this.handleEvent(UserEvent.MPDUpdate, response);
} else {
console.log(response);
}
} catch (error) {
console.error('Error parsing JSON:', error);
}
}
}
}
private handlePropertyChange(response: any) {
const previousTitle = this.getCurrentDynamicTitle();
if (response.id === ObservedProperty.MediaTitle || response.name === "media-title") {
this.currentMediaTitle = typeof response.data === "string" ? response.data : null;
} else if (response.id === ObservedProperty.Metadata || response.name === "metadata") {
this.currentPlaybackMetadata = this.normalizeMpvMetadata(response.data);
}
const currentTitle = this.getCurrentDynamicTitle();
if (currentTitle !== previousTitle) {
this.handleEvent(UserEvent.MetadataUpdate, {
url: this.currentFile,
title: currentTitle,
metadata: this.currentPlaybackMetadata
});
}
}
private withCurrentDynamicTitle(item: PlaylistItem, dynamicTitle: string | null = this.getCurrentDynamicTitle(item)): PlaylistItem {
if (!dynamicTitle) {
return item;
}
return {
...item,
title: dynamicTitle,
metadata: {
...item.metadata,
title: dynamicTitle
}
};
}
private getCurrentDynamicTitle(item?: PlaylistItem): string | null {
if (this.currentMediaTitle && !this.isFallbackMediaTitle(this.currentMediaTitle, item?.filename)) {
return this.currentMediaTitle.trim();
}
return this.extractTitleFromMpvMetadata();
}
private extractTitleFromMpvMetadata(): string | null {
const titleKeys = new Set(["title", "icy-title", "icy_title", "streamtitle", "stream-title"]);
for (const key of Object.keys(this.currentPlaybackMetadata)) {
const normalizedKey = key.toLowerCase();
if (!titleKeys.has(normalizedKey)) {
continue;
}
const title = this.parseStreamTitle(this.currentPlaybackMetadata[key]);
if (title) {
return title;
}
}
return null;
}
private parseStreamTitle(value: string | undefined): string | null {
const trimmedValue = value?.trim();
if (!trimmedValue) {
return null;
}
const streamTitleMatch = trimmedValue.match(/StreamTitle='([^']*)'/i);
return (streamTitleMatch?.[1] || trimmedValue).trim() || null;
}
private normalizeMpvMetadata(data: unknown): Record<string, string> {
if (data === null || typeof data !== "object" || Array.isArray(data)) {
return {};
}
const metadata: Record<string, string> = {};
const rawMetadata = data as Record<string, unknown>;
for (const key of Object.keys(rawMetadata)) {
const value = rawMetadata[key];
if (value !== null && value !== undefined) {
metadata[key] = String(value);
}
}
return metadata;
}
private isFallbackMediaTitle(title: string, filename?: string): boolean {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return true;
}
const fallbackCandidates = new Set<string>();
if (filename) {
this.addFallbackTitleCandidates(fallbackCandidates, filename);
}
if (this.currentFile) {
this.addFallbackTitleCandidates(fallbackCandidates, this.currentFile);
}
return fallbackCandidates.has(normalizedTitle);
}
private addFallbackTitleCandidates(candidates: Set<string>, value: string) {
candidates.add(value);
try {
candidates.add(decodeURIComponent(value));
} catch {
// Keep the original value if it is not URI-encoded.
}
try {
const url = new URL(value);
this.addPathTitleCandidates(candidates, url.pathname);
} catch {
this.addPathTitleCandidates(candidates, value);
}
}
private addPathTitleCandidates(candidates: Set<string>, path: string) {
const pathSegments = path.split("/").filter(Boolean);
const lastPathSegment = pathSegments[pathSegments.length - 1];
if (!lastPathSegment) {
return;
}
candidates.add(lastPathSegment);
try {
candidates.add(decodeURIComponent(lastPathSegment));
} catch {
// Keep the original segment if it is not URI-encoded.
}
}
}
@@ -26,23 +26,46 @@ 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) => {
try {
await func(req, res);
} catch (error: any) {
console.log(`Error (${func.name}): ${error}`);
res.status(500).send(JSON.stringify({ success: false, error: error.message }));
}
};
@@ -108,6 +131,9 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
const pauseState = await mediaPlayer.getPauseState();
const volume = await mediaPlayer.getVolume();
const idle = await mediaPlayer.getIdle();
const timePosition = await mediaPlayer.getTimePosition();
const duration = await mediaPlayer.getDuration();
const seekable = await mediaPlayer.getSeekable();
res.send(JSON.stringify({
success: true,
@@ -115,7 +141,10 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
isPaused: pauseState,
volume: volume,
isIdle: idle,
currentFile: currentFile
currentFile: currentFile,
timePosition: timePosition,
duration: duration,
seekable: seekable
}));
}));
@@ -125,6 +154,12 @@ apiRouter.post("/volume", withErrorHandling(async (req, res) => {
res.send(JSON.stringify({ success: true }));
}));
apiRouter.post("/player/seek", withErrorHandling(async (req, res) => {
const { time } = req.body as { time: number };
await mediaPlayer.seek(time);
res.send(JSON.stringify({ success: true }));
}));
apiRouter.ws("/events", (ws, req) => {
console.log("Events client connected");
mediaPlayer.subscribe(ws);
@@ -139,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();
});
});
@@ -281,26 +345,37 @@ apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) =
res.send(JSON.stringify({ success: true }));
}));
apiRouter.get("/features", withErrorHandling(async (req, res) => {
const features = await mediaPlayer.getFeatures();
res.send(JSON.stringify(features));
}));
// 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;
const server = app.listen(port, () => {
console.log(`Server is running on port ${port}`);
// Start zeroconf service advertisement
mediaPlayer.startZeroconfService(Number(port));
});
// Add graceful shutdown handling
const shutdown = async () => {
console.log('Received shutdown signal. Closing server...');
// Stop zeroconf service
mediaPlayer.stopZeroconfService();
server.close(() => {
console.log('Server closed');
process.exit(0);
@@ -29,4 +29,5 @@ export interface PlaylistItem {
playing?: boolean;
current?: boolean;
metadata?: LinkMetadata;
playbackError?: string;
}
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Check if browser playback is enabled
if [ "$ENABLE_BROWSER_PLAYBACK" = "1" ]; then
echo "Browser playback enabled - setting up audio streaming..."
echo "Starting PulseAudio..."
pulseaudio --start --log-target=syslog --system=false
# Wait a moment for PulseAudio to initialize
sleep 2
# Create virtual sink
echo "Creating virtual audio sink..."
pactl load-module module-null-sink sink_name=virtual_output sink_properties=device.description="Virtual_Audio_Output"
# Make it the default sink
pactl set-default-sink virtual_output
# Create stream directory if it doesn't exist
mkdir -p ./dist/frontend/stream
# Start FFmpeg streaming in background
echo "Starting audio stream..."
FFMPEG_OPTS="-loglevel error -f pulse \
-i virtual_output.monitor \
-c:a aac -b:a 128k \
-f hls \
-hls_time 1 \
-hls_list_size 3 \
-hls_flags delete_segments+append_list \
-hls_segment_type mpegts \
-hls_segment_filename ./dist/frontend/stream/segment_%03d.ts \
./dist/frontend/stream/audio.m3u8"
echo "FFmpeg options: $FFMPEG_OPTS"
ffmpeg $FFMPEG_OPTS &
else
echo "Browser playback disabled - skipping audio streaming setup"
fi
# Start the Node.js server
echo "Starting Node.js server..."
exec node build/server.js
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1740828860,
"narHash": "sha256-cjbHI+zUzK5CPsQZqMhE3npTyYFt9tJ3+ohcfaOF/WM=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "303bd8071377433a2d8f76e684ec773d70c5b642",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
+235
View File
@@ -0,0 +1,235 @@
{
description = "NodeJS application with mpv, yt-dlp, and pulseaudio dependencies";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
let
mkQueuecube = pkgs: pkgs.buildNpmPackage {
pname = "queuecube";
version = "0.1.0";
src = ./.;
# Skip the standard buildPhase and provide our own
dontNpmBuild = true;
buildPhase = ''
# First install all dependencies
npm install
# Then run the build with workspaces flag
npm run build --workspaces
'';
# Runtime dependencies
buildInputs = with pkgs; [
mpv
yt-dlp
pulseaudio
];
# Create a wrapper script to ensure runtime deps are available
postInstall = ''
# Create the necessary directories
mkdir -p $out/lib/node_modules/queuecube
# Copy the entire project with built files
cp -r . $out/lib/node_modules/queuecube
# Install the frontend build to the backend dist directory
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
# Create bin directory if it doesn't exist
mkdir -p $out/bin
# Create executable script
cat > $out/bin/queuecube <<EOF
#!/bin/sh
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
EOF
# Make it executable
chmod +x $out/bin/queuecube
# Wrap the program to include runtime deps in PATH
wrapProgram $out/bin/queuecube \
--prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv
pkgs.yt-dlp
pkgs.pulseaudio
]}
'';
# Let buildNpmPackage handle npm package hash
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
meta = with pkgs.lib; {
description = "NodeJS application with media playback capabilities";
platforms = platforms.linux;
};
};
# Define the NixOS module for the systemd service
nixosModule = { config, lib, pkgs, ... }:
let
cfg = config.services.queuecube;
package = mkQueuecube pkgs;
in {
options.services.queuecube = {
enable = lib.mkEnableOption "QueueCube media player service";
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port on which QueueCube will listen";
};
enable_video = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable video playback";
};
enable_screenshare = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable screensharing";
};
mpv_ytdl_format = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "best";
description = "yt-dlp format selector passed to mpv. Set to null to use mpv's default format selection.";
};
store_path = lib.mkOption {
type = lib.types.str;
default = "/var/tmp/queuecube";
description = "Path to the store for QueueCube";
};
invidious = lib.mkOption {
type = lib.types.submodule {
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Invidious";
};
url = lib.mkOption {
type = lib.types.str;
default = "http://invidious.nor";
description = "URL of the Invidious instance to use";
};
};
};
default = {
enable = false;
url = "http://invidious.nor";
};
};
user = lib.mkOption {
type = lib.types.str;
description = "User account under which QueueCube runs (required)";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = {
packages = [ package ];
};
systemd.user.services.queuecube = {
description = "QueueCube media player service";
wantedBy = [ "default.target" ];
after = [ "pipewire.service" "pipewire-pulse.service" ];
serviceConfig = {
ExecStart = "${package}/bin/queuecube";
Restart = "on-failure";
RestartSec = 5;
# Remove all resource limits for mpv to function properly
LimitNOFILE = "infinity"; # No limit on file descriptors
LimitMEMLOCK = "infinity"; # No limit on locked memory (for real-time audio)
LimitNPROC = "infinity"; # No limit on number of processes
LimitAS = "infinity"; # No limit on address space
LimitRSS = "infinity"; # No limit on resident set size
LimitCORE = "infinity"; # Allow core dumps for debugging
LimitDATA = "infinity"; # No limit on data segment
LimitSTACK = "infinity"; # No limit on stack size
LimitCPU = "infinity"; # No limit on CPU time
LimitRTPRIO = "99"; # Allow real-time priority
LimitRTTIME = "infinity"; # No limit on real-time scheduling
# Nice level for better performance
Nice = "-10";
# Allow access to necessary devices and features
PrivateDevices = false;
ProtectHome = false;
ProtectSystem = false;
NoNewPrivileges = false;
# Environment for X11 and runtime directories
Environment = [
"DISPLAY=:0"
];
};
environment = {
PORT = toString cfg.port;
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
ENABLE_SCREENSHARE = if cfg.enable_screenshare then "1" else "0";
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
INVIDIOUS_BASE_URL = cfg.invidious.url;
STORE_PATH = cfg.store_path;
} // lib.optionalAttrs (cfg.mpv_ytdl_format != null) {
MPV_YTDL_FORMAT = cfg.mpv_ytdl_format;
};
};
};
};
in
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
queuecube = mkQueuecube pkgs;
in {
packages = {
default = queuecube;
queuecube = queuecube;
};
apps.default = {
type = "app";
program = "${queuecube}/bin/queuecube";
};
# Development environment
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
mpv
yt-dlp
pulseaudio
];
};
# Add a basic check to verify the package builds
checks.queuecube = queuecube;
}
) // {
# Export the NixOS module
nixosModules.default = nixosModule;
nixosModules.queuecube = nixosModule;
};
}
@@ -24,7 +24,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"typescript": "~5.7.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
}

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -5,6 +5,15 @@ export interface NowPlayingResponse {
volume: number;
isIdle: boolean;
currentFile: string;
timePosition?: number;
duration?: number;
seekable?: boolean;
}
export interface Features {
video: boolean;
screenshare: boolean;
browserPlayback: boolean;
}
export interface Metadata {
@@ -19,6 +28,7 @@ export interface PlaylistItem {
id: number;
playing: boolean | null;
metadata?: Metadata;
playbackError?: string;
}
export const getDisplayTitle = (item: PlaylistItem): string => {
@@ -53,10 +63,16 @@ export enum ServerEvent {
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
PlaybackError = "playback_error",
ScreenShare = "screen_share",
}
export const API = {
async getFeatures(): Promise<Features> {
const response = await fetch('/api/features');
return response.json();
},
async getPlaylist(): Promise<PlaylistItem[]> {
const response = await fetch('/api/playlist');
return response.json();
@@ -127,6 +143,16 @@ export const API = {
});
},
async seek(time: number): Promise<void> {
await fetch('/api/player/seek', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ time }),
});
},
async search(query: string): Promise<SearchResponse> {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return response.json();
@@ -177,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;
}
};
@@ -4,11 +4,12 @@ import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel';
import RenameFavoriteModal from './RenameFavoriteModal';
import { TabView, Tab } from './TabView';
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { API, Features, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
import useWebSocket from 'react-use-websocket';
import classNames from 'classnames';
import { useScreenShare } from '../hooks/useScreenShare';
import AudioPlayer from './AudioPlayer';
enum Tabs {
Playlist = "playlist",
@@ -86,6 +87,9 @@ const App: React.FC = () => {
const [isIdle, setIsIdle] = useState(false);
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
const [timePosition, setTimePosition] = useState<number | undefined>(undefined);
const [duration, setDuration] = useState<number | undefined>(undefined);
const [seekable, setSeekable] = useState<boolean | undefined>(undefined);
const [volume, setVolume] = useState(100);
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
@@ -93,6 +97,8 @@ const App: React.FC = () => {
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
const [audioEnabled, setAudioEnabled] = useState(false);
const [features, setFeatures] = useState<Features | null>(null);
const {
isScreenSharing,
@@ -124,6 +130,12 @@ const App: React.FC = () => {
setIsPlaying(!nowPlaying.isPaused);
setVolume(nowPlaying.volume);
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
setTimePosition(nowPlaying.timePosition);
setDuration(nowPlaying.duration);
setSeekable(nowPlaying.seekable);
const features = await API.getFeatures();
setFeatures(features);
}, [volumeSettingIsLocked]);
const handleAddURL = async (url: string) => {
@@ -170,6 +182,11 @@ const App: React.FC = () => {
fetchNowPlaying();
};
const handleSeek = async (time: number) => {
await API.seek(time);
fetchNowPlaying();
};
const handleVolumeSettingChange = async (volume: number) => {
setVolume(volume);
await API.setVolume(volume);
@@ -182,6 +199,7 @@ const App: React.FC = () => {
case ServerEvent.NowPlayingUpdate:
case ServerEvent.MetadataUpdate:
case ServerEvent.MPDUpdate:
case ServerEvent.PlaybackError:
fetchPlaylist();
fetchNowPlaying();
break;
@@ -198,7 +216,6 @@ const App: React.FC = () => {
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
console.log('Connecting to WebSocket at', wsUrl);
useWebSocket(wsUrl, {
onOpen: () => {
console.log('WebSocket connected');
@@ -241,6 +258,15 @@ const App: React.FC = () => {
fetchFavorites();
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
useEffect(() => {
const interval = setInterval(() => {
if (isPlaying) {
fetchNowPlaying();
}
}, 1000);
return () => clearInterval(interval);
}, [isPlaying, fetchNowPlaying]);
const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
<button
className={
@@ -313,16 +339,24 @@ const App: React.FC = () => {
return (
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
{features?.browserPlayback && (
<AudioPlayer isPlaying={isPlaying} enabled={audioEnabled} />
)}
<NowPlaying
className="flex flex-row md:rounded-t-2xl"
songName={nowPlayingSong || "(Not Playing)"}
fileName={nowPlayingFileName || ""}
isPlaying={isPlaying}
isIdle={isIdle}
timePosition={timePosition}
duration={duration}
seekable={seekable}
onPlayPause={togglePlayPause}
onStop={handleStop}
onSkip={handleSkip}
onPrevious={handlePrevious}
onSeek={handleSeek}
onScreenShare={toggleScreenShare}
isScreenSharing={isScreenSharing}
volume={volume}
@@ -330,6 +364,9 @@ const App: React.FC = () => {
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
isScreenSharingSupported={isScreenSharingSupported}
features={features}
audioEnabled={audioEnabled}
onAudioEnabledChange={setAudioEnabled}
/>
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
@@ -0,0 +1,32 @@
import React, { useEffect, useRef } from 'react';
interface AudioPlayerProps {
isPlaying: boolean;
enabled: boolean;
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({ isPlaying, enabled }) => {
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (enabled && isPlaying) {
console.log("Playing audio");
audioRef.current?.play().catch((error) => {
console.error("Audio playback error:", error);
});
} else {
console.log("Pausing audio");
audioRef.current?.pause();
}
}, [isPlaying, enabled]);
return (
<audio
ref={audioRef}
src="/stream/audio.m3u8"
preload="metadata"
/>
);
};
export default AudioPlayer;
+176
View File
@@ -0,0 +1,176 @@
import React, { HTMLAttributes, useState, useRef } from 'react';
import classNames from 'classnames';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
import { Features } from '../api/player';
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
songName: string;
fileName: string;
isPlaying: boolean;
isIdle: boolean;
volume: number;
timePosition?: number;
duration?: number;
seekable?: boolean;
onPlayPause: () => void;
onStop: () => void;
onSkip: () => void;
onPrevious: () => void;
onSeek: (time: number) => void;
onScreenShare: () => void;
isScreenSharingSupported: boolean;
isScreenSharing: boolean;
// Sent when the volume setting actually changes value
onVolumeSettingChange: (volume: number) => void;
// Sent when the volume is about to start changing
onVolumeWillChange: (volume: number) => void;
// Sent when the volume has changed
onVolumeDidChange: (volume: number) => void;
features: Features | null;
audioEnabled: boolean;
onAudioEnabledChange: (enabled: boolean) => void;
}
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
const [isSeeking, setIsSeeking] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null);
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (progressBarRef.current) {
const rect = progressBarRef.current.getBoundingClientRect();
const newSeekPosition = (e.clientX - rect.left) / rect.width;
if (props.duration) {
props.onSeek(newSeekPosition * props.duration);
}
}
};
const titleArea = props.isScreenSharing ? (
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
<FaDesktop size={24} />
<div className="text-lg font-bold truncate">Screen Sharing</div>
</div>
) : (
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100', "flex flex-row items-center justify-between gap-2 w-full")}>
<div className="truncate">
<div className="text-lg font-bold truncate">{props.songName}</div>
<div className="text-sm truncate">{props.fileName}</div>
</div>
<div className="text-sm opacity-50 shrink-0">
{props.timePosition && props.duration ?
(props.seekable ? `${formatTime(props.timePosition)} / ${formatTime(props.duration)}`
: `${formatTime(props.timePosition)}` )
: ''}
</div>
</div>
);
return (
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg gap-4 overflow-hidden">
<div className="p-5">
<div className="flex-grow min-w-0 w-full text-white text-left">
{titleArea}
</div>
<div className="flex flex-row items-center gap-4 w-full pt-4">
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
<FaVolumeUp size={20} />
<input
type="range"
min="0"
max="100"
value={props.volume}
onMouseDown={() => props.onVolumeWillChange(props.volume)}
onMouseUp={() => props.onVolumeDidChange(props.volume)}
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
className="fancy-slider h-2 w-full"
/>
</div>
<div className="flex-grow"></div>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
<FaStepBackward size={24} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
<FaStepForward size={24} />
</button>
{(props.isScreenSharingSupported && props.features?.screenshare) && (
<button
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
onClick={props.onScreenShare}
title="Share your screen"
>
<FaDesktop size={24} />
</button>
)}
</div>
</div>
{props.seekable !== false && (
<div
ref={progressBarRef}
className="w-full h-2 bg-gray-600 cursor-pointer -mt-3"
onMouseDown={(e) => {
setIsSeeking(true);
handleSeek(e);
}}
onMouseMove={(e) => {
if (isSeeking) {
handleSeek(e);
}
}}
onMouseUp={() => setIsSeeking(false)}
onMouseLeave={() => setIsSeeking(false)}
>
<div
className="h-full bg-violet-500"
style={{ width: `${(props.timePosition && props.duration ? (props.timePosition / props.duration) * 100 : 0)}%` }}
/>
</div>
)}
{props.features?.browserPlayback && (
<div>
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
<input
type="checkbox"
checked={props.audioEnabled}
onChange={(e) => props.onAudioEnabledChange(e.target.checked)}
className="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 focus:ring-2"
/>
Enable audio playback in browser
</label>
</div>
)}
</div>
</div>
</div>
);
};
export default NowPlaying;
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
import { FaPlay, FaVolumeUp, FaVolumeOff, FaExclamationTriangle } from 'react-icons/fa';
import { getDisplayTitle, PlaylistItem } from '../api/player';
export enum PlayState {
@@ -19,6 +19,7 @@ export interface SongRowProps {
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@@ -38,6 +39,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
}, [showDeleteConfirm]);
const displayTitle = getDisplayTitle(song);
const hasError = !!song.playbackError;
return (
<div className={classNames(
@@ -46,16 +48,46 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
"bg-black/30": playState === PlayState.NotPlaying,
})}>
<div className="flex flex-row gap-2">
<div className="relative">
<button
className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded"
onClick={onPlay}
className={classNames(
"transition-colors px-3 py-1 rounded",
hasError ? "text-amber-300 hover:text-amber-100" : "text-white/40 hover:text-white"
)}
onClick={() => {
if (hasError) {
setShowErrorDetails((prev) => !prev);
} else {
onPlay();
}
}}
onMouseEnter={() => {
if (hasError) {
setShowErrorDetails(true);
}
}}
onMouseLeave={() => {
if (hasError) {
setShowErrorDetails(false);
}
}}
title={hasError ? song.playbackError : undefined}
>
{
hasError ? <FaExclamationTriangle size={12} /> :
playState === PlayState.Playing ? <FaVolumeUp size={12} />
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
: <FaPlay size={12} />
}
</button>
{hasError && showErrorDetails && (
<div className="absolute z-10 top-full left-0 mt-1 w-64 p-2 text-xs text-white bg-red-600/90 rounded shadow-lg">
<div className="font-semibold mb-1">Playback error</div>
<div className="break-words">{song.playbackError}</div>
</div>
)}
</div>
</div>
<div className="flex-grow min-w-0">
+305
View File
@@ -0,0 +1,305 @@
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;
toggleScreenShare: () => Promise<void>;
stopScreenShare: () => void;
}
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
for (const type of mimeTypes) {
if (MediaRecorder.isTypeSupported(type)) {
console.log(`Using mime type: ${type}`);
return type;
}
}
console.warn('No preferred mime types supported by this browser');
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(() => {
setIsScreenSharingSupported(
typeof navigator !== 'undefined' &&
navigator.mediaDevices !== undefined &&
typeof navigator.mediaDevices.getDisplayMedia === 'function'
);
}, []);
const stopScreenShare = useCallback(() => {
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: {
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
width: { max: MAX_CAPTURE_WIDTH },
height: { max: MAX_CAPTURE_HEIGHT },
},
audio: true,
});
// 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;
}
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
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
socket.onopen = () => {
if (screenShareSocketRef.current !== socket) {
socket.close();
return;
}
console.log('Screen sharing WebSocket connected');
setIsScreenSharing(true);
mediaRecorder.start(RECORDER_TIMESLICE_MS);
};
socket.onclose = () => {
console.log('Screen sharing WebSocket closed');
if (screenShareSocketRef.current === socket) {
stopScreenShare();
}
};
socket.onerror = (error) => {
console.error('Screen sharing WebSocket error:', error);
if (screenShareSocketRef.current === socket) {
stopScreenShare();
}
};
mediaRecorder.onerror = (error) => {
console.error('Screen sharing recorder error:', error);
if (mediaRecorderRef.current === mediaRecorder) {
stopScreenShare();
}
};
// Send data over WebSocket when available
mediaRecorder.ondataavailable = (event) => {
if (event.data.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")
videoTrack.onended = stopScreenShare;
} catch (error) {
if (sessionGenerationRef.current === sessionGeneration) {
console.error('Error starting screen share:', error);
stopScreenShare();
}
}
}, [stopScreenShare]);
useEffect(() => {
return () => stopScreenShare();
}, [stopScreenShare]);
const toggleScreenShare = useCallback(async () => {
if (screenShareSocketRef.current || isStartingRef.current) {
stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
return {
isScreenSharing,
isScreenSharingSupported,
toggleScreenShare,
stopScreenShare
};
};

Some files were not shown because too many files have changed in this diff Show More