@3dsource/angular-unreal-module 0.0.158 → 0.0.159-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -2464,10 +2464,57 @@ class VideoService extends SubService {
|
|
|
2464
2464
|
? interval(250).pipe(map(() => track.pcClient), switchMap$1((pcClient) => from(this.getStats(pcClient))), filter(Truthy))
|
|
2465
2465
|
: of(null)), filter(Truthy), share());
|
|
2466
2466
|
this.destroyAbort = new AbortController();
|
|
2467
|
+
// Teardown is wired ONCE here instead of per `create()`. The per-create
|
|
2468
|
+
// subscription added one more live handler for every session, and the
|
|
2469
|
+
// `take(1)` that bounded it let the shared `disconnect$` refCount fall to
|
|
2470
|
+
// zero between sessions — which re-armed its `skip(1)` and could swallow
|
|
2471
|
+
// the next real disconnect. `destroy()` is idempotent, so reacting to every
|
|
2472
|
+
// emission from a single subscription is equivalent but leak-free.
|
|
2473
|
+
this.disconnect$
|
|
2474
|
+
.pipe(takeUntil$1(this.destroy$))
|
|
2475
|
+
.subscribe(() => this.destroy());
|
|
2467
2476
|
this.destroyRef.onDestroy(() => this.destroy());
|
|
2468
2477
|
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Sets (or replaces) the DOM target for the streaming `<video>`.
|
|
2480
|
+
*
|
|
2481
|
+
* Attaching is idempotent and order-independent on purpose: `create()` is
|
|
2482
|
+
* driven by the network (`config`, or a synchronous prefetch adoption) while
|
|
2483
|
+
* the container comes from the scene component's render pass, so either side
|
|
2484
|
+
* can win the race. If `create()` got there first the element is detached —
|
|
2485
|
+
* adopt it here instead of leaving it orphaned (which showed up as a white
|
|
2486
|
+
* scene with an empty `#player` and no `videoStreamInitialized`).
|
|
2487
|
+
*/
|
|
2469
2488
|
setContainer(container = null) {
|
|
2470
2489
|
this.container = container;
|
|
2490
|
+
this.attachVideoToContainer();
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* Moves `this.video` under the current container unless it is already there.
|
|
2494
|
+
* `parentElement` (not `isConnected`) is the guard: it also covers a swap to a
|
|
2495
|
+
* different container while the element sits in a still-live old one.
|
|
2496
|
+
*/
|
|
2497
|
+
attachVideoToContainer() {
|
|
2498
|
+
const container = this.container;
|
|
2499
|
+
const video = this.video;
|
|
2500
|
+
if (!container || !video || video.parentElement === container) {
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
container.appendChild(video);
|
|
2504
|
+
// Positive confirmation that the element the stream renders into actually
|
|
2505
|
+
// exists in the document — the failure this guards against is silent
|
|
2506
|
+
// (detached element, white scene), so the healthy path must be visible too.
|
|
2507
|
+
Logger.info(`VideoService: <video id="${video.id}"> attached to #${container.id || '(container without id)'} | inDom=${video.isConnected} | hasStream=${!!video.srcObject}`);
|
|
2508
|
+
// A detached element cannot start playing, so the play() issued by
|
|
2509
|
+
// attachVideoStream before this point was lost — re-issue it now that the
|
|
2510
|
+
// element is in the DOM. Deliberately NOT gated on `video.paused`: per spec
|
|
2511
|
+
// play() clears `paused` synchronously, so a play that was accepted but
|
|
2512
|
+
// never produced frames (detached element) is indistinguishable from a
|
|
2513
|
+
// healthy one by that flag. play() on an already-playing element is a
|
|
2514
|
+
// no-op, which makes the unconditional call the safe direction.
|
|
2515
|
+
if (video.srcObject) {
|
|
2516
|
+
void this.safePlay(video);
|
|
2517
|
+
}
|
|
2471
2518
|
}
|
|
2472
2519
|
setLatencyTimings(latencyTimings) {
|
|
2473
2520
|
this.latencyTestTimings.SetUETimings(latencyTimings);
|
|
@@ -2477,11 +2524,8 @@ class VideoService extends SubService {
|
|
|
2477
2524
|
}
|
|
2478
2525
|
create() {
|
|
2479
2526
|
if (!this.container) {
|
|
2480
|
-
Logger.warn('VideoService.create(): no container set; <video>
|
|
2527
|
+
Logger.warn('VideoService.create(): no container set yet; <video> is built detached and will be attached (and replayed) by the next setContainer().');
|
|
2481
2528
|
}
|
|
2482
|
-
this.disconnect$
|
|
2483
|
-
.pipe(take(1), takeUntil$1(this.destroy$))
|
|
2484
|
-
.subscribe(() => this.destroy());
|
|
2485
2529
|
this.destroy();
|
|
2486
2530
|
this.createWebRtcVideo();
|
|
2487
2531
|
this.createWebRtcAudio();
|
|
@@ -2529,10 +2573,10 @@ class VideoService extends SubService {
|
|
|
2529
2573
|
return;
|
|
2530
2574
|
}
|
|
2531
2575
|
catch (error) {
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2576
|
+
// Only an autoplay block is worth retrying. Anything else (AbortError,
|
|
2577
|
+
// a decode failure, a non-Error rejection value) is reported and left
|
|
2578
|
+
// alone — retrying muted or on a gesture cannot fix those.
|
|
2579
|
+
if (!isAutoplayBlocked(error)) {
|
|
2536
2580
|
Logger.warn('safePlay() rejected with non-autoplay error:', error);
|
|
2537
2581
|
return;
|
|
2538
2582
|
}
|
|
@@ -2546,33 +2590,43 @@ class VideoService extends SubService {
|
|
|
2546
2590
|
catch {
|
|
2547
2591
|
/* fall through */
|
|
2548
2592
|
}
|
|
2549
|
-
// Last resort: wait for the next real user gesture
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
// choose whether to unmute here
|
|
2553
|
-
// video.muted = false;
|
|
2554
|
-
await video.play();
|
|
2555
|
-
}
|
|
2556
|
-
catch {
|
|
2557
|
-
/* ignore, maybe another gesture needed */
|
|
2558
|
-
}
|
|
2559
|
-
finally {
|
|
2560
|
-
window.removeEventListener('pointerdown', resume, true);
|
|
2561
|
-
window.removeEventListener('keydown', resume, true);
|
|
2562
|
-
}
|
|
2563
|
-
};
|
|
2564
|
-
window.addEventListener('pointerdown', resume, {
|
|
2565
|
-
once: true,
|
|
2566
|
-
capture: true,
|
|
2567
|
-
signal: this.destroyAbort.signal,
|
|
2568
|
-
});
|
|
2569
|
-
window.addEventListener('keydown', resume, {
|
|
2570
|
-
once: true,
|
|
2571
|
-
capture: true,
|
|
2572
|
-
signal: this.destroyAbort.signal,
|
|
2573
|
-
});
|
|
2593
|
+
// Last resort: wait for the next real user gesture.
|
|
2594
|
+
// choose whether to unmute in armGestureRetry's caller
|
|
2595
|
+
this.armGestureRetry(video);
|
|
2574
2596
|
}
|
|
2575
2597
|
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Retries playback on the next real user gesture — the only thing that lifts
|
|
2600
|
+
* a browser autoplay block. Shared by `safePlay()` and `playAudio()` so both
|
|
2601
|
+
* media elements use one arming/cleanup path.
|
|
2602
|
+
*
|
|
2603
|
+
* Listeners are `once` + `capture` and bound to `destroyAbort.signal`, so a
|
|
2604
|
+
* `destroy()` in this session drops them instead of leaking into the next one.
|
|
2605
|
+
*/
|
|
2606
|
+
armGestureRetry(element) {
|
|
2607
|
+
const resume = async () => {
|
|
2608
|
+
try {
|
|
2609
|
+
await element.play();
|
|
2610
|
+
}
|
|
2611
|
+
catch {
|
|
2612
|
+
/* ignore — another gesture may be needed */
|
|
2613
|
+
}
|
|
2614
|
+
finally {
|
|
2615
|
+
window.removeEventListener('pointerdown', resume, true);
|
|
2616
|
+
window.removeEventListener('keydown', resume, true);
|
|
2617
|
+
}
|
|
2618
|
+
};
|
|
2619
|
+
window.addEventListener('pointerdown', resume, {
|
|
2620
|
+
once: true,
|
|
2621
|
+
capture: true,
|
|
2622
|
+
signal: this.destroyAbort.signal,
|
|
2623
|
+
});
|
|
2624
|
+
window.addEventListener('keydown', resume, {
|
|
2625
|
+
once: true,
|
|
2626
|
+
capture: true,
|
|
2627
|
+
signal: this.destroyAbort.signal,
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2576
2630
|
async getStats(pcClient) {
|
|
2577
2631
|
if (!pcClient || pcClient.connectionState === 'closed') {
|
|
2578
2632
|
return null;
|
|
@@ -2603,47 +2657,7 @@ class VideoService extends SubService {
|
|
|
2603
2657
|
stats.forEach((report) => {
|
|
2604
2658
|
// Get the inbound-rtp for video
|
|
2605
2659
|
if (report.type === 'inbound-rtp' && report.kind === 'video') {
|
|
2606
|
-
|
|
2607
|
-
newStat.bytesReceivedStart =
|
|
2608
|
-
this.aggregatedStats && this.aggregatedStats.bytesReceivedStart
|
|
2609
|
-
? this.aggregatedStats.bytesReceivedStart
|
|
2610
|
-
: report.bytesReceived;
|
|
2611
|
-
newStat.framesDecodedStart =
|
|
2612
|
-
this.aggregatedStats && this.aggregatedStats.framesDecodedStart
|
|
2613
|
-
? this.aggregatedStats.framesDecodedStart
|
|
2614
|
-
: report.framesDecoded;
|
|
2615
|
-
newStat.timestampStart =
|
|
2616
|
-
this.aggregatedStats && this.aggregatedStats.timestampStart
|
|
2617
|
-
? this.aggregatedStats.timestampStart
|
|
2618
|
-
: report.timestamp;
|
|
2619
|
-
if (this.aggregatedStats && this.aggregatedStats.timestamp) {
|
|
2620
|
-
// Get the mimetype of the video codec being used
|
|
2621
|
-
if (report.codecId &&
|
|
2622
|
-
this.aggregatedStats.codecs &&
|
|
2623
|
-
Object.hasOwn(this.aggregatedStats.codecs, report.codecId)) {
|
|
2624
|
-
newStat.videoCodec = this.aggregatedStats.codecs[report.codecId];
|
|
2625
|
-
}
|
|
2626
|
-
if (this.aggregatedStats.bytesReceived) {
|
|
2627
|
-
// bitrate = bits received since last time / number of ms since last time
|
|
2628
|
-
// This is automatically in kbits (where k=1000) since time is in ms and stat we want is in seconds (so a '* 1000' then a '/ 1000' would negate each other)
|
|
2629
|
-
newStat.bitrate = Math.floor((8 *
|
|
2630
|
-
((newStat.bytesReceived || 0) -
|
|
2631
|
-
this.aggregatedStats.bytesReceived)) /
|
|
2632
|
-
(newStat.timestamp - this.aggregatedStats.timestamp));
|
|
2633
|
-
}
|
|
2634
|
-
if (this.aggregatedStats.bytesReceivedStart) {
|
|
2635
|
-
newStat.avgBitrate = Math.floor((8 *
|
|
2636
|
-
((newStat.bytesReceived || 0) -
|
|
2637
|
-
this.aggregatedStats.bytesReceivedStart)) /
|
|
2638
|
-
(newStat.timestamp - this.aggregatedStats.timestampStart));
|
|
2639
|
-
}
|
|
2640
|
-
if (this.aggregatedStats.framesDecodedStart) {
|
|
2641
|
-
newStat.avgFrameRate = Math.floor(((newStat.framesDecoded || 0) -
|
|
2642
|
-
this.aggregatedStats.framesDecodedStart) /
|
|
2643
|
-
((newStat.timestamp - this.aggregatedStats.timestampStart) /
|
|
2644
|
-
1000));
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2660
|
+
this.applyInboundVideoReport(newStat, report);
|
|
2647
2661
|
}
|
|
2648
2662
|
// Only read RTT from the *nominated* ICE candidate pair. The
|
|
2649
2663
|
// stats report contains multiple `candidate-pair` entries (one
|
|
@@ -2662,15 +2676,7 @@ class VideoService extends SubService {
|
|
|
2662
2676
|
}
|
|
2663
2677
|
if (report.type === 'transport' &&
|
|
2664
2678
|
Object.hasOwn(report, 'selectedCandidatePairId')) {
|
|
2665
|
-
|
|
2666
|
-
if (selectedPair) {
|
|
2667
|
-
const local = stats.get(selectedPair.localCandidateId);
|
|
2668
|
-
const remote = stats.get(selectedPair.remoteCandidateId);
|
|
2669
|
-
newStat.selectedPair = {
|
|
2670
|
-
local: { type: local?.candidateType, url: local?.url },
|
|
2671
|
-
remote: { type: remote?.candidateType, url: remote?.url },
|
|
2672
|
-
};
|
|
2673
|
-
}
|
|
2679
|
+
this.applySelectedPair(newStat, stats, report.selectedCandidatePairId);
|
|
2674
2680
|
}
|
|
2675
2681
|
// Store mimetype of each codec
|
|
2676
2682
|
if (Object.hasOwn(newStat, 'codecs') &&
|
|
@@ -2683,6 +2689,68 @@ class VideoService extends SubService {
|
|
|
2683
2689
|
.replace('audio/', '');
|
|
2684
2690
|
}
|
|
2685
2691
|
});
|
|
2692
|
+
this.applySessionTotals(newStat);
|
|
2693
|
+
this.aggregatedStats = newStat;
|
|
2694
|
+
return this.onAggregatedStats(newStat);
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Folds one `inbound-rtp` video report into the accumulator: copies the raw
|
|
2698
|
+
* report, pins the per-session baselines on the first pass, then derives the
|
|
2699
|
+
* deltas relative to the previous pass.
|
|
2700
|
+
*/
|
|
2701
|
+
applyInboundVideoReport(newStat, report) {
|
|
2702
|
+
Object.assign(newStat, report);
|
|
2703
|
+
const previous = this.aggregatedStats;
|
|
2704
|
+
newStat.bytesReceivedStart = pinBaseline(previous?.bytesReceivedStart, report.bytesReceived);
|
|
2705
|
+
newStat.framesDecodedStart = pinBaseline(previous?.framesDecodedStart, report.framesDecoded);
|
|
2706
|
+
newStat.timestampStart = pinBaseline(previous?.timestampStart, report.timestamp);
|
|
2707
|
+
// Nothing to diff against on the first report of a session.
|
|
2708
|
+
if (!previous?.timestamp) {
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
// Get the mimetype of the video codec being used
|
|
2712
|
+
if (report.codecId &&
|
|
2713
|
+
previous.codecs &&
|
|
2714
|
+
Object.hasOwn(previous.codecs, report.codecId)) {
|
|
2715
|
+
newStat.videoCodec = previous.codecs[report.codecId];
|
|
2716
|
+
}
|
|
2717
|
+
if (previous.bytesReceived) {
|
|
2718
|
+
// bitrate = bits received since last time / number of ms since last time
|
|
2719
|
+
// This is automatically in kbits (where k=1000) since time is in ms and stat we want is in seconds (so a '* 1000' then a '/ 1000' would negate each other)
|
|
2720
|
+
newStat.bitrate = Math.floor((8 * ((newStat.bytesReceived || 0) - previous.bytesReceived)) /
|
|
2721
|
+
(newStat.timestamp - previous.timestamp));
|
|
2722
|
+
}
|
|
2723
|
+
if (previous.bytesReceivedStart) {
|
|
2724
|
+
newStat.avgBitrate = Math.floor((8 * ((newStat.bytesReceived || 0) - previous.bytesReceivedStart)) /
|
|
2725
|
+
(newStat.timestamp - previous.timestampStart));
|
|
2726
|
+
}
|
|
2727
|
+
if (previous.framesDecodedStart) {
|
|
2728
|
+
newStat.avgFrameRate = Math.floor(((newStat.framesDecoded || 0) - previous.framesDecodedStart) /
|
|
2729
|
+
((newStat.timestamp - previous.timestampStart) / 1000));
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
/**
|
|
2733
|
+
* Resolves the transport's selected candidate pair into the local/remote
|
|
2734
|
+
* descriptors shown in the stats overlay. Silently skips when the pair (or
|
|
2735
|
+
* either of its candidates) is missing from this report.
|
|
2736
|
+
*/
|
|
2737
|
+
applySelectedPair(newStat, stats, selectedCandidatePairId) {
|
|
2738
|
+
if (!selectedCandidatePairId) {
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
const selectedPair = stats.get(selectedCandidatePairId);
|
|
2742
|
+
if (!selectedPair) {
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
const local = stats.get(selectedPair.localCandidateId);
|
|
2746
|
+
const remote = stats.get(selectedPair.remoteCandidateId);
|
|
2747
|
+
newStat.selectedPair = {
|
|
2748
|
+
local: { type: local?.candidateType, url: local?.url },
|
|
2749
|
+
remote: { type: remote?.candidateType, url: remote?.url },
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
/** Per-session values that do not come from a single report. */
|
|
2753
|
+
applySessionTotals(newStat) {
|
|
2686
2754
|
newStat.pixelRatio = +window.devicePixelRatio.toFixed(2);
|
|
2687
2755
|
if (this.aggregatedStats.receiveToCompositeMs) {
|
|
2688
2756
|
newStat.receiveToCompositeMs = this.aggregatedStats.receiveToCompositeMs;
|
|
@@ -2690,8 +2758,6 @@ class VideoService extends SubService {
|
|
|
2690
2758
|
}
|
|
2691
2759
|
// Calculate duration of run
|
|
2692
2760
|
newStat.runTime = Math.floor((newStat.timestamp - newStat.timestampStart) / 1000);
|
|
2693
|
-
this.aggregatedStats = newStat;
|
|
2694
|
-
return this.onAggregatedStats(newStat);
|
|
2695
2761
|
}
|
|
2696
2762
|
onAggregatedStats(aggregatedStats) {
|
|
2697
2763
|
return {
|
|
@@ -2716,6 +2782,11 @@ class VideoService extends SubService {
|
|
|
2716
2782
|
// on to the RTCRtpReceiver until GC eventually clears srcObject.
|
|
2717
2783
|
this.video.pause();
|
|
2718
2784
|
this.video.srcObject = null;
|
|
2785
|
+
// Remove exactly the element we own. Clearing every child of
|
|
2786
|
+
// `this.container` instead would wipe DOM this service never created —
|
|
2787
|
+
// including the children of a container that was swapped in after
|
|
2788
|
+
// `create()`, since `container` always points at the newest one.
|
|
2789
|
+
this.video.remove();
|
|
2719
2790
|
if (this.audio) {
|
|
2720
2791
|
this.audio.pause();
|
|
2721
2792
|
this.audio.srcObject = null;
|
|
@@ -2723,12 +2794,12 @@ class VideoService extends SubService {
|
|
|
2723
2794
|
this.video = null;
|
|
2724
2795
|
this.audio = null;
|
|
2725
2796
|
this.videoTrack$.next(null);
|
|
2726
|
-
// Reset stat state to prevent stale deltas on next session
|
|
2797
|
+
// Reset stat state to prevent stale deltas on next session — including the
|
|
2798
|
+
// latency timings, which otherwise carry the previous session's UE/display
|
|
2799
|
+
// deltas into the first measurements of the next one.
|
|
2727
2800
|
this.aggregatedStats = {};
|
|
2728
2801
|
this.VideoEncoderQP = 0;
|
|
2729
|
-
|
|
2730
|
-
this.container.removeChild(this.container.firstChild);
|
|
2731
|
-
}
|
|
2802
|
+
this.latencyTestTimings = new LatencyTimings();
|
|
2732
2803
|
}
|
|
2733
2804
|
changeStatusMainVideoOnScene(isVideoPlaying) {
|
|
2734
2805
|
if (this.isVideoPlaying() === isVideoPlaying) {
|
|
@@ -2753,7 +2824,8 @@ class VideoService extends SubService {
|
|
|
2753
2824
|
video.preload = 'auto';
|
|
2754
2825
|
video.disablePictureInPicture = true;
|
|
2755
2826
|
this.video = video;
|
|
2756
|
-
this.
|
|
2827
|
+
this.attachVideoToContainer();
|
|
2828
|
+
Logger.info(`VideoService.create(): <video id="${video.id}"> created | inDom=${video.isConnected}`);
|
|
2757
2829
|
fromEvent(this.video, 'play')
|
|
2758
2830
|
.pipe(takeUntil$1(merge(this.disconnect$, this.destroy$)))
|
|
2759
2831
|
.subscribe(() => this.changeStatusMainVideoOnScene(true));
|
|
@@ -2769,32 +2841,11 @@ class VideoService extends SubService {
|
|
|
2769
2841
|
async playAudio(audio) {
|
|
2770
2842
|
try {
|
|
2771
2843
|
await audio.play();
|
|
2772
|
-
return;
|
|
2773
2844
|
}
|
|
2774
2845
|
catch {
|
|
2775
|
-
//
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
await audio.play();
|
|
2779
|
-
}
|
|
2780
|
-
catch {
|
|
2781
|
-
/* ignore — caller may need another gesture */
|
|
2782
|
-
}
|
|
2783
|
-
finally {
|
|
2784
|
-
window.removeEventListener('pointerdown', resume, true);
|
|
2785
|
-
window.removeEventListener('keydown', resume, true);
|
|
2786
|
-
}
|
|
2787
|
-
};
|
|
2788
|
-
window.addEventListener('pointerdown', resume, {
|
|
2789
|
-
once: true,
|
|
2790
|
-
capture: true,
|
|
2791
|
-
signal: this.destroyAbort.signal,
|
|
2792
|
-
});
|
|
2793
|
-
window.addEventListener('keydown', resume, {
|
|
2794
|
-
once: true,
|
|
2795
|
-
capture: true,
|
|
2796
|
-
signal: this.destroyAbort.signal,
|
|
2797
|
-
});
|
|
2846
|
+
// Any rejection here is treated as "needs a gesture": unlike video, audio
|
|
2847
|
+
// has no muted fallback left to try.
|
|
2848
|
+
this.armGestureRetry(audio);
|
|
2798
2849
|
}
|
|
2799
2850
|
}
|
|
2800
2851
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: VideoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
@@ -2804,6 +2855,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
|
|
|
2804
2855
|
type: Injectable,
|
|
2805
2856
|
args: [{ providedIn: 'root' }]
|
|
2806
2857
|
}], ctorParameters: () => [] });
|
|
2858
|
+
/**
|
|
2859
|
+
* Keeps a per-session baseline once it has been pinned, otherwise starts it from
|
|
2860
|
+
* the current report. Mirrors the original `previous?.x ? previous.x : now`
|
|
2861
|
+
* chain — `0` counts as "not pinned yet", which is what the deltas below expect.
|
|
2862
|
+
*/
|
|
2863
|
+
function pinBaseline(pinned, current) {
|
|
2864
|
+
return (pinned || current);
|
|
2865
|
+
}
|
|
2866
|
+
/**
|
|
2867
|
+
* Whether a rejected `play()` was blocked by the browser autoplay policy.
|
|
2868
|
+
* Narrows an `unknown` rejection value without assuming it is an Error — a
|
|
2869
|
+
* rejection with a non-object value must NOT be mistaken for an autoplay block,
|
|
2870
|
+
* or it would trigger a pointless muted/gesture retry cycle.
|
|
2871
|
+
*/
|
|
2872
|
+
function isAutoplayBlocked(error) {
|
|
2873
|
+
return (!!error &&
|
|
2874
|
+
typeof error === 'object' &&
|
|
2875
|
+
'name' in error &&
|
|
2876
|
+
error.name === 'NotAllowedError');
|
|
2877
|
+
}
|
|
2807
2878
|
|
|
2808
2879
|
class WebRtcPlayerService extends SubService {
|
|
2809
2880
|
constructor() {
|
|
@@ -7493,6 +7564,12 @@ class UnrealSceneComponent {
|
|
|
7493
7564
|
// keeps the next 3D route's connect unblocked (connectToSignaling$ gates
|
|
7494
7565
|
// on `!cirrusConnected`).
|
|
7495
7566
|
this.#store.dispatch(destroyUnrealScene());
|
|
7567
|
+
// Drop the container reference last: `VideoService` is a root singleton
|
|
7568
|
+
// that outlives this scene, so keeping the old `#player` wired would hold
|
|
7569
|
+
// a detached subtree reachable and let a late `create()` attach the
|
|
7570
|
+
// <video> to DOM nobody can see. The dispatch above already ran the
|
|
7571
|
+
// teardown that removes the element itself.
|
|
7572
|
+
this.videoService.setContainer(null);
|
|
7496
7573
|
});
|
|
7497
7574
|
effect(() => {
|
|
7498
7575
|
const videoElement = this.videoElement();
|