Compare commits
30
Commits
ac4c22c2fb
..
3.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71142e99fc | ||
|
|
b47d60c17f | ||
|
|
d6d280338d | ||
|
|
e6e0b1d736 | ||
|
|
c9fe5ff272 | ||
|
|
1c9916c69d | ||
|
|
cfd023be69 | ||
|
|
719359b940 | ||
|
|
f4d0bd7ca0 | ||
|
|
26a13c27d6 | ||
|
|
cfe5063828 | ||
|
|
69742ce2d9 | ||
|
|
5b3787f19f | ||
|
|
9b0f6e2123 | ||
|
|
2d4eae9676 | ||
|
|
848c4c2b55 | ||
|
|
a6ca763730 | ||
|
|
0916de60f3 | ||
|
|
cfc6e6c411 | ||
|
|
bc54735d1f | ||
|
|
04d23bec1e | ||
|
|
3aa819eccc | ||
|
|
718518c3f2 | ||
|
|
f3053c1db1 | ||
|
|
58a43a617e | ||
|
|
85fd112a1a | ||
|
|
8ac494049d | ||
|
|
4e3bcb406a | ||
|
|
ec1ee508b3 | ||
|
|
4e8cd11d8f |
@@ -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=
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
/.env
|
||||
/.bundle/
|
||||
ios/build/
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/
|
||||
fastlane/test_output/
|
||||
+231
@@ -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
|
||||
@@ -4,6 +4,27 @@ 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.
|
||||
|
||||
## 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. `bundle exec fastlane ios build` creates
|
||||
a signed IPA without uploading it, while `bundle exec fastlane ios beta`
|
||||
performs the release.
|
||||
|
||||

