@livepeer-frameworks/streamcrafter-wc 0.1.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.
Files changed (81) hide show
  1. package/dist/cjs/components/fw-sc-advanced.js +198 -0
  2. package/dist/cjs/components/fw-sc-advanced.js.map +1 -0
  3. package/dist/cjs/components/fw-sc-compositor.js +116 -0
  4. package/dist/cjs/components/fw-sc-compositor.js.map +1 -0
  5. package/dist/cjs/components/fw-sc-layer-list.js +253 -0
  6. package/dist/cjs/components/fw-sc-layer-list.js.map +1 -0
  7. package/dist/cjs/components/fw-sc-scene-switcher.js +164 -0
  8. package/dist/cjs/components/fw-sc-scene-switcher.js.map +1 -0
  9. package/dist/cjs/components/fw-sc-volume.js +183 -0
  10. package/dist/cjs/components/fw-sc-volume.js.map +1 -0
  11. package/dist/cjs/components/fw-streamcrafter.js +508 -0
  12. package/dist/cjs/components/fw-streamcrafter.js.map +1 -0
  13. package/dist/cjs/controllers/ingest-controller-host.js +236 -0
  14. package/dist/cjs/controllers/ingest-controller-host.js.map +1 -0
  15. package/dist/cjs/define.js +25 -0
  16. package/dist/cjs/define.js.map +1 -0
  17. package/dist/cjs/icons/index.js +283 -0
  18. package/dist/cjs/icons/index.js.map +1 -0
  19. package/dist/cjs/index.js +38 -0
  20. package/dist/cjs/index.js.map +1 -0
  21. package/dist/cjs/node_modules/.pnpm/@rollup_plugin-typescript@12.3.0_rollup@4.57.1_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.js +33 -0
  22. package/dist/cjs/node_modules/.pnpm/@rollup_plugin-typescript@12.3.0_rollup@4.57.1_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.js.map +1 -0
  23. package/dist/cjs/styles/shared-styles.js +2019 -0
  24. package/dist/cjs/styles/shared-styles.js.map +1 -0
  25. package/dist/cjs/styles/utility-styles.js +182 -0
  26. package/dist/cjs/styles/utility-styles.js.map +1 -0
  27. package/dist/esm/components/fw-sc-advanced.js +198 -0
  28. package/dist/esm/components/fw-sc-advanced.js.map +1 -0
  29. package/dist/esm/components/fw-sc-compositor.js +116 -0
  30. package/dist/esm/components/fw-sc-compositor.js.map +1 -0
  31. package/dist/esm/components/fw-sc-layer-list.js +253 -0
  32. package/dist/esm/components/fw-sc-layer-list.js.map +1 -0
  33. package/dist/esm/components/fw-sc-scene-switcher.js +164 -0
  34. package/dist/esm/components/fw-sc-scene-switcher.js.map +1 -0
  35. package/dist/esm/components/fw-sc-volume.js +183 -0
  36. package/dist/esm/components/fw-sc-volume.js.map +1 -0
  37. package/dist/esm/components/fw-streamcrafter.js +508 -0
  38. package/dist/esm/components/fw-streamcrafter.js.map +1 -0
  39. package/dist/esm/controllers/ingest-controller-host.js +234 -0
  40. package/dist/esm/controllers/ingest-controller-host.js.map +1 -0
  41. package/dist/esm/define.js +23 -0
  42. package/dist/esm/define.js.map +1 -0
  43. package/dist/esm/icons/index.js +253 -0
  44. package/dist/esm/icons/index.js.map +1 -0
  45. package/dist/esm/index.js +8 -0
  46. package/dist/esm/index.js.map +1 -0
  47. package/dist/esm/node_modules/.pnpm/@rollup_plugin-typescript@12.3.0_rollup@4.57.1_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.js +31 -0
  48. package/dist/esm/node_modules/.pnpm/@rollup_plugin-typescript@12.3.0_rollup@4.57.1_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.js.map +1 -0
  49. package/dist/esm/styles/shared-styles.js +2017 -0
  50. package/dist/esm/styles/shared-styles.js.map +1 -0
  51. package/dist/esm/styles/utility-styles.js +180 -0
  52. package/dist/esm/styles/utility-styles.js.map +1 -0
  53. package/dist/fw-streamcrafter.iife.js +3121 -0
  54. package/dist/fw-streamcrafter.iife.js.map +1 -0
  55. package/dist/types/components/fw-sc-advanced.d.ts +20 -0
  56. package/dist/types/components/fw-sc-compositor.d.ts +19 -0
  57. package/dist/types/components/fw-sc-layer-list.d.ts +30 -0
  58. package/dist/types/components/fw-sc-scene-switcher.d.ts +23 -0
  59. package/dist/types/components/fw-sc-volume.d.ts +30 -0
  60. package/dist/types/components/fw-streamcrafter.d.ts +49 -0
  61. package/dist/types/controllers/ingest-controller-host.d.ts +77 -0
  62. package/dist/types/define.d.ts +1 -0
  63. package/dist/types/icons/index.d.ts +29 -0
  64. package/dist/types/iife-entry.d.ts +11 -0
  65. package/dist/types/index.d.ts +12 -0
  66. package/dist/types/styles/shared-styles.d.ts +1 -0
  67. package/dist/types/styles/utility-styles.d.ts +1 -0
  68. package/package.json +55 -0
  69. package/src/components/fw-sc-advanced.ts +221 -0
  70. package/src/components/fw-sc-compositor.ts +162 -0
  71. package/src/components/fw-sc-layer-list.ts +251 -0
  72. package/src/components/fw-sc-scene-switcher.ts +163 -0
  73. package/src/components/fw-sc-volume.ts +171 -0
  74. package/src/components/fw-streamcrafter.ts +515 -0
  75. package/src/controllers/ingest-controller-host.ts +358 -0
  76. package/src/define.ts +23 -0
  77. package/src/icons/index.ts +291 -0
  78. package/src/iife-entry.ts +11 -0
  79. package/src/index.ts +15 -0
  80. package/src/styles/shared-styles.ts +2014 -0
  81. package/src/styles/utility-styles.ts +177 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * <fw-sc-volume> — Volume slider with snap-to-100% and popup tooltip.
