@livepeer-frameworks/streamcrafter-wc 0.1.1 → 0.1.2

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.
@@ -17,6 +17,8 @@ import {
17
17
  micMutedIcon,
18
18
  videoIcon,
19
19
  xIcon,
20
+ eyeIcon,
21
+ eyeOffIcon,
20
22
  } from "../icons/index.js";
21
23
  import { IngestControllerHost } from "../controllers/ingest-controller-host.js";
22
24
  import type {
@@ -81,6 +83,7 @@ export class FwStreamCrafter extends LitElement {
81
83
  @state() private _showSettings = false;
82
84
  @state() private _showSources = true;
83
85
  @state() private _isAdvancedPanelOpen = false;
86
+ @state() private _contextMenu: { x: number; y: number } | null = null;
84
87
 
85
88
  @query(".fw-sc-preview video") private _videoEl!: HTMLVideoElement | null;
86
89
 
@@ -155,6 +158,45 @@ export class FwStreamCrafter extends LitElement {
155
158
  }
156
159
  }
157
160
 
161
+ // ---- Context Menu ----
162
+
163
+ private _boundDismissContextMenu = this._dismissContextMenu.bind(this);
164
+
165
+ private _handleContextMenu(e: MouseEvent) {
166
+ e.preventDefault();
167
+ this._contextMenu = { x: e.clientX, y: e.clientY };
168
+ document.addEventListener("click", this._boundDismissContextMenu, { once: true });
169
+ document.addEventListener("contextmenu", this._boundDismissContextMenu, { once: true });
170
+ }
171
+
172
+ private _dismissContextMenu() {
173
+ this._contextMenu = null;
174
+ document.removeEventListener("click", this._boundDismissContextMenu);
175
+ document.removeEventListener("contextmenu", this._boundDismissContextMenu);
176
+ }
177
+
178
+ private _copyWhipUrl() {
179
+ if (this.whipUrl) {
180
+ navigator.clipboard.writeText(this.whipUrl).catch(console.error);
181
+ }
182
+ this._contextMenu = null;
183
+ }
184
+
185
+ private _copyStreamInfo() {
186
+ const s = this.pc.s;
187
+ const profile = QUALITY_PROFILES.find((p) => p.id === s.qualityProfile);
188
+ const info = [
189
+ `Status: ${s.state}`,
190
+ `Quality: ${profile?.label ?? s.qualityProfile} (${profile?.description ?? ""})`,
191
+ `Sources: ${s.sources.length}`,
192
+ this.whipUrl ? `WHIP: ${this.whipUrl}` : null,
193
+ ]
194
+ .filter(Boolean)
195
+ .join("\n");
196
+ navigator.clipboard.writeText(info).catch(console.error);
197
+ this._contextMenu = null;
198
+ }
199
+
158
200
  // ---- Public API ----
159
201
 
160
202
  async startCamera(options?: Parameters<IngestControllerHost["startCamera"]>[0]) {
@@ -205,6 +247,7 @@ export class FwStreamCrafter extends LitElement {
205
247
  return html`
206
248
  <div
207
249
  class=${classMap({ root: true, "fw-sc-root": true, "fw-sc-root--devmode": this.devMode })}
250
+ @contextmenu=${(e: MouseEvent) => this._handleContextMenu(e)}
208
251
  >
209
252
  <div class="main fw-sc-main">
210
253
  <!-- Header -->
@@ -279,6 +322,22 @@ export class FwStreamCrafter extends LitElement {
279
322
  : nothing}
280
323
  </div>
281
324
 
325
+ <!-- VU Meter -->
326
+ ${s.isCapturing
327
+ ? html`
328
+ <div class="fw-sc-vu-meter">
329
+ <div
330
+ class="fw-sc-vu-meter-fill"
331
+ style="width:${Math.min(s.audioLevel * 100, 100)}%"
332
+ ></div>
333
+ <div
334
+ class="fw-sc-vu-meter-peak"
335
+ style="left:${Math.min(s.peakAudioLevel * 100, 100)}%"
336
+ ></div>
337
+ </div>
338
+ `
339
+ : nothing}
340
+
282
341
  <!-- Error -->
283
342
  ${s.error
284
343
  ? html`
@@ -360,11 +419,63 @@ export class FwStreamCrafter extends LitElement {
360
419
  </div>
361
420
  </div>
362
421
 
422
+ <!-- Context Menu -->
423
+ ${this._contextMenu
424
+ ? html`
425
+ <div
426
+ class="fw-sc-context-menu"
427
+ style="position:fixed;top:${this._contextMenu.y}px;left:${this._contextMenu.x}px;z-index:1000;background:#1a1b26;border:1px solid rgba(90,96,127,0.3);border-radius:6px;padding:4px;box-shadow:0 4px 12px rgba(0,0,0,0.5);min-width:160px"
428
+ >
429
+ ${this.whipUrl
430
+ ? html`
431
+ <button
432
+ type="button"
433
+ class="fw-sc-context-menu-item"
434
+ @click=${() => this._copyWhipUrl()}
435
+ >
436
+ Copy WHIP URL
437
+ </button>
438
+ `
439
+ : nothing}
440
+ <button
441
+ type="button"
442
+ class="fw-sc-context-menu-item"
443
+ @click=${() => this._copyStreamInfo()}
444
+ >
445
+ Copy Stream Info
446
+ </button>
447
+ ${this.devMode
448
+ ? html`
449
+ <div class="fw-sc-context-menu-separator"></div>
450
+ <button
451
+ type="button"
452
+ class="fw-sc-context-menu-item"
453
+ @click=${() => {
454
+ this._isAdvancedPanelOpen = !this._isAdvancedPanelOpen;
455
+ this._contextMenu = null;
456
+ }}
457
+ >
458
+ ${settingsIcon(14)}
459
+ <span
460
+ >${this._isAdvancedPanelOpen ? "Hide Advanced" : "Advanced"}</span
461
+ >
462
+ </button>
463
+ `
464
+ : nothing}
465
+ </div>
466
+ `
467
+ : nothing}
468
+
363
469
  <!-- Advanced Panel -->
364
470
  ${this.devMode && this._isAdvancedPanelOpen
365
471
  ? html`
366
472
  <fw-sc-advanced
367
473
  .ic=${this.pc}
474
+ .compositorEnabled=${this.enableCompositor}
475
+ .compositorRendererType=${this._getCompositorRendererType()}
476
+ .compositorStats=${this._getCompositorStats()}
477
+ .sceneCount=${this._getSceneCount()}
478
+ .layerCount=${this._getLayerCount()}
368
479
  @fw-close=${() => {
369
480
  this._isAdvancedPanelOpen = false;
370
481
  }}
@@ -375,11 +486,77 @@ export class FwStreamCrafter extends LitElement {
375
486
  `;
376
487
  }
377
488
 
489
+ private _getSourceLayerVisibility(sourceId: string): boolean {
490
+ const ctrl = this.pc.getController();
491
+ if (!ctrl || !this.enableCompositor) return true;
492
+ const sm = ctrl.getSceneManager();
493
+ if (!sm) return true;
494
+ const scene = sm.getActiveScene();
495
+ if (!scene) return true;
496
+ const layer = scene.layers.find((l: { sourceId: string }) => l.sourceId === sourceId);
497
+ return layer?.visible ?? true;
498
+ }
499
+
500
+ private _toggleSourceLayerVisibility(sourceId: string) {
501
+ const ctrl = this.pc.getController();
502
+ if (!ctrl) return;
503
+ const sm = ctrl.getSceneManager();
504
+ if (!sm) return;
505
+ const scene = sm.getActiveScene();
506
+ if (!scene) return;
507
+ const layer = scene.layers.find((l: { sourceId: string }) => l.sourceId === sourceId);
508
+ if (layer) {
509
+ sm.setLayerVisibility(scene.id, layer.id, !layer.visible);
510
+ this.requestUpdate();
511
+ }
512
+ }
513
+
514
+ // ---- Compositor helpers (for advanced panel) ----
515
+
516
+ private _getCompositorRendererType() {
517
+ if (!this.enableCompositor) return null;
518
+ return this.pc.getController()?.getSceneManager()?.getRendererType() ?? null;
519
+ }
520
+
521
+ private _getCompositorStats() {
522
+ if (!this.enableCompositor) return null;
523
+ return this.pc.getController()?.getSceneManager()?.getStats() ?? null;
524
+ }
525
+
526
+ private _getSceneCount(): number {
527
+ if (!this.enableCompositor) return 0;
528
+ return this.pc.getController()?.getSceneManager()?.getAllScenes()?.length ?? 0;
529
+ }
530
+
531
+ private _getLayerCount(): number {
532
+ if (!this.enableCompositor) return 0;
533
+ const scene = this.pc.getController()?.getSceneManager()?.getActiveScene();
534
+ return scene?.layers?.length ?? 0;
535
+ }
536
+
378
537
  private _renderSourceRow(source: MediaSource) {
379
538
  const s = this.pc.s;
380
539
  const hasVideo = source.stream.getVideoTracks().length > 0;
540
+ const isVisible = this._getSourceLayerVisibility(source.id);
381
541
  return html`
382
- <div class=${classMap({ "fw-sc-source": true })}>
542
+ <div
543
+ class=${classMap({ "fw-sc-source": true, "fw-sc-source--hidden": !isVisible })}
544
+ >
545
+ ${this.enableCompositor
546
+ ? html`
547
+ <button
548
+ type="button"
549
+ class=${classMap({
550
+ "fw-sc-icon-btn": true,
551
+ "fw-sc-icon-btn--muted": !isVisible,
552
+ })}
553
+ @click=${() => this._toggleSourceLayerVisibility(source.id)}
554
+ title=${isVisible ? "Hide from composition" : "Show in composition"}
555
+ >
556
+ ${isVisible ? eyeIcon(14) : eyeOffIcon(14)}
557
+ </button>
558
+ `
559
+ : nothing}
383
560
  <div class="fw-sc-source-icon">
384
561
  ${source.type === "camera" ? cameraIcon(16) : monitorIcon(16)}
385
562
  </div>
@@ -52,6 +52,8 @@ export interface IngestControllerHostState {
52
52
  isWebCodecsActive: boolean;
53
53
  isWebCodecsAvailable: boolean;
54
54
  encoderStats: EncoderStats | null;
55
+ audioLevel: number;
56
+ peakAudioLevel: number;
55
57
  }
56
58
 
57
59
  type HostElement = ReactiveControllerHost & HTMLElement;
@@ -61,6 +63,7 @@ export class IngestControllerHost implements ReactiveController {
61
63
  private controller: IngestControllerV2 | null = null;
62
64
  private unsubs: Array<() => void> = [];
63
65
  private encoderStatsCleanup: (() => void) | null = null;
66
+ private audioLevelCleanup: (() => void) | null = null;
64
67
 
65
68
  s: IngestControllerHostState;
66
69
 
@@ -85,6 +88,8 @@ export class IngestControllerHost implements ReactiveController {
85
88
  isWebCodecsActive: false,
86
89
  isWebCodecsAvailable: isWebCodecsEncodingPathSupported(),
87
90
  encoderStats: null,
91
+ audioLevel: 0,
92
+ peakAudioLevel: 0,
88
93
  };
89
94
  }
90
95
 
@@ -114,6 +119,7 @@ export class IngestControllerHost implements ReactiveController {
114
119
  this.encoderStatsCleanup();
115
120
  this.encoderStatsCleanup = null;
116
121
  }
122
+ this.stopAudioLevelMonitoring();
117
123
  this.controller?.destroy();
118
124
  this.controller = null;
119
125
  }
@@ -134,16 +140,22 @@ export class IngestControllerHost implements ReactiveController {
134
140
  controller.on("stateChange", (event) => {
135
141
  const state = event.state;
136
142
  const ctx = (event.context ?? {}) as IngestStateContextV2;
143
+ const isCapturing = state === "capturing" || state === "streaming";
137
144
  this.update({
138
145
  state,
139
146
  stateContext: ctx,
140
147
  isStreaming: state === "streaming",
141
- isCapturing: state === "capturing" || state === "streaming",
148
+ isCapturing,
142
149
  isReconnecting: state === "reconnecting",
143
150
  mediaStream: controller.getMediaStream(),
144
151
  sources: controller.getSources(),
145
152
  reconnectionState: ctx.reconnection ?? this.s.reconnectionState,
146
153
  });
154
+ if (isCapturing) {
155
+ this.startAudioLevelMonitoring();
156
+ } else {
157
+ this.stopAudioLevelMonitoring();
158
+ }
147
159
  this.dispatchEvent("fw-sc-state-change", { state, context: ctx });
148
160
  })
149
161
  );
@@ -242,6 +254,29 @@ export class IngestControllerHost implements ReactiveController {
242
254
  }
243
255
  }
244
256
 
257
+ private startAudioLevelMonitoring() {
258
+ if (this.audioLevelCleanup || !this.controller) return;
259
+ const audioMixer = this.controller.getAudioMixer();
260
+ if (!audioMixer) return;
261
+
262
+ const unsub = audioMixer.on("levelUpdate", (event: { level: number; peakLevel: number }) => {
263
+ this.update({ audioLevel: event.level, peakAudioLevel: event.peakLevel });
264
+ });
265
+ audioMixer.startLevelMonitoring();
266
+ this.audioLevelCleanup = () => {
267
+ unsub();
268
+ audioMixer.stopLevelMonitoring();
269
+ };
270
+ }
271
+
272
+ private stopAudioLevelMonitoring() {
273
+ if (this.audioLevelCleanup) {
274
+ this.audioLevelCleanup();
275
+ this.audioLevelCleanup = null;
276
+ }
277
+ this.update({ audioLevel: 0, peakAudioLevel: 0 });
278
+ }
279
+
245
280
  private dispatchEvent(name: string, detail: unknown) {
246
281
  this.host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
247
282
  }