Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
463bb823d8 | ||
|
|
946771d406 | ||
|
|
907984d241 | ||
|
|
f80fd99211 | ||
|
|
3d7104b6b7 | ||
|
|
57b7d4cb82 | ||
|
|
cedd0c6aa2 | ||
|
|
174a8f8255 | ||
|
|
a846d9b4ec |
@@ -7,7 +7,7 @@ It allows you to remotely manage mpv's playqueue as well as the ability to searc
|
|||||||
## Sharing video links
|
## Sharing video links
|
||||||
|
|
||||||
The iOS and Mac Catalyst app includes a QueueCube share extension. Share one
|
The iOS and Mac Catalyst app includes a QueueCube share extension. Share one
|
||||||
video link, choose a player, and tap **Queue & Play** to append and play it while
|
video link and tap a saved player to append and play it while
|
||||||
keeping the existing queue. See [share extension setup, API behavior, and validation](ios/SHARE_EXTENSION.md).
|
keeping the existing queue. See [share extension setup, API behavior, and validation](ios/SHARE_EXTENSION.md).
|
||||||
|
|
||||||
## iOS TestFlight releases
|
## iOS TestFlight releases
|
||||||
|
|||||||
+3
-2
@@ -13,7 +13,7 @@ QueueCube is a SwiftUI-based jukebox client application for iOS and macOS (via M
|
|||||||
- **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates.
|
- **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates.
|
||||||
- **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow.
|
- **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow.
|
||||||
- **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery.
|
- **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery.
|
||||||
- **ServerDiscovery.swift**: Shared Bonjour browsing and bounded, cancellation-safe endpoint resolution for the app and share extension.
|
- **ServerDiscovery.swift**: Main-app Bonjour browsing and bounded, cancellation-safe endpoint resolution for Add Server.
|
||||||
- **RequestBuilder.swift**: Shared extension-safe HTTP builder and error handling.
|
- **RequestBuilder.swift**: Shared extension-safe HTTP builder and error handling.
|
||||||
- **SharePlayback.swift**: Append once, select the new queue item, unpause, and verify playback using existing endpoints.
|
- **SharePlayback.swift**: Append once, select the new queue item, unpause, and verify playback using existing endpoints.
|
||||||
- **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes.
|
- **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes.
|
||||||
@@ -99,5 +99,6 @@ QueueCubeApp
|
|||||||
## Share extension
|
## Share extension
|
||||||
|
|
||||||
`QueueCubeShare` is embedded in iOS and Mac Catalyst builds. It reads an App Group
|
`QueueCubeShare` is embedded in iOS and Mac Catalyst builds. It reads an App Group
|
||||||
settings snapshot and reuses the API/discovery code. See [SHARE_EXTENSION.md](SHARE_EXTENSION.md)
|
settings snapshot and reuses the API code. Tapping a saved player queues and plays
|
||||||
|
the shared link; discovery remains in the main app. See [SHARE_EXTENSION.md](SHARE_EXTENSION.md)
|
||||||
for authentication limitations, queue/play failure semantics, signing setup, and focused tests.
|
for authentication limitations, queue/play failure semantics, signing setup, and focused tests.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
CD2026102000000000000201 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000101 /* RequestBuilder.swift */; };
|
CD2026102000000000000201 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000101 /* RequestBuilder.swift */; };
|
||||||
CD2026102000000000000202 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000102 /* Server.swift */; };
|
CD2026102000000000000202 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000102 /* Server.swift */; };
|
||||||
CD2026102000000000000203 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000103 /* Settings.swift */; };
|
CD2026102000000000000203 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000103 /* Settings.swift */; };
|
||||||
CD2026102000000000000204 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000104 /* ServerDiscovery.swift */; };
|
|
||||||
CD2026102000000000000205 /* SharedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000105 /* SharedVideo.swift */; };
|
CD2026102000000000000205 /* SharedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000105 /* SharedVideo.swift */; };
|
||||||
CD2026102000000000000206 /* SharePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000106 /* SharePlayback.swift */; };
|
CD2026102000000000000206 /* SharePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000106 /* SharePlayback.swift */; };
|
||||||
CD2026102000000000000207 /* SharedInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000107 /* SharedInput.swift */; };
|
CD2026102000000000000207 /* SharedInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000107 /* SharedInput.swift */; };
|
||||||
@@ -49,7 +48,6 @@
|
|||||||
CD2026102000000000000101 /* RequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = "<group>"; };
|
CD2026102000000000000101 /* RequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = "<group>"; };
|
||||||
CD2026102000000000000102 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; };
|
CD2026102000000000000102 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; };
|
||||||
CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
|
CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
|
||||||
CD2026102000000000000104 /* ServerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
|
|
||||||
CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = "<group>"; };
|
CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = "<group>"; };
|
||||||
CD2026102000000000000106 /* SharePlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayback.swift; sourceTree = "<group>"; };
|
CD2026102000000000000106 /* SharePlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayback.swift; sourceTree = "<group>"; };
|
||||||
CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = "<group>"; };
|
CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = "<group>"; };
|
||||||
@@ -137,7 +135,6 @@
|
|||||||
CD2026102000000000000101 /* RequestBuilder.swift */,
|
CD2026102000000000000101 /* RequestBuilder.swift */,
|
||||||
CD2026102000000000000102 /* Server.swift */,
|
CD2026102000000000000102 /* Server.swift */,
|
||||||
CD2026102000000000000103 /* Settings.swift */,
|
CD2026102000000000000103 /* Settings.swift */,
|
||||||
CD2026102000000000000104 /* ServerDiscovery.swift */,
|
|
||||||
CD2026102000000000000105 /* SharedVideo.swift */,
|
CD2026102000000000000105 /* SharedVideo.swift */,
|
||||||
CD2026102000000000000106 /* SharePlayback.swift */,
|
CD2026102000000000000106 /* SharePlayback.swift */,
|
||||||
CD2026102000000000000107 /* SharedInput.swift */,
|
CD2026102000000000000107 /* SharedInput.swift */,
|
||||||
@@ -272,7 +269,6 @@
|
|||||||
CD2026102000000000000201 /* RequestBuilder.swift in Sources */,
|
CD2026102000000000000201 /* RequestBuilder.swift in Sources */,
|
||||||
CD2026102000000000000202 /* Server.swift in Sources */,
|
CD2026102000000000000202 /* Server.swift in Sources */,
|
||||||
CD2026102000000000000203 /* Settings.swift in Sources */,
|
CD2026102000000000000203 /* Settings.swift in Sources */,
|
||||||
CD2026102000000000000204 /* ServerDiscovery.swift in Sources */,
|
|
||||||
CD2026102000000000000205 /* SharedVideo.swift in Sources */,
|
CD2026102000000000000205 /* SharedVideo.swift in Sources */,
|
||||||
CD2026102000000000000206 /* SharePlayback.swift in Sources */,
|
CD2026102000000000000206 /* SharePlayback.swift in Sources */,
|
||||||
CD2026102000000000000207 /* SharedInput.swift in Sources */,
|
CD2026102000000000000207 /* SharedInput.swift in Sources */,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Shared Bonjour discovery for the app and share extension.
|
// Bonjour discovery for the main app's Add Server view.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|||||||
@@ -4,12 +4,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSBonjourServices</key>
|
|
||||||
<array>
|
|
||||||
<string>_queuecube._tcp.</string>
|
|
||||||
</array>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>QueueCube discovers nearby players so you can choose where to play this video.</string>
|
<string>QueueCube connects to your saved players to queue and play this video.</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ final class ShareViewController: UIViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
preferredContentSize = CGSize(width: 480, height: 600)
|
preferredContentSize = CGSize(width: 480, height: 600)
|
||||||
let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] in
|
let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] server in
|
||||||
self?.send()
|
self?.send(to: server)
|
||||||
}, close: { [weak self] in
|
}, close: { [weak self] in
|
||||||
self?.close()
|
self?.close()
|
||||||
}))
|
}))
|
||||||
@@ -24,7 +24,6 @@ final class ShareViewController: UIViewController {
|
|||||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
])
|
])
|
||||||
host.didMove(toParent: self)
|
host.didMove(toParent: self)
|
||||||
model.discovery.start()
|
|
||||||
inputTask = Task { [weak self] in
|
inputTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
@@ -37,12 +36,10 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send() {
|
private func send(to server: Server) {
|
||||||
guard !model.sending, !model.finished, let url = model.url,
|
guard !model.sending, !model.finished, let url = model.url else { return }
|
||||||
let server = model.selectedServer else { return }
|
|
||||||
model.sending = true
|
model.sending = true
|
||||||
model.message = nil
|
model.message = nil
|
||||||
model.discovery.stop()
|
|
||||||
playbackTask = Task { [weak self] in
|
playbackTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
@@ -55,7 +52,6 @@ final class ShareViewController: UIViewController {
|
|||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
model.message = error.localizedDescription
|
model.message = error.localizedDescription
|
||||||
model.finished = !error.canRetry
|
model.finished = !error.canRetry
|
||||||
if error.canRetry { model.discovery.start() }
|
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
model.message = "The request could not be confirmed. Check the player in QueueCube before sharing again."
|
model.message = "The request could not be confirmed. Check the player in QueueCube before sharing again."
|
||||||
@@ -68,7 +64,6 @@ final class ShareViewController: UIViewController {
|
|||||||
private func close() {
|
private func close() {
|
||||||
inputTask?.cancel()
|
inputTask?.cancel()
|
||||||
playbackTask?.cancel()
|
playbackTask?.cancel()
|
||||||
model.stop()
|
|
||||||
if model.finished {
|
if model.finished {
|
||||||
extensionContext?.completeRequest(returningItems: nil)
|
extensionContext?.completeRequest(returningItems: nil)
|
||||||
} else {
|
} else {
|
||||||
@@ -80,7 +75,6 @@ final class ShareViewController: UIViewController {
|
|||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
inputTask?.cancel()
|
inputTask?.cancel()
|
||||||
playbackTask?.cancel()
|
playbackTask?.cancel()
|
||||||
model.stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -91,53 +85,18 @@ final class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
@MainActor @Observable
|
@MainActor @Observable
|
||||||
private final class ShareViewModel {
|
private final class ShareViewModel {
|
||||||
let discovery = ServerDiscovery()
|
let configuredServers = Settings.fromSharedDefaults().configuredServers
|
||||||
let configuredServers: [Server]
|
|
||||||
var selectedServer: Server?
|
|
||||||
var url: URL?
|
var url: URL?
|
||||||
var loading = true
|
var loading = true
|
||||||
var sending = false
|
var sending = false
|
||||||
var finished = false
|
var finished = false
|
||||||
var succeeded = false
|
var succeeded = false
|
||||||
var resolving = false
|
|
||||||
var message: String?
|
var message: String?
|
||||||
private var resolutionTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let settings = Settings.fromSharedDefaults()
|
|
||||||
configuredServers = settings.configuredServers
|
|
||||||
selectedServer = settings.selectedServer.flatMap { selected in
|
|
||||||
settings.configuredServers.first { $0.id == selected.id }
|
|
||||||
} ?? settings.configuredServers.first
|
|
||||||
}
|
|
||||||
|
|
||||||
func select(_ endpoint: DiscoveredEndpoint) {
|
|
||||||
resolutionTask?.cancel()
|
|
||||||
resolving = true
|
|
||||||
message = nil
|
|
||||||
resolutionTask = Task {
|
|
||||||
do {
|
|
||||||
let server = try await endpoint.resolve()
|
|
||||||
try Task.checkCancellation()
|
|
||||||
selectedServer = server
|
|
||||||
} catch {
|
|
||||||
if !Task.isCancelled {
|
|
||||||
message = "Could not connect to that player. Check its connection and try again."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !Task.isCancelled { resolving = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
resolutionTask?.cancel()
|
|
||||||
discovery.stop()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ShareView: View {
|
private struct ShareView: View {
|
||||||
@Bindable var model: ShareViewModel
|
@Bindable var model: ShareViewModel
|
||||||
let send: () -> Void
|
let send: (Server) -> Void
|
||||||
let close: () -> Void
|
let close: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -149,40 +108,35 @@ private struct ShareView: View {
|
|||||||
} else if let url = model.url {
|
} else if let url = model.url {
|
||||||
Text(url.absoluteString)
|
Text(url.absoluteString)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.lineLimit(3)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !model.finished {
|
if !model.finished {
|
||||||
Section("Choose player") {
|
Section("Choose player") {
|
||||||
if model.configuredServers.isEmpty {
|
if model.configuredServers.isEmpty {
|
||||||
Text("Open QueueCube once to share your saved players, or choose a nearby player below. Allow local network access in QueueCube to reach nearby players.")
|
Text("Open QueueCube to set up your players.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
ForEach(model.configuredServers) { server in
|
ForEach(model.configuredServers) { server in
|
||||||
Button {
|
Button {
|
||||||
model.selectedServer = server
|
send(server)
|
||||||
} label: {
|
} label: {
|
||||||
playerLabel(server.displayName, selected: model.selectedServer?.id == server.id)
|
HStack {
|
||||||
|
Label(server.displayName, systemImage: "hifispeaker.fill")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.primary)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.disabled(model.sending || model.resolving)
|
.tint(.primary)
|
||||||
}
|
.disabled(model.url == nil || model.sending)
|
||||||
if let selected = model.selectedServer,
|
|
||||||
!model.configuredServers.contains(where: { $0.id == selected.id }) {
|
|
||||||
playerLabel(selected.displayName, selected: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !model.sending {
|
}
|
||||||
Section("Nearby players") {
|
if model.sending {
|
||||||
if model.discovery.servers.isEmpty {
|
Section {
|
||||||
Text(model.discovery.errorMessage ?? "Looking for QueueCube players on your local network…")
|
ProgressView("Queuing and starting playback…")
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
ForEach(model.discovery.servers) { endpoint in
|
|
||||||
Button(endpoint.displayName) { model.select(endpoint) }
|
|
||||||
.disabled(model.resolving)
|
|
||||||
}
|
|
||||||
if model.resolving { ProgressView("Connecting…") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let message = model.message {
|
if let message = model.message {
|
||||||
@@ -191,22 +145,6 @@ private struct ShareView: View {
|
|||||||
.foregroundStyle(model.succeeded ? Color.green : Color.primary)
|
.foregroundStyle(model.succeeded ? Color.green : Color.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !model.finished {
|
|
||||||
Section {
|
|
||||||
Button(action: send) {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
if model.sending { ProgressView() }
|
|
||||||
Text(model.sending ? "Queuing and starting playback…" : "Queue & Play")
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(model.url == nil || model.selectedServer == nil || model.sending || model.resolving)
|
|
||||||
Text("Adds this link to the queue and plays it now. Existing queued items are kept.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle("QueueCube")
|
.navigationTitle("QueueCube")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -218,12 +156,4 @@ private struct ShareView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playerLabel(_ name: String, selected: Bool) -> some View {
|
|
||||||
HStack {
|
|
||||||
Label(name, systemImage: "hifispeaker.fill")
|
|
||||||
Spacer()
|
|
||||||
if selected { Image(systemName: "checkmark").accessibilityLabel("Selected") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
Open QueueCube once after updating to make saved players available to the share
|
Open QueueCube once after updating to make saved players available to the share
|
||||||
extension. In Safari or a video app, share one HTTP(S) link, choose QueueCube,
|
extension. In Safari or a video app, share one HTTP(S) link, choose QueueCube,
|
||||||
select a saved or nearby player, and tap **Queue & Play**. The link is appended
|
and tap a saved player. The link is appended
|
||||||
and played immediately; existing queue entries remain. A successful request
|
and played immediately; existing queue entries remain. A successful request
|
||||||
shows the selected player and a Done button. Video support depends on the
|
shows the selected player and a Done button. Video support depends on the
|
||||||
player's existing mpv/yt-dlp support.
|
player's existing mpv/yt-dlp support.
|
||||||
|
|
||||||
Nearby players use the same `_queuecube._tcp.` Bonjour discovery as Add Server.
|
The extension shows only saved players and does not browse for nearby players.
|
||||||
Resolved IPv4 interface suffixes are removed; scoped IPv6 addresses remain valid.
|
Add players manually or with Bonjour discovery in QueueCube Settings first.
|
||||||
Allow local network access in the containing app first. Players can also be
|
Allow local network access in the containing app to reach local players.
|
||||||
configured manually in QueueCube Settings. The extension handles URL attachments
|
The extension handles URL attachments
|
||||||
(including UTF-8 data and the absolute-URL property-list form observed from
|
(including UTF-8 data and the absolute-URL property-list form observed from
|
||||||
UIKit) and plain text containing one link. URL data keeps the same single-link
|
UIKit) and plain text containing one link. URL data keeps the same single-link
|
||||||
validation; property lists with a nonempty base or properties dictionary and
|
validation; property lists with a nonempty base or properties dictionary and
|
||||||
@@ -25,7 +25,7 @@ The app and `net.buzzert.QueueCube.Share` extension share
|
|||||||
publishes a single versioned settings snapshot on read/save. The extension reads
|
publishes a single versioned settings snapshot on read/save. The extension reads
|
||||||
that snapshot without changing the app's selected player or server list. No
|
that snapshot without changing the app's selected player or server list. No
|
||||||
queued link, credentials store, background transfer, or keychain group is added.
|
queued link, credentials store, background transfer, or keychain group is added.
|
||||||
The extension has Bonjour/local network declarations and the same HTTP transport
|
The extension has a local network usage declaration and the same HTTP transport
|
||||||
compatibility as the existing app; Catalyst adds only app sandbox and outgoing
|
compatibility as the existing app; Catalyst adds only app sandbox and outgoing
|
||||||
network access.
|
network access.
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ app/extension versions synchronized. Do not apply a single global bundle ID or
|
|||||||
provisioning profile override to both targets.
|
provisioning profile override to both targets.
|
||||||
|
|
||||||
For manual UI validation, use a dedicated simulator or test installation:
|
For manual UI validation, use a dedicated simulator or test installation:
|
||||||
configure a test player in QueueCube, share a URL from Safari, verify saved and
|
configure a test player in QueueCube, share a URL from Safari, verify saved-player
|
||||||
nearby player selection, Queue & Play success, cancellation, invalid text, and
|
tap-to-play, the empty saved-player state, cancellation, invalid text, and
|
||||||
an offline/partially failing player. Confirm the old queue is retained and the
|
an offline/partially failing player. Confirm the old queue is retained and the
|
||||||
new item is current and unpaused. On macOS also confirm QueueCube appears in
|
new item is current and unpaused. On macOS also confirm QueueCube appears in
|
||||||
Share extensions and can be invoked from a host app. Record signed-device and
|
Share extensions and can be invoked from a host app. Record signed-device and
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"main": "build/server.js",
|
"main": "build/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
|
"test": "npm run build && node test/run.cjs",
|
||||||
"dev": "concurrently \"tsc -w -p src\" \"nodemon build/server.js\"",
|
"dev": "concurrently \"tsc -w -p src\" \"nodemon build/server.js\"",
|
||||||
"start": "node build/index.js"
|
"start": "node build/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { ChildProcess, spawn } from "child_process";
|
||||||
|
import { accessSync, constants } from "fs";
|
||||||
|
import { mkdtemp, rm } from "fs/promises";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export interface ChromiumProcess {
|
||||||
|
process: ChildProcess;
|
||||||
|
profilePath: string;
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChromiumFallback {
|
||||||
|
private active: ChromiumProcess | null = null;
|
||||||
|
private generation = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly onExit: (url: string) => void = () => undefined,
|
||||||
|
private readonly executable = process.env.QUEUECUBE_CHROME_BIN || ChromiumFallback.findExecutable(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private static findExecutable(): string {
|
||||||
|
const directories = (process.env.PATH || "").split(path.delimiter);
|
||||||
|
for (const name of ["chromium", "chromium-browser", "google-chrome"]) {
|
||||||
|
for (const directory of directories) {
|
||||||
|
if (!directory) continue;
|
||||||
|
const candidate = path.join(directory, name);
|
||||||
|
try {
|
||||||
|
accessSync(candidate, constants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// Keep checking the remaining supported executable names.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "chromium";
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isRunning(): boolean {
|
||||||
|
return this.active !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(url: string, isCurrent: () => boolean): Promise<boolean> {
|
||||||
|
await this.stop();
|
||||||
|
const generation = ++this.generation;
|
||||||
|
const profilePath = await mkdtemp(path.join(os.tmpdir(), "queuecube-chrome-"));
|
||||||
|
|
||||||
|
if (generation !== this.generation || !isCurrent()) {
|
||||||
|
await this.removeProfile(profilePath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let child: ChildProcess;
|
||||||
|
try {
|
||||||
|
child = spawn(this.executable, [
|
||||||
|
"--kiosk",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
`--user-data-dir=${profilePath}`,
|
||||||
|
"--",
|
||||||
|
url,
|
||||||
|
], { detached: true, stdio: "ignore" });
|
||||||
|
} catch (error) {
|
||||||
|
await this.removeProfile(profilePath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = { process: child, profilePath, generation };
|
||||||
|
this.active = active;
|
||||||
|
child.once("spawn", () => {
|
||||||
|
if (this.active !== active || generation !== this.generation) {
|
||||||
|
this.signalOwnedGroup(child, "SIGTERM");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (this.active === active) {
|
||||||
|
this.active = null;
|
||||||
|
// Chromium can exit before its helper processes. Reap only the
|
||||||
|
// process group created for this fallback instance.
|
||||||
|
this.signalOwnedGroup(child, "SIGTERM");
|
||||||
|
this.onExit(url);
|
||||||
|
}
|
||||||
|
void this.removeProfile(profilePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.once("error", (error) => {
|
||||||
|
cleanup();
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", () => {
|
||||||
|
cleanup();
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("spawn", () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (generation !== this.generation || !isCurrent()) {
|
||||||
|
void this.stopActive(active);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.generation++;
|
||||||
|
const active = this.active;
|
||||||
|
if (active) await this.stopActive(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopActive(active: ChromiumProcess): Promise<void> {
|
||||||
|
if (this.active === active) this.active = null;
|
||||||
|
|
||||||
|
const child = active.process;
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
if (child.pid) this.signalOwnedGroup(child, "SIGTERM");
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve) => child.once("exit", () => resolve())),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 2000)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
if (child.pid) this.signalOwnedGroup(child, "SIGKILL");
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve) => child.once("exit", () => resolve())),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.removeProfile(active.profilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeProfile(profilePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rm(profilePath, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to remove Chromium profile ${profilePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private signalOwnedGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||||
|
if (!child.pid) return;
|
||||||
|
try {
|
||||||
|
// detached=true gives this instance its own process group. Signal only
|
||||||
|
// that group so Chromium helpers cannot outlive the owned browser.
|
||||||
|
process.kill(-child.pid, signal);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== "ESRCH") child.kill(signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+219
-37
@@ -24,6 +24,7 @@ import { PlaylistItem, LinkMetadata } from './types';
|
|||||||
import { FavoritesStore } from "./FavoritesStore";
|
import { FavoritesStore } from "./FavoritesStore";
|
||||||
import { Bonjour } from "bonjour-service";
|
import { Bonjour } from "bonjour-service";
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import { ChromiumFallback } from "./ChromiumFallback";
|
||||||
|
|
||||||
interface PendingCommand {
|
interface PendingCommand {
|
||||||
resolve: (value: any) => void;
|
resolve: (value: any) => void;
|
||||||
@@ -68,6 +69,21 @@ export class MediaPlayer {
|
|||||||
private lastLoadCandidate: string | null = null;
|
private lastLoadCandidate: string | null = null;
|
||||||
private currentMediaTitle: string | null = null;
|
private currentMediaTitle: string | null = null;
|
||||||
private currentPlaybackMetadata: Record<string, string> = {};
|
private currentPlaybackMetadata: Record<string, string> = {};
|
||||||
|
private activeAttemptGeneration = 0;
|
||||||
|
private fallbackCandidates: Set<string> = new Set();
|
||||||
|
private entryCandidates: Map<number, string> = new Map();
|
||||||
|
private pendingCandidateUrls: string[] = [];
|
||||||
|
private activeEntryId: number | null = null;
|
||||||
|
private activeUrl: string | null = null;
|
||||||
|
private fallbackSuppressedUntilStart = false;
|
||||||
|
private fallbackPendingUrl: string | null = null;
|
||||||
|
private browserUrl: string | null = null;
|
||||||
|
private browserFallback = new ChromiumFallback((url) => {
|
||||||
|
if (this.browserUrl === url) {
|
||||||
|
this.browserUrl = null;
|
||||||
|
this.handleEvent(UserEvent.NowPlayingUpdate, {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = this.tryRespawnPlayerProcess();
|
this.socket = this.tryRespawnPlayerProcess();
|
||||||
@@ -271,6 +287,10 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getNowPlaying(): Promise<PlaylistItem> {
|
public async getNowPlaying(): Promise<PlaylistItem> {
|
||||||
|
if (this.browserUrl) {
|
||||||
|
return { id: -1, filename: this.browserUrl, title: this.browserUrl, playing: true, current: true };
|
||||||
|
}
|
||||||
|
|
||||||
const playlist = await this.getPlaylist();
|
const playlist = await this.getPlaylist();
|
||||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||||
@@ -310,6 +330,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getCurrentFile(): Promise<string | null> {
|
public async getCurrentFile(): Promise<string | null> {
|
||||||
|
if (this.browserUrl) return this.browserUrl;
|
||||||
return this.writeCommand("get_property", ["stream-open-filename"])
|
return this.writeCommand("get_property", ["stream-open-filename"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -317,6 +338,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getPauseState(): Promise<boolean> {
|
public async getPauseState(): Promise<boolean> {
|
||||||
|
if (this.browserUrl) return false;
|
||||||
return this.writeCommand("get_property", ["pause"])
|
return this.writeCommand("get_property", ["pause"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -331,6 +353,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getTimePosition(): Promise<number | null> {
|
public async getTimePosition(): Promise<number | null> {
|
||||||
|
if (this.browserUrl) return null;
|
||||||
return this.writeCommand("get_property", ["time-pos"])
|
return this.writeCommand("get_property", ["time-pos"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -338,6 +361,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getDuration(): Promise<number | null> {
|
public async getDuration(): Promise<number | null> {
|
||||||
|
if (this.browserUrl) return null;
|
||||||
return this.writeCommand("get_property", ["duration"])
|
return this.writeCommand("get_property", ["duration"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -345,6 +369,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSeekable(): Promise<boolean | null> {
|
public async getSeekable(): Promise<boolean | null> {
|
||||||
|
if (this.browserUrl) return false;
|
||||||
return this.writeCommand("get_property", ["seekable"])
|
return this.writeCommand("get_property", ["seekable"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -352,6 +377,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getIdle(): Promise<boolean> {
|
public async getIdle(): Promise<boolean> {
|
||||||
|
if (this.browserUrl) return false;
|
||||||
return this.writeCommand("get_property", ["idle"])
|
return this.writeCommand("get_property", ["idle"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -359,14 +385,21 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async append(url: string) {
|
public async append(url: string) {
|
||||||
await this.loadFile(url, "append-play");
|
const mode = this.browserUrl || this.fallbackPendingUrl || this.browserFallback.isRunning
|
||||||
|
? "append"
|
||||||
|
: "append-play";
|
||||||
|
await this.loadFile(url, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async replace(url: string) {
|
public async replace(url: string) {
|
||||||
await this.loadFile(url, "replace");
|
await this.cancelBrowserFallback();
|
||||||
|
this.clearFallbackCandidates();
|
||||||
|
await this.loadFile(url, "replace", true, [], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initiateScreenSharing(url: string) {
|
public async initiateScreenSharing(url: string) {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
|
this.clearFallbackCandidates();
|
||||||
console.log(`Initiating screen sharing with file: ${url}`);
|
console.log(`Initiating screen sharing with file: ${url}`);
|
||||||
|
|
||||||
this.metadata.set(url, {
|
this.metadata.set(url, {
|
||||||
@@ -375,29 +408,27 @@ export class MediaPlayer {
|
|||||||
siteName: "Screen Sharing",
|
siteName: "Screen Sharing",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Special options for mpv to better handle screen sharing (AI recommended...)
|
// Start from mpv's maintained low-latency profile, then keep only a
|
||||||
|
// couple of frames of demuxed data ready for smooth 60 fps playback.
|
||||||
await this.loadFile(url, "replace", false, [
|
await this.loadFile(url, "replace", false, [
|
||||||
"demuxer-lavf-o=fflags=+nobuffer+discardcorrupt", // Reduce buffering and discard corrupt frames
|
"profile=low-latency",
|
||||||
"demuxer-lavf-o=analyzeduration=100000", // Reduce analyze duration
|
"cache=no",
|
||||||
"demuxer-lavf-o=probesize=1000000", // Reduce probe size
|
"force-seekable=no",
|
||||||
"untimed=yes", // Ignore timing info
|
"demuxer-readahead-secs=0.05",
|
||||||
"cache=no", // Disable cache
|
"demuxer-max-bytes=256K",
|
||||||
"force-seekable=yes", // Force seekable
|
"demuxer-mkv-probe-start-time=no",
|
||||||
"no-cache=yes", // Disable cache
|
"vd-lavc-threads=2",
|
||||||
"demuxer-max-bytes=500K", // Limit demuxer buffer
|
"vd-lavc-fast=yes",
|
||||||
"demuxer-readahead-secs=0.1", // Reduce readahead
|
"audio-buffer=0",
|
||||||
"hr-seek=no", // Disable high-res seeking
|
"pulse-buffer=20",
|
||||||
"video-sync=display-resample", // Better sync mode
|
"pipewire-buffer=20",
|
||||||
"video-latency-hacks=yes", // Enable latency hacks
|
"alsa-buffer-time=20000",
|
||||||
"audio-sync=yes", // Enable audio sync
|
"initial-audio-sync=no",
|
||||||
"audio-buffer=0.1", // Reduce audio buffer
|
|
||||||
"audio-channels=stereo", // Force stereo audio
|
|
||||||
"audio-samplerate=44100", // Match sample rate
|
|
||||||
"audio-format=s16", // Use 16-bit audio
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Make sure it's playing
|
// loadfile normally unpauses, but make that explicit without adding the
|
||||||
setTimeout(() => this.play(), 100);
|
// previous fixed 100 ms startup delay.
|
||||||
|
await this.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async play() {
|
public async play() {
|
||||||
@@ -409,18 +440,36 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async shutdown() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
|
}
|
||||||
|
|
||||||
public async skip() {
|
public async skip() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async skipTo(index: number) {
|
public async skipTo(index: number) {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
|
try {
|
||||||
|
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
|
||||||
|
const target = playlist[index];
|
||||||
|
if (target && this.fallbackCandidates.has(target.filename)) {
|
||||||
|
this.entryCandidates.set(target.id, target.filename);
|
||||||
|
this.removePendingCandidate(target.filename);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Existing entry-to-URL mappings remain available if MPV is transitioning.
|
||||||
|
}
|
||||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async previous() {
|
public async previous() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,16 +521,36 @@ export class MediaPlayer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = [], fallbackEligible: boolean = true) {
|
||||||
|
if (fallbackEligible) {
|
||||||
|
this.fallbackCandidates.add(url);
|
||||||
|
this.pendingCandidateUrls.push(url);
|
||||||
|
}
|
||||||
this.lastLoadCandidate = url;
|
this.lastLoadCandidate = url;
|
||||||
this.playbackErrors.delete(url);
|
this.playbackErrors.delete(url);
|
||||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
const loadPromise = this.modify(
|
||||||
|
UserEvent.PlaylistUpdate,
|
||||||
|
() => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]),
|
||||||
|
);
|
||||||
|
|
||||||
if (fetchMetadata && this.shouldFetchLinkPreview(url)) {
|
if (fetchMetadata && this.shouldFetchLinkPreview(url)) {
|
||||||
this.fetchMetadataAndNotify(url).catch(error => {
|
this.fetchMetadataAndNotify(url).catch(error => {
|
||||||
console.warn(`Failed to fetch metadata for ${this.loggableUrl(url)}:`, error);
|
console.warn(`Failed to fetch metadata for ${this.loggableUrl(url)}:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await loadPromise;
|
||||||
|
if (result?.error !== "success") {
|
||||||
|
if (fallbackEligible) {
|
||||||
|
this.removePendingCandidate(url);
|
||||||
|
if (!this.pendingCandidateUrls.includes(url) &&
|
||||||
|
![...this.entryCandidates.values()].includes(url)) {
|
||||||
|
this.fallbackCandidates.delete(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fallbackEligible) void this.rememberPlaylistEntry(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||||
@@ -671,26 +740,35 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
} else if (response.event) {
|
} else if (response.event) {
|
||||||
if (response.event === "start-file") {
|
if (response.event === "start-file") {
|
||||||
// Clear any previous error for the file that is starting
|
// A new MPV entry supersedes pending browser startup and playback state.
|
||||||
const file = response.file || this.lastLoadCandidate;
|
const hadBrowser = !!this.browserUrl || this.browserFallback.isRunning;
|
||||||
if (file) {
|
this.activeAttemptGeneration++;
|
||||||
this.currentFile = file;
|
this.fallbackSuppressedUntilStart = false;
|
||||||
this.playbackErrors.delete(file);
|
this.fallbackPendingUrl = null;
|
||||||
|
this.browserUrl = null;
|
||||||
|
if (hadBrowser) void this.browserFallback.stop();
|
||||||
|
const entryId = typeof response.playlist_entry_id === "number"
|
||||||
|
? response.playlist_entry_id
|
||||||
|
: null;
|
||||||
|
let file = response.file || (entryId === null ? null : this.entryCandidates.get(entryId)) || null;
|
||||||
|
if (!file && this.pendingCandidateUrls.length > 0) {
|
||||||
|
file = this.pendingCandidateUrls.shift() || null;
|
||||||
|
if (entryId !== null && file) this.entryCandidates.set(entryId, file);
|
||||||
}
|
}
|
||||||
|
this.activeEntryId = entryId;
|
||||||
|
this.activeUrl = file;
|
||||||
|
this.currentFile = file || null;
|
||||||
|
if (file) this.playbackErrors.delete(file);
|
||||||
|
if (entryId !== null && !file) void this.refreshCurrentFile(entryId, this.activeAttemptGeneration);
|
||||||
|
|
||||||
this.currentMediaTitle = null;
|
this.currentMediaTitle = null;
|
||||||
this.currentPlaybackMetadata = {};
|
this.currentPlaybackMetadata = {};
|
||||||
} else if (response.event === "property-change") {
|
} else if (response.event === "property-change") {
|
||||||
this.handlePropertyChange(response);
|
this.handlePropertyChange(response);
|
||||||
} else if (response.event === "end-file" && response.reason === "error") {
|
} else if (response.event === "end-file" && response.reason === "error") {
|
||||||
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
|
void this.handlePlaybackFailure(response);
|
||||||
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
|
} else if (response.event === "end-file") {
|
||||||
|
// Keep entry eligibility for a later replay of this queue item.
|
||||||
this.playbackErrors.set(file, errorMessage);
|
|
||||||
this.handleEvent(UserEvent.PlaybackError, {
|
|
||||||
filename: file,
|
|
||||||
error: errorMessage
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handleEvent(UserEvent.MPDUpdate, response);
|
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||||
@@ -704,6 +782,110 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handlePlaybackFailure(response: any) {
|
||||||
|
const entryId = typeof response.playlist_entry_id === "number"
|
||||||
|
? response.playlist_entry_id
|
||||||
|
: this.activeEntryId;
|
||||||
|
if (entryId === null) return;
|
||||||
|
|
||||||
|
const attemptGeneration = this.activeAttemptGeneration;
|
||||||
|
const file = this.entryCandidates.get(entryId) || response.file ||
|
||||||
|
(entryId === this.activeEntryId ? this.activeUrl : null);
|
||||||
|
if (!file) return;
|
||||||
|
if (this.activeEntryId === null && !this.fallbackSuppressedUntilStart) {
|
||||||
|
// MPV can clear its current entry before delivering end-file. Restore
|
||||||
|
// this event's known entry from its playlist_entry_id for mirroring.
|
||||||
|
this.activeEntryId = entryId;
|
||||||
|
this.activeUrl = file;
|
||||||
|
this.currentFile = file;
|
||||||
|
}
|
||||||
|
const errorMessage = response.file_error || response.error || response["file-error"] || "Unknown playback error";
|
||||||
|
this.playbackErrors.set(file, errorMessage);
|
||||||
|
this.handleEvent(UserEvent.PlaybackError, { filename: file, error: errorMessage });
|
||||||
|
|
||||||
|
// end-file identifies the failed playlist entry even if MPV has already
|
||||||
|
// cleared its current entry or advanced before any asynchronous query.
|
||||||
|
// A user transition increments the attempt generation and prevents stale
|
||||||
|
// failures from opening a browser.
|
||||||
|
if (this.fallbackCandidates.has(file) &&
|
||||||
|
(entryId === this.activeEntryId || (this.activeEntryId === null && !this.fallbackSuppressedUntilStart)) &&
|
||||||
|
attemptGeneration === this.activeAttemptGeneration) {
|
||||||
|
this.fallbackPendingUrl = file;
|
||||||
|
void this.startBrowserFallback(file, attemptGeneration, entryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshCurrentFile(entryId: number, attemptGeneration: number) {
|
||||||
|
try {
|
||||||
|
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
|
||||||
|
if (this.activeEntryId !== entryId || this.activeAttemptGeneration !== attemptGeneration) return;
|
||||||
|
const file = playlist.find((item) => item.id === entryId)?.filename || null;
|
||||||
|
this.currentFile = file;
|
||||||
|
this.activeUrl = file;
|
||||||
|
if (file && this.fallbackCandidates.has(file)) this.entryCandidates.set(entryId, file);
|
||||||
|
if (this.browserUrl && this.browserUrl !== file) void this.cancelBrowserFallback();
|
||||||
|
} catch {
|
||||||
|
// The player may be transitioning; a later start-file event will refresh it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rememberPlaylistEntry(url: string) {
|
||||||
|
try {
|
||||||
|
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
|
||||||
|
const entry = playlist.filter((item) => item.filename === url && !this.entryCandidates.has(item.id)).at(-1);
|
||||||
|
if (entry) {
|
||||||
|
this.entryCandidates.set(entry.id, url);
|
||||||
|
this.removePendingCandidate(url);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The pending candidate is associated when MPV emits start-file instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startBrowserFallback(url: string, generation: number, entryId: number) {
|
||||||
|
try {
|
||||||
|
const started = await this.browserFallback.start(url, () =>
|
||||||
|
generation === this.activeAttemptGeneration &&
|
||||||
|
this.activeEntryId === entryId &&
|
||||||
|
this.activeUrl === url,
|
||||||
|
);
|
||||||
|
if (started && this.browserFallback.isRunning &&
|
||||||
|
generation === this.activeAttemptGeneration &&
|
||||||
|
this.activeEntryId === entryId && this.activeUrl === url) {
|
||||||
|
this.browserUrl = url;
|
||||||
|
this.handleEvent(UserEvent.NowPlayingUpdate, {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to start Chromium fallback for ${url}:`, error);
|
||||||
|
} finally {
|
||||||
|
if (generation === this.activeAttemptGeneration && this.fallbackPendingUrl === url) {
|
||||||
|
this.fallbackPendingUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancelBrowserFallback() {
|
||||||
|
this.activeAttemptGeneration++;
|
||||||
|
this.fallbackSuppressedUntilStart = true;
|
||||||
|
this.activeEntryId = null;
|
||||||
|
this.activeUrl = null;
|
||||||
|
this.currentFile = null;
|
||||||
|
this.fallbackPendingUrl = null;
|
||||||
|
this.browserUrl = null;
|
||||||
|
await this.browserFallback.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearFallbackCandidates() {
|
||||||
|
this.fallbackCandidates.clear();
|
||||||
|
this.entryCandidates.clear();
|
||||||
|
this.pendingCandidateUrls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private removePendingCandidate(url: string) {
|
||||||
|
const index = this.pendingCandidateUrls.indexOf(url);
|
||||||
|
if (index >= 0) this.pendingCandidateUrls.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
private handlePropertyChange(response: any) {
|
private handlePropertyChange(response: any) {
|
||||||
const previousTitle = this.getCurrentDynamicTitle();
|
const previousTitle = this.getCurrentDynamicTitle();
|
||||||
|
|
||||||
|
|||||||
+90
-42
@@ -26,6 +26,7 @@ import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
|||||||
import { PlaylistItem } from './types';
|
import { PlaylistItem } from './types';
|
||||||
import { PassThrough } from "stream";
|
import { PassThrough } from "stream";
|
||||||
import { AddressInfo } from "net";
|
import { AddressInfo } from "net";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -43,6 +44,21 @@ const mediaPlayer = new MediaPlayer();
|
|||||||
// Create a shared stream that both endpoints can access
|
// Create a shared stream that both endpoints can access
|
||||||
let activeScreenshareStream: PassThrough | null = null;
|
let activeScreenshareStream: PassThrough | null = null;
|
||||||
let activeScreenshareMimeType: string | null = null;
|
let activeScreenshareMimeType: string | null = null;
|
||||||
|
let activeScreenshareSocket: WebSocket | null = null;
|
||||||
|
const screenshareStreamsClosedByViewer = new WeakSet<PassThrough>();
|
||||||
|
|
||||||
|
const SCREENSHARE_STREAM_HIGH_WATER_MARK = 32 * 1024;
|
||||||
|
|
||||||
|
const getScreenshareContentType = (mimeType: unknown): string => {
|
||||||
|
if (typeof mimeType !== "string") {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = mimeType.split(";", 1)[0].trim().toLowerCase();
|
||||||
|
return contentType === "video/webm" || contentType === "video/mp4"
|
||||||
|
? contentType
|
||||||
|
: "application/octet-stream";
|
||||||
|
};
|
||||||
|
|
||||||
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
||||||
return async (req: any, res: any) => {
|
return async (req: any, res: any) => {
|
||||||
@@ -158,75 +174,104 @@ apiRouter.ws("/events", (ws, req) => {
|
|||||||
// using the fact that QueueCube/MPV is based all around streaming URLs, so the active
|
// using the fact that QueueCube/MPV is based all around streaming URLs, so the active
|
||||||
// screenshare stream manifests as just another URL to play.
|
// screenshare stream manifests as just another URL to play.
|
||||||
apiRouter.get("/screenshareStream", withErrorHandling(async (req, res) => {
|
apiRouter.get("/screenshareStream", withErrorHandling(async (req, res) => {
|
||||||
res.setHeader("Content-Type", activeScreenshareMimeType || "video/mp4");
|
const screenshareStream = activeScreenshareStream;
|
||||||
|
if (!screenshareStream) {
|
||||||
|
res.status(503).send("No active screen sharing session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.socket.setNoDelay(true);
|
||||||
|
res.setHeader("Content-Type", activeScreenshareMimeType || "application/octet-stream");
|
||||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
res.setHeader("Pragma", "no-cache");
|
res.setHeader("Pragma", "no-cache");
|
||||||
res.setHeader("Expires", "0");
|
res.setHeader("Expires", "0");
|
||||||
res.setHeader("Connection", "keep-alive");
|
res.setHeader("Connection", "keep-alive");
|
||||||
res.setHeader("Transfer-Encoding", "chunked");
|
res.setHeader("Transfer-Encoding", "chunked");
|
||||||
|
res.setHeader("Accept-Ranges", "none");
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
if (!activeScreenshareStream) {
|
res.on('close', () => {
|
||||||
res.status(503).send("No active screen sharing session");
|
screenshareStream.unpipe(res);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle client disconnection
|
|
||||||
req.on('close', () => {
|
|
||||||
console.log("Screenshare viewer disconnected");
|
console.log("Screenshare viewer disconnected");
|
||||||
|
|
||||||
|
// If mpv goes away unexpectedly, stop capture instead of leaving an
|
||||||
|
// unconsumed stream to build backpressure forever.
|
||||||
|
if (activeScreenshareStream === screenshareStream && !screenshareStream.writableEnded) {
|
||||||
|
screenshareStreamsClosedByViewer.add(screenshareStream);
|
||||||
|
activeScreenshareSocket?.close(1011, "Screen sharing viewer disconnected");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure stream for low latency
|
screenshareStream.pipe(res);
|
||||||
activeScreenshareStream.setMaxListeners(0);
|
|
||||||
|
|
||||||
// Pipe with immediate flush
|
|
||||||
activeScreenshareStream.pipe(res, { end: false });
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
apiRouter.ws("/screenshare", (ws, req) => {
|
apiRouter.ws("/screenshare", (ws, req) => {
|
||||||
const mimeType = req.query.mimeType as string;
|
const mimeType = getScreenshareContentType(req.query.mimeType);
|
||||||
console.log("Screen sharing client connected with mimeType: " + mimeType);
|
console.log("Screen sharing client connected with mimeType: " + mimeType);
|
||||||
ws.binaryType = "arraybuffer";
|
req.socket.setNoDelay(true);
|
||||||
|
|
||||||
let firstChunk = false;
|
// Supersede an old sender without allowing its close handler to tear down
|
||||||
|
// the new session.
|
||||||
|
const previousSocket = activeScreenshareSocket;
|
||||||
|
const previousStream = activeScreenshareStream;
|
||||||
|
activeScreenshareSocket = ws;
|
||||||
|
activeScreenshareMimeType = mimeType;
|
||||||
|
activeScreenshareStream = new PassThrough({
|
||||||
|
highWaterMark: SCREENSHARE_STREAM_HIGH_WATER_MARK,
|
||||||
|
allowHalfOpen: false,
|
||||||
|
});
|
||||||
|
const screenshareStream = activeScreenshareStream;
|
||||||
|
let waitingForDrain = false;
|
||||||
|
|
||||||
// Configure WebSocket for low latency
|
previousStream?.end();
|
||||||
ws.setMaxListeners(0);
|
if (previousSocket && previousSocket.readyState === WebSocket.OPEN) {
|
||||||
ws.binaryType = "arraybuffer";
|
previousSocket.close(1000, "A newer screen sharing session started");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start mpv while the browser starts its recorder, instead of waiting for a
|
||||||
|
// whole encoded chunk before beginning the HTTP connection and demux setup.
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
const url = `http://127.0.0.1:${port}/api/screenshareStream`;
|
||||||
|
console.log(`Starting screen share stream at ${url}`);
|
||||||
|
mediaPlayer.initiateScreenSharing(url).catch((error: unknown) => {
|
||||||
|
console.error("Failed to start screen sharing:", error);
|
||||||
|
if (activeScreenshareSocket === ws) {
|
||||||
|
ws.close(1011, "Unable to start the media player");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ws.on('message', (data: any) => {
|
ws.on('message', (data: any) => {
|
||||||
const buffer = data instanceof Buffer ? data : Buffer.from(data);
|
const buffer = data instanceof Buffer ? data : Buffer.from(data);
|
||||||
|
|
||||||
if (!firstChunk) {
|
|
||||||
firstChunk = true;
|
|
||||||
|
|
||||||
const port = (server.address() as AddressInfo).port;
|
if (activeScreenshareStream !== screenshareStream) {
|
||||||
const url = `http://localhost:${port}/api/screenshareStream`;
|
return;
|
||||||
console.log(`Starting screen share stream at ${url}`);
|
}
|
||||||
|
|
||||||
// Create new shared stream with immediate flush
|
if (!screenshareStream.write(buffer) && !waitingForDrain) {
|
||||||
activeScreenshareStream = new PassThrough({
|
waitingForDrain = true;
|
||||||
highWaterMark: 1024 * 1024, // 1MB buffer
|
ws.pause();
|
||||||
allowHalfOpen: false
|
screenshareStream.once('drain', () => {
|
||||||
});
|
waitingForDrain = false;
|
||||||
|
if (activeScreenshareStream === screenshareStream && ws.readyState === WebSocket.OPEN) {
|
||||||
activeScreenshareStream.write(buffer);
|
ws.resume();
|
||||||
mediaPlayer.initiateScreenSharing(url);
|
}
|
||||||
} else if (activeScreenshareStream) {
|
|
||||||
// Write with immediate flush
|
|
||||||
activeScreenshareStream.write(buffer, () => {
|
|
||||||
activeScreenshareStream?.cork();
|
|
||||||
activeScreenshareStream?.uncork();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
console.log("Screen sharing client disconnected");
|
console.log("Screen sharing client disconnected");
|
||||||
if (activeScreenshareStream) {
|
if (activeScreenshareStream === screenshareStream) {
|
||||||
activeScreenshareStream.end();
|
screenshareStream.end();
|
||||||
activeScreenshareStream = null;
|
activeScreenshareStream = null;
|
||||||
|
activeScreenshareMimeType = null;
|
||||||
|
activeScreenshareSocket = null;
|
||||||
|
if (!screenshareStreamsClosedByViewer.has(screenshareStream)) {
|
||||||
|
mediaPlayer.stop().catch((error: unknown) => {
|
||||||
|
console.error("Failed to stop screen sharing:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mediaPlayer.stop();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,6 +372,9 @@ const server = app.listen(port, () => {
|
|||||||
// Add graceful shutdown handling
|
// Add graceful shutdown handling
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
console.log('Received shutdown signal. Closing server...');
|
console.log('Received shutdown signal. Closing server...');
|
||||||
|
|
||||||
|
// Invalidate pending playback failures and close only QueueCube's owned browser.
|
||||||
|
await mediaPlayer.shutdown();
|
||||||
|
|
||||||
// Stop zeroconf service
|
// Stop zeroconf service
|
||||||
mediaPlayer.stopZeroconfService();
|
mediaPlayer.stopZeroconfService();
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { ChromiumFallback } = require("../build/ChromiumFallback");
|
||||||
|
|
||||||
|
const waitFor = async (predicate) => {
|
||||||
|
const deadline = Date.now() + 3000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await predicate()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
throw new Error("timed out waiting for subprocess fixture");
|
||||||
|
};
|
||||||
|
|
||||||
|
test("uses isolated profile and safe argv; stop kills only its process and cleans profile", async () => {
|
||||||
|
const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "queuecube-test-"));
|
||||||
|
const argsFile = path.join(fixture, "argv.json");
|
||||||
|
const executable = path.join(fixture, "fake-chrome.js");
|
||||||
|
await fs.writeFile(executable, `#!/usr/bin/env node\nconst fs=require('fs'); fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2))); setInterval(()=>{},1000);\n`);
|
||||||
|
await fs.chmod(executable, 0o700);
|
||||||
|
const browser = new ChromiumFallback(() => undefined, executable);
|
||||||
|
const url = "https://example.test/?x=1; touch /tmp/should-not-exist";
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(await browser.start(url, () => true), true);
|
||||||
|
await waitFor(async () => { try { await fs.access(argsFile); return true; } catch { return false; } });
|
||||||
|
const args = JSON.parse(await fs.readFile(argsFile, "utf8"));
|
||||||
|
assert.equal(args.at(-1), url);
|
||||||
|
assert.ok(args.some((arg) => arg.startsWith("--user-data-dir=")));
|
||||||
|
assert.ok(args.includes("--kiosk"));
|
||||||
|
assert.ok(!args.some((arg) => /no-sandbox|disable-web-security|ignore-certificate/i.test(arg)));
|
||||||
|
const profilePath = args.find((arg) => arg.startsWith("--user-data-dir=")).split("=", 2)[1];
|
||||||
|
await browser.stop();
|
||||||
|
assert.equal(browser.isRunning, false);
|
||||||
|
await assert.rejects(fs.access(profilePath));
|
||||||
|
} finally {
|
||||||
|
await browser.stop();
|
||||||
|
await fs.rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discovers chromium-browser when that is the installed executable", async () => {
|
||||||
|
const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "queuecube-browser-path-test-"));
|
||||||
|
const argsFile = path.join(fixture, "argv.json");
|
||||||
|
const executable = path.join(fixture, "chromium-browser");
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
const previousOverride = process.env.QUEUECUBE_CHROME_BIN;
|
||||||
|
await fs.writeFile(executable, `#!/usr/bin/env node\nconst fs=require('fs'); fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2))); setInterval(()=>{},1000);\n`);
|
||||||
|
await fs.chmod(executable, 0o700);
|
||||||
|
await fs.symlink(process.execPath, path.join(fixture, "node"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.PATH = fixture;
|
||||||
|
delete process.env.QUEUECUBE_CHROME_BIN;
|
||||||
|
const browser = new ChromiumFallback();
|
||||||
|
assert.equal(await browser.start("https://example.test/browser-discovery", () => true), true);
|
||||||
|
await waitFor(async () => { try { await fs.access(argsFile); return true; } catch { return false; } });
|
||||||
|
assert.equal(JSON.parse(await fs.readFile(argsFile, "utf8")).at(-1), "https://example.test/browser-discovery");
|
||||||
|
await browser.stop();
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = previousPath;
|
||||||
|
if (previousOverride === undefined) delete process.env.QUEUECUBE_CHROME_BIN;
|
||||||
|
else process.env.QUEUECUBE_CHROME_BIN = previousOverride;
|
||||||
|
await fs.rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canceled startup cleans its profile without spawning", async () => {
|
||||||
|
const browser = new ChromiumFallback(() => undefined, "/bin/false");
|
||||||
|
assert.equal(await browser.start("https://example.test/", () => false), false);
|
||||||
|
assert.equal(browser.isRunning, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancellation during process startup stops the late process and removes its profile", async () => {
|
||||||
|
const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "queuecube-race-test-"));
|
||||||
|
const executable = path.join(fixture, "fake-chrome.js");
|
||||||
|
await fs.writeFile(executable, "#!/usr/bin/env node\nsetInterval(()=>{},1000);\n");
|
||||||
|
await fs.chmod(executable, 0o700);
|
||||||
|
const browser = new ChromiumFallback(() => undefined, executable);
|
||||||
|
const cleanedProfiles = [];
|
||||||
|
const removeProfile = browser.removeProfile.bind(browser);
|
||||||
|
browser.removeProfile = async (profile) => { cleanedProfiles.push(profile); await removeProfile(profile); };
|
||||||
|
let checks = 0;
|
||||||
|
try {
|
||||||
|
assert.equal(await browser.start("https://example.test/race", () => ++checks === 1), false);
|
||||||
|
assert.equal(checks, 2, "the request became stale after process startup");
|
||||||
|
await waitFor(async () => {
|
||||||
|
if (!cleanedProfiles.length) return false;
|
||||||
|
try { await fs.access(cleanedProfiles[0]); return false; } catch { return true; }
|
||||||
|
});
|
||||||
|
assert.equal(browser.isRunning, false);
|
||||||
|
} finally {
|
||||||
|
await browser.stop();
|
||||||
|
await fs.rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing browser executable reports startup failure and clears ownership", async () => {
|
||||||
|
const browser = new ChromiumFallback(() => undefined, "/definitely/missing/queuecube-chrome");
|
||||||
|
await assert.rejects(browser.start("https://example.test/", () => true), /ENOENT/);
|
||||||
|
assert.equal(browser.isRunning, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { MediaPlayer } = require("../build/MediaPlayer");
|
||||||
|
|
||||||
|
const makePlayer = (url = "https://example.test/watch?v=abc", playlist = [{ id: 42, filename: url }]) => {
|
||||||
|
const player = Object.create(MediaPlayer.prototype);
|
||||||
|
const starts = [];
|
||||||
|
const commands = [];
|
||||||
|
let stops = 0;
|
||||||
|
const state = {
|
||||||
|
eventSubscribers: [],
|
||||||
|
playbackErrors: new Map(),
|
||||||
|
currentFile: null,
|
||||||
|
lastLoadCandidate: null,
|
||||||
|
activeEntryId: null,
|
||||||
|
activeUrl: null,
|
||||||
|
currentMediaTitle: null,
|
||||||
|
currentPlaybackMetadata: {},
|
||||||
|
dataBuffer: "",
|
||||||
|
activeAttemptGeneration: 0,
|
||||||
|
fallbackSuppressedUntilStart: false,
|
||||||
|
fallbackCandidates: new Set(),
|
||||||
|
entryCandidates: new Map(),
|
||||||
|
pendingCandidateUrls: [],
|
||||||
|
browserUrl: null,
|
||||||
|
metadata: new Map(),
|
||||||
|
};
|
||||||
|
Object.assign(player, state);
|
||||||
|
player.browserFallback = {
|
||||||
|
isRunning: false,
|
||||||
|
start: async (requestedUrl, isCurrent) => {
|
||||||
|
starts.push(requestedUrl);
|
||||||
|
if (!isCurrent()) return false;
|
||||||
|
player.browserFallback.isRunning = true;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
stop: async () => {
|
||||||
|
stops += 1;
|
||||||
|
player.browserFallback.isRunning = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
player.writeCommand = async (command, args) => {
|
||||||
|
commands.push([command, args]);
|
||||||
|
if (command === "loadfile") return { error: "success" };
|
||||||
|
if (command === "get_property") return { data: playlist };
|
||||||
|
return { error: "success" };
|
||||||
|
};
|
||||||
|
player.fetchMetadataAndNotify = async () => undefined;
|
||||||
|
return { player, starts, commands, get stops() { return stops; }, set playlist(value) { playlist = value; } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const begin = (player, entryId = 42) => player.receiveData(`${JSON.stringify({ event: "start-file", playlist_entry_id: entryId })}\n`);
|
||||||
|
const send = (player, event, entryId = player.activeEntryId) => player.receiveData(`${JSON.stringify({
|
||||||
|
event: "end-file", playlist_entry_id: entryId, ...event,
|
||||||
|
})}\n`);
|
||||||
|
const settle = async () => { await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); };
|
||||||
|
|
||||||
|
test("successful MPV completion does not start fallback", async () => {
|
||||||
|
const { player, starts } = makePlayer();
|
||||||
|
player.fallbackCandidates.add("https://example.test/watch?v=abc");
|
||||||
|
player.entryCandidates.set(42, "https://example.test/watch?v=abc");
|
||||||
|
begin(player);
|
||||||
|
send(player, { reason: "eof" });
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(starts, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actual MPV error opens the exact original URL and mirrors indeterminate state", async () => {
|
||||||
|
const url = "https://example.test/watch?v=x&next=1;touch%20/tmp/nope";
|
||||||
|
const { player, starts } = makePlayer(url);
|
||||||
|
player.fallbackCandidates.add(url);
|
||||||
|
player.entryCandidates.set(42, url);
|
||||||
|
begin(player);
|
||||||
|
send(player, { reason: "error", file_error: "demux failed" });
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(starts, [url]);
|
||||||
|
assert.equal(player.browserUrl, url);
|
||||||
|
assert.equal((await player.getNowPlaying()).filename, url);
|
||||||
|
assert.equal(await player.getPauseState(), false);
|
||||||
|
assert.equal(await player.getIdle(), false);
|
||||||
|
assert.equal(await player.getTimePosition(), null);
|
||||||
|
assert.equal(await player.getDuration(), null);
|
||||||
|
assert.equal(await player.getSeekable(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queue append leaves active A eligible when queued B is added", async () => {
|
||||||
|
const a = "https://example.test/a";
|
||||||
|
const b = "https://example.test/b";
|
||||||
|
const { player, starts, commands } = makePlayer(a, [{ id: 42, filename: a }]);
|
||||||
|
player.fallbackCandidates.add(a);
|
||||||
|
player.entryCandidates.set(42, a);
|
||||||
|
begin(player, 42);
|
||||||
|
await player.append(b);
|
||||||
|
await settle();
|
||||||
|
assert.equal(commands.filter(([name]) => name === "loadfile").at(-1)[1][1], "append-play");
|
||||||
|
send(player, { reason: "error", file_error: "failed A" }, 42);
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(starts, [a]);
|
||||||
|
assert.equal(player.browserUrl, a);
|
||||||
|
assert.equal(player.fallbackCandidates.has(b), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queue append during fallback startup keeps browser A open without starting B", async () => {
|
||||||
|
const a = "https://example.test/a";
|
||||||
|
const b = "https://example.test/b";
|
||||||
|
const { player, starts, commands } = makePlayer(a, [{ id: 42, filename: a }]);
|
||||||
|
player.fallbackCandidates.add(a);
|
||||||
|
player.entryCandidates.set(42, a);
|
||||||
|
begin(player, 42);
|
||||||
|
send(player, { reason: "error", file_error: "failed A" }, 42);
|
||||||
|
await player.append(b);
|
||||||
|
await settle();
|
||||||
|
const load = commands.filter(([name]) => name === "loadfile").at(-1);
|
||||||
|
assert.equal(load[1][1], "append");
|
||||||
|
assert.deepEqual(starts, [a]);
|
||||||
|
assert.equal(player.browserUrl, a);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("append then skipTo keeps the selected entry eligible for its actual MPV error", async () => {
|
||||||
|
const url = "https://example.test/shared-original?x=1&y=2";
|
||||||
|
const { player, starts } = makePlayer(url, [{ id: 42, filename: url }]);
|
||||||
|
await player.append(url);
|
||||||
|
await settle();
|
||||||
|
await player.skipTo(0);
|
||||||
|
begin(player, 42);
|
||||||
|
send(player, { reason: "error", file_error: "load failed" }, 42);
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(starts, [url]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failure is correlated from MPV end-file entry id without querying a cleared playlist", async () => {
|
||||||
|
const url = "https://example.test/fails";
|
||||||
|
const { player, starts } = makePlayer(url);
|
||||||
|
player.fallbackCandidates.add(url);
|
||||||
|
player.entryCandidates.set(42, url);
|
||||||
|
begin(player, 42);
|
||||||
|
player.writeCommand = async (command) => {
|
||||||
|
assert.notEqual(command, "get_property", "failure correlation must use the emitted entry id");
|
||||||
|
return { data: [] };
|
||||||
|
};
|
||||||
|
player.activeEntryId = null;
|
||||||
|
player.activeUrl = null;
|
||||||
|
player.currentFile = null;
|
||||||
|
send(player, { reason: "error", file_error: "network unavailable" }, 42);
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(starts, [url]);
|
||||||
|
assert.equal(player.playbackErrors.get(url), "network unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("EOF, user stop, cancel, replacement, or stale queue events do not fallback", async () => {
|
||||||
|
const eof = makePlayer();
|
||||||
|
eof.player.fallbackCandidates.add("https://example.test/watch?v=abc");
|
||||||
|
eof.player.entryCandidates.set(42, "https://example.test/watch?v=abc");
|
||||||
|
begin(eof.player);
|
||||||
|
send(eof.player, { reason: "eof" });
|
||||||
|
|
||||||
|
for (const action of ["stop", "cancel", "replacement", "shutdown"]) {
|
||||||
|
const replacementUrl = "https://example.test/replacement";
|
||||||
|
const { player, starts } = makePlayer(replacementUrl);
|
||||||
|
if (action === "shutdown") await player.shutdown();
|
||||||
|
else if (action === "stop") await player.stop();
|
||||||
|
else if (action === "replacement") await player.replace(replacementUrl);
|
||||||
|
else await player.cancelBrowserFallback();
|
||||||
|
send(player, { reason: "error" }, 42);
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(starts, [], `${action} must invalidate the callback`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = makePlayer();
|
||||||
|
stale.player.fallbackCandidates.add("https://example.test/watch?v=abc");
|
||||||
|
stale.player.entryCandidates.set(42, "https://example.test/watch?v=abc");
|
||||||
|
begin(stale.player, 42);
|
||||||
|
begin(stale.player, 43);
|
||||||
|
send(stale.player, { reason: "error" }, 42);
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(stale.starts, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fallback startup failure leaves MPV state usable", async () => {
|
||||||
|
const { player, starts } = makePlayer();
|
||||||
|
const url = "https://example.test/watch?v=abc";
|
||||||
|
player.fallbackCandidates.add(url);
|
||||||
|
player.entryCandidates.set(42, url);
|
||||||
|
player.browserFallback.start = async () => { throw new Error("chromium missing"); };
|
||||||
|
begin(player);
|
||||||
|
send(player, { reason: "error" });
|
||||||
|
await settle();
|
||||||
|
assert.equal(player.browserUrl, null);
|
||||||
|
assert.deepEqual(starts, []);
|
||||||
|
assert.equal(player.playbackErrors.get(url), "Unknown playback error");
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
require("./ChromiumFallback.test.cjs");
|
||||||
|
require("./MediaPlayerFallback.test.cjs");
|
||||||
@@ -203,7 +203,9 @@ export const API = {
|
|||||||
|
|
||||||
startScreenShare(mimeType: string): WebSocket {
|
startScreenShare(mimeType: string): WebSocket {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/screenshare?mimeType=${mimeType}`);
|
const ws = new WebSocket(
|
||||||
|
`${protocol}://${window.location.host}/api/screenshare?mimeType=${encodeURIComponent(mimeType)}`,
|
||||||
|
);
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { API } from '../api/player';
|
import { API } from '../api/player';
|
||||||
|
|
||||||
|
const TARGET_FRAME_RATE = 60;
|
||||||
|
const MAX_CAPTURE_WIDTH = 1920;
|
||||||
|
const MAX_CAPTURE_HEIGHT = 1080;
|
||||||
|
const RECORDER_TIMESLICE_MS = 16;
|
||||||
|
const MIN_VIDEO_BIT_RATE = 2_500_000;
|
||||||
|
const MAX_VIDEO_BIT_RATE = 8_000_000;
|
||||||
|
const VIDEO_BITS_PER_PIXEL = 0.065;
|
||||||
|
const SOCKET_BUFFER_TARGET_MS = 75;
|
||||||
|
const MIN_SOCKET_BUFFER_BYTES = 32 * 1024;
|
||||||
|
const MIN_RESUME_BUFFER_BYTES = 8 * 1024;
|
||||||
|
|
||||||
interface UseScreenShareResult {
|
interface UseScreenShareResult {
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
isScreenSharingSupported: boolean;
|
isScreenSharingSupported: boolean;
|
||||||
@@ -8,15 +19,21 @@ interface UseScreenShareResult {
|
|||||||
stopScreenShare: () => void;
|
stopScreenShare: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBestSupportedMimeType() {
|
function getBestSupportedMimeType(hasAudio: boolean): string | undefined {
|
||||||
// Ordered by preference (best first) - all of these include audio+video
|
// VP8 is substantially cheaper to encode than VP9 on most systems. Prefer it
|
||||||
const mimeTypes = [
|
// for real-time capture, then use hardware-friendly H.264 where available.
|
||||||
'video/webm;codecs=vp9,opus', // Best quality, good compression
|
const mimeTypes = hasAudio ? [
|
||||||
'video/webm;codecs=vp8,opus', // Good fallback, well supported
|
'video/webm;codecs=vp8,opus',
|
||||||
'video/webm;codecs=h264,opus', // Better compatibility with some systems
|
'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
|
||||||
'video/mp4;codecs=h264,aac', // Good for Safari but may not be supported for MediaRecorder
|
'video/webm;codecs=vp9,opus',
|
||||||
'video/webm', // Generic fallback (browser will choose codecs)
|
'video/webm',
|
||||||
'video/mp4' // Last resort
|
'video/mp4',
|
||||||
|
] : [
|
||||||
|
'video/webm;codecs=vp8',
|
||||||
|
'video/mp4;codecs=avc1.42E01E',
|
||||||
|
'video/webm;codecs=vp9',
|
||||||
|
'video/webm',
|
||||||
|
'video/mp4',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Find the first supported mimetype
|
// Find the first supported mimetype
|
||||||
@@ -27,15 +44,42 @@ function getBestSupportedMimeType() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If none are supported, return null or a basic fallback
|
|
||||||
console.warn('No preferred mime types supported by this browser');
|
console.warn('No preferred mime types supported by this browser');
|
||||||
return 'video/webm'; // Most basic fallback
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetVideoBitRate(track: MediaStreamTrack): number {
|
||||||
|
const settings = track.getSettings();
|
||||||
|
const width = settings.width ?? MAX_CAPTURE_WIDTH;
|
||||||
|
const height = settings.height ?? MAX_CAPTURE_HEIGHT;
|
||||||
|
const frameRate = Math.min(settings.frameRate ?? TARGET_FRAME_RATE, TARGET_FRAME_RATE);
|
||||||
|
const target = width * height * frameRate * VIDEO_BITS_PER_PIXEL;
|
||||||
|
|
||||||
|
return Math.round(Math.min(MAX_VIDEO_BIT_RATE, Math.max(MIN_VIDEO_BIT_RATE, target)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocketBufferLimits(recorder: MediaRecorder) {
|
||||||
|
const bytesPerSecond = (recorder.videoBitsPerSecond + recorder.audioBitsPerSecond) / 8;
|
||||||
|
const maximum = Math.max(
|
||||||
|
MIN_SOCKET_BUFFER_BYTES,
|
||||||
|
Math.ceil(bytesPerSecond * SOCKET_BUFFER_TARGET_MS / 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
maximum,
|
||||||
|
resumeAt: Math.max(MIN_RESUME_BUFFER_BYTES, Math.floor(maximum / 4)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useScreenShare = (): UseScreenShareResult => {
|
export const useScreenShare = (): UseScreenShareResult => {
|
||||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||||
const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false);
|
const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false);
|
||||||
const screenShareSocketRef = useRef<WebSocket | null>(null);
|
const screenShareSocketRef = useRef<WebSocket | null>(null);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const backpressureTimerRef = useRef<number | null>(null);
|
||||||
|
const isStartingRef = useRef(false);
|
||||||
|
const sessionGenerationRef = useRef(0);
|
||||||
|
|
||||||
// Check if screen sharing is supported
|
// Check if screen sharing is supported
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,80 +91,205 @@ export const useScreenShare = (): UseScreenShareResult => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stopScreenShare = useCallback(() => {
|
const stopScreenShare = useCallback(() => {
|
||||||
if (screenShareSocketRef.current) {
|
sessionGenerationRef.current += 1;
|
||||||
screenShareSocketRef.current.close();
|
isStartingRef.current = false;
|
||||||
screenShareSocketRef.current = null;
|
|
||||||
|
if (backpressureTimerRef.current !== null) {
|
||||||
|
window.clearInterval(backpressureTimerRef.current);
|
||||||
|
backpressureTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recorder = mediaRecorderRef.current;
|
||||||
|
mediaRecorderRef.current = null;
|
||||||
|
if (recorder) {
|
||||||
|
recorder.ondataavailable = null;
|
||||||
|
recorder.onerror = null;
|
||||||
|
if (recorder.state !== 'inactive') {
|
||||||
|
recorder.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaStream = mediaStreamRef.current;
|
||||||
|
mediaStreamRef.current = null;
|
||||||
|
mediaStream?.getTracks().forEach((track) => {
|
||||||
|
track.onended = null;
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = screenShareSocketRef.current;
|
||||||
|
screenShareSocketRef.current = null;
|
||||||
|
if (socket) {
|
||||||
|
socket.onopen = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.onerror = null;
|
||||||
|
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startScreenShare = useCallback(async () => {
|
const startScreenShare = useCallback(async () => {
|
||||||
|
if (isStartingRef.current || screenShareSocketRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isStartingRef.current = true;
|
||||||
|
const sessionGeneration = ++sessionGenerationRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
video: true,
|
video: {
|
||||||
|
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
||||||
|
width: { max: MAX_CAPTURE_WIDTH },
|
||||||
|
height: { max: MAX_CAPTURE_HEIGHT },
|
||||||
|
},
|
||||||
audio: true,
|
audio: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mimeType = getBestSupportedMimeType();
|
// getDisplayMedia cannot be programmatically dismissed. If the user
|
||||||
console.log('Using MIME type:', mimeType);
|
// stopped or restarted while its picker was open, discard this result.
|
||||||
|
if (sessionGenerationRef.current !== sessionGeneration) {
|
||||||
|
mediaStream.getTracks().forEach((track) => track.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mediaRecorder = new MediaRecorder(mediaStream, {
|
mediaStreamRef.current = mediaStream;
|
||||||
mimeType: mimeType,
|
|
||||||
videoBitsPerSecond: 2500000, // 2.5 Mbps
|
const videoTrack = mediaStream.getVideoTracks()[0];
|
||||||
audioBitsPerSecond: 128000, // 128 kbps
|
if (!videoTrack) {
|
||||||
|
throw new Error('The selected source did not provide a video track');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favor smooth motion over static-text quality when the browser needs to
|
||||||
|
// trade capture resolution for frame rate.
|
||||||
|
videoTrack.contentHint = 'motion';
|
||||||
|
mediaStream.getAudioTracks().forEach((track) => {
|
||||||
|
track.contentHint = 'music';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasAudio = mediaStream.getAudioTracks().length > 0;
|
||||||
|
const preferredMimeType = getBestSupportedMimeType(hasAudio);
|
||||||
|
const recorderOptions: MediaRecorderOptions = {
|
||||||
|
videoBitsPerSecond: getTargetVideoBitRate(videoTrack),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preferredMimeType) {
|
||||||
|
recorderOptions.mimeType = preferredMimeType;
|
||||||
|
}
|
||||||
|
if (hasAudio) {
|
||||||
|
recorderOptions.audioBitsPerSecond = 128_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaRecorder = new MediaRecorder(mediaStream, recorderOptions);
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
const mimeType = mediaRecorder.mimeType || preferredMimeType || 'application/octet-stream';
|
||||||
|
const socketBufferLimits = getSocketBufferLimits(mediaRecorder);
|
||||||
|
console.log(
|
||||||
|
`Screen sharing at up to ${TARGET_FRAME_RATE} fps, ${Math.round(mediaRecorder.videoBitsPerSecond / 1000)} kbps, using ${mimeType}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Connect to WebSocket
|
// Connect to WebSocket
|
||||||
screenShareSocketRef.current = API.startScreenShare(mimeType);
|
const socket = API.startScreenShare(mimeType);
|
||||||
|
screenShareSocketRef.current = socket;
|
||||||
|
isStartingRef.current = false;
|
||||||
|
|
||||||
|
const resumeRecorderWhenDrained = () => {
|
||||||
|
if (screenShareSocketRef.current !== socket || mediaRecorderRef.current !== mediaRecorder) {
|
||||||
|
if (backpressureTimerRef.current !== null) {
|
||||||
|
window.clearInterval(backpressureTimerRef.current);
|
||||||
|
backpressureTimerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket.bufferedAmount <= socketBufferLimits.resumeAt && mediaRecorder.state === 'paused') {
|
||||||
|
mediaRecorder.resume();
|
||||||
|
if (backpressureTimerRef.current !== null) {
|
||||||
|
window.clearInterval(backpressureTimerRef.current);
|
||||||
|
backpressureTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pauseRecorderForBackpressure = () => {
|
||||||
|
if (mediaRecorder.state === 'recording') {
|
||||||
|
mediaRecorder.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backpressureTimerRef.current === null) {
|
||||||
|
backpressureTimerRef.current = window.setInterval(
|
||||||
|
resumeRecorderWhenDrained,
|
||||||
|
RECORDER_TIMESLICE_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Set up WebSocket event handlers
|
// Set up WebSocket event handlers
|
||||||
screenShareSocketRef.current.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
if (screenShareSocketRef.current !== socket) {
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Screen sharing WebSocket connected');
|
console.log('Screen sharing WebSocket connected');
|
||||||
setIsScreenSharing(true);
|
setIsScreenSharing(true);
|
||||||
|
mediaRecorder.start(RECORDER_TIMESLICE_MS);
|
||||||
mediaRecorder.start(100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
screenShareSocketRef.current.onclose = () => {
|
socket.onclose = () => {
|
||||||
console.log('Screen sharing WebSocket closed');
|
console.log('Screen sharing WebSocket closed');
|
||||||
setIsScreenSharing(false);
|
if (screenShareSocketRef.current === socket) {
|
||||||
|
stopScreenShare();
|
||||||
// Stop all tracks when WebSocket is closed
|
}
|
||||||
mediaStream.getTracks().forEach(track => track.stop());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
screenShareSocketRef.current.onerror = (error) => {
|
socket.onerror = (error) => {
|
||||||
console.error('Screen sharing WebSocket error:', error);
|
console.error('Screen sharing WebSocket error:', error);
|
||||||
setIsScreenSharing(false);
|
if (screenShareSocketRef.current === socket) {
|
||||||
|
stopScreenShare();
|
||||||
// Stop all tracks on error
|
}
|
||||||
mediaStream.getTracks().forEach(track => track.stop());
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onerror = (error) => {
|
||||||
|
console.error('Screen sharing recorder error:', error);
|
||||||
|
if (mediaRecorderRef.current === mediaRecorder) {
|
||||||
|
stopScreenShare();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send data over WebSocket when available
|
// Send data over WebSocket when available
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
if (event.data && event.data.size > 0 && screenShareSocketRef.current && screenShareSocketRef.current.readyState === WebSocket.OPEN) {
|
if (event.data.size > 0 && screenShareSocketRef.current === socket && socket.readyState === WebSocket.OPEN) {
|
||||||
screenShareSocketRef.current.send(event.data);
|
socket.send(event.data);
|
||||||
|
|
||||||
|
// Browser WebSockets otherwise queue without a limit, turning a brief
|
||||||
|
// network slowdown into permanently stale playback. Pausing capture
|
||||||
|
// bounds that queue and resumes at the live edge once it drains.
|
||||||
|
if (socket.bufferedAmount >= socketBufferLimits.maximum) {
|
||||||
|
pauseRecorderForBackpressure();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle stream ending (user clicks "Stop sharing")
|
// Handle stream ending (user clicks "Stop sharing")
|
||||||
mediaStream.getVideoTracks()[0].onended = () => {
|
videoTrack.onended = stopScreenShare;
|
||||||
if (screenShareSocketRef.current) {
|
|
||||||
screenShareSocketRef.current.close();
|
|
||||||
screenShareSocketRef.current = null;
|
|
||||||
}
|
|
||||||
setIsScreenSharing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting screen share:', error);
|
if (sessionGenerationRef.current === sessionGeneration) {
|
||||||
setIsScreenSharing(false);
|
console.error('Error starting screen share:', error);
|
||||||
|
stopScreenShare();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [stopScreenShare]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopScreenShare();
|
||||||
|
}, [stopScreenShare]);
|
||||||
|
|
||||||
const toggleScreenShare = useCallback(async () => {
|
const toggleScreenShare = useCallback(async () => {
|
||||||
if (screenShareSocketRef.current) {
|
if (screenShareSocketRef.current || isStartingRef.current) {
|
||||||
stopScreenShare();
|
stopScreenShare();
|
||||||
} else {
|
} else {
|
||||||
await startScreenShare();
|
await startScreenShare();
|
||||||
@@ -133,4 +302,4 @@ export const useScreenShare = (): UseScreenShareResult => {
|
|||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
stopScreenShare
|
stopScreenShare
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user