3
+ * Port of VolumeSlider.tsx from streamcrafter-react.
4
+ */
5
+ import { LitElement, html, css } from "lit";
6
+ import { customElement, property, state, query } from "lit/decorators.js";
7
+ import { sharedStyles } from "../styles/shared-styles.js";
8
+
9
+ @customElement("fw-sc-volume")
10
+ export class FwScVolume extends LitElement {
11
+ @property({ type: Number }) value = 1;
12
+ @property({ type: Number }) min = 0;
13
+ @property({ type: Number }) max = 2;
14
+ @property({ type: Number, attribute: "snap-threshold" }) snapThreshold = 0.05;
15
+ @property({ type: Boolean }) compact = false;
16
+
17
+ @state() private _isDragging = false;
18
+ @state() private _popupPosition = 0;
19
+
20
+ @query("input[type=range]") private _slider!: HTMLInputElement;
21
+
22
+ static styles = [
23
+ sharedStyles,
24
+ css`
25
+ :host {
26
+ display: inline-flex;
27
+ position: relative;
28
+ flex: 1;
29
+ }
30
+ :host([compact]) {
31
+ min-width: 60px;
32
+ }
33
+ :host(:not([compact])) {
34
+ min-width: 100px;
35
+ }
36
+ .track {
37
+ position: relative;
38
+ width: 100%;
39
+ }
40
+ .marker {
41
+ position: absolute;
42
+ top: 0;
43
+ bottom: 0;
44
+ width: 2px;
45
+ border-radius: 1px;
46
+ z-index: 1;
47
+ pointer-events: none;
48
+ transform: translateX(-50%);
49
+ }
50
+ input[type="range"] {
51
+ width: 100%;
52
+ height: 6px;
53
+ border-radius: 3px;
54
+ cursor: pointer;
55
+ }
56
+ .popup {
57
+ position: absolute;
58
+ bottom: 100%;
59
+ transform: translateX(-50%);
60
+ margin-bottom: 8px;
61
+ padding: 4px 8px;
62
+ color: #1a1b26;
63
+ border-radius: 4px;
64
+ font-size: 12px;
65
+ font-weight: 600;
66
+ font-family: monospace;
67
+ white-space: nowrap;
68
+ pointer-events: none;
69
+ z-index: 100;
70
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
71
+ }
72
+ .popup-arrow {
73
+ position: absolute;
74
+ top: 100%;
75
+ left: 50%;
76
+ transform: translateX(-50%);
77
+ width: 0;
78
+ height: 0;
79
+ border-left: 6px solid transparent;
80
+ border-right: 6px solid transparent;
81
+ }
82
+ `,
83
+ ];
84
+
85
+ private get _displayValue(): number {
86
+ return Math.round(this.value * 100);
87
+ }
88
+ private get _isBoost(): boolean {
89
+ return this.value > 1;
90
+ }
91
+ private get _isDefault(): boolean {
92
+ return this.value === 1;
93
+ }
94
+
95
+ private get _accentColor(): string {
96
+ if (this._isBoost) return "#e0af68";
97
+ if (this._isDefault) return "#9ece6a";
98
+ return "#7aa2f7";
99
+ }
100
+
101
+ private _handleChange(e: Event) {
102
+ let newValue = parseInt((e.target as HTMLInputElement).value, 10) / 100;
103
+ if (Math.abs(newValue - 1) <= this.snapThreshold) newValue = 1;
104
+ this.dispatchEvent(
105
+ new CustomEvent("fw-sc-volume-change", {
106
+ detail: { value: newValue },
107
+ bubbles: true,
108
+ composed: true,
109
+ })
110
+ );
111
+ this._updatePopupPosition(newValue);
112
+ }
113
+
114
+ private _handleMouseDown() {
115
+ this._isDragging = true;
116
+ this._updatePopupPosition(this.value);
117
+ }
118
+
119
+ private _handleMouseUp() {
120
+ this._isDragging = false;
121
+ }
122
+
123
+ private _updatePopupPosition(value: number) {
124
+ if (this._slider) {
125
+ const rect = this._slider.getBoundingClientRect();
126
+ const percent = (value - this.min) / (this.max - this.min);
127
+ this._popupPosition = percent * rect.width;
128
+ }
129
+ }
130
+
131
+ protected render() {
132
+ const markerLeft = `${(1 / this.max) * 100}%`;
133
+ const markerBg = this._isDefault ? "#9ece6a" : "rgba(158, 206, 106, 0.3)";
134
+
135
+ return html`
136
+ ${this._isDragging
137
+ ? html`
138
+ <div
139
+ class="popup"
140
+ style="left:${this._popupPosition}px;background:${this._accentColor}"
141
+ >
142
+ ${this._displayValue}%${this._isDefault ? " (default)" : ""}
143
+ <div class="popup-arrow" style="border-top:6px solid ${this._accentColor}"></div>
144
+ </div>
145
+ `
146
+ : ""}
147
+ <div class="track">
148
+ <div class="marker" style="left:${markerLeft};background:${markerBg}"></div>
149
+ <input
150
+ type="range"
151
+ .min=${String(this.min * 100)}
152
+ .max=${String(this.max * 100)}
153
+ .value=${String(Math.round(this.value * 100))}
154
+ @input=${this._handleChange}
155
+ @mousedown=${this._handleMouseDown}
156
+ @mouseup=${this._handleMouseUp}
157
+ @mouseleave=${this._handleMouseUp}
158
+ @touchstart=${this._handleMouseDown}
159
+ @touchend=${this._handleMouseUp}
160
+ style="accent-color:${this._accentColor}"
161
+ />
162
+ </div>
163
+ `;
164
+ }
165
+ }
166
+
167
+ declare global {
168
+ interface HTMLElementTagNameMap {
169
+ "fw-sc-volume": FwScVolume;
170
+ }
171
+ }
@@ -0,0 +1,515 @@
1
+ /**
2
+ * <fw-streamcrafter> — Main orchestrator for StreamCrafter Web Component.
3
+ * Port of StreamCrafter.tsx from streamcrafter-react.
4
+ */
5
+ import { LitElement, html, css, nothing } from "lit";
6
+ import { customElement, property, state, query } from "lit/decorators.js";
7
+ import { classMap } from "lit/directives/class-map.js";
8
+ import { sharedStyles } from "../styles/shared-styles.js";
9
+ import { utilityStyles } from "../styles/utility-styles.js";
10
+ import {
11
+ cameraIcon,
12
+ monitorIcon,
13
+ settingsIcon,
14
+ chevronsRightIcon,
15
+ chevronsLeftIcon,
16
+ micIcon,
17
+ micMutedIcon,
18
+ videoIcon,
19
+ xIcon,
20
+ } from "../icons/index.js";
21
+ import { IngestControllerHost } from "../controllers/ingest-controller-host.js";
22
+ import type {
23
+ QualityProfile,
24
+ MediaSource,
25
+ IngestState,
26
+ ReconnectionState,
27
+ } from "@livepeer-frameworks/streamcrafter-core";
28
+
29
+ const QUALITY_PROFILES: { id: QualityProfile; label: string; description: string }[] = [
30
+ { id: "professional", label: "Professional", description: "1080p @ 8 Mbps" },
31
+ { id: "broadcast", label: "Broadcast", description: "1080p @ 4.5 Mbps" },
32
+ { id: "conference", label: "Conference", description: "720p @ 2.5 Mbps" },
33
+ ];
34
+
35
+ function getStatusText(state: IngestState, reconnectionState?: ReconnectionState | null): string {
36
+ if (reconnectionState?.isReconnecting) {
37
+ return `Reconnecting (${reconnectionState.attemptNumber}/5)...`;
38
+ }
39
+ switch (state) {
40
+ case "idle":
41
+ return "Idle";
42
+ case "requesting_permissions":
43
+ return "Permissions...";
44
+ case "capturing":
45
+ return "Ready";
46
+ case "connecting":
47
+ return "Connecting...";
48
+ case "streaming":
49
+ return "Live";
50
+ case "reconnecting":
51
+ return "Reconnecting...";
52
+ case "error":
53
+ return "Error";
54
+ case "destroyed":
55
+ return "Destroyed";
56
+ default:
57
+ return state;
58
+ }
59
+ }
60
+
61
+ function getStatusBadgeClass(state: IngestState, isReconnecting: boolean): string {
62
+ if (state === "streaming") return "fw-sc-badge fw-sc-badge--live";
63
+ if (isReconnecting) return "fw-sc-badge fw-sc-badge--connecting";
64
+ if (state === "error") return "fw-sc-badge fw-sc-badge--error";
65
+ if (state === "capturing") return "fw-sc-badge fw-sc-badge--ready";
66
+ return "fw-sc-badge fw-sc-badge--idle";
67
+ }
68
+
69
+ @customElement("fw-streamcrafter")
70
+ export class FwStreamCrafter extends LitElement {
71
+ @property({ type: String, attribute: "whip-url" }) whipUrl = "";
72
+ @property({ type: String, attribute: "gateway-url" }) gatewayUrl = "";
73
+ @property({ type: String, attribute: "stream-key" }) streamKey = "";
74
+ @property({ type: String, attribute: "initial-profile" }) initialProfile: QualityProfile =
75
+ "broadcast";
76
+ @property({ type: Boolean, attribute: "auto-start-camera" }) autoStartCamera = false;
77
+ @property({ type: Boolean, attribute: "dev-mode" }) devMode = false;
78
+ @property({ type: Boolean }) debug = false;
79
+ @property({ type: Boolean, attribute: "enable-compositor" }) enableCompositor = false;
80
+
81
+ @state() private _showSettings = false;
82
+ @state() private _showSources = true;
83
+ @state() private _isAdvancedPanelOpen = false;
84
+
85
+ @query(".fw-sc-preview video") private _videoEl!: HTMLVideoElement | null;
86
+
87
+ pc: IngestControllerHost;
88
+
89
+ static styles = [
90
+ sharedStyles,
91
+ utilityStyles,
92
+ css`
93
+ :host {
94
+ display: block;
95
+ }
96
+ .root {
97
+ display: flex;
98
+ height: 100%;
99
+ }
100
+ .main {
101
+ display: flex;
102
+ flex-direction: column;
103
+ flex: 1;
104
+ min-width: 0;
105
+ }
106
+ `,
107
+ ];
108
+
109
+ constructor() {
110
+ super();
111
+ this.pc = new IngestControllerHost(this, this.initialProfile);
112
+ }
113
+
114
+ connectedCallback() {
115
+ super.connectedCallback();
116
+ this._initController();
117
+ }
118
+
119
+ willUpdate(changed: Map<string, unknown>) {
120
+ if (changed.has("whipUrl") || changed.has("initialProfile") || changed.has("debug")) {
121
+ this._initController();
122
+ }
123
+ }
124
+
125
+ updated(changed: Map<string, unknown>) {
126
+ if (changed.has("_showSources") || changed.has("_showSettings")) {
127
+ // no-op, reactive update handles UI
128
+ }
129
+ this._syncVideoPreview();
130
+ }
131
+
132
+ private _initController() {
133
+ if (!this.whipUrl) return;
134
+ this.pc.initialize({
135
+ whipUrl: this.whipUrl,
136
+ profile: this.initialProfile,
137
+ debug: this.debug,
138
+ reconnection: { enabled: true, maxAttempts: 5 },
139
+ audioMixing: true,
140
+ });
141
+
142
+ if (this.autoStartCamera && this.pc.s.state === "idle") {
143
+ this.pc.startCamera().catch(console.error);
144
+ }
145
+ }
146
+
147
+ private _syncVideoPreview() {
148
+ const video = this._videoEl;
149
+ const stream = this.pc.s.mediaStream;
150
+ if (video && stream && video.srcObject !== stream) {
151
+ video.srcObject = stream;
152
+ video.play().catch(() => {});
153
+ } else if (video && !stream) {
154
+ video.srcObject = null;
155
+ }
156
+ }
157
+
158
+ // ---- Public API ----
159
+
160
+ async startCamera(options?: Parameters<IngestControllerHost["startCamera"]>[0]) {
161
+ return this.pc.startCamera(options);
162
+ }
163
+ async startScreenShare(options?: Parameters<IngestControllerHost["startScreenShare"]>[0]) {
164
+ return this.pc.startScreenShare(options);
165
+ }
166
+ async startStreaming() {
167
+ return this.pc.startStreaming();
168
+ }
169
+ async stopStreaming() {
170
+ return this.pc.stopStreaming();
171
+ }
172
+ async stopCapture() {
173
+ return this.pc.stopCapture();
174
+ }
175
+ removeSource(id: string) {
176
+ this.pc.removeSource(id);
177
+ }
178
+ setSourceVolume(id: string, vol: number) {
179
+ this.pc.setSourceVolume(id, vol);
180
+ }
181
+ setSourceMuted(id: string, m: boolean) {
182
+ this.pc.setSourceMuted(id, m);
183
+ }
184
+ setPrimaryVideoSource(id: string) {
185
+ this.pc.setPrimaryVideoSource(id);
186
+ }
187
+ setMasterVolume(vol: number) {
188
+ this.pc.setMasterVolume(vol);
189
+ }
190
+ async setQualityProfile(p: QualityProfile) {
191
+ return this.pc.setQualityProfile(p);
192
+ }
193
+ destroy() {
194
+ this.pc.getController()?.destroy();
195
+ }
196
+
197
+ protected render() {
198
+ const s = this.pc.s;
199
+ const statusText = getStatusText(s.state, s.reconnectionState);
200
+ const statusBadgeClass = getStatusBadgeClass(s.state, s.isReconnecting);
201
+ const canAddSource = s.state !== "destroyed" && s.state !== "error";
202
+ const canStream = s.isCapturing && !s.isStreaming && !!this.whipUrl;
203
+ const hasCamera = s.sources.some((src: MediaSource) => src.type === "camera");
204
+
205
+ return html`
206
+ <div
207
+ class=${classMap({ root: true, "fw-sc-root": true, "fw-sc-root--devmode": this.devMode })}
208
+ >
209
+ <div class="main fw-sc-main">
210
+ <!-- Header -->
211
+ <div class="fw-sc-header">
212
+ <span class="fw-sc-header-title">StreamCrafter</span>
213
+ <div class="fw-sc-header-status">
214
+ <span class=${statusBadgeClass}>${statusText}</span>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- Content -->
219
+ <div class="fw-sc-content">
220
+ <div class="fw-sc-preview-wrapper">
221
+ <div class="fw-sc-preview">
222
+ <video playsinline muted autoplay aria-label="Stream preview"></video>
223
+
224
+ ${!s.mediaStream
225
+ ? html`
226
+ <div class="fw-sc-preview-placeholder">
227
+ ${cameraIcon(48)}
228
+ <span>Add a camera or screen to preview</span>
229
+ </div>
230
+ `
231
+ : nothing}
232
+ ${s.state === "connecting" || s.state === "reconnecting"
233
+ ? html`
234
+ <div class="fw-sc-status-overlay">
235
+ <div class="fw-sc-status-spinner"></div>
236
+ <span class="fw-sc-status-text">${statusText}</span>
237
+ </div>
238
+ `
239
+ : nothing}
240
+ ${s.isStreaming ? html`<div class="fw-sc-live-badge">Live</div>` : nothing}
241
+ ${this.enableCompositor
242
+ ? html` <fw-sc-compositor .ic=${this.pc}></fw-sc-compositor> `
243
+ : nothing}
244
+ </div>
245
+ </div>
246
+
247
+ <!-- Sources Mixer -->
248
+ ${s.sources.length > 0
249
+ ? html`
250
+ <div
251
+ class=${classMap({
252
+ "fw-sc-section": true,
253
+ "fw-sc-mixer": true,
254
+ "fw-sc-section--collapsed": !this._showSources,
255
+ })}
256
+ >
257
+ <div
258
+ class="fw-sc-section-header"
259
+ @click=${() => {
260
+ this._showSources = !this._showSources;
261
+ }}
262
+ >
263
+ <span>Mixer (${s.sources.length})</span>
264
+ ${this._showSources ? chevronsRightIcon(14) : chevronsLeftIcon(14)}
265
+ </div>
266
+ ${this._showSources
267
+ ? html`
268
+ <div class="fw-sc-section-body--flush">
269
+ <div class="fw-sc-sources">
270
+ ${s.sources.map((source: MediaSource) =>
271
+ this._renderSourceRow(source)
272
+ )}
273
+ </div>
274
+ </div>
275
+ `
276
+ : nothing}
277
+ </div>
278
+ `
279
+ : nothing}
280
+ </div>
281
+
282
+ <!-- Error -->
283
+ ${s.error
284
+ ? html`
285
+ <div class="fw-sc-error">
286
+ <div class="fw-sc-error-title">Error</div>
287
+ <div class="fw-sc-error-message">${s.error}</div>
288
+ </div>
289
+ `
290
+ : nothing}
291
+ ${!this.whipUrl && !s.error
292
+ ? html`
293
+ <div class="fw-sc-error" style="border-left-color: hsl(40 80% 65%)">
294
+ <div class="fw-sc-error-title" style="color: hsl(40 80% 65%)">Warning</div>
295
+ <div class="fw-sc-error-message">Configure WHIP endpoint to stream</div>
296
+ </div>
297
+ `
298
+ : nothing}
299
+
300
+ <!-- Action Bar -->
301
+ <div class="fw-sc-actions">
302
+ <button
303
+ type="button"
304
+ class="fw-sc-action-secondary"
305
+ @click=${() => this.pc.startCamera().catch(console.error)}
306
+ ?disabled=${!canAddSource || hasCamera}
307
+ title=${hasCamera ? "Camera active" : "Add Camera"}
308
+ >
309
+ ${cameraIcon(18)}
310
+ </button>
311
+ <button
312
+ type="button"
313
+ class="fw-sc-action-secondary"
314
+ @click=${() => this.pc.startScreenShare({ audio: true }).catch(console.error)}
315
+ ?disabled=${!canAddSource}
316
+ title="Share Screen"
317
+ >
318
+ ${monitorIcon(18)}
319
+ </button>
320
+
321
+ <!-- Settings -->
322
+ <div style="position:relative">
323
+ <button
324
+ type="button"
325
+ class=${classMap({
326
+ "fw-sc-action-secondary": true,
327
+ "fw-sc-action-secondary--active": this._showSettings,
328
+ })}
329
+ @click=${() => {
330
+ this._showSettings = !this._showSettings;
331
+ }}
332
+ title="Settings"
333
+ >
334
+ ${settingsIcon(16)}
335
+ </button>
336
+ ${this._showSettings ? this._renderSettingsPopup() : nothing}
337
+ </div>
338
+
339
+ <!-- Go Live / Stop -->
340
+ ${!s.isStreaming
341
+ ? html`
342
+ <button
343
+ type="button"
344
+ class="fw-sc-action-primary"
345
+ @click=${() => this.pc.startStreaming().catch(console.error)}
346
+ ?disabled=${!canStream}
347
+ >
348
+ ${s.state === "connecting" ? "Connecting..." : "Go Live"}
349
+ </button>
350
+ `
351
+ : html`
352
+ <button
353
+ type="button"
354
+ class="fw-sc-action-primary fw-sc-action-stop"
355
+ @click=${() => this.pc.stopStreaming().catch(console.error)}
356
+ >
357
+ Stop Streaming
358
+ </button>
359
+ `}
360
+ </div>
361
+ </div>
362
+
363
+ <!-- Advanced Panel -->
364
+ ${this.devMode && this._isAdvancedPanelOpen
365
+ ? html`
366
+ <fw-sc-advanced
367
+ .ic=${this.pc}
368
+ @fw-close=${() => {
369
+ this._isAdvancedPanelOpen = false;
370
+ }}
371
+ ></fw-sc-advanced>
372
+ `
373
+ : nothing}
374
+ </div>
375
+ `;
376
+ }
377
+
378
+ private _renderSourceRow(source: MediaSource) {
379
+ const s = this.pc.s;
380
+ const hasVideo = source.stream.getVideoTracks().length > 0;
381
+ return html`
382
+ <div class=${classMap({ "fw-sc-source": true })}>
383
+ <div class="fw-sc-source-icon">
384
+ ${source.type === "camera" ? cameraIcon(16) : monitorIcon(16)}
385
+ </div>
386
+ <div class="fw-sc-source-info">
387
+ <div class="fw-sc-source-label">
388
+ ${source.label}
389
+ ${source.primaryVideo && !this.enableCompositor
390
+ ? html`<span class="fw-sc-primary-badge">PRIMARY</span>`
391
+ : nothing}
392
+ </div>
393
+ <div class="fw-sc-source-type">${source.type}</div>
394
+ </div>
395
+ <div class="fw-sc-source-controls">
396
+ ${hasVideo && !this.enableCompositor
397
+ ? html`
398
+ <button
399
+ type="button"
400
+ class=${classMap({
401
+ "fw-sc-icon-btn": true,
402
+ "fw-sc-icon-btn--primary": !!source.primaryVideo,
403
+ })}
404
+ @click=${() => this.pc.setPrimaryVideoSource(source.id)}
405
+ ?disabled=${source.primaryVideo}
406
+ title=${source.primaryVideo ? "Primary video source" : "Set as primary video"}
407
+ >
408
+ ${videoIcon(14)}
409
+ </button>
410
+ `
411
+ : nothing}
412
+ <span class="fw-sc-volume-label">${Math.round(source.volume * 100)}%</span>
413
+ <fw-sc-volume
414
+ .value=${source.volume}
415
+ @fw-sc-volume-change=${(e: CustomEvent) =>
416
+ this.pc.setSourceVolume(source.id, e.detail.value)}
417
+ compact
418
+ ></fw-sc-volume>
419
+ <button
420
+ type="button"
421
+ class=${classMap({ "fw-sc-icon-btn": true, "fw-sc-icon-btn--active": source.muted })}
422
+ @click=${() => this.pc.setSourceMuted(source.id, !source.muted)}
423
+ title=${source.muted ? "Unmute" : "Mute"}
424
+ >
425
+ ${source.muted ? micMutedIcon(14) : micIcon(14)}
426
+ </button>
427
+ <button
428
+ type="button"
429
+ class="fw-sc-icon-btn fw-sc-icon-btn--destructive"
430
+ @click=${() => this.pc.removeSource(source.id)}
431
+ ?disabled=${s.isStreaming}
432
+ title=${s.isStreaming ? "Cannot remove source while streaming" : "Remove source"}
433
+ >
434
+ ${xIcon(14)}
435
+ </button>
436
+ </div>
437
+ </div>
438
+ `;
439
+ }
440
+
441
+ private _renderSettingsPopup() {
442
+ const s = this.pc.s;
443
+ return html`
444
+ <div
445
+ class="fw-sc-settings-popup"
446
+ style="position:absolute;bottom:100%;left:0;margin-bottom:8px;width:192px;background:#1a1b26;border:1px solid rgba(90,96,127,0.3);box-shadow:0 4px 12px rgba(0,0,0,0.4);border-radius:4px;overflow:hidden;z-index:50"
447
+ >
448
+ <div style="padding:8px;border-bottom:1px solid rgba(90,96,127,0.3)">
449
+ <div
450
+ style="font-size:10px;color:#565f89;text-transform:uppercase;font-weight:600;margin-bottom:4px;padding-left:4px"
451
+ >
452
+ Quality
453
+ </div>
454
+ <div style="display:flex;flex-direction:column;gap:2px">
455
+ ${QUALITY_PROFILES.map(
456
+ (p) => html`
457
+ <button
458
+ type="button"
459
+ @click=${() => {
460
+ if (!s.isStreaming) {
461
+ this.pc.setQualityProfile(p.id);
462
+ if (!this.devMode) this._showSettings = false;
463
+ }
464
+ }}
465
+ ?disabled=${s.isStreaming}
466
+ style="width:100%;padding:6px 8px;text-align:left;font-size:12px;border-radius:4px;border:none;cursor:${s.isStreaming
467
+ ? "not-allowed"
468
+ : "pointer"};opacity:${s.isStreaming
469
+ ? "0.5"
470
+ : "1"};background:${s.qualityProfile === p.id
471
+ ? "rgba(122,162,247,0.2)"
472
+ : "transparent"};color:${s.qualityProfile === p.id ? "#7aa2f7" : "#a9b1d6"}"
473
+ >
474
+ <div style="font-weight:500">${p.label}</div>
475
+ <div style="font-size:10px;color:#565f89">${p.description}</div>
476
+ </button>
477
+ `
478
+ )}
479
+ </div>
480
+ </div>
481
+ ${this.devMode
482
+ ? html`
483
+ <div style="padding:8px">
484
+ <div
485
+ style="font-size:10px;color:#565f89;text-transform:uppercase;font-weight:600;margin-bottom:4px;padding-left:4px"
486
+ >
487
+ Debug
488
+ </div>
489
+ <div
490
+ style="display:flex;flex-direction:column;gap:4px;padding-left:4px;font-size:12px;font-family:ui-monospace,monospace"
491
+ >
492
+ <div style="display:flex;justify-content:space-between">
493
+ <span style="color:#565f89">State</span
494
+ ><span style="color:#c0caf5">${s.state}</span>
495
+ </div>
496
+ <div style="display:flex;justify-content:space-between">
497
+ <span style="color:#565f89">WHIP</span
498
+ ><span style="color:${this.whipUrl ? "#9ece6a" : "#f7768e"}"
499
+ >${this.whipUrl ? "OK" : "Not set"}</span
500
+ >
501
+ </div>
502
+ </div>
503
+ </div>
504
+ `
505
+ : nothing}
506
+ </div>
507
+ `;
508
+ }
509
+ }
510
+
511
+ declare global {
512
+ interface HTMLElementTagNameMap {
513
+ "fw-streamcrafter": FwStreamCrafter;
514
+ }
515
+ }