@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> will be built but never attached to the DOM, which suppresses autoplay.');
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
- if (error &&
2533
- typeof error === 'object' &&
2534
- 'name' in error &&
2535
- error?.name !== 'NotAllowedError') {
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 and play with/without sound
2550
- const resume = async () => {
2551
- try {
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
- Object.assign(newStat, report);
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
- const selectedPair = stats.get(report.selectedCandidatePairId);
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
- while (this.container?.firstChild) {
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.container?.appendChild(this.video);
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
- // Wait for the next real user gesture and retry.
2776
- const resume = async () => {
2777
- try {
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();