5 Commits
Author SHA1 Message Date
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
19 changed files with 835 additions and 65 deletions
+12
View File
@@ -0,0 +1,12 @@
FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1
# App Store Connect API key (the key content must be base64 encoded).
APP_STORE_CONNECT_KEY_ID=
APP_STORE_CONNECT_ISSUER_ID=
APP_STORE_CONNECT_KEY_CONTENT=
# Shared fastlane match repository.
MATCH_GIT_URL=
MATCH_PASSWORD=
MATCH_GIT_BASIC_AUTHORIZATION=
+43
View File
@@ -0,0 +1,43 @@
name: TestFlight
on:
workflow_dispatch:
push:
tags:
- "release/ios/v*"
- "v[0-9]*"
- "[0-9]*"
jobs:
testflight:
runs-on: macos-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.7"
bundler-cache: true
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
QUEUECUBE_VERSION_TAG: ${{ github.ref_name }}
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
# act_runner does not propagate setup-ruby's PATH changes into later
# steps, so put the selected Ruby back on PATH before running Bundler.
run: |
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
bundle exec fastlane ios beta
+1
View File
@@ -1,4 +1,5 @@
/.env /.env
/.bundle/
ios/build/ ios/build/
fastlane/report.xml fastlane/report.xml
fastlane/Preview.html fastlane/Preview.html
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
+231
View File
@@ -0,0 +1,231 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.2)
aws-partitions (1.1109.0)
aws-sdk-core (3.224.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.101.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.188.0)
aws-sdk-core (~> 3, >= 3.224.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.29.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.7.6)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
public_suffix (5.1.1)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
DEPENDENCIES
fastlane
BUNDLED WITH
2.5.23
+37 -1
View File
@@ -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. 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.
![queuecube user interface](screenshots/queuecube.png) ![queuecube user interface](screenshots/queuecube.png)
## Running ## 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`. 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 #### Video + Docker
When running in a Docker container, there are a few extra steps needed to make this work. 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`). (or `--privileged`).
On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`). On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`).
+139 -14
View File
@@ -7,6 +7,10 @@ default_platform(:ios)
BUNDLE_IDENTIFIER = "net.buzzert.QueueCube" BUNDLE_IDENTIFIER = "net.buzzert.QueueCube"
DEVELOPMENT_TEAM = "DQQH5H6GBD" 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__)) APP_ROOT = File.expand_path("..", File.expand_path(__dir__))
IOS_PROJECT_DIR = File.join(APP_ROOT, "ios") IOS_PROJECT_DIR = File.join(APP_ROOT, "ios")
XCODE_PROJECT = File.join(IOS_PROJECT_DIR, "QueueCube.xcodeproj") XCODE_PROJECT = File.join(IOS_PROJECT_DIR, "QueueCube.xcodeproj")
@@ -33,18 +37,57 @@ def git_output(*args)
stdout.strip stdout.strip
end end
def app_version def present?(value)
tag = git_output("describe", "--tags", "--abbrev=0") !value.to_s.strip.empty?
version = tag.sub(/\Av/, "") end
unless version.match?(/\A\d+(?:\.\d+){0,2}\z/)
UI.user_error!("Latest git tag #{tag.inspect} is not a valid App Store version. Use a tag like 1.5.2 or v1.5.2.") def 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 end
version nil
end
def app_version
candidates = [
ENV["QUEUECUBE_VERSION_TAG"],
ENV["GITHUB_REF_NAME"],
ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "")
]
candidates.each do |tag|
version = version_from_tag(tag)
return version if version
end
latest_tag = git_output("describe", "--tags", "--abbrev=0")
version = version_from_tag(latest_tag)
return version if version
candidates << latest_tag
UI.user_error!("Release tag must look like 3.2, v3.2, or release/ios/v3.2; got #{candidates.compact.inspect}")
end end
def build_number def build_number
git_output("rev-list", "--count", "HEAD") value = ENV["QUEUECUBE_BUILD_NUMBER"]
value = Time.now.utc.strftime("%Y%m%d%H%M%S") unless present?(value)
unless value.to_s.match?(/\A\d+\z/)
UI.user_error!("Build number must be numeric; got #{value.inspect}")
end
value.to_s
end end
def upload_export_options def upload_export_options
@@ -56,9 +99,16 @@ def upload_export_options
<key>method</key> <key>method</key>
<string>app-store-connect</string> <string>app-store-connect</string>
<key>signingStyle</key> <key>signingStyle</key>
<string>automatic</string> <string>manual</string>
<key>teamID</key> <key>teamID</key>
<string>#{DEVELOPMENT_TEAM}</string> <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> <key>manageAppVersionAndBuildNumber</key>
<false/> <false/>
<key>stripSwiftSymbols</key> <key>stripSwiftSymbols</key>
@@ -71,12 +121,70 @@ def upload_export_options
end end
platform :ios do platform :ios do
desc "Build QueueCube for iOS and upload the archive to TestFlight" private_lane :app_store_api_key do
lane :beta 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 version = app_version
build = build_number build = build_number
UI.message("Using QueueCube version #{version} (build #{build}) from git") 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(archive_path)
FileUtils.rm_rf(export_path) FileUtils.rm_rf(export_path)
@@ -94,7 +202,10 @@ platform :ios do
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}", "DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
"PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}", "PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}",
"MARKETING_VERSION=#{version}", "MARKETING_VERSION=#{version}",
"CURRENT_PROJECT_VERSION=#{build}" "CURRENT_PROJECT_VERSION=#{build}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROVISIONING_PROFILE_NAME}"
)) ))
end end
@@ -117,13 +228,27 @@ platform :ios do
ipa_path = Dir[File.join(export_path, "*.ipa")].first ipa_path = Dir[File.join(export_path, "*.ipa")].first
UI.user_error!("No IPA found in #{export_path}") unless ipa_path 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( upload_to_testflight(
api_key: release.fetch(:api_key),
app_identifier: BUNDLE_IDENTIFIER, app_identifier: BUNDLE_IDENTIFIER,
ipa: ipa_path, ipa: release.fetch(:ipa_path),
skip_waiting_for_build_processing: true, skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false uses_non_exempt_encryption: false
) )
ensure
export_options&.unlink
end end
end end
+16
View File
@@ -15,6 +15,22 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## iOS ## 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 ### ios beta
```sh ```sh
+2
View File
@@ -6,6 +6,8 @@
<array> <array>
<string>_queuecube._tcp.</string> <string>_queuecube._tcp.</string>
</array> </array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>QueueCube needs access to your local network to discover nearby jukebox servers.</string> <string>QueueCube needs access to your local network to discover nearby jukebox servers.</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
+20 -1
View File
@@ -62,6 +62,9 @@ struct NowPlayingInfo: Codable
let playingItem: MediaItem? let playingItem: MediaItem?
let isPaused: Bool let isPaused: Bool
let volume: Int let volume: Int
let timePosition: Double?
let duration: Double?
let seekable: Bool?
} }
actor API actor API
@@ -172,6 +175,13 @@ actor API
.body([ "volume" : Int(value * 100) ]) .body([ "volume" : Int(value * 100) ])
.post() .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]> { public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
try await request() try await request()
@@ -255,10 +265,19 @@ actor API
// MARK: - Types // MARK: - Types
enum Error: Swift.Error enum Error: Swift.Error, LocalizedError
{ {
case apiNotConfigured case apiNotConfigured
case websocketError(Swift.Error) case websocketError(Swift.Error)
var errorDescription: String? {
switch self {
case .apiNotConfigured:
"No server is configured."
case .websocketError(let error):
error.localizedDescription
}
}
} }
enum StreamEvent { enum StreamEvent {
+42 -9
View File
@@ -50,8 +50,9 @@ struct RequestBuilder
} }
public func build() -> URLRequest { public func build() -> URLRequest {
var request = URLRequest(url: self.url) var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData)
request.httpMethod = self.httpMethod.rawValue request.httpMethod = self.httpMethod.rawValue
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
if let body { if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body request.httpBody = body
@@ -61,8 +62,7 @@ struct RequestBuilder
} }
public func json<T: Decodable>() async throws -> T { public func json<T: Decodable>() async throws -> T {
let urlRequest = self.build() let data = try await responseData()
let (data, _) = try await URLSession.shared.data(for: urlRequest)
return try JSONDecoder().decode(T.self, from: data) return try JSONDecoder().decode(T.self, from: data)
} }
@@ -71,13 +71,24 @@ struct RequestBuilder
} }
public func execute() async throws { public func execute() async throws {
let urlRequest = self.build() _ = try await responseData()
let (data, response) = try await URLSession.shared.data(for: urlRequest) }
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode != 200 { private func responseData() async throws -> Data {
print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)") 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 { public func websocket() -> URL {
@@ -93,6 +104,28 @@ struct RequestBuilder
case post = "POST" case post = "POST"
case delete = "DELETE" 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 extension Color
+3
View File
@@ -20,12 +20,14 @@ struct AddMediaView: View
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents) AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
.autocapitalization(.none) .autocapitalization(.none)
.autocorrectionDisabled() .autocorrectionDisabled()
.frame(minWidth: 0.0, maxWidth: .infinity)
PasteButton(payloadType: String.self) { payload in PasteButton(payloadType: String.self) { payload in
guard let contents = payload.first else { return } guard let contents = payload.first else { return }
model.fieldContents = contents model.fieldContents = contents
} }
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.fixedSize()
} }
} }
@@ -98,6 +100,7 @@ struct SearchMediaView: View
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch) AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
.focused($searchFieldFocused) .focused($searchFieldFocused)
.frame(minWidth: 0.0, maxWidth: .infinity)
if !searchText.isEmpty { if !searchText.isEmpty {
Button { Button {
@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
tf.delegate = context.coordinator tf.delegate = context.coordinator
tf.returnKeyType = .done tf.returnKeyType = .done
tf.setContentHuggingPriority(.defaultHigh, for: .vertical) tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
tf.setContentHuggingPriority(.defaultLow, for: .horizontal)
tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
tf.autocorrectionType = .no tf.autocorrectionType = .no
tf.autocapitalizationType = .none tf.autocapitalizationType = .none
return tf return tf
@@ -64,6 +66,12 @@ struct AutofocusingTextField: UIViewRepresentable
} }
final class FirstResponderTextField: UITextField { final class FirstResponderTextField: UITextField {
override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
size.width = UIView.noIntrinsicMetric
return size
}
override func didMoveToSuperview() { override func didMoveToSuperview() {
super.didMoveToSuperview() super.didMoveToSuperview()
becomeFirstResponder() becomeFirstResponder()
+16 -1
View File
@@ -17,6 +17,7 @@ struct ContentView: View
MainView(model: $model) MainView(model: $model)
.task(id: websocketRestartTrigger) { await watchWebsocket() } .task(id: websocketRestartTrigger) { await watchWebsocket() }
.task { await refresh([.nowPlaying, .playlist, .favorites]) } .task { await refresh([.nowPlaying, .playlist, .favorites]) }
.task { await pollPlaybackProgress() }
.task { await watchForSettingsChanges() } .task { await watchForSettingsChanges() }
.onChange(of: scenePhase) { oldPhase, newPhase in .onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase) handleScenePhaseChange(from: oldPhase, to: newPhase)
@@ -24,7 +25,7 @@ struct ContentView: View
.sheet(isPresented: $model.isNowPlayingSheetPresented) { .sheet(isPresented: $model.isNowPlayingSheetPresented) {
NowPlayingView(model: model.nowPlayingViewModel) NowPlayingView(model: model.nowPlayingViewModel)
.presentationBackground(.regularMaterial) .presentationBackground(.regularMaterial)
.presentationDetents([ .height(320.0) ]) .presentationDetents([ .height(390.0) ])
} }
.sheet(isPresented: $model.isAddMediaSheetPresented) { .sheet(isPresented: $model.isAddMediaSheetPresented) {
AddMediaView(model: $model.addMediaViewModel) AddMediaView(model: $model.addMediaViewModel)
@@ -75,6 +76,9 @@ extension ContentView
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 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.playlistModel.isPlaying = !nowPlaying.isPaused
model.favoritesModel.isPlaying = !nowPlaying.isPaused model.favoritesModel.isPlaying = !nowPlaying.isPaused
} }
@@ -108,6 +112,17 @@ extension ContentView
} }
} }
} }
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 { private func watchWebsocket() async {
guard let api = model.selectedServer?.api else { return } guard let api = model.selectedServer?.api else { return }
+8 -1
View File
@@ -77,6 +77,14 @@ class MainViewModel
nowPlayingViewModel.onPrev = apiCallback { _, api in nowPlayingViewModel.onPrev = apiCallback { _, api in
try await api.previous() 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 nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
self?.isNowPlayingSheetPresented = false self?.isNowPlayingSheetPresented = false
@@ -411,4 +419,3 @@ extension View {
modifier(ErrorDisplayModifier(error: error)) modifier(ErrorDisplayModifier(error: error))
} }
} }
+36 -26
View File
@@ -20,6 +20,7 @@ struct NowPlayingMiniView: View {
var body: some View { var body: some View {
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
let containerShape = RoundedRectangle(cornerRadius: 12.0, style: .continuous)
let tapGesture = DragGesture(minimumDistance: 0) let tapGesture = DragGesture(minimumDistance: 0)
.updating($tapGestureState) { _, state, _ in .updating($tapGestureState) { _, state, _ in
state = true state = true
@@ -28,41 +29,50 @@ struct NowPlayingMiniView: View {
onTap() onTap()
} }
HStack { VStack(spacing: 0.0) {
VStack(alignment: .leading) { HStack {
if let title = model.title, !title.isEmpty { VStack(alignment: .leading) {
Text(title) if let title = model.title, !title.isEmpty {
.font(.caption) Text(title)
.lineLimit(1) .font(.caption)
.bold() .lineLimit(1)
.bold()
}
if let subtitle = model.subtitle, !subtitle.isEmpty {
Text(subtitle)
.lineLimit(1)
.font(.caption)
.foregroundStyle(.secondary)
}
if nothingQueued {
Text(.notPlaying)
.font(.caption)
.foregroundStyle(.secondary)
}
} }
if let subtitle = model.subtitle, !subtitle.isEmpty { Spacer()
Text(subtitle)
.lineLimit(1)
.font(.caption)
.foregroundStyle(.secondary)
}
if nothingQueued { Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
Text(.notPlaying) .imageScale(.large)
.font(.caption) .padding(12.0)
.foregroundStyle(.secondary) }
} .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)
} }
Spacer()
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
.imageScale(.large)
.padding(12.0)
} }
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
.background( .background(
RoundedRectangle(cornerRadius: 12) containerShape
.fill(tapGestureState ? .ultraThinMaterial : .bar) .fill(tapGestureState ? .ultraThinMaterial : .bar)
.stroke(.ultraThinMaterial, lineWidth: 1.0) .stroke(.ultraThinMaterial, lineWidth: 1.0)
) )
.clipShape(containerShape)
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0) .shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
.gesture(tapGesture) .gesture(tapGesture)
} }
+79 -1
View File
@@ -5,6 +5,7 @@
// Created by James Magahern on 3/3/25. // Created by James Magahern on 3/3/25.
// //
import Foundation
import SwiftUI import SwiftUI
@Observable @Observable
@@ -14,17 +15,40 @@ class NowPlayingViewModel
var onStop: (NowPlayingViewModel) -> Void = { _ in } var onStop: (NowPlayingViewModel) -> Void = { _ in }
var onNext: (NowPlayingViewModel) -> Void = { _ in } var onNext: (NowPlayingViewModel) -> Void = { _ in }
var onPrev: (NowPlayingViewModel) -> Void = { _ in } var onPrev: (NowPlayingViewModel) -> Void = { _ in }
var onSeek: (NowPlayingViewModel, Double) -> Void = { _, _ in }
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in } var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
var isPlaying: Bool = false var isPlaying: Bool = false
var title: String? = "" var title: String? = ""
var subtitle: String? = "" var subtitle: String? = ""
var volume: Double = 0.5 var volume: Double = 0.5
var timePosition: Double?
var duration: Double?
var isSeekable: Bool = false
fileprivate var isSettingVolume: Bool = false fileprivate var isSettingVolume: Bool = false
fileprivate var settingVolume: Double = 0.0 { fileprivate var settingVolume: Double = 0.0 {
didSet { volume = settingVolume } 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 struct NowPlayingView: View
@@ -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 { VStack {
HStack { HStack {
@@ -123,6 +169,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 // MARK: - Types
+131 -9
View File
@@ -117,15 +117,86 @@ export class MediaPlayer {
} }
} }
private envEnabled(name: string, defaultValue: boolean = false): boolean {
const value = process.env[name];
if (value === undefined) {
return defaultValue;
}
switch (value.trim().toLowerCase()) {
case "1":
case "true":
case "yes":
case "on":
return true;
case "":
case "0":
case "false":
case "no":
case "off":
return false;
default:
return defaultValue;
}
}
private addMpvEnvOption(args: string[], envName: string, optionName: string) {
const value = process.env[envName];
if (value) {
args.push(`${optionName}=${value}`);
}
}
private parseMpvExtraArgs(): string[] {
const value = process.env.MPV_EXTRA_ARGS;
if (!value) {
return [];
}
const args: string[] = [];
let current = "";
let quote: string | null = null;
let escaped = false;
for (const char of value) {
if (escaped) {
current += char;
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
} else if (char === "\"" || char === "'") {
quote = char;
} else if (/\s/.test(char)) {
if (current) {
args.push(current);
current = "";
}
} else {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}
private tryRespawnPlayerProcess(): Promise<Socket> { private tryRespawnPlayerProcess(): Promise<Socket> {
const socketFilename = Math.random().toString(36).substring(2, 10); const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`; 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 ytdlFormat = process.env.MPV_YTDL_FORMAT;
const logfilePath = `/tmp/mpv-logfile.txt`; const logfilePath = `/tmp/mpv-logfile.txt`;
const playerArgs = [ const playerArgs = [
"--video=" + (enableVideo ? "auto" : "no"), "--video=" + (enableVideo ? "auto" : "no"),
"--fullscreen",
"--no-terminal", "--no-terminal",
"--idle=yes", "--idle=yes",
"--input-ipc-server=" + socketPath, "--input-ipc-server=" + socketPath,
@@ -133,10 +204,24 @@ export class MediaPlayer {
"--msg-level=all=v" "--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) { if (ytdlFormat) {
playerArgs.push("--ytdl-format=" + ytdlFormat); playerArgs.push("--ytdl-format=" + ytdlFormat);
} }
playerArgs.push(...this.parseMpvExtraArgs());
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")"); console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
this.playerProcess = spawn("mpv", playerArgs); this.playerProcess = spawn("mpv", playerArgs);
@@ -381,9 +466,9 @@ export class MediaPlayer {
public async getFeatures(): Promise<Features> { public async getFeatures(): Promise<Features> {
return { return {
video: !!process.env.ENABLE_VIDEO, video: this.envEnabled("ENABLE_VIDEO"),
screenshare: !!process.env.ENABLE_SCREENSHARE, screenshare: this.envEnabled("ENABLE_SCREENSHARE"),
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK browserPlayback: this.envEnabled("ENABLE_BROWSER_PLAYBACK")
}; };
} }
@@ -392,9 +477,9 @@ export class MediaPlayer {
this.playbackErrors.delete(url); this.playbackErrors.delete(url);
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')])); this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
if (fetchMetadata) { if (fetchMetadata && this.shouldFetchLinkPreview(url)) {
this.fetchMetadataAndNotify(url).catch(error => { 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);
}); });
} }
} }
@@ -446,7 +531,7 @@ export class MediaPlayer {
private async fetchMetadataAndNotify(url: string) { private async fetchMetadataAndNotify(url: string) {
try { try {
console.log("Fetching metadata for " + url); console.log("Fetching metadata for " + this.loggableUrl(url));
const metadata = await getLinkPreview(url); const metadata = await getLinkPreview(url);
this.metadata.set(url, { this.metadata.set(url, {
title: (metadata as any)?.title, title: (metadata as any)?.title,
@@ -454,7 +539,7 @@ export class MediaPlayer {
siteName: (metadata as any)?.siteName, 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)); console.log(this.metadata.get(url));
// Notify clients that metadata has been updated // Notify clients that metadata has been updated
@@ -467,6 +552,43 @@ export class MediaPlayer {
} }
} }
private shouldFetchLinkPreview(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol === "file:") {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return false;
}
const pathname = parsed.pathname.toLowerCase();
if (/\.(ts|m3u8|mpd|mp4|m4v|mkv|webm|avi|mov|mpeg|mpg|mp3|aac|flac|wav|ogg|opus)$/.test(pathname)) {
return false;
}
if (/\/download\/?$/.test(pathname)) {
return false;
}
return true;
} catch {
return false;
}
}
private loggableUrl(url: string): string {
try {
const parsed = new URL(url);
const segments = parsed.pathname.split("/").filter(Boolean);
const basename = segments.length > 0 ? segments[segments.length - 1] : "";
return `${parsed.protocol}//${parsed.host}/${basename ? ".../" + basename : ""}${parsed.search ? "?..." : ""}`;
} catch {
return url;
}
}
private connectToSocket(path: string): Promise<Socket> { private connectToSocket(path: string): Promise<Socket> {
const retryDelayMs = 100; const retryDelayMs = 100;
const maxAttempts = 50; const maxAttempts = 50;
+8 -2
View File
@@ -31,6 +31,12 @@ const app = express();
app.use(express.json()); app.use(express.json());
expressWs(app); 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 apiRouter = express.Router();
const mediaPlayer = new MediaPlayer(); const mediaPlayer = new MediaPlayer();
@@ -300,14 +306,14 @@ apiRouter.get("/features", withErrorHandling(async (req, res) => {
})); }));
// Serve static files for React app (after building) // 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 // Mount API routes under /api
app.use("/api", apiRouter); app.use("/api", apiRouter);
// Serve React app for all other routes (client-side routing) // Serve React app for all other routes (client-side routing)
app.get("*", (req, res) => { 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 port = process.env.PORT || 3000;