|
||||
|
||||
## Running
|
||||
@@ -36,6 +57,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 +105,3 @@ privileged: true
|
||||
(or `--privileged`).
|
||||
|
||||
On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`).
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,254 @@
|
||||
require "fileutils"
|
||||
require "open3"
|
||||
require "shellwords"
|
||||
require "tempfile"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
BUNDLE_IDENTIFIER = "net.buzzert.QueueCube"
|
||||
DEVELOPMENT_TEAM = "DQQH5H6GBD"
|
||||
PROVISIONING_PROFILE_NAME = "QueueCube 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>
|
||||
</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|
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: options.fetch(:readonly),
|
||||
app_identifier: BUNDLE_IDENTIFIER,
|
||||
team_id: DEVELOPMENT_TEAM,
|
||||
profile_name: PROVISIONING_PROFILE_NAME,
|
||||
git_url: ENV.fetch("MATCH_GIT_URL"),
|
||||
git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"),
|
||||
git_full_name: "QueueCube Release Bot",
|
||||
git_user_email: "james.magahern@me.com",
|
||||
api_key: options.fetch(:api_key)
|
||||
)
|
||||
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}",
|
||||
"PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}",
|
||||
"MARKETING_VERSION=#{version}",
|
||||
"CURRENT_PROJECT_VERSION=#{build}",
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY=Apple Distribution",
|
||||
"PROVISIONING_PROFILE_SPECIFIER=#{PROVISIONING_PROFILE_NAME}"
|
||||
))
|
||||
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
|
||||
@@ -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).
|
||||
@@ -14,6 +14,7 @@
|
||||
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
App/Entitlements.plist,
|
||||
App/Info.plist,
|
||||
);
|
||||
target = CD4E9B962D7691C20066FC17 /* QueueCube */;
|
||||
@@ -268,7 +269,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -283,7 +284,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -304,7 +305,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -319,7 +320,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<array>
|
||||
<string>_queuecube._tcp.</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>QueueCube needs access to your local network to discover nearby jukebox servers.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -16,9 +16,19 @@ struct MediaItem: Codable
|
||||
let current: Bool?
|
||||
let playing: Bool?
|
||||
let metadata: Metadata?
|
||||
let playbackError: String?
|
||||
|
||||
var displayTitle: String {
|
||||
metadata?.title ?? title ?? filename ?? "item \(id)"
|
||||
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
|
||||
@@ -52,6 +62,9 @@ struct NowPlayingInfo: Codable
|
||||
let playingItem: MediaItem?
|
||||
let isPaused: Bool
|
||||
let volume: Int
|
||||
let timePosition: Double?
|
||||
let duration: Double?
|
||||
let seekable: Bool?
|
||||
}
|
||||
|
||||
actor API
|
||||
@@ -163,6 +176,13 @@ actor API
|
||||
.post()
|
||||
}
|
||||
|
||||
public func seek(to time: Double) async throws {
|
||||
try await request()
|
||||
.path("/player/seek")
|
||||
.body([ "time" : time ])
|
||||
.post()
|
||||
}
|
||||
|
||||
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
|
||||
try await request()
|
||||
.pathString("/search?q=\(query.uriEncoded())")
|
||||
@@ -234,8 +254,6 @@ actor API
|
||||
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
|
||||
print("Websocket Error: \(error)")
|
||||
|
||||
let nsError = error as NSError
|
||||
|
||||
// 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)))
|
||||
@@ -247,10 +265,19 @@ actor API
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Error: Swift.Error
|
||||
enum Error: Swift.Error, LocalizedError
|
||||
{
|
||||
case apiNotConfigured
|
||||
case websocketError(Swift.Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .apiNotConfigured:
|
||||
"No server is configured."
|
||||
case .websocketError(let error):
|
||||
error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamEvent {
|
||||
@@ -273,6 +300,7 @@ actor API
|
||||
case favoritesUpdate = "favorites_update"
|
||||
case metadataUpdate = "metadata_update"
|
||||
case mpdUpdate = "mpd_update"
|
||||
case playbackError = "playback_error"
|
||||
|
||||
// Private UI events
|
||||
case receivedWebsocketPong
|
||||
|
||||
@@ -50,8 +50,9 @@ struct RequestBuilder
|
||||
}
|
||||
|
||||
public func build() -> URLRequest {
|
||||
var request = URLRequest(url: self.url)
|
||||
var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
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
|
||||
@@ -61,8 +62,7 @@ struct RequestBuilder
|
||||
}
|
||||
|
||||
public func json<T: Decodable>() async throws -> T {
|
||||
let urlRequest = self.build()
|
||||
let (data, _) = try await URLSession.shared.data(for: urlRequest)
|
||||
let data = try await responseData()
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
@@ -71,13 +71,24 @@ struct RequestBuilder
|
||||
}
|
||||
|
||||
public func execute() async throws {
|
||||
let urlRequest = self.build()
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
if httpResponse.statusCode != 200 {
|
||||
print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)")
|
||||
_ = 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 {
|
||||
@@ -93,6 +104,28 @@ struct RequestBuilder
|
||||
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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Color
|
||||
|
||||
@@ -104,6 +104,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DELETE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DISCOVERED" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -247,6 +257,16 @@
|
||||
},
|
||||
"Nothing here yet." : {
|
||||
|
||||
},
|
||||
"PLAYBACK_ERROR" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Playback Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PLAYLIST" : {
|
||||
"localizations" : {
|
||||
@@ -360,6 +380,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unknown error" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Unknown Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"URL" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@@ -46,4 +46,6 @@ extension LocalizedStringKey
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -10,17 +10,25 @@ import SwiftUI
|
||||
struct AddMediaView: View
|
||||
{
|
||||
@Binding var model: ViewModel
|
||||
@FocusState var fieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Add URL
|
||||
Section {
|
||||
TextField(.addAnyURL, text: $model.fieldContents)
|
||||
HStack {
|
||||
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.focused($fieldFocused)
|
||||
.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 {
|
||||
@@ -35,8 +43,6 @@ struct AddMediaView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { fieldFocused = true }
|
||||
.onAppear { model.activeDetent = ViewModel.Detent.collapsed.value }
|
||||
.navigationTitle(.addMedia)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -72,21 +78,6 @@ struct AddMediaView: View
|
||||
var onSearch: () -> Void = { }
|
||||
var supportsSearch: Bool = true
|
||||
|
||||
var activeDetent: PresentationDetent = Detent.collapsed.value
|
||||
|
||||
enum Detent: CaseIterable
|
||||
{
|
||||
case collapsed
|
||||
case expanded
|
||||
|
||||
var value: PresentationDetent {
|
||||
switch self {
|
||||
case .collapsed: .height(320.0)
|
||||
case .expanded: .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func addButtonTapped() {
|
||||
onAdd(fieldContents)
|
||||
}
|
||||
@@ -107,11 +98,9 @@ struct SearchMediaView: View
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField(.searchForMedia, text: $searchText)
|
||||
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
|
||||
.focused($searchFieldFocused)
|
||||
.onSubmit {
|
||||
performSearch()
|
||||
}
|
||||
.frame(minWidth: 0.0, maxWidth: .infinity)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
@@ -153,7 +142,6 @@ struct SearchMediaView: View
|
||||
.navigationTitle(.searchForMedia)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.onAppear {
|
||||
model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value
|
||||
searchFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ struct ContentView: View
|
||||
MainView(model: $model)
|
||||
.task(id: websocketRestartTrigger) { await watchWebsocket() }
|
||||
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
|
||||
.task { await pollPlaybackProgress() }
|
||||
.task { await watchForSettingsChanges() }
|
||||
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||
handleScenePhaseChange(from: oldPhase, to: newPhase)
|
||||
@@ -24,15 +25,11 @@ struct ContentView: View
|
||||
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
||||
NowPlayingView(model: model.nowPlayingViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents([ .height(320.0) ])
|
||||
.presentationDetents([ .height(390.0) ])
|
||||
}
|
||||
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
||||
AddMediaView(model: $model.addMediaViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents(
|
||||
Set(AddMediaView.ViewModel.Detent.allCases.map { $0.value }),
|
||||
selection: $model.addMediaViewModel.activeDetent
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $model.isEditSheetPresented) {
|
||||
EditItemView(model: $model.editMediaViewModel)
|
||||
@@ -79,6 +76,9 @@ extension ContentView
|
||||
|
||||
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
||||
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
|
||||
model.nowPlayingViewModel.timePosition = nowPlaying.timePosition
|
||||
model.nowPlayingViewModel.duration = nowPlaying.duration
|
||||
model.nowPlayingViewModel.isSeekable = nowPlaying.seekable ?? false
|
||||
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||
model.favoritesModel.isPlaying = !nowPlaying.isPaused
|
||||
}
|
||||
@@ -91,7 +91,8 @@ extension ContentView
|
||||
title: mediaItem.displayTitle,
|
||||
filename: mediaItem.filename ?? "<null>",
|
||||
index: idx,
|
||||
isCurrent: mediaItem.current ?? false
|
||||
isCurrent: mediaItem.current ?? false,
|
||||
playbackError: mediaItem.playbackError
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -104,13 +105,25 @@ extension ContentView
|
||||
id: String(mediaItem.id),
|
||||
title: mediaItem.displayTitle,
|
||||
filename: mediaItem.filename ?? "<null>",
|
||||
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename
|
||||
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename,
|
||||
playbackError: mediaItem.playbackError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pollPlaybackProgress() async {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(1.0))
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
if scenePhase == .active && model.nowPlayingViewModel.isPlaying {
|
||||
await refresh(.nowPlaying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func watchWebsocket() async {
|
||||
guard let api = model.selectedServer?.api else { return }
|
||||
|
||||
@@ -121,9 +134,15 @@ extension ContentView
|
||||
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)
|
||||
let nsError = error as NSError
|
||||
let isBackgroundingError = nsError.code == 53
|
||||
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 {
|
||||
@@ -158,7 +177,8 @@ extension ContentView
|
||||
|
||||
case .websocketReconnected: fallthrough
|
||||
case .metadataUpdate: fallthrough
|
||||
case .mpdUpdate:
|
||||
case .mpdUpdate: fallthrough
|
||||
case .playbackError:
|
||||
await refresh([.playlist, .nowPlaying, .favorites])
|
||||
|
||||
case .receivedWebsocketPong:
|
||||
|
||||
@@ -78,6 +78,14 @@ class MainViewModel
|
||||
try await api.previous()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onSeek = { [weak self] model, time in
|
||||
Task {
|
||||
await self?.withModificationsViaAPI { api in
|
||||
try await api.seek(to: time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
|
||||
self?.isNowPlayingSheetPresented = false
|
||||
}
|
||||
@@ -325,18 +333,9 @@ struct ServerSelectionToolbarModifier: ViewModifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if false
|
||||
// TODO
|
||||
Section {
|
||||
Button(.addServer) {
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.buttonStyle(.bordered)
|
||||
@@ -420,4 +419,3 @@ extension View {
|
||||
modifier(ErrorDisplayModifier(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ struct NowPlayingMiniView: View {
|
||||
|
||||
var body: some View {
|
||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||
let containerShape = RoundedRectangle(cornerRadius: 12.0, style: .continuous)
|
||||
let tapGesture = DragGesture(minimumDistance: 0)
|
||||
.updating($tapGestureState) { _, state, _ in
|
||||
state = true
|
||||
@@ -28,6 +29,7 @@ struct NowPlayingMiniView: View {
|
||||
onTap()
|
||||
}
|
||||
|
||||
VStack(spacing: 0.0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
if let title = model.title, !title.isEmpty {
|
||||
@@ -58,11 +60,19 @@ struct NowPlayingMiniView: View {
|
||||
.padding(12.0)
|
||||
}
|
||||
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
|
||||
|
||||
if let progress = model.playbackProgress {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
containerShape
|
||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||
.stroke(.ultraThinMaterial, lineWidth: 1.0)
|
||||
)
|
||||
.clipShape(containerShape)
|
||||
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
|
||||
.gesture(tapGesture)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@@ -14,17 +15,40 @@ class NowPlayingViewModel
|
||||
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onSeek: (NowPlayingViewModel, Double) -> Void = { _, _ in }
|
||||
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
||||
|
||||
var isPlaying: Bool = false
|
||||
var title: String? = ""
|
||||
var subtitle: String? = ""
|
||||
var volume: Double = 0.5
|
||||
var timePosition: Double?
|
||||
var duration: Double?
|
||||
var isSeekable: Bool = false
|
||||
|
||||
fileprivate var isSettingVolume: Bool = false
|
||||
fileprivate var settingVolume: Double = 0.0 {
|
||||
didSet { volume = settingVolume }
|
||||
}
|
||||
|
||||
fileprivate var isSettingPlaybackPosition: Bool = false
|
||||
fileprivate var settingPlaybackPosition: Double = 0.0
|
||||
|
||||
var playbackDuration: Double? {
|
||||
guard let duration, duration.isFinite, duration > 0 else { return nil }
|
||||
return duration
|
||||
}
|
||||
|
||||
var displayedPlaybackPosition: Double {
|
||||
let position = isSettingPlaybackPosition ? settingPlaybackPosition : (timePosition ?? 0.0)
|
||||
guard position.isFinite else { return 0.0 }
|
||||
return min(max(position, 0.0), playbackDuration ?? position)
|
||||
}
|
||||
|
||||
var playbackProgress: Double? {
|
||||
guard let playbackDuration else { return nil }
|
||||
return displayedPlaybackPosition / playbackDuration
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingView: View
|
||||
@@ -41,16 +65,16 @@ struct NowPlayingView: View
|
||||
VStack {
|
||||
if let title = model.title {
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
.font(.title3)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
}
|
||||
|
||||
if let subtitle = model.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.title3)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
if nothingQueued {
|
||||
@@ -60,7 +84,29 @@ struct NowPlayingView: View
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 24.0)
|
||||
Spacer(minLength: 20.0)
|
||||
|
||||
if let duration = model.playbackDuration {
|
||||
VStack(spacing: 2.0) {
|
||||
Slider(
|
||||
value: playbackPositionBinding,
|
||||
in: 0.0...duration,
|
||||
onEditingChanged: playbackPositionEditingChanged
|
||||
)
|
||||
.disabled(!model.isSeekable || nothingQueued)
|
||||
|
||||
HStack {
|
||||
Text(formatTime(model.displayedPlaybackPosition))
|
||||
Spacer()
|
||||
Text(formatTime(duration))
|
||||
}
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 4.0)
|
||||
|
||||
Spacer(minLength: 20.0)
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
@@ -124,6 +170,38 @@ struct NowPlayingView: View
|
||||
}
|
||||
}
|
||||
|
||||
private var playbackPositionBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { model.displayedPlaybackPosition },
|
||||
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.timePosition = 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 {
|
||||
|
||||
@@ -14,17 +14,19 @@ struct MediaListItem: Identifiable
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ class MediaListViewModel
|
||||
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
|
||||
@@ -54,6 +57,8 @@ class MediaListViewModel
|
||||
struct MediaListView: View
|
||||
{
|
||||
@Binding var model: MediaListViewModel
|
||||
@State private var errorAlertItem: MediaListItem? = nil
|
||||
@State private var isShowingErrorAlert: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -70,20 +75,29 @@ struct MediaListView: View
|
||||
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
|
||||
state: state,
|
||||
playbackError: item.playbackError
|
||||
)
|
||||
}
|
||||
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
|
||||
.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
|
||||
@@ -123,6 +137,20 @@ struct MediaListView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,26 +162,24 @@ struct MediaItemCell: View
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let state: State
|
||||
let playbackError: String?
|
||||
|
||||
var body: some View {
|
||||
let icon: String = switch state {
|
||||
case .queued: "play.fill"
|
||||
case .playing: "speaker.wave.3.fill"
|
||||
case .paused: "speaker.fill"
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.tint(Color.primary)
|
||||
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)
|
||||
}
|
||||
@@ -161,6 +187,19 @@ struct MediaItemCell: View
|
||||
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
|
||||
@@ -171,4 +210,3 @@ struct MediaItemCell: View
|
||||
case paused
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
+395
-33
@@ -30,6 +30,11 @@ interface PendingCommand {
|
||||
reject: (reason: any) => void;
|
||||
}
|
||||
|
||||
enum ObservedProperty {
|
||||
MediaTitle = 1,
|
||||
Metadata = 2,
|
||||
}
|
||||
|
||||
enum UserEvent {
|
||||
PlaylistUpdate = "playlist_update",
|
||||
NowPlayingUpdate = "now_playing_update",
|
||||
@@ -37,6 +42,7 @@ enum UserEvent {
|
||||
FavoritesUpdate = "favorites_update",
|
||||
MetadataUpdate = "metadata_update",
|
||||
MPDUpdate = "mpd_update",
|
||||
PlaybackError = "playback_error",
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
@@ -57,6 +63,11 @@ export class MediaPlayer {
|
||||
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();
|
||||
@@ -106,40 +117,137 @@ export class MediaPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private envEnabled(name: string, defaultValue: boolean = false): boolean {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case "1":
|
||||
case "true":
|
||||
case "yes":
|
||||
case "on":
|
||||
return true;
|
||||
case "":
|
||||
case "0":
|
||||
case "false":
|
||||
case "no":
|
||||
case "off":
|
||||
return false;
|
||||
default:
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private addMpvEnvOption(args: string[], envName: string, optionName: string) {
|
||||
const value = process.env[envName];
|
||||
if (value) {
|
||||
args.push(`${optionName}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseMpvExtraArgs(): string[] {
|
||||
const value = process.env.MPV_EXTRA_ARGS;
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (const char of value) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === "\"" || char === "'") {
|
||||
quote = char;
|
||||
} else if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = "";
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private tryRespawnPlayerProcess(): Promise<Socket> {
|
||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||
const enableVideo = this.envEnabled("ENABLE_VIDEO");
|
||||
const ytdlFormat = process.env.MPV_YTDL_FORMAT;
|
||||
const logfilePath = `/tmp/mpv-logfile.txt`;
|
||||
|
||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||
this.playerProcess = spawn("mpv", [
|
||||
const playerArgs = [
|
||||
"--video=" + (enableVideo ? "auto" : "no"),
|
||||
"--fullscreen",
|
||||
"--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 socketPromise = new Promise<Socket>(resolve => {
|
||||
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}`);
|
||||
setTimeout(() => {
|
||||
let socket = this.connectToSocket(socketPath);
|
||||
socketReady(socket);
|
||||
}, 500);
|
||||
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) => {
|
||||
this.playerProcess.on("error", (error: unknown) => {
|
||||
console.error("Player process error:", error);
|
||||
console.log("Continuing without mpv player...");
|
||||
socketFailed(error);
|
||||
});
|
||||
|
||||
return socketPromise;
|
||||
@@ -150,10 +258,15 @@ export class MediaPlayer {
|
||||
.then((response) => {
|
||||
// Enhance playlist items with metadata
|
||||
const playlist = response.data as PlaylistItem[];
|
||||
return playlist.map((item: PlaylistItem) => ({
|
||||
return playlist.map((item: PlaylistItem) => {
|
||||
const enhancedItem = {
|
||||
...item,
|
||||
metadata: this.metadata.get(item.filename) || {}
|
||||
}));
|
||||
metadata: this.metadata.get(item.filename) || {},
|
||||
playbackError: this.playbackErrors.get(item.filename)
|
||||
};
|
||||
|
||||
return item.current ? this.withCurrentDynamicTitle(enhancedItem) : enhancedItem;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,29 +275,37 @@ export class MediaPlayer {
|
||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||
try {
|
||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||
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: await fetchMediaTitle() || currentlyPlayingSong.filename
|
||||
title: mediaTitle || currentlyPlayingSong.filename
|
||||
};
|
||||
}
|
||||
|
||||
return currentlyPlayingSong;
|
||||
}
|
||||
|
||||
const mediaTitle = await fetchMediaTitle() || "";
|
||||
return {
|
||||
id: 0,
|
||||
filename: mediaTitle,
|
||||
title: mediaTitle
|
||||
filename: mediaTitle || "",
|
||||
title: mediaTitle || ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,18 +466,20 @@ export class MediaPlayer {
|
||||
|
||||
public async getFeatures(): Promise<Features> {
|
||||
return {
|
||||
video: !!process.env.ENABLE_VIDEO,
|
||||
screenshare: !!process.env.ENABLE_SCREENSHARE,
|
||||
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK
|
||||
video: this.envEnabled("ENABLE_VIDEO"),
|
||||
screenshare: this.envEnabled("ENABLE_SCREENSHARE"),
|
||||
browserPlayback: this.envEnabled("ENABLE_BROWSER_PLAYBACK")
|
||||
};
|
||||
}
|
||||
|
||||
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
||||
this.lastLoadCandidate = url;
|
||||
this.playbackErrors.delete(url);
|
||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||
|
||||
if (fetchMetadata) {
|
||||
if (fetchMetadata && this.shouldFetchLinkPreview(url)) {
|
||||
this.fetchMetadataAndNotify(url).catch(error => {
|
||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||
console.warn(`Failed to fetch metadata for ${this.loggableUrl(url)}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -390,7 +513,7 @@ export class MediaPlayer {
|
||||
socket.write(commandObject + '\n');
|
||||
} catch (e: any) {
|
||||
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
|
||||
this.tryRespawnPlayerProcess();
|
||||
this.socket = this.tryRespawnPlayerProcess();
|
||||
}
|
||||
|
||||
// Add timeout to prevent hanging promises
|
||||
@@ -408,7 +531,7 @@ export class MediaPlayer {
|
||||
|
||||
private async fetchMetadataAndNotify(url: string) {
|
||||
try {
|
||||
console.log("Fetching metadata for " + url);
|
||||
console.log("Fetching metadata for " + this.loggableUrl(url));
|
||||
const metadata = await getLinkPreview(url);
|
||||
this.metadata.set(url, {
|
||||
title: (metadata as any)?.title,
|
||||
@@ -416,7 +539,7 @@ export class MediaPlayer {
|
||||
siteName: (metadata as any)?.siteName,
|
||||
});
|
||||
|
||||
console.log("Metadata fetched for " + url);
|
||||
console.log("Metadata fetched for " + this.loggableUrl(url));
|
||||
console.log(this.metadata.get(url));
|
||||
|
||||
// Notify clients that metadata has been updated
|
||||
@@ -429,12 +552,89 @@ export class MediaPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private connectToSocket(path: string): Socket {
|
||||
let socket = new Socket();
|
||||
socket.connect(path);
|
||||
socket.on("data", data => this.receiveData(data.toString()));
|
||||
private shouldFetchLinkPreview(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "file:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return socket;
|
||||
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) {
|
||||
@@ -470,6 +670,29 @@ export class MediaPlayer {
|
||||
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);
|
||||
@@ -480,4 +703,143 @@ export class MediaPlayer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ 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();
|
||||
|
||||
@@ -300,14 +306,14 @@ apiRouter.get("/features", withErrorHandling(async (req, res) => {
|
||||
}));
|
||||
|
||||
// Serve static files for React app (after building)
|
||||
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
||||
app.use(express.static(frontendDistPath));
|
||||
|
||||
// Mount API routes under /api
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
// Serve React app for all other routes (client-side routing)
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../dist/frontend/index.html"));
|
||||
res.sendFile(path.join(frontendDistPath, "index.html"));
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
@@ -29,4 +29,5 @@ export interface PlaylistItem {
|
||||
playing?: boolean;
|
||||
current?: boolean;
|
||||
metadata?: LinkMetadata;
|
||||
playbackError?: string;
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -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": {
|
||||
|
||||
+132
-93
@@ -8,97 +8,7 @@
|
||||
|
||||
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_BASE_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 {
|
||||
mkQueuecube = pkgs: pkgs.buildNpmPackage {
|
||||
pname = "queuecube";
|
||||
version = "0.1.0";
|
||||
|
||||
@@ -163,6 +73,136 @@
|
||||
};
|
||||
};
|
||||
|
||||
# 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;
|
||||
@@ -177,8 +217,7 @@
|
||||
# Development environment
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.npm
|
||||
nodejs
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface PlaylistItem {
|
||||
id: number;
|
||||
playing: boolean | null;
|
||||
metadata?: Metadata;
|
||||
playbackError?: string;
|
||||
}
|
||||
|
||||
export const getDisplayTitle = (item: PlaylistItem): string => {
|
||||
@@ -62,6 +63,7 @@ export enum ServerEvent {
|
||||
FavoritesUpdate = "favorites_update",
|
||||
MetadataUpdate = "metadata_update",
|
||||
MPDUpdate = "mpd_update",
|
||||
PlaybackError = "playback_error",
|
||||
ScreenShare = "screen_share",
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ const App: React.FC = () => {
|
||||
case ServerEvent.NowPlayingUpdate:
|
||||
case ServerEvent.MetadataUpdate:
|
||||
case ServerEvent.MPDUpdate:
|
||||
case ServerEvent.PlaybackError:
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
break;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user