@dialtribe/react-sdk 0.1.0-alpha.8 → 0.1.4

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.
@@ -0,0 +1,4799 @@
1
+ import React2, { createContext, useState, useCallback, useEffect, useContext, useRef, useMemo, Component } from 'react';
2
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+ import ReactPlayer from 'react-player';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ // src/context/DialtribeProvider.tsx
7
+ var DialtribeContext = createContext(null);
8
+ function DialtribeProvider({
9
+ sessionToken: initialToken,
10
+ onTokenRefresh,
11
+ onTokenExpired,
12
+ apiBaseUrl,
13
+ children
14
+ }) {
15
+ const [sessionToken, setSessionTokenState] = useState(initialToken);
16
+ const [isExpired, setIsExpired] = useState(false);
17
+ const setSessionToken = useCallback(
18
+ (newToken, expiresAt) => {
19
+ setSessionTokenState(newToken);
20
+ setIsExpired(false);
21
+ if (expiresAt) {
22
+ onTokenRefresh?.(newToken, expiresAt);
23
+ }
24
+ },
25
+ [onTokenRefresh]
26
+ );
27
+ const markExpired = useCallback(() => {
28
+ setIsExpired(true);
29
+ onTokenExpired?.();
30
+ }, [onTokenExpired]);
31
+ useEffect(() => {
32
+ if (initialToken !== sessionToken) {
33
+ setSessionTokenState(initialToken);
34
+ setIsExpired(false);
35
+ }
36
+ }, [initialToken, sessionToken]);
37
+ const value = {
38
+ sessionToken,
39
+ setSessionToken,
40
+ isExpired,
41
+ markExpired,
42
+ apiBaseUrl
43
+ };
44
+ return /* @__PURE__ */ jsx(DialtribeContext.Provider, { value, children });
45
+ }
46
+ function useDialtribe() {
47
+ const context = useContext(DialtribeContext);
48
+ if (!context) {
49
+ throw new Error(
50
+ 'useDialtribe must be used within a DialtribeProvider. Wrap your app with <DialtribeProvider sessionToken="sess_xxx">...</DialtribeProvider>'
51
+ );
52
+ }
53
+ return context;
54
+ }
55
+ function useDialtribeOptional() {
56
+ return useContext(DialtribeContext);
57
+ }
58
+
59
+ // src/client/DialtribeClient.ts
60
+ function getDefaultApiBaseUrl() {
61
+ if (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_DIALTRIBE_API_URL) {
62
+ return process.env.NEXT_PUBLIC_DIALTRIBE_API_URL;
63
+ }
64
+ return "https://dialtribe.com/api/public/v1";
65
+ }
66
+ var DIALTRIBE_API_BASE = getDefaultApiBaseUrl();
67
+ function getEndpoints(baseUrl = DIALTRIBE_API_BASE) {
68
+ return {
69
+ broadcasts: `${baseUrl}/broadcasts`,
70
+ broadcast: (id) => `${baseUrl}/broadcasts/${id}`,
71
+ contentPlay: `${baseUrl}/content/play`,
72
+ presignedUrl: `${baseUrl}/media/presigned-url`,
73
+ audienceStart: `${baseUrl}/audiences/start`,
74
+ audiencePing: `${baseUrl}/audiences/ping`,
75
+ sessionPing: `${baseUrl}/sessions/ping`
76
+ };
77
+ }
78
+ var ENDPOINTS = getEndpoints();
79
+ var DialtribeClient = class {
80
+ constructor(config) {
81
+ this.config = config;
82
+ this.endpoints = config.apiBaseUrl ? getEndpoints(config.apiBaseUrl) : ENDPOINTS;
83
+ }
84
+ /**
85
+ * Make an authenticated request to Dialtribe API
86
+ *
87
+ * Automatically:
88
+ * - Adds Authorization header with session token
89
+ * - Checks for X-Session-Token header in response (token refresh)
90
+ * - Calls onTokenRefresh if new token is provided
91
+ * - Calls onTokenExpired on 401 errors
92
+ */
93
+ async fetch(url, options = {}) {
94
+ const headers = new Headers(options.headers);
95
+ headers.set("Authorization", `Bearer ${this.config.sessionToken}`);
96
+ headers.set("Content-Type", "application/json");
97
+ const response = await fetch(url, {
98
+ ...options,
99
+ headers
100
+ });
101
+ const newToken = response.headers.get("X-Session-Token");
102
+ const expiresAt = response.headers.get("X-Session-Expires");
103
+ if (newToken && expiresAt) {
104
+ this.config.onTokenRefresh?.(newToken, expiresAt);
105
+ }
106
+ if (response.status === 401) {
107
+ this.config.onTokenExpired?.();
108
+ throw new Error("Session token expired or invalid");
109
+ }
110
+ return response;
111
+ }
112
+ /**
113
+ * Update the session token
114
+ * Called automatically when token is refreshed, or manually by user
115
+ */
116
+ setSessionToken(token) {
117
+ this.config.sessionToken = token;
118
+ }
119
+ /**
120
+ * Get list of broadcasts for the authenticated app
121
+ */
122
+ async getBroadcasts(params) {
123
+ const searchParams = new URLSearchParams();
124
+ if (params?.page) searchParams.set("page", params.page.toString());
125
+ if (params?.limit) searchParams.set("limit", params.limit.toString());
126
+ if (params?.broadcastStatus) searchParams.set("broadcastStatus", params.broadcastStatus.toString());
127
+ if (params?.search) searchParams.set("search", params.search);
128
+ if (params?.includeDeleted) searchParams.set("includeDeleted", "true");
129
+ const url = `${this.endpoints.broadcasts}${searchParams.toString() ? `?${searchParams}` : ""}`;
130
+ const response = await this.fetch(url);
131
+ if (!response.ok) {
132
+ throw new Error(`Failed to fetch broadcasts: ${response.status} ${response.statusText}`);
133
+ }
134
+ return response.json();
135
+ }
136
+ /**
137
+ * Get a single broadcast by ID
138
+ */
139
+ async getBroadcast(id) {
140
+ const response = await this.fetch(this.endpoints.broadcast(id));
141
+ if (!response.ok) {
142
+ if (response.status === 404) {
143
+ throw new Error("Broadcast not found");
144
+ }
145
+ throw new Error(`Failed to fetch broadcast: ${response.status} ${response.statusText}`);
146
+ }
147
+ return response.json();
148
+ }
149
+ /**
150
+ * Get presigned URL for media playback
151
+ *
152
+ * @param broadcastId - Broadcast ID
153
+ * @param hash - Broadcast hash (optional if using session token)
154
+ * @param action - 'download' to force download, otherwise streams
155
+ */
156
+ async getPlaybackUrl(params) {
157
+ const searchParams = new URLSearchParams({
158
+ broadcastId: params.broadcastId.toString()
159
+ });
160
+ if (params.hash) searchParams.set("hash", params.hash);
161
+ if (params.action) searchParams.set("action", params.action);
162
+ const url = `${this.endpoints.contentPlay}?${searchParams}`;
163
+ const response = await this.fetch(url, {
164
+ redirect: "manual"
165
+ // Don't follow redirect, we want the URL
166
+ });
167
+ const location = response.headers.get("Location");
168
+ if (!location) {
169
+ throw new Error("No playback URL returned from API");
170
+ }
171
+ return location;
172
+ }
173
+ /**
174
+ * Refresh a presigned URL before it expires
175
+ *
176
+ * @param broadcastId - Broadcast ID
177
+ * @param hash - Broadcast hash
178
+ * @param fileType - Type of media file
179
+ */
180
+ async refreshPresignedUrl(params) {
181
+ const searchParams = new URLSearchParams({
182
+ broadcastId: params.broadcastId.toString(),
183
+ hash: params.hash,
184
+ fileType: params.fileType
185
+ });
186
+ const url = `${this.endpoints.presignedUrl}?${searchParams}`;
187
+ const response = await this.fetch(url);
188
+ if (!response.ok) {
189
+ throw new Error(`Failed to refresh URL: ${response.status} ${response.statusText}`);
190
+ }
191
+ return response.json();
192
+ }
193
+ /**
194
+ * Start a new audience tracking session
195
+ *
196
+ * @returns audienceId and optional resumePosition
197
+ */
198
+ async startSession(params) {
199
+ const response = await this.fetch(this.endpoints.audienceStart, {
200
+ method: "POST",
201
+ body: JSON.stringify(params)
202
+ });
203
+ if (!response.ok) {
204
+ throw new Error(`Failed to start session: ${response.status} ${response.statusText}`);
205
+ }
206
+ return response.json();
207
+ }
208
+ /**
209
+ * Send a session ping event
210
+ *
211
+ * Event types:
212
+ * - 0: PAUSE/STOP
213
+ * - 1: PLAY/START
214
+ * - 2: HEARTBEAT
215
+ * - 3: UNMOUNT
216
+ */
217
+ async sendSessionPing(params) {
218
+ const response = await this.fetch(this.endpoints.audiencePing, {
219
+ method: "POST",
220
+ body: JSON.stringify(params)
221
+ });
222
+ if (!response.ok) {
223
+ throw new Error(`Failed to send session ping: ${response.status} ${response.statusText}`);
224
+ }
225
+ if (response.status === 204) {
226
+ return;
227
+ }
228
+ return response.json();
229
+ }
230
+ };
231
+
232
+ // src/utils/media-constraints.ts
233
+ function getMediaConstraints(options) {
234
+ const { isVideo, facingMode = "user" } = options;
235
+ const audioConstraints = {
236
+ autoGainControl: true,
237
+ channelCount: 2,
238
+ // Stereo
239
+ echoCancellation: false,
240
+ noiseSuppression: false,
241
+ sampleRate: 48e3
242
+ // 48kHz
243
+ };
244
+ const videoConstraints = isVideo ? {
245
+ aspectRatio: 16 / 9,
246
+ width: { ideal: 1280 },
247
+ height: { ideal: 720 },
248
+ frameRate: { ideal: 30 },
249
+ facingMode
250
+ // "user" (front) or "environment" (back)
251
+ } : false;
252
+ return {
253
+ audio: audioConstraints,
254
+ video: videoConstraints
255
+ };
256
+ }
257
+ function getMediaRecorderOptions(isVideo) {
258
+ let mimeType;
259
+ if (isVideo) {
260
+ if (MediaRecorder.isTypeSupported("video/mp4;codecs=avc1,mp4a")) {
261
+ mimeType = "video/mp4;codecs=avc1,mp4a";
262
+ } else if (MediaRecorder.isTypeSupported("video/webm;codecs=h264,opus")) {
263
+ mimeType = "video/webm;codecs=h264,opus";
264
+ } else if (MediaRecorder.isTypeSupported("video/webm;codecs=vp8,opus")) {
265
+ mimeType = "video/webm;codecs=vp8,opus";
266
+ console.warn("\u26A0\uFE0F Browser only supports VP8/Opus - recordings will be disabled");
267
+ }
268
+ } else {
269
+ if (MediaRecorder.isTypeSupported("audio/mp4;codecs=mp4a")) {
270
+ mimeType = "audio/mp4;codecs=mp4a";
271
+ } else if (MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
272
+ mimeType = "audio/webm;codecs=opus";
273
+ console.warn("\u26A0\uFE0F Browser only supports Opus - recordings may be disabled");
274
+ }
275
+ }
276
+ return {
277
+ mimeType,
278
+ audioBitsPerSecond: 128e3,
279
+ // 128 kbps audio
280
+ videoBitsPerSecond: isVideo ? 25e5 : void 0
281
+ // 2.5 Mbps for 720p video
282
+ };
283
+ }
284
+ function checkBrowserCompatibility() {
285
+ if (typeof window === "undefined") {
286
+ return {
287
+ compatible: false,
288
+ error: "This component requires a browser environment"
289
+ };
290
+ }
291
+ if (!window.MediaRecorder) {
292
+ return {
293
+ compatible: false,
294
+ error: "MediaRecorder API not supported in this browser"
295
+ };
296
+ }
297
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
298
+ return {
299
+ compatible: false,
300
+ error: "getUserMedia API not supported in this browser"
301
+ };
302
+ }
303
+ return { compatible: true };
304
+ }
305
+
306
+ // src/utils/websocket-streamer.ts
307
+ var DEFAULT_ENCODER_SERVER_URL = "https://broadcastapi.dialtribe.com";
308
+ var WebSocketStreamer = class {
309
+ constructor(options) {
310
+ this.websocket = null;
311
+ this.mediaRecorder = null;
312
+ this.bytesSent = 0;
313
+ this.chunksSent = 0;
314
+ this.userStopped = false;
315
+ // Track if user initiated the stop
316
+ this.isHotSwapping = false;
317
+ // Track if we're swapping media streams
318
+ this.startTime = 0;
319
+ // Canvas-based rendering for seamless camera flips
320
+ // MediaRecorder records from canvas stream, so track changes don't affect it
321
+ this.canvasState = null;
322
+ this.streamKey = options.streamKey;
323
+ this.mediaStream = options.mediaStream;
324
+ this.isVideo = options.isVideo;
325
+ this.encoderServerUrl = options.encoderServerUrl || DEFAULT_ENCODER_SERVER_URL;
326
+ this.disableCanvasRendering = options.disableCanvasRendering || false;
327
+ this.onBytesUpdate = options.onBytesUpdate;
328
+ this.onStateChange = options.onStateChange;
329
+ this.onError = options.onError;
330
+ }
331
+ /**
332
+ * Calculate scaled dimensions for fitting video into canvas.
333
+ * @param mode - "contain" fits video inside canvas, "cover" fills canvas (cropping)
334
+ */
335
+ calculateScaledDimensions(videoWidth, videoHeight, canvasWidth, canvasHeight, mode) {
336
+ const videoAspect = videoWidth / videoHeight;
337
+ const canvasAspect = canvasWidth / canvasHeight;
338
+ const useWidthBased = mode === "contain" ? videoAspect > canvasAspect : videoAspect <= canvasAspect;
339
+ if (useWidthBased) {
340
+ const width = canvasWidth;
341
+ const height = canvasWidth / videoAspect;
342
+ return { x: 0, y: (canvasHeight - height) / 2, width, height };
343
+ } else {
344
+ const height = canvasHeight;
345
+ const width = canvasHeight * videoAspect;
346
+ return { x: (canvasWidth - width) / 2, y: 0, width, height };
347
+ }
348
+ }
349
+ /**
350
+ * Invalidate cached scaling dimensions (call when video source changes)
351
+ */
352
+ invalidateScalingCache() {
353
+ if (this.canvasState) {
354
+ this.canvasState.cachedContain = null;
355
+ this.canvasState.cachedCover = null;
356
+ this.canvasState.cachedNeedsBackground = false;
357
+ this.canvasState.lastVideoWidth = 0;
358
+ this.canvasState.lastVideoHeight = 0;
359
+ }
360
+ }
361
+ /**
362
+ * Validate stream key format
363
+ * Stream keys must follow format: {tierCode}{foreignId}_{randomKey}
364
+ * Tier codes: a (audio shared), b (audio VIP), v (video shared), w (video VIP)
365
+ */
366
+ validateStreamKeyFormat() {
367
+ if (!this.streamKey || this.streamKey.length < 10) {
368
+ throw new Error("Invalid stream key: too short");
369
+ }
370
+ const tierCode = this.streamKey[0];
371
+ if (!["a", "b", "v", "w"].includes(tierCode)) {
372
+ throw new Error(
373
+ `Invalid stream key format: must start with 'a', 'b', 'v', or 'w' (got '${tierCode}')`
374
+ );
375
+ }
376
+ if (!this.streamKey.includes("_")) {
377
+ throw new Error(
378
+ "Invalid stream key format: must contain underscore separator (format: {tier}{id}_{key})"
379
+ );
380
+ }
381
+ console.log("\u2705 Stream key format validated:", {
382
+ tierCode,
383
+ isVideo: tierCode === "v" || tierCode === "w",
384
+ isVIP: tierCode === "b" || tierCode === "w"
385
+ });
386
+ }
387
+ /**
388
+ * Set up canvas-based rendering pipeline for video streams.
389
+ * This allows seamless camera flips by changing the video source
390
+ * without affecting MediaRecorder (which records from the canvas).
391
+ *
392
+ * This is async to ensure the video is producing frames before returning,
393
+ * which prevents black initial thumbnails.
394
+ */
395
+ async setupCanvasRendering() {
396
+ console.log("\u{1F3A8} Setting up canvas-based rendering for seamless camera flips");
397
+ const videoTrack = this.mediaStream.getVideoTracks()[0];
398
+ const settings = videoTrack?.getSettings() || {};
399
+ const width = settings.width || 1280;
400
+ const height = settings.height || 720;
401
+ console.log(`\u{1F4D0} Video dimensions: ${width}x${height}`);
402
+ const canvas = document.createElement("canvas");
403
+ canvas.width = width;
404
+ canvas.height = height;
405
+ const ctx = canvas.getContext("2d");
406
+ if (!ctx) {
407
+ throw new Error("Failed to get 2D canvas context - canvas rendering unavailable");
408
+ }
409
+ const videoElement = document.createElement("video");
410
+ videoElement.srcObject = this.mediaStream;
411
+ videoElement.muted = true;
412
+ videoElement.playsInline = true;
413
+ await new Promise((resolve) => {
414
+ const checkReady = () => {
415
+ if (videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
416
+ console.log(`\u{1F4F9} Video ready: ${videoElement.videoWidth}x${videoElement.videoHeight}`);
417
+ resolve();
418
+ } else {
419
+ requestAnimationFrame(checkReady);
420
+ }
421
+ };
422
+ videoElement.addEventListener("loadeddata", () => {
423
+ checkReady();
424
+ }, { once: true });
425
+ videoElement.play().catch((e) => {
426
+ console.warn("Video autoplay warning:", e);
427
+ resolve();
428
+ });
429
+ setTimeout(() => {
430
+ console.warn("\u26A0\uFE0F Video ready timeout - continuing anyway");
431
+ resolve();
432
+ }, 2e3);
433
+ });
434
+ const frameRate = settings.frameRate || 30;
435
+ const stream = canvas.captureStream(frameRate);
436
+ const audioTracks = this.mediaStream.getAudioTracks();
437
+ audioTracks.forEach((track) => {
438
+ stream.addTrack(track);
439
+ });
440
+ console.log(`\u{1F3AC} Canvas stream created with ${frameRate}fps video + ${audioTracks.length} audio track(s)`);
441
+ this.canvasState = {
442
+ canvas,
443
+ ctx,
444
+ videoElement,
445
+ stream,
446
+ renderLoopId: 0,
447
+ // Will be set below
448
+ useBlurBackground: true,
449
+ slowFrameCount: 0,
450
+ cachedContain: null,
451
+ cachedCover: null,
452
+ cachedNeedsBackground: false,
453
+ lastVideoWidth: 0,
454
+ lastVideoHeight: 0
455
+ };
456
+ if (videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
457
+ const vw = videoElement.videoWidth;
458
+ const vh = videoElement.videoHeight;
459
+ const cw = canvas.width;
460
+ const ch = canvas.height;
461
+ const scale = Math.min(cw / vw, ch / vh);
462
+ const sw = vw * scale;
463
+ const sh = vh * scale;
464
+ const sx = (cw - sw) / 2;
465
+ const sy = (ch - sh) / 2;
466
+ ctx.fillStyle = "#000";
467
+ ctx.fillRect(0, 0, cw, ch);
468
+ ctx.drawImage(videoElement, sx, sy, sw, sh);
469
+ console.log("\u{1F5BC}\uFE0F Drew first frame synchronously to prevent black thumbnail");
470
+ }
471
+ const state = this.canvasState;
472
+ const renderFrame = () => {
473
+ if (!this.canvasState || state !== this.canvasState) return;
474
+ const { ctx: ctx2, canvas: canvas2, videoElement: videoElement2 } = state;
475
+ if (videoElement2.paused) {
476
+ videoElement2.play().catch(() => {
477
+ });
478
+ }
479
+ const canvasWidth = canvas2.width;
480
+ const canvasHeight = canvas2.height;
481
+ const videoWidth = videoElement2.videoWidth;
482
+ const videoHeight = videoElement2.videoHeight;
483
+ if (videoWidth === 0 || videoHeight === 0) {
484
+ state.renderLoopId = requestAnimationFrame(renderFrame);
485
+ return;
486
+ }
487
+ if (videoWidth !== state.lastVideoWidth || videoHeight !== state.lastVideoHeight) {
488
+ state.lastVideoWidth = videoWidth;
489
+ state.lastVideoHeight = videoHeight;
490
+ state.cachedContain = this.calculateScaledDimensions(
491
+ videoWidth,
492
+ videoHeight,
493
+ canvasWidth,
494
+ canvasHeight,
495
+ "contain"
496
+ );
497
+ state.cachedCover = this.calculateScaledDimensions(
498
+ videoWidth,
499
+ videoHeight,
500
+ canvasWidth,
501
+ canvasHeight,
502
+ "cover"
503
+ );
504
+ state.cachedNeedsBackground = Math.abs(state.cachedContain.width - canvasWidth) > 1 || Math.abs(state.cachedContain.height - canvasHeight) > 1;
505
+ console.log(`\u{1F4D0} Video dimensions changed: ${videoWidth}x${videoHeight}, needsBackground: ${state.cachedNeedsBackground}`);
506
+ }
507
+ const contain = state.cachedContain;
508
+ const cover = state.cachedCover;
509
+ const frameStart = performance.now();
510
+ if (state.cachedNeedsBackground && state.useBlurBackground) {
511
+ ctx2.save();
512
+ ctx2.filter = "blur(20px)";
513
+ ctx2.drawImage(videoElement2, cover.x, cover.y, cover.width, cover.height);
514
+ ctx2.restore();
515
+ ctx2.fillStyle = "rgba(0, 0, 0, 0.5)";
516
+ ctx2.fillRect(0, 0, canvasWidth, canvasHeight);
517
+ } else if (state.cachedNeedsBackground) {
518
+ ctx2.fillStyle = "#000";
519
+ ctx2.fillRect(0, 0, canvasWidth, canvasHeight);
520
+ }
521
+ ctx2.drawImage(videoElement2, contain.x, contain.y, contain.width, contain.height);
522
+ const frameDuration = performance.now() - frameStart;
523
+ if (frameDuration > 16 && state.useBlurBackground) {
524
+ state.slowFrameCount++;
525
+ if (state.slowFrameCount > 5) {
526
+ console.log("\u26A1 Disabling blur background for performance");
527
+ state.useBlurBackground = false;
528
+ }
529
+ } else if (frameDuration <= 16) {
530
+ state.slowFrameCount = 0;
531
+ }
532
+ state.renderLoopId = requestAnimationFrame(renderFrame);
533
+ };
534
+ state.renderLoopId = requestAnimationFrame(renderFrame);
535
+ console.log("\u2705 Canvas rendering pipeline ready (with adaptive blur background)");
536
+ return stream;
537
+ }
538
+ /**
539
+ * Clean up canvas rendering resources
540
+ */
541
+ cleanupCanvasRendering() {
542
+ if (!this.canvasState) return;
543
+ cancelAnimationFrame(this.canvasState.renderLoopId);
544
+ this.canvasState.videoElement.pause();
545
+ this.canvasState.videoElement.srcObject = null;
546
+ this.canvasState.stream.getTracks().forEach((track) => track.stop());
547
+ this.canvasState = null;
548
+ }
549
+ /**
550
+ * Build WebSocket URL from stream key
551
+ */
552
+ buildWebSocketUrl() {
553
+ const url = new URL(this.encoderServerUrl);
554
+ if (url.protocol === "http:") {
555
+ url.protocol = "ws:";
556
+ } else if (url.protocol === "https:") {
557
+ url.protocol = "wss:";
558
+ }
559
+ url.pathname = "/targets/dialtribe";
560
+ url.searchParams.set("key", this.streamKey);
561
+ return url.toString();
562
+ }
563
+ /**
564
+ * Start streaming
565
+ */
566
+ async start() {
567
+ try {
568
+ this.userStopped = false;
569
+ this.chunksSent = 0;
570
+ this.bytesSent = 0;
571
+ this.startTime = 0;
572
+ this.validateStreamKeyFormat();
573
+ this.onStateChange?.("connecting");
574
+ const wsUrl = this.buildWebSocketUrl();
575
+ console.log("\u{1F4E1} Connecting to WebSocket:", wsUrl.replace(this.streamKey, "***"));
576
+ this.websocket = new WebSocket(wsUrl);
577
+ await new Promise((resolve, reject) => {
578
+ if (!this.websocket) {
579
+ reject(new Error("WebSocket not initialized"));
580
+ return;
581
+ }
582
+ const timeoutId = setTimeout(() => {
583
+ reject(new Error(`WebSocket connection timeout. URL: ${wsUrl}`));
584
+ }, 1e4);
585
+ this.websocket.addEventListener("open", () => {
586
+ clearTimeout(timeoutId);
587
+ resolve();
588
+ }, { once: true });
589
+ this.websocket.addEventListener("error", (event) => {
590
+ clearTimeout(timeoutId);
591
+ console.error("\u274C WebSocket error event:", event);
592
+ console.error("\u{1F50D} Connection diagnostics:", {
593
+ url: wsUrl.replace(this.streamKey, "***"),
594
+ streamKeyFormat: this.streamKey.substring(0, 5) + "***",
595
+ readyState: this.websocket?.readyState,
596
+ readyStateText: ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][this.websocket?.readyState || 0]
597
+ });
598
+ reject(new Error(
599
+ `WebSocket connection failed (likely 403 Forbidden).
600
+
601
+ Common causes:
602
+ 1. Encoder server cannot connect to the database
603
+ 2. Encoder server is using a different database
604
+ 3. Stream key validation failed on encoder server
605
+
606
+ Please check encoder server logs and DATABASE_URL configuration.`
607
+ ));
608
+ }, { once: true });
609
+ });
610
+ console.log("\u2705 WebSocket connected");
611
+ this.setupWebSocketHandlers();
612
+ const useCanvas = this.isVideo && !this.disableCanvasRendering;
613
+ const streamToRecord = useCanvas ? await this.setupCanvasRendering() : this.mediaStream;
614
+ const recorderOptions = getMediaRecorderOptions(this.isVideo);
615
+ this.mimeType = recorderOptions.mimeType;
616
+ this.mediaRecorder = new MediaRecorder(streamToRecord, recorderOptions);
617
+ console.log("\u{1F399}\uFE0F MediaRecorder created with options:", recorderOptions);
618
+ if (useCanvas) {
619
+ console.log("\u{1F3A8} Recording from canvas stream (enables seamless camera flips)");
620
+ } else if (this.isVideo) {
621
+ console.log("\u{1F4F9} Recording directly from camera (canvas disabled)");
622
+ }
623
+ this.setupMediaRecorderHandlers();
624
+ this.mediaRecorder.start(300);
625
+ this.startTime = Date.now();
626
+ console.log("\u{1F534} Recording started");
627
+ this.onStateChange?.("live");
628
+ } catch (error) {
629
+ console.error("\u274C Error starting stream:", error);
630
+ this.onError?.(error instanceof Error ? error.message : "Failed to start stream");
631
+ this.onStateChange?.("error");
632
+ this.stop();
633
+ throw error;
634
+ }
635
+ }
636
+ /**
637
+ * Stop streaming
638
+ */
639
+ stop() {
640
+ console.log("\u23F9\uFE0F Stopping stream");
641
+ this.userStopped = true;
642
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
643
+ this.mediaRecorder.stop();
644
+ console.log("\u23F9\uFE0F MediaRecorder stopped");
645
+ }
646
+ if (this.websocket) {
647
+ const readyStateNames = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
648
+ const stateName = readyStateNames[this.websocket.readyState] || "UNKNOWN";
649
+ console.log(`\u{1F50C} WebSocket state: ${stateName} (${this.websocket.readyState})`);
650
+ if (this.websocket.readyState !== WebSocket.CLOSED) {
651
+ this.websocket.close();
652
+ console.log("\u{1F50C} WebSocket close() called");
653
+ } else {
654
+ console.log("\u{1F50C} WebSocket already closed");
655
+ }
656
+ } else {
657
+ console.log("\u26A0\uFE0F No WebSocket to close");
658
+ }
659
+ this.cleanupCanvasRendering();
660
+ this.mediaRecorder = null;
661
+ this.websocket = null;
662
+ this.onStateChange?.("stopped");
663
+ }
664
+ /**
665
+ * Get total bytes sent
666
+ */
667
+ getBytesSent() {
668
+ return this.bytesSent;
669
+ }
670
+ /**
671
+ * Get the current source media stream.
672
+ * This may change after replaceVideoTrack() is called.
673
+ */
674
+ getMediaStream() {
675
+ return this.mediaStream;
676
+ }
677
+ /**
678
+ * Get current diagnostics
679
+ */
680
+ getDiagnostics(closeCode, closeReason) {
681
+ return {
682
+ mimeType: this.mimeType,
683
+ chunksSent: this.chunksSent,
684
+ bytesSent: this.bytesSent,
685
+ elapsedMs: this.startTime ? Date.now() - this.startTime : 0,
686
+ closeCode,
687
+ closeReason
688
+ };
689
+ }
690
+ /**
691
+ * Replace the video track for camera flips.
692
+ *
693
+ * When using canvas-based rendering (video streams), this preloads the new
694
+ * camera in a temporary video element, waits for it to be ready, then swaps
695
+ * it in. This ensures continuous frame output with no gaps.
696
+ *
697
+ * @param newVideoTrack - The new video track from the flipped camera
698
+ * @returns Promise that resolves when the swap is complete
699
+ */
700
+ async replaceVideoTrack(newVideoTrack) {
701
+ console.log("\u{1F504} Replacing video track");
702
+ if (this.canvasState) {
703
+ console.log("\u{1F3A8} Using canvas-based swap with preloading (no frame gaps)");
704
+ const audioTracks = this.mediaStream.getAudioTracks();
705
+ const newStream = new MediaStream([newVideoTrack, ...audioTracks]);
706
+ const preloadVideo = document.createElement("video");
707
+ preloadVideo.srcObject = newStream;
708
+ preloadVideo.muted = true;
709
+ preloadVideo.playsInline = true;
710
+ await new Promise((resolve, reject) => {
711
+ const timeout = setTimeout(() => {
712
+ console.warn("\u26A0\uFE0F Video preload timeout - switching anyway");
713
+ if (preloadVideo.paused) {
714
+ preloadVideo.play().catch(() => {
715
+ });
716
+ }
717
+ resolve();
718
+ }, 3e3);
719
+ const checkFullyReady = () => {
720
+ if (preloadVideo.videoWidth > 0 && preloadVideo.videoHeight > 0 && !preloadVideo.paused) {
721
+ clearTimeout(timeout);
722
+ console.log(`\u{1F4F9} New camera ready and playing: ${preloadVideo.videoWidth}x${preloadVideo.videoHeight}`);
723
+ resolve();
724
+ return true;
725
+ }
726
+ return false;
727
+ };
728
+ preloadVideo.addEventListener("loadeddata", () => {
729
+ preloadVideo.play().then(() => {
730
+ requestAnimationFrame(() => {
731
+ if (!checkFullyReady()) {
732
+ const pollPlaying = setInterval(() => {
733
+ if (checkFullyReady()) {
734
+ clearInterval(pollPlaying);
735
+ }
736
+ }, 50);
737
+ setTimeout(() => clearInterval(pollPlaying), 2e3);
738
+ }
739
+ });
740
+ }).catch((e) => {
741
+ console.warn("Video preload play warning:", e);
742
+ checkFullyReady();
743
+ });
744
+ }, { once: true });
745
+ preloadVideo.addEventListener("error", (e) => {
746
+ clearTimeout(timeout);
747
+ reject(new Error(`Video preload failed: ${e}`));
748
+ }, { once: true });
749
+ preloadVideo.play().catch(() => {
750
+ });
751
+ });
752
+ const oldVideoElement = this.canvasState.videoElement;
753
+ this.canvasState.videoElement = preloadVideo;
754
+ this.mediaStream.getVideoTracks().forEach((track) => track.stop());
755
+ oldVideoElement.pause();
756
+ oldVideoElement.srcObject = null;
757
+ this.mediaStream = newStream;
758
+ this.invalidateScalingCache();
759
+ const settings = newVideoTrack.getSettings();
760
+ if (settings.width && settings.height) {
761
+ console.log(`\u{1F4D0} New camera resolution: ${settings.width}x${settings.height}`);
762
+ }
763
+ console.log("\u2705 Video source swapped - canvas continues seamlessly");
764
+ } else {
765
+ console.warn("\u26A0\uFE0F Canvas not available - attempting direct track replacement");
766
+ const oldVideoTracks = this.mediaStream.getVideoTracks();
767
+ this.mediaStream.addTrack(newVideoTrack);
768
+ console.log("\u2795 New video track added");
769
+ oldVideoTracks.forEach((track) => {
770
+ this.mediaStream.removeTrack(track);
771
+ track.stop();
772
+ });
773
+ console.log("\u2796 Old video track(s) removed");
774
+ console.log("\u2705 Video track replaced");
775
+ }
776
+ }
777
+ /**
778
+ * Replace the audio track in the current MediaStream without stopping MediaRecorder.
779
+ *
780
+ * @param newAudioTrack - The new audio track
781
+ */
782
+ replaceAudioTrack(newAudioTrack) {
783
+ console.log("\u{1F504} Replacing audio track (no MediaRecorder restart)");
784
+ const oldAudioTracks = this.mediaStream.getAudioTracks();
785
+ this.mediaStream.addTrack(newAudioTrack);
786
+ console.log("\u2795 New audio track added to source stream");
787
+ oldAudioTracks.forEach((track) => {
788
+ this.mediaStream.removeTrack(track);
789
+ track.stop();
790
+ });
791
+ console.log("\u2796 Old audio track(s) removed from source stream");
792
+ if (this.canvasState) {
793
+ this.canvasState.stream.getAudioTracks().forEach((track) => {
794
+ this.canvasState.stream.removeTrack(track);
795
+ });
796
+ this.canvasState.stream.addTrack(newAudioTrack);
797
+ console.log("\u{1F3A8} Audio track synced to canvas stream");
798
+ }
799
+ console.log("\u2705 Audio track replaced - streaming continues seamlessly");
800
+ }
801
+ /**
802
+ * Update the media stream (e.g., when switching devices from settings)
803
+ * This keeps the WebSocket connection alive while swapping the media source.
804
+ * Restarts the MediaRecorder with the new stream.
805
+ *
806
+ * Note: For camera flips, prefer replaceVideoTrack() which doesn't restart MediaRecorder.
807
+ * Note: Errors are thrown to the caller, not sent to onError callback.
808
+ */
809
+ async updateMediaStream(newMediaStream) {
810
+ console.log("\u{1F504} Updating media stream (hot-swap)");
811
+ this.isHotSwapping = true;
812
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
813
+ this.mediaRecorder.stop();
814
+ console.log("\u23F9\uFE0F Old MediaRecorder stopped");
815
+ }
816
+ this.mediaStream = newMediaStream;
817
+ const useCanvas = this.isVideo && !this.disableCanvasRendering;
818
+ let streamToRecord = this.mediaStream;
819
+ if (useCanvas) {
820
+ this.cleanupCanvasRendering();
821
+ streamToRecord = await this.setupCanvasRendering();
822
+ console.log("\u{1F3A8} Canvas rendering recreated for new stream");
823
+ }
824
+ const recorderOptions = getMediaRecorderOptions(this.isVideo);
825
+ this.mediaRecorder = new MediaRecorder(streamToRecord, recorderOptions);
826
+ console.log("\u{1F399}\uFE0F New MediaRecorder created");
827
+ this.setupMediaRecorderHandlers();
828
+ this.mediaRecorder.start(300);
829
+ this.isHotSwapping = false;
830
+ console.log("\u2705 Media stream updated - streaming continues");
831
+ }
832
+ /**
833
+ * Set up WebSocket event handlers
834
+ */
835
+ setupWebSocketHandlers() {
836
+ if (!this.websocket) return;
837
+ this.websocket.addEventListener("close", (event) => {
838
+ console.log("\u{1F50C} WebSocket closed", { code: event.code, reason: event.reason });
839
+ if (!this.userStopped) {
840
+ const diagnostics = this.getDiagnostics(event.code, event.reason);
841
+ console.warn("\u26A0\uFE0F Stream ended unexpectedly", diagnostics);
842
+ let errorMessage;
843
+ if (event.code === 1e3) {
844
+ errorMessage = event.reason || "Stream ended by server";
845
+ } else if (event.code === 1001) {
846
+ errorMessage = "Connection closed - server going away";
847
+ } else if (event.code === 1006) {
848
+ errorMessage = "Connection lost unexpectedly";
849
+ } else if (event.code >= 4e3 && event.code < 5e3) {
850
+ errorMessage = event.reason || "Stream terminated by server";
851
+ } else {
852
+ errorMessage = `Connection closed (code: ${event.code})`;
853
+ }
854
+ this.onStateChange?.("terminated");
855
+ this.onError?.(errorMessage, diagnostics);
856
+ }
857
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
858
+ this.mediaRecorder.stop();
859
+ }
860
+ });
861
+ this.websocket.addEventListener("error", (event) => {
862
+ console.error("\u274C WebSocket error:", event);
863
+ this.onError?.("WebSocket connection error");
864
+ this.onStateChange?.("error");
865
+ });
866
+ }
867
+ /**
868
+ * Set up MediaRecorder event handlers
869
+ */
870
+ setupMediaRecorderHandlers() {
871
+ if (!this.mediaRecorder) return;
872
+ this.mediaRecorder.addEventListener("dataavailable", (event) => {
873
+ if (event.data.size > 0 && this.websocket?.readyState === WebSocket.OPEN) {
874
+ this.websocket.send(event.data);
875
+ this.bytesSent += event.data.size;
876
+ this.chunksSent += 1;
877
+ this.onBytesUpdate?.(this.bytesSent);
878
+ if (this.chunksSent % 10 === 0) {
879
+ console.log(`\u{1F4E4} Sent ${this.chunksSent} chunks (${(this.bytesSent / 1024 / 1024).toFixed(2)} MB total)`);
880
+ }
881
+ }
882
+ });
883
+ this.mediaRecorder.addEventListener("error", (event) => {
884
+ const errorEvent = event;
885
+ console.error("\u274C MediaRecorder error:", errorEvent.error);
886
+ this.onError?.(`Encoding error: ${errorEvent.error?.toString() || "Unknown error"}`);
887
+ this.onStateChange?.("error");
888
+ this.stop();
889
+ });
890
+ this.mediaRecorder.addEventListener("stop", () => {
891
+ console.log("\u23F9\uFE0F MediaRecorder stopped");
892
+ if (!this.isHotSwapping && this.websocket?.readyState === WebSocket.OPEN) {
893
+ this.websocket.close();
894
+ }
895
+ });
896
+ }
897
+ };
898
+ function AudioWaveform({
899
+ audioElement,
900
+ mediaStream,
901
+ isPlaying = false,
902
+ isLive = false
903
+ }) {
904
+ const canvasRef = useRef(null);
905
+ const animationFrameRef = useRef(void 0);
906
+ const [setupError, setSetupError] = useState(false);
907
+ const isPlayingRef = useRef(isPlaying);
908
+ const isLiveRef = useRef(isLive);
909
+ useEffect(() => {
910
+ isPlayingRef.current = isPlaying;
911
+ }, [isPlaying]);
912
+ useEffect(() => {
913
+ isLiveRef.current = isLive;
914
+ }, [isLive]);
915
+ useEffect(() => {
916
+ const canvas = canvasRef.current;
917
+ if (!canvas) return;
918
+ const ctx = canvas.getContext("2d");
919
+ if (!ctx) return;
920
+ if (audioElement) {
921
+ const hasMediaAPI = "play" in audioElement && "pause" in audioElement && "currentTime" in audioElement;
922
+ const isMediaElement = audioElement instanceof HTMLMediaElement;
923
+ if (!hasMediaAPI && !isMediaElement) {
924
+ console.warn(
925
+ "[AudioWaveform] Invalid audio element - missing media API"
926
+ );
927
+ return;
928
+ }
929
+ console.log("[AudioWaveform] Audio element validation:", {
930
+ tagName: audioElement.tagName,
931
+ isHTMLMediaElement: isMediaElement,
932
+ hasMediaAPI,
933
+ willAttemptVisualization: true
934
+ });
935
+ }
936
+ const canUseAudioElement = audioElement && audioElement instanceof HTMLMediaElement;
937
+ const isHLSLiveStream = audioElement && !canUseAudioElement;
938
+ if (!audioElement && !mediaStream || audioElement && !canUseAudioElement) {
939
+ if (isHLSLiveStream) {
940
+ let time = 0;
941
+ let frozenTime = 0;
942
+ let wasFrozen = false;
943
+ const barPhases = Array.from(
944
+ { length: 128 },
945
+ () => Math.random() * Math.PI * 2
946
+ );
947
+ const barSpeeds = Array.from(
948
+ { length: 128 },
949
+ () => 0.8 + Math.random() * 0.4
950
+ );
951
+ const glowPhases = Array.from(
952
+ { length: 128 },
953
+ () => Math.random() * Math.PI * 2
954
+ );
955
+ const glowSpeeds = Array.from(
956
+ { length: 128 },
957
+ () => 0.7 + Math.random() * 0.6
958
+ );
959
+ const drawEnhancedWaveform = () => {
960
+ ctx.fillStyle = "#000";
961
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
962
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
963
+ gradient.addColorStop(0, "#06b6d4");
964
+ gradient.addColorStop(0.3, "#3b82f6");
965
+ gradient.addColorStop(0.6, "#8b5cf6");
966
+ gradient.addColorStop(1, "#ec4899");
967
+ const currentlyLive = isLiveRef.current;
968
+ if (currentlyLive) {
969
+ time += 0.02;
970
+ wasFrozen = false;
971
+ } else if (!wasFrozen) {
972
+ frozenTime = time;
973
+ wasFrozen = true;
974
+ }
975
+ const currentTime = wasFrozen ? frozenTime : time;
976
+ const barCount = 128;
977
+ const barWidth = canvas.width / barCount;
978
+ const gap = 2;
979
+ const maxHeight = canvas.height * 0.9;
980
+ for (let i = 0; i < barCount; i++) {
981
+ const primaryWave = Math.sin(
982
+ i / barCount * Math.PI * 2 * 2.5 - currentTime * barSpeeds[i] + barPhases[i]
983
+ ) * (maxHeight * 0.35);
984
+ const secondaryWave = Math.sin(
985
+ i / barCount * Math.PI * 2 * 4 - currentTime * barSpeeds[i] * 1.3 + barPhases[i] * 0.7
986
+ ) * (maxHeight * 0.15);
987
+ const tertiaryWave = Math.sin(
988
+ i / barCount * Math.PI * 2 * 7 - currentTime * barSpeeds[i] * 0.8 + barPhases[i] * 1.5
989
+ ) * (maxHeight * 0.1);
990
+ const baseHeight = maxHeight * 0.15;
991
+ const combinedWave = primaryWave + secondaryWave + tertiaryWave;
992
+ const barHeight = Math.max(
993
+ 10,
994
+ Math.min(maxHeight, baseHeight + combinedWave)
995
+ );
996
+ const opacityWave1 = Math.sin(
997
+ i / barCount * Math.PI * 2 * 1.5 - currentTime * 1.2
998
+ );
999
+ const opacityWave2 = Math.sin(
1000
+ i / barCount * Math.PI * 2 * 3.5 - currentTime * 0.7
1001
+ );
1002
+ const opacity = 0.3 + opacityWave1 * 0.25 + opacityWave2 * 0.15;
1003
+ const glowWave = Math.sin(
1004
+ currentTime * glowSpeeds[i] + glowPhases[i]
1005
+ );
1006
+ const glowIntensity = 8 + glowWave * 12;
1007
+ const x = i * barWidth;
1008
+ const y = canvas.height / 2 - barHeight / 2;
1009
+ ctx.shadowBlur = glowIntensity;
1010
+ ctx.shadowColor = "#3b82f6";
1011
+ ctx.fillStyle = gradient;
1012
+ ctx.globalAlpha = Math.max(0.15, Math.min(0.9, opacity));
1013
+ ctx.fillRect(x + gap / 2, y, barWidth - gap, barHeight);
1014
+ }
1015
+ ctx.globalAlpha = 1;
1016
+ ctx.shadowBlur = 0;
1017
+ };
1018
+ const animationId2 = setInterval(drawEnhancedWaveform, 1e3 / 60);
1019
+ return () => clearInterval(animationId2);
1020
+ }
1021
+ let waveOffset = 0;
1022
+ const drawPlaceholder = () => {
1023
+ ctx.fillStyle = "#000";
1024
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1025
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
1026
+ gradient.addColorStop(0, "#06b6d4");
1027
+ gradient.addColorStop(0.3, "#3b82f6");
1028
+ gradient.addColorStop(0.6, "#8b5cf6");
1029
+ gradient.addColorStop(1, "#ec4899");
1030
+ waveOffset += 83e-4;
1031
+ const barCount = 128;
1032
+ const barWidth = canvas.width / barCount;
1033
+ const gap = 2;
1034
+ const baseHeight = 15;
1035
+ const waveAmplitude = 10;
1036
+ for (let i = 0; i < barCount; i++) {
1037
+ const wave = Math.sin(i / barCount * Math.PI * 2 * 3 - waveOffset) * waveAmplitude;
1038
+ const barHeight = baseHeight + wave;
1039
+ const opacityWave = Math.sin(
1040
+ i / barCount * Math.PI * 2 * 2 - waveOffset * 1.5
1041
+ );
1042
+ const opacity = 0.5 + opacityWave * 0.3;
1043
+ const x = i * barWidth;
1044
+ const y = canvas.height / 2 - barHeight / 2;
1045
+ ctx.shadowBlur = 15;
1046
+ ctx.shadowColor = "#3b82f6";
1047
+ ctx.fillStyle = gradient;
1048
+ ctx.globalAlpha = opacity;
1049
+ ctx.fillRect(x + gap / 2, y, barWidth - gap, barHeight);
1050
+ }
1051
+ ctx.globalAlpha = 1;
1052
+ ctx.shadowBlur = 0;
1053
+ };
1054
+ const animationId = setInterval(drawPlaceholder, 1e3 / 60);
1055
+ return () => clearInterval(animationId);
1056
+ }
1057
+ let audioContext = null;
1058
+ let analyser = null;
1059
+ let source = null;
1060
+ try {
1061
+ audioContext = new AudioContext();
1062
+ analyser = audioContext.createAnalyser();
1063
+ analyser.fftSize = 2048;
1064
+ if (audioElement) {
1065
+ console.log("[AudioWaveform] Creating audio source from element:", {
1066
+ tagName: audioElement.tagName,
1067
+ src: audioElement.src?.substring(0, 80),
1068
+ readyState: audioElement.readyState,
1069
+ paused: audioElement.paused,
1070
+ currentTime: audioElement.currentTime,
1071
+ hasSourceNode: !!audioElement.audioSourceNode,
1072
+ isNativeElement: audioElement instanceof HTMLMediaElement
1073
+ });
1074
+ if (!(audioElement instanceof HTMLMediaElement)) {
1075
+ console.warn(
1076
+ "[AudioWaveform] Cannot visualize custom element (HLS-VIDEO), falling back to static waveform"
1077
+ );
1078
+ setSetupError(true);
1079
+ return;
1080
+ }
1081
+ if (audioElement.audioSourceNode) {
1082
+ console.log(
1083
+ "[AudioWaveform] Audio source already exists, reusing it"
1084
+ );
1085
+ source = audioElement.audioSourceNode;
1086
+ source?.connect(analyser);
1087
+ analyser.connect(audioContext.destination);
1088
+ } else {
1089
+ try {
1090
+ source = audioContext.createMediaElementSource(audioElement);
1091
+ source.connect(analyser);
1092
+ analyser.connect(audioContext.destination);
1093
+ audioElement.audioSourceNode = source;
1094
+ console.log(
1095
+ "[AudioWaveform] Audio source created and connected successfully"
1096
+ );
1097
+ } catch (error) {
1098
+ console.error(
1099
+ "[AudioWaveform] Failed to create media element source:",
1100
+ error
1101
+ );
1102
+ setSetupError(true);
1103
+ return;
1104
+ }
1105
+ }
1106
+ audioElement.addEventListener("play", () => {
1107
+ console.log("[AudioWaveform] Play event - setting isPlaying to true");
1108
+ isPlayingRef.current = true;
1109
+ });
1110
+ audioElement.addEventListener("pause", () => {
1111
+ console.log(
1112
+ "[AudioWaveform] Pause event - setting isPlaying to false"
1113
+ );
1114
+ isPlayingRef.current = false;
1115
+ });
1116
+ audioElement.addEventListener("ended", () => {
1117
+ console.log(
1118
+ "[AudioWaveform] Ended event - setting isPlaying to false"
1119
+ );
1120
+ isPlayingRef.current = false;
1121
+ });
1122
+ console.log("[AudioWaveform] Initial audio state:", {
1123
+ paused: audioElement.paused,
1124
+ currentTime: audioElement.currentTime,
1125
+ readyState: audioElement.readyState
1126
+ });
1127
+ if (!audioElement.paused) {
1128
+ isPlayingRef.current = true;
1129
+ }
1130
+ } else if (mediaStream) {
1131
+ source = audioContext.createMediaStreamSource(mediaStream);
1132
+ source.connect(analyser);
1133
+ isPlayingRef.current = true;
1134
+ } else {
1135
+ return;
1136
+ }
1137
+ const bufferLength = analyser.frequencyBinCount;
1138
+ const dataArray = new Uint8Array(bufferLength);
1139
+ let waveOffset = 0;
1140
+ const drawStaticWaveform = () => {
1141
+ ctx.fillStyle = "#000";
1142
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1143
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
1144
+ gradient.addColorStop(0, "#06b6d4");
1145
+ gradient.addColorStop(0.3, "#3b82f6");
1146
+ gradient.addColorStop(0.6, "#8b5cf6");
1147
+ gradient.addColorStop(1, "#ec4899");
1148
+ waveOffset += 83e-4;
1149
+ const barCount = 128;
1150
+ const barWidth = canvas.width / barCount;
1151
+ const gap = 2;
1152
+ const baseHeight = 15;
1153
+ const waveAmplitude = 10;
1154
+ for (let i = 0; i < barCount; i++) {
1155
+ const wave = Math.sin(i / barCount * Math.PI * 2 * 3 - waveOffset) * waveAmplitude;
1156
+ const barHeight = baseHeight + wave;
1157
+ const opacityWave = Math.sin(
1158
+ i / barCount * Math.PI * 2 * 2 - waveOffset * 1.5
1159
+ );
1160
+ const opacity = 0.5 + opacityWave * 0.3;
1161
+ const x = i * barWidth;
1162
+ const y = canvas.height / 2 - barHeight / 2;
1163
+ ctx.shadowBlur = 15;
1164
+ ctx.shadowColor = "#3b82f6";
1165
+ ctx.fillStyle = gradient;
1166
+ ctx.globalAlpha = opacity;
1167
+ ctx.fillRect(x + gap / 2, y, barWidth - gap, barHeight);
1168
+ }
1169
+ ctx.globalAlpha = 1;
1170
+ ctx.shadowBlur = 0;
1171
+ };
1172
+ let frameCount = 0;
1173
+ const draw = () => {
1174
+ if (!analyser) return;
1175
+ animationFrameRef.current = requestAnimationFrame(draw);
1176
+ analyser.getByteFrequencyData(dataArray);
1177
+ const hasActivity = dataArray.some((value) => value > 0);
1178
+ const maxValue = Math.max(...dataArray);
1179
+ frameCount++;
1180
+ if (frameCount < 5 || frameCount % 60 === 0) {
1181
+ console.log("[AudioWaveform] Frame", frameCount, "Audio activity:", {
1182
+ hasActivity,
1183
+ maxValue,
1184
+ isPlaying: isPlayingRef.current,
1185
+ sampleValues: [
1186
+ dataArray[0],
1187
+ dataArray[10],
1188
+ dataArray[50],
1189
+ dataArray[100]
1190
+ ],
1191
+ avgValue: dataArray.reduce((a, b) => a + b, 0) / dataArray.length
1192
+ });
1193
+ }
1194
+ if (!hasActivity || !isPlayingRef.current) {
1195
+ if (frameCount < 5) {
1196
+ console.log(
1197
+ "[AudioWaveform] No activity or not playing, showing static waveform"
1198
+ );
1199
+ }
1200
+ drawStaticWaveform();
1201
+ return;
1202
+ }
1203
+ ctx.fillStyle = "rgba(0, 0, 0, 0.1)";
1204
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1205
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
1206
+ gradient.addColorStop(0, "#06b6d4");
1207
+ gradient.addColorStop(0.3, "#3b82f6");
1208
+ gradient.addColorStop(0.6, "#8b5cf6");
1209
+ gradient.addColorStop(1, "#ec4899");
1210
+ const barCount = 128;
1211
+ const barWidth = canvas.width / barCount;
1212
+ const gap = 2;
1213
+ for (let i = 0; i < barCount; i++) {
1214
+ const barHeight = dataArray[i] / 255 * canvas.height * 0.8;
1215
+ const x = i * barWidth;
1216
+ const y = canvas.height / 2 - barHeight / 2;
1217
+ ctx.shadowBlur = 20;
1218
+ ctx.shadowColor = "#3b82f6";
1219
+ ctx.fillStyle = gradient;
1220
+ ctx.fillRect(x + gap / 2, y, barWidth - gap, barHeight);
1221
+ }
1222
+ ctx.shadowBlur = 0;
1223
+ };
1224
+ draw();
1225
+ } catch (error) {
1226
+ console.error("Error setting up audio visualization:", error);
1227
+ setSetupError(true);
1228
+ if (ctx) {
1229
+ ctx.fillStyle = "#000";
1230
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1231
+ ctx.strokeStyle = "#3b82f6";
1232
+ ctx.lineWidth = 2;
1233
+ ctx.beginPath();
1234
+ ctx.moveTo(0, canvas.height / 2);
1235
+ ctx.lineTo(canvas.width, canvas.height / 2);
1236
+ ctx.stroke();
1237
+ }
1238
+ }
1239
+ return () => {
1240
+ if (animationFrameRef.current) {
1241
+ cancelAnimationFrame(animationFrameRef.current);
1242
+ }
1243
+ if (audioContext) {
1244
+ audioContext.close();
1245
+ }
1246
+ };
1247
+ }, [audioElement, mediaStream, isLive]);
1248
+ return /* @__PURE__ */ jsxs("div", { className: "w-full h-full", children: [
1249
+ /* @__PURE__ */ jsx(
1250
+ "canvas",
1251
+ {
1252
+ ref: canvasRef,
1253
+ width: 1600,
1254
+ height: 400,
1255
+ className: "w-full h-full",
1256
+ style: { display: "block" }
1257
+ }
1258
+ ),
1259
+ setupError && /* @__PURE__ */ jsx("p", { className: "text-gray-400 text-xs text-center mt-2 absolute bottom-4", children: "Audio visualization unavailable" })
1260
+ ] });
1261
+ }
1262
+ function StreamingPreview({
1263
+ videoRef,
1264
+ isVideoKey,
1265
+ isVideoEnabled,
1266
+ mediaStream,
1267
+ facingMode
1268
+ }) {
1269
+ return /* @__PURE__ */ jsxs("div", { className: "dialtribe-streaming-preview flex-1 relative bg-black overflow-hidden", children: [
1270
+ isVideoKey && isVideoEnabled && /* @__PURE__ */ jsx(
1271
+ "video",
1272
+ {
1273
+ ref: videoRef,
1274
+ autoPlay: true,
1275
+ muted: true,
1276
+ playsInline: true,
1277
+ className: `w-full h-full object-cover ${facingMode === "user" ? "scale-x-[-1]" : ""}`,
1278
+ style: { maxHeight: "100vh" }
1279
+ }
1280
+ ),
1281
+ !isVideoKey && /* @__PURE__ */ jsx("div", { className: "w-full h-full flex items-center justify-center p-8", children: /* @__PURE__ */ jsxs("div", { className: "w-full max-w-4xl", children: [
1282
+ /* @__PURE__ */ jsx("div", { className: "w-full h-auto border border-gray-800 rounded-lg overflow-hidden", children: /* @__PURE__ */ jsx(AudioWaveform, { mediaStream, isPlaying: true }) }),
1283
+ /* @__PURE__ */ jsxs("div", { className: "text-center mt-4", children: [
1284
+ /* @__PURE__ */ jsx("div", { className: "w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-3", children: /* @__PURE__ */ jsx(
1285
+ "svg",
1286
+ {
1287
+ className: "w-8 h-8 text-white",
1288
+ fill: "none",
1289
+ stroke: "currentColor",
1290
+ viewBox: "0 0 24 24",
1291
+ children: /* @__PURE__ */ jsx(
1292
+ "path",
1293
+ {
1294
+ strokeLinecap: "round",
1295
+ strokeLinejoin: "round",
1296
+ strokeWidth: 2,
1297
+ d: "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
1298
+ }
1299
+ )
1300
+ }
1301
+ ) }),
1302
+ /* @__PURE__ */ jsx("p", { className: "text-white text-xl font-medium", children: "Audio-Only Stream" }),
1303
+ /* @__PURE__ */ jsx("p", { className: "text-gray-400 text-sm mt-1", children: "Your audio is being captured" })
1304
+ ] })
1305
+ ] }) }),
1306
+ isVideoKey && !isVideoEnabled && /* @__PURE__ */ jsx("div", { className: "w-full h-full flex items-center justify-center p-8 bg-gray-900", children: /* @__PURE__ */ jsxs("div", { className: "w-full max-w-4xl", children: [
1307
+ /* @__PURE__ */ jsx("div", { className: "w-full h-auto border border-gray-800 rounded-lg overflow-hidden mb-6", children: /* @__PURE__ */ jsx(AudioWaveform, { mediaStream, isPlaying: true }) }),
1308
+ /* @__PURE__ */ jsxs("div", { className: "text-center", children: [
1309
+ /* @__PURE__ */ jsx("div", { className: "w-16 h-16 bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3", children: /* @__PURE__ */ jsx(
1310
+ "svg",
1311
+ {
1312
+ className: "w-8 h-8 text-gray-400",
1313
+ fill: "none",
1314
+ stroke: "currentColor",
1315
+ viewBox: "0 0 24 24",
1316
+ "aria-hidden": "true",
1317
+ children: /* @__PURE__ */ jsx(
1318
+ "path",
1319
+ {
1320
+ strokeLinecap: "round",
1321
+ strokeLinejoin: "round",
1322
+ strokeWidth: 2,
1323
+ d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z M3 3l18 18"
1324
+ }
1325
+ )
1326
+ }
1327
+ ) }),
1328
+ /* @__PURE__ */ jsx("p", { className: "text-white text-xl font-medium", children: "Camera Off" }),
1329
+ /* @__PURE__ */ jsx("p", { className: "text-gray-400 text-sm mt-1", children: "Your audio is still being broadcast" })
1330
+ ] })
1331
+ ] }) })
1332
+ ] });
1333
+ }
1334
+ function StreamKeyDisplay({
1335
+ streamKey,
1336
+ className = "",
1337
+ showLabel = true,
1338
+ showCopy = true,
1339
+ editable = false,
1340
+ onChange,
1341
+ size = "md",
1342
+ layout = "vertical",
1343
+ darkMode = false
1344
+ }) {
1345
+ const [isRevealed, setIsRevealed] = useState(false);
1346
+ const [copySuccess, setCopySuccess] = useState(false);
1347
+ const obscureStreamKey = (key) => {
1348
+ if (key.length <= 12) {
1349
+ return "\u2022".repeat(key.length);
1350
+ }
1351
+ return `${key.substring(0, 6)}${"\u2022".repeat(key.length - 12)}${key.substring(key.length - 6)}`;
1352
+ };
1353
+ const handleCopy = async () => {
1354
+ try {
1355
+ await navigator.clipboard.writeText(streamKey);
1356
+ setCopySuccess(true);
1357
+ setTimeout(() => setCopySuccess(false), 2e3);
1358
+ } catch (err) {
1359
+ console.error("Failed to copy stream key:", err);
1360
+ }
1361
+ };
1362
+ const handleReveal = () => {
1363
+ setIsRevealed(!isRevealed);
1364
+ };
1365
+ const sizeClasses2 = {
1366
+ sm: {
1367
+ label: "text-xs",
1368
+ code: "text-xs px-2 py-1",
1369
+ button: "text-xs px-2 py-1"
1370
+ },
1371
+ md: {
1372
+ label: "text-sm",
1373
+ code: "text-sm px-2 py-1",
1374
+ button: "text-sm px-3 py-1.5"
1375
+ },
1376
+ lg: {
1377
+ label: "text-base",
1378
+ code: "text-base px-3 py-2",
1379
+ button: "text-base px-4 py-2"
1380
+ }
1381
+ };
1382
+ const styles = sizeClasses2[size];
1383
+ const isHorizontal = layout === "horizontal";
1384
+ const containerClass = isHorizontal ? "flex items-center gap-3" : "flex flex-col gap-2";
1385
+ const codeClass = darkMode && !isRevealed ? `${styles.code} font-mono text-white bg-transparent rounded truncate` : `${styles.code} font-mono text-black dark:text-white bg-gray-100 dark:bg-zinc-800 rounded truncate flex-1 min-w-0`;
1386
+ const labelClass = darkMode ? `${styles.label} text-white/80 font-medium whitespace-nowrap` : `${styles.label} text-gray-600 dark:text-gray-400 font-medium`;
1387
+ const revealButtonClass = darkMode ? `${styles.button} text-blue-400 hover:text-blue-300 hover:bg-white/10 rounded transition-colors whitespace-nowrap` : `${styles.button} text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors whitespace-nowrap`;
1388
+ return /* @__PURE__ */ jsxs("div", { className: `dialtribe-stream-key-display ${containerClass} ${className}`, children: [
1389
+ showLabel && /* @__PURE__ */ jsx("span", { className: labelClass, children: "Stream Key:" }),
1390
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 min-w-0 overflow-hidden", children: [
1391
+ isRevealed && editable ? /* @__PURE__ */ jsx(
1392
+ "input",
1393
+ {
1394
+ type: "text",
1395
+ value: streamKey,
1396
+ onChange: (e) => onChange?.(e.target.value),
1397
+ className: `${styles.code} font-mono text-black dark:text-white bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1 min-w-0`,
1398
+ placeholder: "Enter stream key"
1399
+ }
1400
+ ) : /* @__PURE__ */ jsx("code", { className: codeClass, children: isRevealed ? streamKey : obscureStreamKey(streamKey) }),
1401
+ /* @__PURE__ */ jsx(
1402
+ "button",
1403
+ {
1404
+ onClick: handleReveal,
1405
+ className: `${revealButtonClass} shrink-0`,
1406
+ children: isRevealed ? "Hide" : "Reveal"
1407
+ }
1408
+ ),
1409
+ showCopy && /* @__PURE__ */ jsx(
1410
+ "button",
1411
+ {
1412
+ onClick: handleCopy,
1413
+ className: `${styles.button} text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded transition-colors whitespace-nowrap shrink-0`,
1414
+ title: "Copy to clipboard",
1415
+ children: copySuccess ? "Copied!" : "Copy"
1416
+ }
1417
+ )
1418
+ ] })
1419
+ ] });
1420
+ }
1421
+ function StreamingControls({
1422
+ state,
1423
+ isVideoKey,
1424
+ isMuted,
1425
+ isVideoEnabled,
1426
+ facingMode: _facingMode,
1427
+ // Reserved for future use (e.g., showing camera direction)
1428
+ hasMultipleCameras,
1429
+ startTime,
1430
+ bytesSent,
1431
+ showStopConfirm,
1432
+ streamKey,
1433
+ onStreamKeyChange,
1434
+ onStart,
1435
+ onStop,
1436
+ onConfirmStop,
1437
+ onCancelStop,
1438
+ onToggleMute,
1439
+ onToggleVideo,
1440
+ onFlipCamera,
1441
+ onClose,
1442
+ showCloseConfirm,
1443
+ onConfirmClose,
1444
+ onCancelClose,
1445
+ // Device selection props
1446
+ videoDevices = [],
1447
+ audioDevices = [],
1448
+ selectedVideoDeviceId,
1449
+ selectedAudioDeviceId,
1450
+ onVideoDeviceChange,
1451
+ onAudioDeviceChange,
1452
+ mediaStream
1453
+ }) {
1454
+ const [duration, setDuration] = useState(0);
1455
+ const [showSettings, setShowSettings] = useState(false);
1456
+ useEffect(() => {
1457
+ if (state !== "live" || !startTime) return;
1458
+ const interval = setInterval(() => {
1459
+ const elapsed = Math.floor((Date.now() - startTime.getTime()) / 1e3);
1460
+ setDuration(elapsed);
1461
+ }, 1e3);
1462
+ return () => clearInterval(interval);
1463
+ }, [state, startTime]);
1464
+ const formatDuration = (seconds) => {
1465
+ const hours = Math.floor(seconds / 3600);
1466
+ const minutes = Math.floor(seconds % 3600 / 60);
1467
+ const secs = seconds % 60;
1468
+ if (hours > 0) {
1469
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
1470
+ }
1471
+ return `${minutes}:${secs.toString().padStart(2, "0")}`;
1472
+ };
1473
+ const formatBytes = (bytes) => {
1474
+ if (bytes === 0) return "0 B";
1475
+ const k = 1024;
1476
+ const sizes = ["B", "KB", "MB", "GB"];
1477
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1478
+ return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
1479
+ };
1480
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1481
+ state === "previewing" && onClose && /* @__PURE__ */ jsx("div", { className: "absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/60 to-transparent z-10 flex items-start justify-end", children: /* @__PURE__ */ jsx(
1482
+ "button",
1483
+ {
1484
+ onClick: onClose,
1485
+ className: "w-10 h-10 shrink-0 bg-black/50 hover:bg-black/70 backdrop-blur rounded-full flex items-center justify-center transition-colors",
1486
+ title: "Close broadcast preview",
1487
+ "aria-label": "Close broadcast preview",
1488
+ children: /* @__PURE__ */ jsx("svg", { className: "w-5 h-5 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
1489
+ }
1490
+ ) }),
1491
+ state === "live" && /* @__PURE__ */ jsx("div", { className: "absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/60 to-transparent z-10", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
1492
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1493
+ /* @__PURE__ */ jsx("div", { className: "w-3 h-3 bg-red-600 rounded-full animate-pulse" }),
1494
+ /* @__PURE__ */ jsx("span", { className: "text-white font-semibold text-lg", children: "LIVE" })
1495
+ ] }),
1496
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
1497
+ /* @__PURE__ */ jsxs("div", { className: "text-right", children: [
1498
+ /* @__PURE__ */ jsx("div", { className: "text-white font-mono text-lg font-semibold", children: formatDuration(duration) }),
1499
+ /* @__PURE__ */ jsxs("div", { className: "text-white/80 text-sm", children: [
1500
+ formatBytes(bytesSent),
1501
+ " sent"
1502
+ ] })
1503
+ ] }),
1504
+ onClose && /* @__PURE__ */ jsx(
1505
+ "button",
1506
+ {
1507
+ onClick: onClose,
1508
+ className: "w-10 h-10 bg-black/50 hover:bg-black/70 backdrop-blur rounded-full flex items-center justify-center transition-colors",
1509
+ title: "Close and end broadcast",
1510
+ "aria-label": "Close and end broadcast",
1511
+ children: /* @__PURE__ */ jsx("svg", { className: "w-5 h-5 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
1512
+ }
1513
+ )
1514
+ ] })
1515
+ ] }) }),
1516
+ /* @__PURE__ */ jsxs("div", { className: "absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 to-transparent z-10", children: [
1517
+ state === "previewing" && /* @__PURE__ */ jsxs("div", { className: "relative flex justify-center items-center", children: [
1518
+ isVideoKey && hasMultipleCameras && /* @__PURE__ */ jsx(
1519
+ "button",
1520
+ {
1521
+ onClick: onFlipCamera,
1522
+ className: "absolute left-0 w-10 h-10 bg-black/50 hover:bg-black/70 backdrop-blur rounded-full flex items-center justify-center transition-colors",
1523
+ title: "Switch camera",
1524
+ "aria-label": "Switch between front and back camera",
1525
+ children: /* @__PURE__ */ jsx("svg", { className: "w-5 h-5 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
1526
+ "path",
1527
+ {
1528
+ strokeLinecap: "round",
1529
+ strokeLinejoin: "round",
1530
+ strokeWidth: 2,
1531
+ d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1532
+ }
1533
+ ) })
1534
+ }
1535
+ ),
1536
+ /* @__PURE__ */ jsx(
1537
+ "button",
1538
+ {
1539
+ onClick: onStart,
1540
+ className: "px-12 py-4 bg-blue-600 hover:bg-blue-700 text-white text-xl font-bold rounded-full transition-all transform hover:scale-105 active:scale-95 shadow-lg",
1541
+ "aria-label": "Start live streaming",
1542
+ children: "Start Streaming"
1543
+ }
1544
+ ),
1545
+ /* @__PURE__ */ jsx(
1546
+ "button",
1547
+ {
1548
+ onClick: () => setShowSettings(true),
1549
+ className: "absolute right-0 w-10 h-10 bg-black/50 hover:bg-black/70 backdrop-blur rounded-full flex items-center justify-center transition-colors",
1550
+ title: "Stream settings",
1551
+ "aria-label": "Open stream settings",
1552
+ children: /* @__PURE__ */ jsx("svg", { className: "w-5 h-5 text-white", fill: "currentColor", viewBox: "0 0 20 20", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { fillRule: "evenodd", d: "M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z", clipRule: "evenodd" }) })
1553
+ }
1554
+ )
1555
+ ] }),
1556
+ state === "connecting" && /* @__PURE__ */ jsx("div", { className: "flex justify-center", children: /* @__PURE__ */ jsxs("div", { className: "px-12 py-4 bg-blue-600 text-white text-xl font-bold rounded-full flex items-center gap-3", children: [
1557
+ /* @__PURE__ */ jsx("div", { className: "w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" }),
1558
+ "Connecting..."
1559
+ ] }) }),
1560
+ state === "live" && !showStopConfirm && !showCloseConfirm && /* @__PURE__ */ jsx("div", { className: "space-y-4", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-4", children: [
1561
+ /* @__PURE__ */ jsx(
1562
+ "button",
1563
+ {
1564
+ onClick: onToggleMute,
1565
+ className: `w-14 h-14 rounded-full flex items-center justify-center transition-all shadow-lg ${isMuted ? "bg-red-600 hover:bg-red-700" : "bg-white/20 hover:bg-white/30 backdrop-blur"}`,
1566
+ title: isMuted ? "Unmute microphone" : "Mute microphone",
1567
+ "aria-label": isMuted ? "Unmute microphone" : "Mute microphone",
1568
+ "aria-pressed": isMuted,
1569
+ children: isMuted ? /* @__PURE__ */ jsx("svg", { className: "w-6 h-6 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
1570
+ "path",
1571
+ {
1572
+ strokeLinecap: "round",
1573
+ strokeLinejoin: "round",
1574
+ strokeWidth: 2,
1575
+ d: "M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
1576
+ }
1577
+ ) }) : /* @__PURE__ */ jsx("svg", { className: "w-6 h-6 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
1578
+ "path",
1579
+ {
1580
+ strokeLinecap: "round",
1581
+ strokeLinejoin: "round",
1582
+ strokeWidth: 2,
1583
+ d: "M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
1584
+ }
1585
+ ) })
1586
+ }
1587
+ ),
1588
+ /* @__PURE__ */ jsx(
1589
+ "button",
1590
+ {
1591
+ onClick: onStop,
1592
+ className: "w-16 h-16 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center transition-all shadow-lg",
1593
+ title: "Stop streaming",
1594
+ "aria-label": "Stop streaming",
1595
+ children: /* @__PURE__ */ jsx("div", { className: "w-6 h-6 bg-white rounded-sm", "aria-hidden": "true" })
1596
+ }
1597
+ ),
1598
+ isVideoKey && /* @__PURE__ */ jsx(
1599
+ "button",
1600
+ {
1601
+ onClick: onToggleVideo,
1602
+ className: `w-14 h-14 rounded-full flex items-center justify-center transition-all shadow-lg ${!isVideoEnabled ? "bg-red-600 hover:bg-red-700" : "bg-white/20 hover:bg-white/30 backdrop-blur"}`,
1603
+ title: isVideoEnabled ? "Turn camera off" : "Turn camera on",
1604
+ "aria-label": isVideoEnabled ? "Turn camera off" : "Turn camera on",
1605
+ "aria-pressed": !isVideoEnabled,
1606
+ children: isVideoEnabled ? /* @__PURE__ */ jsx("svg", { className: "w-6 h-6 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
1607
+ "path",
1608
+ {
1609
+ strokeLinecap: "round",
1610
+ strokeLinejoin: "round",
1611
+ strokeWidth: 2,
1612
+ d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
1613
+ }
1614
+ ) }) : /* @__PURE__ */ jsx("svg", { className: "w-6 h-6 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
1615
+ "path",
1616
+ {
1617
+ strokeLinecap: "round",
1618
+ strokeLinejoin: "round",
1619
+ strokeWidth: 2,
1620
+ d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z M3 3l18 18"
1621
+ }
1622
+ ) })
1623
+ }
1624
+ ),
1625
+ isVideoKey && hasMultipleCameras && /* @__PURE__ */ jsx(
1626
+ "button",
1627
+ {
1628
+ onClick: onFlipCamera,
1629
+ className: "w-14 h-14 bg-white/20 hover:bg-white/30 backdrop-blur rounded-full flex items-center justify-center transition-all shadow-lg",
1630
+ title: "Switch camera",
1631
+ "aria-label": "Switch between front and back camera",
1632
+ children: /* @__PURE__ */ jsx("svg", { className: "w-6 h-6 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
1633
+ "path",
1634
+ {
1635
+ strokeLinecap: "round",
1636
+ strokeLinejoin: "round",
1637
+ strokeWidth: 2,
1638
+ d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1639
+ }
1640
+ ) })
1641
+ }
1642
+ ),
1643
+ /* @__PURE__ */ jsx(
1644
+ "button",
1645
+ {
1646
+ disabled: true,
1647
+ className: "w-14 h-14 bg-white/10 rounded-full flex items-center justify-center opacity-50 cursor-not-allowed shadow-lg",
1648
+ title: "Create clip (Coming soon)",
1649
+ "aria-label": "Create clip (Coming soon)",
1650
+ "aria-disabled": "true",
1651
+ children: /* @__PURE__ */ jsx("svg", { className: "w-6 h-6 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
1652
+ "path",
1653
+ {
1654
+ strokeLinecap: "round",
1655
+ strokeLinejoin: "round",
1656
+ strokeWidth: 2,
1657
+ d: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1658
+ }
1659
+ ) })
1660
+ }
1661
+ )
1662
+ ] }) })
1663
+ ] }),
1664
+ showStopConfirm && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 z-20 flex items-center justify-center p-4 bg-black/70", children: /* @__PURE__ */ jsxs("div", { className: "bg-black/95 rounded-2xl p-6 backdrop-blur border border-white/20 max-w-sm w-full", role: "dialog", "aria-labelledby": "stop-dialog-title", children: [
1665
+ /* @__PURE__ */ jsx("p", { id: "stop-dialog-title", className: "text-white text-center text-lg font-medium mb-4", children: "Stop streaming?" }),
1666
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-3", children: [
1667
+ /* @__PURE__ */ jsx(
1668
+ "button",
1669
+ {
1670
+ onClick: onCancelStop,
1671
+ className: "flex-1 px-6 py-3 bg-white/20 hover:bg-white/30 text-white font-medium rounded-lg transition-colors",
1672
+ "aria-label": "Cancel and continue streaming",
1673
+ children: "Cancel"
1674
+ }
1675
+ ),
1676
+ /* @__PURE__ */ jsx(
1677
+ "button",
1678
+ {
1679
+ onClick: onConfirmStop,
1680
+ className: "flex-1 px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors",
1681
+ "aria-label": "Confirm stop streaming",
1682
+ children: "Stop"
1683
+ }
1684
+ )
1685
+ ] })
1686
+ ] }) }),
1687
+ showCloseConfirm && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 z-20 flex items-center justify-center p-4 bg-black/70", children: /* @__PURE__ */ jsxs("div", { className: "bg-black/95 rounded-2xl p-6 backdrop-blur border border-white/20 max-w-sm w-full", role: "dialog", "aria-labelledby": "close-dialog-title", "aria-describedby": "close-dialog-description", children: [
1688
+ /* @__PURE__ */ jsx("p", { id: "close-dialog-title", className: "text-white text-center text-lg font-medium mb-2", children: "Close and end stream?" }),
1689
+ /* @__PURE__ */ jsx("p", { id: "close-dialog-description", className: "text-white/70 text-center text-sm mb-4", children: "Closing will stop your live broadcast." }),
1690
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-3", children: [
1691
+ /* @__PURE__ */ jsx(
1692
+ "button",
1693
+ {
1694
+ onClick: onCancelClose,
1695
+ className: "flex-1 px-6 py-3 bg-white/20 hover:bg-white/30 text-white font-medium rounded-lg transition-colors",
1696
+ "aria-label": "Cancel and continue streaming",
1697
+ children: "Cancel"
1698
+ }
1699
+ ),
1700
+ /* @__PURE__ */ jsx(
1701
+ "button",
1702
+ {
1703
+ onClick: onConfirmClose,
1704
+ className: "flex-1 px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors",
1705
+ "aria-label": "Confirm end broadcast and close",
1706
+ children: "End & Close"
1707
+ }
1708
+ )
1709
+ ] })
1710
+ ] }) }),
1711
+ showSettings && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 z-20 flex items-center justify-center p-4 bg-black/80", children: /* @__PURE__ */ jsxs(
1712
+ "div",
1713
+ {
1714
+ className: "bg-black/95 rounded-2xl backdrop-blur border border-white/20 max-w-md w-full max-h-[90%] overflow-hidden flex flex-col",
1715
+ role: "dialog",
1716
+ "aria-labelledby": "settings-dialog-title",
1717
+ children: [
1718
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between p-4 border-b border-white/10", children: [
1719
+ /* @__PURE__ */ jsx("h2", { id: "settings-dialog-title", className: "text-white text-lg font-semibold", children: "Stream Settings" }),
1720
+ /* @__PURE__ */ jsx(
1721
+ "button",
1722
+ {
1723
+ onClick: () => setShowSettings(false),
1724
+ className: "w-8 h-8 bg-white/10 hover:bg-white/20 rounded-full flex items-center justify-center transition-colors",
1725
+ title: "Close settings",
1726
+ "aria-label": "Close settings",
1727
+ children: /* @__PURE__ */ jsx("svg", { className: "w-4 h-4 text-white", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
1728
+ }
1729
+ )
1730
+ ] }),
1731
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 overflow-y-auto p-4 space-y-6", children: [
1732
+ /* @__PURE__ */ jsxs("div", { children: [
1733
+ /* @__PURE__ */ jsx("label", { className: "block text-sm font-medium text-white/80 mb-2", children: "Stream Key" }),
1734
+ /* @__PURE__ */ jsx("div", { className: "bg-white/5 border border-white/10 rounded-lg p-3", children: /* @__PURE__ */ jsx(
1735
+ StreamKeyDisplay,
1736
+ {
1737
+ streamKey,
1738
+ editable: !!onStreamKeyChange,
1739
+ onChange: onStreamKeyChange,
1740
+ showCopy: true,
1741
+ size: "sm",
1742
+ layout: "vertical",
1743
+ darkMode: true
1744
+ }
1745
+ ) })
1746
+ ] }),
1747
+ isVideoKey && videoDevices.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
1748
+ /* @__PURE__ */ jsx("label", { className: "block text-sm font-medium text-white/80 mb-2", children: "Camera" }),
1749
+ /* @__PURE__ */ jsx(
1750
+ "select",
1751
+ {
1752
+ value: selectedVideoDeviceId || "",
1753
+ onChange: (e) => onVideoDeviceChange?.(e.target.value),
1754
+ className: "w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500",
1755
+ "aria-label": "Select camera",
1756
+ children: videoDevices.map((device) => /* @__PURE__ */ jsx("option", { value: device.deviceId, className: "bg-zinc-900", children: device.label || `Camera ${device.deviceId.slice(0, 8)}` }, device.deviceId))
1757
+ }
1758
+ ),
1759
+ /* @__PURE__ */ jsx("div", { className: "mt-3 aspect-video bg-black rounded-lg overflow-hidden border border-white/10", children: mediaStream && isVideoEnabled ? /* @__PURE__ */ jsx(
1760
+ "video",
1761
+ {
1762
+ autoPlay: true,
1763
+ muted: true,
1764
+ playsInline: true,
1765
+ className: "w-full h-full object-cover scale-x-[-1]",
1766
+ ref: (el) => {
1767
+ if (el && mediaStream) {
1768
+ el.srcObject = mediaStream;
1769
+ }
1770
+ }
1771
+ }
1772
+ ) : /* @__PURE__ */ jsx("div", { className: "w-full h-full flex items-center justify-center", children: /* @__PURE__ */ jsxs("div", { className: "text-center", children: [
1773
+ /* @__PURE__ */ jsx("svg", { className: "w-8 h-8 text-gray-500 mx-auto mb-2", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z M3 3l18 18" }) }),
1774
+ /* @__PURE__ */ jsx("p", { className: "text-gray-500 text-xs", children: "Camera off" })
1775
+ ] }) }) })
1776
+ ] }),
1777
+ audioDevices.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
1778
+ /* @__PURE__ */ jsx("label", { className: "block text-sm font-medium text-white/80 mb-2", children: "Microphone" }),
1779
+ /* @__PURE__ */ jsx(
1780
+ "select",
1781
+ {
1782
+ value: selectedAudioDeviceId || "",
1783
+ onChange: (e) => onAudioDeviceChange?.(e.target.value),
1784
+ className: "w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500",
1785
+ "aria-label": "Select microphone",
1786
+ children: audioDevices.map((device) => /* @__PURE__ */ jsx("option", { value: device.deviceId, className: "bg-zinc-900", children: device.label || `Microphone ${device.deviceId.slice(0, 8)}` }, device.deviceId))
1787
+ }
1788
+ ),
1789
+ /* @__PURE__ */ jsx("div", { className: "mt-3 h-16 bg-black rounded-lg overflow-hidden border border-white/10", children: mediaStream && !isMuted ? /* @__PURE__ */ jsx(AudioWaveform, { mediaStream, isPlaying: true }) : /* @__PURE__ */ jsx("div", { className: "w-full h-full flex items-center justify-center", children: /* @__PURE__ */ jsx("p", { className: "text-gray-500 text-xs", children: isMuted ? "Microphone muted" : "No audio" }) }) })
1790
+ ] })
1791
+ ] }),
1792
+ /* @__PURE__ */ jsx("div", { className: "p-4 border-t border-white/10", children: /* @__PURE__ */ jsx(
1793
+ "button",
1794
+ {
1795
+ onClick: () => setShowSettings(false),
1796
+ className: "w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
1797
+ "aria-label": "Save and close settings",
1798
+ children: "Done"
1799
+ }
1800
+ ) })
1801
+ ]
1802
+ }
1803
+ ) })
1804
+ ] });
1805
+ }
1806
+ function StreamKeyInput({ onSubmit, inline = false }) {
1807
+ const [streamKey, setStreamKey] = useState("");
1808
+ const [error, setError] = useState("");
1809
+ const containerClass = inline ? "dialtribe-stream-key-input flex items-center justify-center h-full w-full p-4 overflow-auto" : "dialtribe-stream-key-input flex items-center justify-center min-h-screen p-4";
1810
+ const validateStreamKey = (key) => {
1811
+ const pattern = /^[abvw][a-zA-Z0-9]+_.+$/;
1812
+ return pattern.test(key);
1813
+ };
1814
+ const handleSubmit = (e) => {
1815
+ e.preventDefault();
1816
+ setError("");
1817
+ const trimmedKey = streamKey.trim();
1818
+ if (!trimmedKey) {
1819
+ setError("Please enter a stream key");
1820
+ return;
1821
+ }
1822
+ if (!validateStreamKey(trimmedKey)) {
1823
+ setError(
1824
+ "Invalid stream key format. Expected format: {mediaType}{foreignId}_{key} (e.g., w1_abc123...)"
1825
+ );
1826
+ return;
1827
+ }
1828
+ onSubmit(trimmedKey);
1829
+ };
1830
+ return /* @__PURE__ */ jsx("div", { className: containerClass, children: /* @__PURE__ */ jsxs("div", { className: "bg-white dark:bg-zinc-900 rounded-lg border border-gray-200 dark:border-zinc-800 p-8 max-w-md w-full", children: [
1831
+ /* @__PURE__ */ jsxs("div", { className: "text-center mb-8", children: [
1832
+ /* @__PURE__ */ jsx("h1", { className: "text-3xl font-bold text-black dark:text-white mb-2", children: "Start Broadcasting" }),
1833
+ /* @__PURE__ */ jsx("p", { className: "text-gray-600 dark:text-gray-400", children: "Enter your stream key to get started" })
1834
+ ] }),
1835
+ /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className: "space-y-6", children: [
1836
+ /* @__PURE__ */ jsxs("div", { children: [
1837
+ /* @__PURE__ */ jsx("label", { className: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2", children: "Stream Key" }),
1838
+ /* @__PURE__ */ jsx(
1839
+ "input",
1840
+ {
1841
+ type: "text",
1842
+ value: streamKey,
1843
+ onChange: (e) => {
1844
+ setStreamKey(e.target.value);
1845
+ setError("");
1846
+ },
1847
+ className: "w-full px-4 py-3 bg-white dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg text-black dark:text-white font-mono text-sm placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent",
1848
+ placeholder: "w1_abc123...",
1849
+ autoFocus: true
1850
+ }
1851
+ ),
1852
+ error && /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-red-600 dark:text-red-400", children: error }),
1853
+ /* @__PURE__ */ jsx("p", { className: "mt-2 text-xs text-gray-500 dark:text-gray-400", children: "Paste the stream key provided by your administrator" })
1854
+ ] }),
1855
+ /* @__PURE__ */ jsx(
1856
+ "button",
1857
+ {
1858
+ type: "submit",
1859
+ className: "w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
1860
+ children: "Continue"
1861
+ }
1862
+ )
1863
+ ] }),
1864
+ /* @__PURE__ */ jsxs("div", { className: "mt-8 pt-6 border-t border-gray-200 dark:border-zinc-800", children: [
1865
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-600 dark:text-gray-400 mb-2", children: "Where do I find my stream key?" }),
1866
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-gray-500 dark:text-gray-400", children: [
1867
+ "If you have access to the DialTribe dashboard, navigate to your app's ",
1868
+ /* @__PURE__ */ jsx("strong", { children: "Stream Keys" }),
1869
+ ' page and click the "Broadcast" button next to any active stream key.'
1870
+ ] })
1871
+ ] })
1872
+ ] }) });
1873
+ }
1874
+ function DialtribeStreamer({
1875
+ sessionToken: propSessionToken,
1876
+ streamKey: initialStreamKey,
1877
+ onDone,
1878
+ onStreamKeyChange,
1879
+ encoderServerUrl = DEFAULT_ENCODER_SERVER_URL,
1880
+ apiBaseUrl = DIALTRIBE_API_BASE,
1881
+ inline = false
1882
+ }) {
1883
+ const containerClass = inline ? "dialtribe-dialtribe-streamer h-full w-full bg-black relative" : "dialtribe-dialtribe-streamer min-h-screen bg-black";
1884
+ const centeredContainerClass = inline ? "dialtribe-dialtribe-streamer flex items-center justify-center h-full w-full p-4 bg-black relative" : "dialtribe-dialtribe-streamer flex items-center justify-center min-h-screen p-4 bg-black";
1885
+ const dialTribeContext = useDialtribeOptional();
1886
+ const sessionToken = propSessionToken ?? dialTribeContext?.sessionToken ?? null;
1887
+ const [streamKey, setStreamKey] = useState(initialStreamKey || null);
1888
+ const [state, setState] = useState("idle");
1889
+ useEffect(() => {
1890
+ if (initialStreamKey && initialStreamKey !== streamKey) {
1891
+ setStreamKey(initialStreamKey);
1892
+ }
1893
+ }, [initialStreamKey]);
1894
+ const [error, setError] = useState(null);
1895
+ const [diagnostics, setDiagnostics] = useState(null);
1896
+ const [mediaStream, setMediaStream] = useState(null);
1897
+ const [streamer, setStreamer] = useState(null);
1898
+ const [bytesSent, setBytesSent] = useState(0);
1899
+ const [startTime, setStartTime] = useState(null);
1900
+ const [isMuted, setIsMuted] = useState(false);
1901
+ const [isVideoEnabled, setIsVideoEnabled] = useState(true);
1902
+ const [facingMode, setFacingMode] = useState("user");
1903
+ const [showStopConfirm, setShowStopConfirm] = useState(false);
1904
+ const [showCloseConfirm, setShowCloseConfirm] = useState(false);
1905
+ const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
1906
+ const [videoDevices, setVideoDevices] = useState([]);
1907
+ const [audioDevices, setAudioDevices] = useState([]);
1908
+ const [selectedVideoDeviceId, setSelectedVideoDeviceId] = useState();
1909
+ const [selectedAudioDeviceId, setSelectedAudioDeviceId] = useState();
1910
+ const videoRef = useRef(null);
1911
+ const streamerRef = useRef(null);
1912
+ const mediaStreamRef = useRef(null);
1913
+ const isVideoKey = streamKey ? streamKey.startsWith("v") || streamKey.startsWith("w") : false;
1914
+ const handleStreamKeySubmit = (key) => {
1915
+ setStreamKey(key);
1916
+ onStreamKeyChange?.(key);
1917
+ };
1918
+ const handleStreamKeyChange = (key) => {
1919
+ setStreamKey(key);
1920
+ onStreamKeyChange?.(key);
1921
+ };
1922
+ useEffect(() => {
1923
+ if (!streamKey) return;
1924
+ const compat = checkBrowserCompatibility();
1925
+ if (!compat.compatible) {
1926
+ setError(compat.error || "Browser not compatible");
1927
+ setState("error");
1928
+ return;
1929
+ }
1930
+ detectCameras();
1931
+ requestMediaPermissions();
1932
+ }, [streamKey]);
1933
+ const detectCameras = async () => {
1934
+ try {
1935
+ const devices = await navigator.mediaDevices.enumerateDevices();
1936
+ const videoInputs = devices.filter((device) => device.kind === "videoinput");
1937
+ const audioInputs = devices.filter((device) => device.kind === "audioinput");
1938
+ console.log(`\u{1F4F7} Found ${videoInputs.length} video input device(s)`);
1939
+ console.log(`\u{1F3A4} Found ${audioInputs.length} audio input device(s)`);
1940
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
1941
+ const hasMultiple = videoInputs.length > 1 || isIOS && isVideoKey;
1942
+ setHasMultipleCameras(hasMultiple);
1943
+ if (isIOS && videoInputs.length <= 1) {
1944
+ console.log("\u{1F4F1} iOS device detected - assuming multiple cameras available");
1945
+ }
1946
+ setVideoDevices(videoInputs.map((d) => ({
1947
+ deviceId: d.deviceId,
1948
+ label: d.label || `Camera ${d.deviceId.slice(0, 8)}`
1949
+ })));
1950
+ setAudioDevices(audioInputs.map((d) => ({
1951
+ deviceId: d.deviceId,
1952
+ label: d.label || `Microphone ${d.deviceId.slice(0, 8)}`
1953
+ })));
1954
+ if (videoInputs.length > 0 && !selectedVideoDeviceId) {
1955
+ setSelectedVideoDeviceId(videoInputs[0].deviceId);
1956
+ }
1957
+ if (audioInputs.length > 0 && !selectedAudioDeviceId) {
1958
+ setSelectedAudioDeviceId(audioInputs[0].deviceId);
1959
+ }
1960
+ } catch (err) {
1961
+ console.error("\u274C Failed to enumerate devices:", err);
1962
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
1963
+ setHasMultipleCameras(isIOS && isVideoKey);
1964
+ }
1965
+ };
1966
+ useEffect(() => {
1967
+ streamerRef.current = streamer;
1968
+ }, [streamer]);
1969
+ useEffect(() => {
1970
+ mediaStreamRef.current = mediaStream;
1971
+ }, [mediaStream]);
1972
+ useEffect(() => {
1973
+ return () => {
1974
+ if (streamerRef.current) {
1975
+ streamerRef.current.stop();
1976
+ }
1977
+ if (mediaStreamRef.current) {
1978
+ mediaStreamRef.current.getTracks().forEach((track) => track.stop());
1979
+ }
1980
+ };
1981
+ }, []);
1982
+ useEffect(() => {
1983
+ if (state === "live") {
1984
+ const handleBeforeUnload = (e) => {
1985
+ e.preventDefault();
1986
+ e.returnValue = "You are currently streaming. Are you sure you want to leave?";
1987
+ return e.returnValue;
1988
+ };
1989
+ window.addEventListener("beforeunload", handleBeforeUnload);
1990
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
1991
+ }
1992
+ }, [state]);
1993
+ useEffect(() => {
1994
+ if (videoRef.current && mediaStream) {
1995
+ videoRef.current.srcObject = mediaStream;
1996
+ }
1997
+ }, [mediaStream, isVideoEnabled]);
1998
+ const requestMediaPermissions = async () => {
1999
+ if (!streamKey) return;
2000
+ try {
2001
+ setState("requesting");
2002
+ setError(null);
2003
+ const constraints = getMediaConstraints({
2004
+ isVideo: isVideoKey && isVideoEnabled,
2005
+ facingMode
2006
+ });
2007
+ console.log("\u{1F4F8} Requesting media permissions:", constraints);
2008
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
2009
+ console.log("\u2705 Media permissions granted");
2010
+ setMediaStream(stream);
2011
+ setState("previewing");
2012
+ } catch (err) {
2013
+ console.error("\u274C Media permission error:", err);
2014
+ if (err instanceof Error) {
2015
+ if (err.name === "NotAllowedError" || err.name === "PermissionDeniedError") {
2016
+ setError("Camera/microphone access denied. Please enable permissions in your browser settings.");
2017
+ } else if (err.name === "NotFoundError") {
2018
+ setError(isVideoKey ? "No camera or microphone found." : "No microphone found.");
2019
+ } else {
2020
+ setError(err.message || "Failed to access media devices");
2021
+ }
2022
+ } else {
2023
+ setError("Failed to access media devices");
2024
+ }
2025
+ setState("error");
2026
+ }
2027
+ };
2028
+ const handleStartStreaming = async () => {
2029
+ if (!mediaStream || !streamKey) {
2030
+ setError("No media stream available");
2031
+ return;
2032
+ }
2033
+ try {
2034
+ setState("connecting");
2035
+ setError(null);
2036
+ console.log("\u{1F50D} Checking broadcast availability...");
2037
+ const checkResponse = await fetch(`${apiBaseUrl}/broadcasts/check`, {
2038
+ method: "POST",
2039
+ headers: {
2040
+ "Content-Type": "application/json",
2041
+ "Authorization": `Bearer ${sessionToken}`
2042
+ },
2043
+ body: JSON.stringify({
2044
+ streamKey
2045
+ })
2046
+ });
2047
+ if (!checkResponse.ok) {
2048
+ if (checkResponse.status === 401) {
2049
+ console.error("\u274C Session token invalid or expired");
2050
+ setError("Session expired. Please refresh the page and try again.");
2051
+ setState("error");
2052
+ dialTribeContext?.markExpired();
2053
+ return;
2054
+ }
2055
+ if (checkResponse.status === 403) {
2056
+ const data = await checkResponse.json().catch(() => ({}));
2057
+ console.error("\u274C Permission denied:", data);
2058
+ setError(
2059
+ data.error || "Streaming is not enabled for this app. Please upgrade your plan or contact support."
2060
+ );
2061
+ setState("error");
2062
+ return;
2063
+ }
2064
+ if (checkResponse.status === 409) {
2065
+ const data = await checkResponse.json();
2066
+ console.log("\u274C Broadcast conflict:", data);
2067
+ setError(
2068
+ data.error || "A broadcast is already active for this stream key. Please terminate it before starting a new one."
2069
+ );
2070
+ setState("error");
2071
+ return;
2072
+ }
2073
+ const errorData = await checkResponse.json().catch(() => ({ error: checkResponse.statusText }));
2074
+ console.error("\u274C Check failed:", checkResponse.status, errorData);
2075
+ setError(errorData.error || `Failed to check broadcast availability: ${checkResponse.statusText}`);
2076
+ setState("error");
2077
+ return;
2078
+ }
2079
+ const checkData = await checkResponse.json();
2080
+ console.log("\u2705 Broadcast check passed:", checkData);
2081
+ const newStreamer = new WebSocketStreamer({
2082
+ streamKey,
2083
+ mediaStream,
2084
+ isVideo: isVideoKey && isVideoEnabled,
2085
+ encoderServerUrl,
2086
+ disableCanvasRendering: true,
2087
+ // <-- ADD THIS LINE
2088
+ onBytesUpdate: setBytesSent,
2089
+ onStateChange: (streamerState) => {
2090
+ if (streamerState === "live") {
2091
+ setState("live");
2092
+ setStartTime(/* @__PURE__ */ new Date());
2093
+ } else if (streamerState === "terminated") {
2094
+ setState("terminated");
2095
+ setStartTime(null);
2096
+ } else if (streamerState === "error") {
2097
+ setState("error");
2098
+ }
2099
+ },
2100
+ onError: (errorMsg, diag) => {
2101
+ setError(errorMsg);
2102
+ if (diag) {
2103
+ setDiagnostics(diag);
2104
+ }
2105
+ }
2106
+ });
2107
+ await newStreamer.start();
2108
+ setStreamer(newStreamer);
2109
+ } catch (err) {
2110
+ console.error("\u274C Failed to start streaming:", err);
2111
+ setError(err instanceof Error ? err.message : "Failed to start streaming");
2112
+ setState("previewing");
2113
+ }
2114
+ };
2115
+ const handleStopStreaming = () => {
2116
+ setShowStopConfirm(true);
2117
+ };
2118
+ const confirmStopStreaming = () => {
2119
+ setState("stopping");
2120
+ setShowStopConfirm(false);
2121
+ if (streamer) {
2122
+ streamer.stop();
2123
+ }
2124
+ if (mediaStream) {
2125
+ mediaStream.getTracks().forEach((track) => track.stop());
2126
+ }
2127
+ setState("stopped");
2128
+ setStartTime(null);
2129
+ };
2130
+ const handleToggleMute = () => {
2131
+ if (mediaStream) {
2132
+ const audioTracks = mediaStream.getAudioTracks();
2133
+ audioTracks.forEach((track) => {
2134
+ track.enabled = !track.enabled;
2135
+ });
2136
+ setIsMuted(!isMuted);
2137
+ }
2138
+ };
2139
+ const handleToggleVideo = () => {
2140
+ if (!isVideoKey) return;
2141
+ if (mediaStream) {
2142
+ const videoTracks = mediaStream.getVideoTracks();
2143
+ videoTracks.forEach((track) => {
2144
+ track.enabled = !track.enabled;
2145
+ });
2146
+ setIsVideoEnabled(!isVideoEnabled);
2147
+ }
2148
+ };
2149
+ const handleFlipCamera = async () => {
2150
+ if (!isVideoKey || !hasMultipleCameras) return;
2151
+ const newFacingMode = facingMode === "user" ? "environment" : "user";
2152
+ if (state === "live" && streamer) {
2153
+ console.log("\u{1F504} Flipping camera during live broadcast (canvas-based swap)");
2154
+ try {
2155
+ const constraints = getMediaConstraints({
2156
+ isVideo: true,
2157
+ facingMode: newFacingMode
2158
+ });
2159
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
2160
+ console.log("\u{1F4F7} Got new camera stream:", newFacingMode);
2161
+ const newVideoTrack = newStream.getVideoTracks()[0];
2162
+ if (newVideoTrack) {
2163
+ await streamer.replaceVideoTrack(newVideoTrack);
2164
+ }
2165
+ const updatedStream = streamer.getMediaStream();
2166
+ setMediaStream(updatedStream);
2167
+ setFacingMode(newFacingMode);
2168
+ if (videoRef.current) {
2169
+ videoRef.current.srcObject = updatedStream;
2170
+ }
2171
+ console.log("\u2705 Camera flipped successfully - broadcast continues seamlessly");
2172
+ } catch (err) {
2173
+ console.error("\u274C Failed to flip camera:", err);
2174
+ console.warn("\u26A0\uFE0F Camera flip failed - continuing with current camera");
2175
+ }
2176
+ } else {
2177
+ setFacingMode(newFacingMode);
2178
+ try {
2179
+ const constraints = getMediaConstraints({
2180
+ isVideo: true,
2181
+ facingMode: newFacingMode
2182
+ });
2183
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
2184
+ console.log("\u{1F4F7} Camera flipped to:", newFacingMode);
2185
+ if (mediaStream) {
2186
+ mediaStream.getTracks().forEach((track) => track.stop());
2187
+ }
2188
+ setMediaStream(newStream);
2189
+ } catch (err) {
2190
+ console.error("\u274C Failed to get new camera stream:", err);
2191
+ setFacingMode(facingMode);
2192
+ console.warn("\u26A0\uFE0F Camera flip not available - this device may only have one camera");
2193
+ }
2194
+ }
2195
+ };
2196
+ const handleRetry = () => {
2197
+ setError(null);
2198
+ setState("idle");
2199
+ requestMediaPermissions();
2200
+ };
2201
+ const handleDone = () => {
2202
+ onDone?.();
2203
+ };
2204
+ const handleClose = () => {
2205
+ if (state === "live") {
2206
+ setShowCloseConfirm(true);
2207
+ } else {
2208
+ if (mediaStream) {
2209
+ mediaStream.getTracks().forEach((track) => track.stop());
2210
+ }
2211
+ onDone?.();
2212
+ }
2213
+ };
2214
+ const confirmClose = () => {
2215
+ setShowCloseConfirm(false);
2216
+ if (streamer) {
2217
+ streamer.stop();
2218
+ }
2219
+ if (mediaStream) {
2220
+ mediaStream.getTracks().forEach((track) => track.stop());
2221
+ }
2222
+ onDone?.();
2223
+ };
2224
+ const handleVideoDeviceChange = async (deviceId) => {
2225
+ if (deviceId === selectedVideoDeviceId) return;
2226
+ setSelectedVideoDeviceId(deviceId);
2227
+ if (state !== "previewing" && state !== "live") return;
2228
+ try {
2229
+ const constraints = {
2230
+ video: { deviceId: { exact: deviceId } },
2231
+ audio: selectedAudioDeviceId ? { deviceId: { exact: selectedAudioDeviceId } } : true
2232
+ };
2233
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
2234
+ console.log(`\u{1F4F7} Switched to camera: ${deviceId}`);
2235
+ if (state === "live" && streamer) {
2236
+ try {
2237
+ await streamer.updateMediaStream(newStream);
2238
+ if (mediaStream) {
2239
+ mediaStream.getTracks().forEach((track) => track.stop());
2240
+ }
2241
+ setMediaStream(newStream);
2242
+ } catch (swapErr) {
2243
+ console.error("\u274C Failed to hot-swap video device:", swapErr);
2244
+ newStream.getTracks().forEach((track) => track.stop());
2245
+ }
2246
+ } else {
2247
+ if (mediaStream) {
2248
+ mediaStream.getTracks().forEach((track) => track.stop());
2249
+ }
2250
+ setMediaStream(newStream);
2251
+ }
2252
+ } catch (err) {
2253
+ console.error("\u274C Failed to switch video device:", err);
2254
+ }
2255
+ };
2256
+ const handleAudioDeviceChange = async (deviceId) => {
2257
+ if (deviceId === selectedAudioDeviceId) return;
2258
+ setSelectedAudioDeviceId(deviceId);
2259
+ if (state !== "previewing" && state !== "live") return;
2260
+ try {
2261
+ const constraints = {
2262
+ video: isVideoKey && isVideoEnabled && selectedVideoDeviceId ? { deviceId: { exact: selectedVideoDeviceId } } : isVideoKey && isVideoEnabled,
2263
+ audio: { deviceId: { exact: deviceId } }
2264
+ };
2265
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
2266
+ console.log(`\u{1F3A4} Switched to microphone: ${deviceId}`);
2267
+ if (state === "live" && streamer) {
2268
+ try {
2269
+ await streamer.updateMediaStream(newStream);
2270
+ if (mediaStream) {
2271
+ mediaStream.getTracks().forEach((track) => track.stop());
2272
+ }
2273
+ setMediaStream(newStream);
2274
+ } catch (swapErr) {
2275
+ console.error("\u274C Failed to hot-swap audio device:", swapErr);
2276
+ newStream.getTracks().forEach((track) => track.stop());
2277
+ }
2278
+ } else {
2279
+ if (mediaStream) {
2280
+ mediaStream.getTracks().forEach((track) => track.stop());
2281
+ }
2282
+ setMediaStream(newStream);
2283
+ }
2284
+ } catch (err) {
2285
+ console.error("\u274C Failed to switch audio device:", err);
2286
+ }
2287
+ };
2288
+ if (!sessionToken) {
2289
+ return /* @__PURE__ */ jsx("div", { className: centeredContainerClass, children: /* @__PURE__ */ jsxs("div", { className: "text-center", children: [
2290
+ /* @__PURE__ */ jsx("div", { className: "w-16 h-16 border-4 border-gray-700 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" }),
2291
+ /* @__PURE__ */ jsx("p", { className: "text-white text-lg", children: "Connecting..." })
2292
+ ] }) });
2293
+ }
2294
+ if (!streamKey) {
2295
+ return /* @__PURE__ */ jsx(StreamKeyInput, { onSubmit: handleStreamKeySubmit, inline });
2296
+ }
2297
+ if (state === "error") {
2298
+ const isBroadcastConflict = error?.includes("already active") || error?.includes("terminate it before");
2299
+ return /* @__PURE__ */ jsx("div", { className: centeredContainerClass, children: /* @__PURE__ */ jsxs("div", { className: "bg-white dark:bg-zinc-900 rounded-lg border border-gray-200 dark:border-zinc-800 p-8 max-w-md w-full text-center", children: [
2300
+ /* @__PURE__ */ jsx("div", { className: "w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mx-auto mb-4", children: /* @__PURE__ */ jsx(
2301
+ "svg",
2302
+ {
2303
+ className: "w-8 h-8 text-red-600 dark:text-red-400",
2304
+ fill: "none",
2305
+ stroke: "currentColor",
2306
+ viewBox: "0 0 24 24",
2307
+ children: /* @__PURE__ */ jsx(
2308
+ "path",
2309
+ {
2310
+ strokeLinecap: "round",
2311
+ strokeLinejoin: "round",
2312
+ strokeWidth: 2,
2313
+ d: "M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
2314
+ }
2315
+ )
2316
+ }
2317
+ ) }),
2318
+ /* @__PURE__ */ jsx("h2", { className: "text-xl font-bold text-black dark:text-white mb-2", children: isBroadcastConflict ? "Broadcast Already Active" : "Error" }),
2319
+ /* @__PURE__ */ jsx("p", { className: "text-gray-600 dark:text-gray-400 mb-6", children: error }),
2320
+ isBroadcastConflict && /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-500 dark:text-gray-400 mb-6", children: "You need to terminate the existing broadcast before starting a new one." }),
2321
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
2322
+ !isBroadcastConflict && /* @__PURE__ */ jsx(
2323
+ "button",
2324
+ {
2325
+ onClick: handleRetry,
2326
+ className: "w-full px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
2327
+ children: "Retry"
2328
+ }
2329
+ ),
2330
+ /* @__PURE__ */ jsx(
2331
+ "button",
2332
+ {
2333
+ onClick: handleDone,
2334
+ className: "w-full px-6 py-2 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-black dark:text-white font-medium rounded-lg transition-colors",
2335
+ children: "Close"
2336
+ }
2337
+ )
2338
+ ] })
2339
+ ] }) });
2340
+ }
2341
+ if (state === "stopped") {
2342
+ return /* @__PURE__ */ jsx("div", { className: centeredContainerClass, children: /* @__PURE__ */ jsxs("div", { className: "bg-white dark:bg-zinc-900 rounded-lg border border-gray-200 dark:border-zinc-800 p-8 max-w-md w-full text-center", children: [
2343
+ /* @__PURE__ */ jsx("div", { className: "w-16 h-16 bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center mx-auto mb-4", children: /* @__PURE__ */ jsx(
2344
+ "svg",
2345
+ {
2346
+ className: "w-8 h-8 text-green-600 dark:text-green-400",
2347
+ fill: "none",
2348
+ stroke: "currentColor",
2349
+ viewBox: "0 0 24 24",
2350
+ children: /* @__PURE__ */ jsx(
2351
+ "path",
2352
+ {
2353
+ strokeLinecap: "round",
2354
+ strokeLinejoin: "round",
2355
+ strokeWidth: 2,
2356
+ d: "M5 13l4 4L19 7"
2357
+ }
2358
+ )
2359
+ }
2360
+ ) }),
2361
+ /* @__PURE__ */ jsx("h2", { className: "text-xl font-bold text-black dark:text-white mb-2", children: "Stream Ended" }),
2362
+ /* @__PURE__ */ jsx("p", { className: "text-gray-600 dark:text-gray-400 mb-2", children: "Your broadcast has ended successfully." }),
2363
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-gray-500 dark:text-gray-400 mb-6", children: [
2364
+ "Total sent: ",
2365
+ (bytesSent / 1024 / 1024).toFixed(2),
2366
+ " MB"
2367
+ ] }),
2368
+ /* @__PURE__ */ jsx(
2369
+ "button",
2370
+ {
2371
+ onClick: handleDone,
2372
+ className: "w-full px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
2373
+ children: "Done"
2374
+ }
2375
+ )
2376
+ ] }) });
2377
+ }
2378
+ if (state === "terminated") {
2379
+ return /* @__PURE__ */ jsx("div", { className: centeredContainerClass, children: /* @__PURE__ */ jsxs("div", { className: "bg-white dark:bg-zinc-900 rounded-lg border border-gray-200 dark:border-zinc-800 p-8 max-w-md w-full text-center", children: [
2380
+ /* @__PURE__ */ jsx("div", { className: "w-16 h-16 bg-orange-100 dark:bg-orange-900/20 rounded-full flex items-center justify-center mx-auto mb-4", children: /* @__PURE__ */ jsx(
2381
+ "svg",
2382
+ {
2383
+ className: "w-8 h-8 text-orange-600 dark:text-orange-400",
2384
+ fill: "none",
2385
+ stroke: "currentColor",
2386
+ viewBox: "0 0 24 24",
2387
+ children: /* @__PURE__ */ jsx(
2388
+ "path",
2389
+ {
2390
+ strokeLinecap: "round",
2391
+ strokeLinejoin: "round",
2392
+ strokeWidth: 2,
2393
+ d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
2394
+ }
2395
+ )
2396
+ }
2397
+ ) }),
2398
+ /* @__PURE__ */ jsx("h2", { className: "text-xl font-bold text-black dark:text-white mb-2", children: "Stream Ended" }),
2399
+ /* @__PURE__ */ jsx("p", { className: "text-gray-600 dark:text-gray-400 mb-2", children: error || "Connection closed unexpectedly" }),
2400
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-gray-500 dark:text-gray-400 mb-2", children: [
2401
+ "Total sent: ",
2402
+ (bytesSent / 1024 / 1024).toFixed(2),
2403
+ " MB"
2404
+ ] }),
2405
+ diagnostics && /* @__PURE__ */ jsxs("div", { className: "text-xs text-gray-400 dark:text-gray-500 mb-6 font-mono bg-gray-100 dark:bg-zinc-800 rounded p-2 text-left", children: [
2406
+ /* @__PURE__ */ jsxs("div", { children: [
2407
+ "Codec: ",
2408
+ diagnostics.mimeType || "unknown"
2409
+ ] }),
2410
+ /* @__PURE__ */ jsxs("div", { children: [
2411
+ "Chunks: ",
2412
+ diagnostics.chunksSent,
2413
+ " (",
2414
+ (diagnostics.bytesSent / 1024).toFixed(1),
2415
+ " KB)"
2416
+ ] }),
2417
+ /* @__PURE__ */ jsxs("div", { children: [
2418
+ "Duration: ",
2419
+ (diagnostics.elapsedMs / 1e3).toFixed(1),
2420
+ "s"
2421
+ ] }),
2422
+ /* @__PURE__ */ jsxs("div", { children: [
2423
+ "Close code: ",
2424
+ diagnostics.closeCode ?? "N/A",
2425
+ diagnostics.closeReason ? ` (${diagnostics.closeReason})` : ""
2426
+ ] })
2427
+ ] }),
2428
+ !diagnostics && /* @__PURE__ */ jsx("div", { className: "mb-6" }),
2429
+ /* @__PURE__ */ jsx(
2430
+ "button",
2431
+ {
2432
+ onClick: handleDone,
2433
+ className: "w-full px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
2434
+ children: "Done"
2435
+ }
2436
+ )
2437
+ ] }) });
2438
+ }
2439
+ if (state === "idle" || state === "requesting") {
2440
+ return /* @__PURE__ */ jsx("div", { className: centeredContainerClass, children: /* @__PURE__ */ jsxs("div", { className: "text-center", children: [
2441
+ /* @__PURE__ */ jsx("div", { className: "w-16 h-16 border-4 border-gray-700 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" }),
2442
+ /* @__PURE__ */ jsx("p", { className: "text-white text-lg", children: state === "idle" ? "Initializing..." : "Requesting permissions..." })
2443
+ ] }) });
2444
+ }
2445
+ const controlState = state;
2446
+ return /* @__PURE__ */ jsxs("div", { className: `${containerClass} flex flex-col`, children: [
2447
+ /* @__PURE__ */ jsx(
2448
+ StreamingPreview,
2449
+ {
2450
+ videoRef,
2451
+ isVideoKey,
2452
+ isVideoEnabled,
2453
+ mediaStream,
2454
+ facingMode
2455
+ }
2456
+ ),
2457
+ /* @__PURE__ */ jsx(
2458
+ StreamingControls,
2459
+ {
2460
+ state: controlState,
2461
+ isVideoKey,
2462
+ isMuted,
2463
+ isVideoEnabled,
2464
+ facingMode,
2465
+ hasMultipleCameras,
2466
+ startTime,
2467
+ bytesSent,
2468
+ showStopConfirm,
2469
+ streamKey,
2470
+ onStreamKeyChange: handleStreamKeyChange,
2471
+ onStart: handleStartStreaming,
2472
+ onStop: handleStopStreaming,
2473
+ onConfirmStop: confirmStopStreaming,
2474
+ onCancelStop: () => setShowStopConfirm(false),
2475
+ onToggleMute: handleToggleMute,
2476
+ onToggleVideo: handleToggleVideo,
2477
+ onFlipCamera: handleFlipCamera,
2478
+ onClose: inline ? void 0 : handleClose,
2479
+ showCloseConfirm,
2480
+ onConfirmClose: confirmClose,
2481
+ onCancelClose: () => setShowCloseConfirm(false),
2482
+ videoDevices,
2483
+ audioDevices,
2484
+ selectedVideoDeviceId,
2485
+ selectedAudioDeviceId,
2486
+ onVideoDeviceChange: handleVideoDeviceChange,
2487
+ onAudioDeviceChange: handleAudioDeviceChange,
2488
+ mediaStream
2489
+ }
2490
+ )
2491
+ ] });
2492
+ }
2493
+ var sizeClasses = {
2494
+ sm: "h-4 w-4 border-2",
2495
+ md: "h-8 w-8 border-4",
2496
+ lg: "h-12 w-12 border-4"
2497
+ };
2498
+ var variantClasses = {
2499
+ default: "border-gray-200 dark:border-gray-800 border-t-black dark:border-t-white",
2500
+ primary: "border-gray-300 dark:border-gray-700 border-t-blue-600 dark:border-t-blue-400",
2501
+ white: "border-gray-600 border-t-white"
2502
+ };
2503
+ function LoadingSpinner({
2504
+ text,
2505
+ size = "md",
2506
+ variant = "default"
2507
+ }) {
2508
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-3", children: [
2509
+ /* @__PURE__ */ jsx(
2510
+ "div",
2511
+ {
2512
+ className: `${sizeClasses[size]} ${variantClasses[variant]} rounded-full animate-spin`,
2513
+ role: "status",
2514
+ "aria-label": "Loading"
2515
+ }
2516
+ ),
2517
+ text && /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-500 dark:text-gray-400", children: text })
2518
+ ] });
2519
+ }
2520
+
2521
+ // src/utils/cdn.ts
2522
+ var CDN_DOMAIN = typeof process !== "undefined" && process.env?.CONTENT_CDN_DOMAIN ? process.env.CONTENT_CDN_DOMAIN : "cdn.dialtribe.com";
2523
+ function shardHash(hash) {
2524
+ return hash.toLowerCase().split("").join("/");
2525
+ }
2526
+ function buildBroadcastS3KeyPrefix(appHash, broadcastHash) {
2527
+ return `a/${shardHash(appHash)}/b/${shardHash(broadcastHash)}/`;
2528
+ }
2529
+ function buildBroadcastCdnUrl(appHash, broadcastHash, filename) {
2530
+ const keyPrefix = buildBroadcastS3KeyPrefix(appHash, broadcastHash);
2531
+ return `https://${CDN_DOMAIN}/${keyPrefix}${filename}`;
2532
+ }
2533
+
2534
+ // src/utils/format.ts
2535
+ function formatTime(seconds, includeHours) {
2536
+ if (isNaN(seconds) || !isFinite(seconds) || seconds < 0) {
2537
+ return "0:00";
2538
+ }
2539
+ const hrs = Math.floor(seconds / 3600);
2540
+ const mins = Math.floor(seconds % 3600 / 60);
2541
+ const secs = Math.floor(seconds % 60);
2542
+ const secStr = secs.toString().padStart(2, "0");
2543
+ const minStr = mins.toString().padStart(2, "0");
2544
+ if (hrs > 0 || includeHours) {
2545
+ return `${hrs}:${minStr}:${secStr}`;
2546
+ }
2547
+ return `${mins}:${secStr}`;
2548
+ }
2549
+
2550
+ // src/utils/http-status.ts
2551
+ var HTTP_STATUS = {
2552
+ // 2xx Success
2553
+ OK: 200,
2554
+ CREATED: 201,
2555
+ ACCEPTED: 202,
2556
+ NO_CONTENT: 204,
2557
+ // 3xx Redirection
2558
+ MOVED_PERMANENTLY: 301,
2559
+ FOUND: 302,
2560
+ SEE_OTHER: 303,
2561
+ NOT_MODIFIED: 304,
2562
+ TEMPORARY_REDIRECT: 307,
2563
+ PERMANENT_REDIRECT: 308,
2564
+ // 4xx Client Errors
2565
+ BAD_REQUEST: 400,
2566
+ UNAUTHORIZED: 401,
2567
+ PAYMENT_REQUIRED: 402,
2568
+ FORBIDDEN: 403,
2569
+ NOT_FOUND: 404,
2570
+ METHOD_NOT_ALLOWED: 405,
2571
+ NOT_ACCEPTABLE: 406,
2572
+ CONFLICT: 409,
2573
+ GONE: 410,
2574
+ PAYLOAD_TOO_LARGE: 413,
2575
+ UNSUPPORTED_MEDIA_TYPE: 415,
2576
+ UNPROCESSABLE_ENTITY: 422,
2577
+ TOO_MANY_REQUESTS: 429,
2578
+ // 5xx Server Errors
2579
+ INTERNAL_SERVER_ERROR: 500,
2580
+ NOT_IMPLEMENTED: 501,
2581
+ BAD_GATEWAY: 502,
2582
+ SERVICE_UNAVAILABLE: 503,
2583
+ GATEWAY_TIMEOUT: 504
2584
+ };
2585
+
2586
+ // src/utils/debug.ts
2587
+ var DEBUG = process.env.NODE_ENV === "development";
2588
+ var debug = {
2589
+ log: (...args) => {
2590
+ if (DEBUG) console.log(...args);
2591
+ },
2592
+ warn: (...args) => {
2593
+ if (DEBUG) console.warn(...args);
2594
+ },
2595
+ error: (...args) => {
2596
+ if (DEBUG) console.error(...args);
2597
+ }
2598
+ };
2599
+ var URL_EXPIRATION_MS = 6 * 60 * 60 * 1e3;
2600
+ var REFRESH_THRESHOLD_MS = 5 * 60 * 1e3;
2601
+ var REFRESH_CHECK_INTERVAL_MS = 6e4;
2602
+ var PLAYBACK_RESUME_DELAY_MS = 500;
2603
+ var TRAILING_WORDS = 3;
2604
+ function buildPlaybackUrl(broadcastId, hash, action) {
2605
+ const searchParams = new URLSearchParams({
2606
+ broadcastId: broadcastId.toString(),
2607
+ hash
2608
+ });
2609
+ if (action) {
2610
+ searchParams.set("action", action);
2611
+ }
2612
+ return `${ENDPOINTS.contentPlay}?${searchParams}`;
2613
+ }
2614
+ function getErrorMessage(error) {
2615
+ if (!error) return "Unable to play media. Please try again.";
2616
+ const errorMsg = error instanceof Error ? error.message : String(error);
2617
+ const errorCode = error?.code;
2618
+ const errorStatus = error?.status || error?.statusCode;
2619
+ if (errorMsg.toLowerCase().includes("network") || errorMsg.includes("NetworkError")) {
2620
+ return "No internet connection detected. Please check your network and try again.";
2621
+ }
2622
+ if (errorStatus === 401 || errorMsg.includes("401") || errorMsg.includes("Unauthorized")) {
2623
+ return "Session expired. Please refresh the page and log in again.";
2624
+ }
2625
+ if (errorStatus === 403 || errorMsg.includes("403") || errorMsg.includes("Forbidden")) {
2626
+ return "Access denied. You may not have permission to view this content.";
2627
+ }
2628
+ if (errorStatus === 404 || errorMsg.includes("404") || errorMsg.includes("not found")) {
2629
+ return "Media file not found. It may have been deleted or is still processing.";
2630
+ }
2631
+ if (errorMsg.includes("no supported sources") || errorMsg.includes("NotSupportedError")) {
2632
+ return "This media format is not supported by your browser. Try using Chrome, Firefox, or Safari.";
2633
+ }
2634
+ if (errorMsg.includes("MEDIA_ERR_SRC_NOT_SUPPORTED") || errorCode === 4) {
2635
+ return "Media file is not available or the format is unsupported.";
2636
+ }
2637
+ if (errorMsg.includes("MEDIA_ERR_NETWORK") || errorCode === 2) {
2638
+ return "Network error while loading media. Please check your connection.";
2639
+ }
2640
+ if (errorMsg.includes("MEDIA_ERR_DECODE") || errorCode === 3) {
2641
+ return "Media file is corrupted or cannot be decoded. Please contact support.";
2642
+ }
2643
+ if (errorMsg.includes("AbortError")) {
2644
+ return "Media loading was interrupted. Please try again.";
2645
+ }
2646
+ return "Unable to play media. Please try refreshing the page or contact support if the problem persists.";
2647
+ }
2648
+ function DialtribePlayer({
2649
+ broadcast,
2650
+ appId,
2651
+ contentId,
2652
+ foreignId,
2653
+ foreignTier = "guest",
2654
+ renderClipCreator,
2655
+ onError,
2656
+ className = "",
2657
+ enableKeyboardShortcuts = false
2658
+ }) {
2659
+ const { sessionToken, setSessionToken, markExpired, apiBaseUrl } = useDialtribe();
2660
+ const clientRef = useRef(null);
2661
+ if (!clientRef.current && sessionToken) {
2662
+ clientRef.current = new DialtribeClient({
2663
+ sessionToken,
2664
+ apiBaseUrl,
2665
+ onTokenRefresh: (newToken, expiresAt) => {
2666
+ debug.log(`[DialtribeClient] Token refreshed, expires at ${expiresAt}`);
2667
+ setSessionToken(newToken, expiresAt);
2668
+ },
2669
+ onTokenExpired: () => {
2670
+ debug.error("[DialtribeClient] Token expired");
2671
+ markExpired();
2672
+ }
2673
+ });
2674
+ } else if (clientRef.current && sessionToken) {
2675
+ clientRef.current.setSessionToken(sessionToken);
2676
+ }
2677
+ const client = clientRef.current;
2678
+ const playerRef = useRef(null);
2679
+ const transcriptContainerRef = useRef(null);
2680
+ const activeWordRef = useRef(null);
2681
+ const [audioElement, setAudioElement] = useState(
2682
+ null
2683
+ );
2684
+ const [playing, setPlaying] = useState(false);
2685
+ const [played, setPlayed] = useState(0);
2686
+ const [duration, setDuration] = useState(0);
2687
+ const [volume, setVolume] = useState(1);
2688
+ const [muted, setMuted] = useState(false);
2689
+ const [seeking, setSeeking] = useState(false);
2690
+ const [hasError, setHasError] = useState(false);
2691
+ const [errorMessage, setErrorMessage] = useState("");
2692
+ const [hasEnded, setHasEnded] = useState(false);
2693
+ const [hasStreamEnded, setHasStreamEnded] = useState(false);
2694
+ const [showTranscript, setShowTranscript] = useState(false);
2695
+ const [transcriptData, setTranscriptData] = useState(
2696
+ null
2697
+ );
2698
+ const [currentTime, setCurrentTime] = useState(0);
2699
+ const [isLoadingTranscript, setIsLoadingTranscript] = useState(false);
2700
+ const [isLoadingVideo, setIsLoadingVideo] = useState(true);
2701
+ const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
2702
+ const isScrollingProgrammatically = useRef(false);
2703
+ const lastActiveWordIndex = useRef(-1);
2704
+ const [showClipCreator, setShowClipCreator] = useState(false);
2705
+ const initialPlaybackTypeRef = useRef(null);
2706
+ const [currentPlaybackInfo, setCurrentPlaybackInfo] = useState(null);
2707
+ const [urlExpiresAt, setUrlExpiresAt] = useState(null);
2708
+ const isRefreshingUrl = useRef(false);
2709
+ const [audienceId, setAudienceId] = useState(null);
2710
+ const [sessionId] = useState(() => {
2711
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
2712
+ return crypto.randomUUID();
2713
+ }
2714
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
2715
+ const r = Math.random() * 16 | 0;
2716
+ const v = c === "x" ? r : r & 3 | 8;
2717
+ return v.toString(16);
2718
+ });
2719
+ });
2720
+ const heartbeatIntervalRef = useRef(null);
2721
+ const hasInitializedSession = useRef(false);
2722
+ const refreshPresignedUrl = useCallback(
2723
+ async (fileType) => {
2724
+ if (!broadcast.hash || isRefreshingUrl.current || !client) {
2725
+ debug.log(
2726
+ "[URL Refresh] Skipping refresh - no hash, already refreshing, or no client"
2727
+ );
2728
+ return false;
2729
+ }
2730
+ if (fileType === "hls") {
2731
+ debug.log("[URL Refresh] HLS does not need URL refresh");
2732
+ return false;
2733
+ }
2734
+ isRefreshingUrl.current = true;
2735
+ debug.log(
2736
+ `[URL Refresh] Refreshing ${fileType} URL for broadcast ${broadcast.id}`
2737
+ );
2738
+ try {
2739
+ const data = await client.refreshPresignedUrl({
2740
+ broadcastId: broadcast.id,
2741
+ hash: broadcast.hash,
2742
+ fileType
2743
+ });
2744
+ debug.log(
2745
+ `[URL Refresh] Successfully refreshed URL, expires at ${data.expiresAt}`
2746
+ );
2747
+ setCurrentPlaybackInfo({ url: data.url, type: fileType });
2748
+ setUrlExpiresAt(new Date(data.expiresAt));
2749
+ if (errorMessage.includes("URL") || errorMessage.includes("session") || errorMessage.includes("refresh")) {
2750
+ setHasError(false);
2751
+ setErrorMessage("");
2752
+ }
2753
+ return true;
2754
+ } catch (error) {
2755
+ if (error instanceof Error && error.name === "AbortError") {
2756
+ debug.log("[URL Refresh] Request aborted");
2757
+ return false;
2758
+ }
2759
+ debug.error("[URL Refresh] Failed to refresh presigned URL:", error);
2760
+ setHasError(true);
2761
+ setErrorMessage(
2762
+ "Unable to refresh media URL. The session may have expired."
2763
+ );
2764
+ if (onError && error instanceof Error) {
2765
+ onError(error);
2766
+ }
2767
+ return false;
2768
+ } finally {
2769
+ isRefreshingUrl.current = false;
2770
+ }
2771
+ },
2772
+ [broadcast.hash, broadcast.id, errorMessage, client, onError]
2773
+ );
2774
+ const getScreenSize = () => {
2775
+ if (typeof window === "undefined") return "unknown";
2776
+ const width = window.innerWidth;
2777
+ if (width < 768) return "mobile";
2778
+ if (width < 1024) return "tablet";
2779
+ return "desktop";
2780
+ };
2781
+ const initializeTrackingSession = useCallback(async () => {
2782
+ if (!contentId || !appId || !client) return;
2783
+ if (currentPlaybackInfo?.type === "hls" && broadcast.broadcastStatus === 1)
2784
+ return;
2785
+ if (hasInitializedSession.current) return;
2786
+ hasInitializedSession.current = true;
2787
+ try {
2788
+ const screenSize = getScreenSize();
2789
+ const platformInfo = `${navigator.platform || "Unknown"} (${screenSize})`;
2790
+ const data = await client.startSession({
2791
+ contentId,
2792
+ broadcastId: broadcast.id,
2793
+ appId,
2794
+ foreignId: foreignId || null,
2795
+ foreignTier: foreignTier || "guest",
2796
+ sessionId,
2797
+ fileType: currentPlaybackInfo?.type || "mp3",
2798
+ platform: platformInfo,
2799
+ userAgent: navigator.userAgent || null,
2800
+ origin: window.location.origin || null,
2801
+ country: null,
2802
+ region: null
2803
+ });
2804
+ setAudienceId(data.audienceId);
2805
+ if (data.resumePosition && data.resumePosition > 0 && audioElement) {
2806
+ audioElement.currentTime = data.resumePosition;
2807
+ debug.log(
2808
+ `[Audience Tracking] Resumed playback at ${data.resumePosition}s`
2809
+ );
2810
+ }
2811
+ debug.log("[Audience Tracking] Session initialized:", data.audienceId);
2812
+ } catch (error) {
2813
+ debug.error("[Audience Tracking] Error initializing session:", error);
2814
+ if (onError && error instanceof Error) {
2815
+ onError(error);
2816
+ }
2817
+ }
2818
+ }, [
2819
+ contentId,
2820
+ appId,
2821
+ broadcast.id,
2822
+ broadcast.broadcastStatus,
2823
+ foreignId,
2824
+ foreignTier,
2825
+ sessionId,
2826
+ currentPlaybackInfo?.type,
2827
+ audioElement,
2828
+ client,
2829
+ onError
2830
+ ]);
2831
+ const sendTrackingPing = useCallback(
2832
+ async (eventType) => {
2833
+ if (!audienceId || !sessionId || !client) return;
2834
+ try {
2835
+ await client.sendSessionPing({
2836
+ audienceId,
2837
+ sessionId,
2838
+ eventType,
2839
+ currentTime: Math.floor(audioElement?.currentTime || 0),
2840
+ duration: Math.floor(duration || 0)
2841
+ });
2842
+ } catch (error) {
2843
+ debug.error("[Audience Tracking] Error sending ping:", error);
2844
+ }
2845
+ },
2846
+ [audienceId, sessionId, audioElement, duration, client]
2847
+ );
2848
+ const getPlaybackInfo = () => {
2849
+ if (broadcast.broadcastStatus === 1) {
2850
+ if (broadcast.hlsPlaylistUrl) {
2851
+ return { url: broadcast.hlsPlaylistUrl, type: "hls" };
2852
+ }
2853
+ if (broadcast.hash && broadcast.app?.s3Hash) {
2854
+ const hlsUrl = buildBroadcastCdnUrl(
2855
+ broadcast.app.s3Hash,
2856
+ broadcast.hash,
2857
+ "index.m3u8"
2858
+ );
2859
+ return { url: hlsUrl, type: "hls" };
2860
+ }
2861
+ }
2862
+ if (broadcast.recordingMp4Url && broadcast.isVideo && broadcast.hash) {
2863
+ return {
2864
+ url: buildPlaybackUrl(broadcast.id, broadcast.hash),
2865
+ type: "mp4"
2866
+ };
2867
+ }
2868
+ if (broadcast.recordingMp3Url && broadcast.hash) {
2869
+ return {
2870
+ url: buildPlaybackUrl(broadcast.id, broadcast.hash),
2871
+ type: "mp3"
2872
+ };
2873
+ }
2874
+ if (broadcast.hlsPlaylistUrl) {
2875
+ return { url: broadcast.hlsPlaylistUrl, type: "hls" };
2876
+ }
2877
+ return null;
2878
+ };
2879
+ useEffect(() => {
2880
+ if (!currentPlaybackInfo) {
2881
+ const info = getPlaybackInfo();
2882
+ setCurrentPlaybackInfo(info);
2883
+ initialPlaybackTypeRef.current = info?.type || null;
2884
+ if (info && (info.type === "mp4" || info.type === "mp3")) {
2885
+ const expiresAt = new Date(Date.now() + URL_EXPIRATION_MS);
2886
+ setUrlExpiresAt(expiresAt);
2887
+ debug.log(
2888
+ `[URL Refresh] Initial ${info.type} URL expires at ${expiresAt.toISOString()}`
2889
+ );
2890
+ }
2891
+ if (info) {
2892
+ setPlaying(true);
2893
+ setIsLoadingVideo(true);
2894
+ }
2895
+ }
2896
+ }, [currentPlaybackInfo]);
2897
+ useEffect(() => {
2898
+ if (currentPlaybackInfo?.url) {
2899
+ setIsLoadingVideo(true);
2900
+ }
2901
+ }, [currentPlaybackInfo?.url]);
2902
+ useEffect(() => {
2903
+ if (!urlExpiresAt || !currentPlaybackInfo?.type) return;
2904
+ const checkExpiration = () => {
2905
+ const now = /* @__PURE__ */ new Date();
2906
+ const timeUntilExpiration = urlExpiresAt.getTime() - now.getTime();
2907
+ if (timeUntilExpiration <= REFRESH_THRESHOLD_MS && timeUntilExpiration > 0) {
2908
+ debug.log("[URL Refresh] Proactively refreshing URL before expiration");
2909
+ const fileType = currentPlaybackInfo.type;
2910
+ if (fileType === "mp4" || fileType === "mp3" || fileType === "hls") {
2911
+ refreshPresignedUrl(fileType);
2912
+ }
2913
+ }
2914
+ };
2915
+ const interval = setInterval(checkExpiration, REFRESH_CHECK_INTERVAL_MS);
2916
+ checkExpiration();
2917
+ return () => {
2918
+ clearInterval(interval);
2919
+ };
2920
+ }, [urlExpiresAt, currentPlaybackInfo?.type, refreshPresignedUrl]);
2921
+ useEffect(() => {
2922
+ if (initialPlaybackTypeRef.current === "hls" && currentPlaybackInfo?.type === "hls" && broadcast.broadcastStatus !== 1 && broadcast.recordingMp3Url && broadcast.hash && parseInt(broadcast.mp3Size || "0") > 0) {
2923
+ const secureUrl = buildPlaybackUrl(broadcast.id, broadcast.hash);
2924
+ setCurrentPlaybackInfo({ url: secureUrl, type: "mp3" });
2925
+ setAudioElement(null);
2926
+ setPlaying(true);
2927
+ }
2928
+ }, [
2929
+ broadcast.broadcastStatus,
2930
+ broadcast.recordingMp3Url,
2931
+ broadcast.mp3Size,
2932
+ broadcast.hash,
2933
+ broadcast.id,
2934
+ currentPlaybackInfo
2935
+ ]);
2936
+ const playbackUrl = currentPlaybackInfo?.url || null;
2937
+ const playbackType = currentPlaybackInfo?.type || null;
2938
+ const isAudioOnly = playbackType === "mp3" || !broadcast.isVideo && playbackType !== "mp4";
2939
+ const isLiveStream = broadcast.broadcastStatus === 1 && playbackType === "hls" && !hasStreamEnded;
2940
+ const wasLiveStream = initialPlaybackTypeRef.current === "hls";
2941
+ const playerConfig = useMemo(
2942
+ () => ({
2943
+ file: {
2944
+ forceHLS: playbackType === "hls",
2945
+ hlsOptions: isLiveStream ? {
2946
+ maxLoadingDelay: 10,
2947
+ minAutoBitrate: 0,
2948
+ lowLatencyMode: true,
2949
+ enableWorker: true
2950
+ } : {}
2951
+ }
2952
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2953
+ }),
2954
+ [playbackType, isLiveStream]
2955
+ );
2956
+ const formatTimestamp = (seconds) => {
2957
+ if (!seconds || isNaN(seconds) || !isFinite(seconds)) return "00:00:00";
2958
+ const hrs = Math.floor(seconds / 3600);
2959
+ const mins = Math.floor(seconds % 3600 / 60);
2960
+ const secs = Math.floor(seconds % 60);
2961
+ return `${hrs.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
2962
+ };
2963
+ const handlePlay = () => {
2964
+ setPlaying(true);
2965
+ setIsLoadingVideo(false);
2966
+ };
2967
+ const handlePause = () => {
2968
+ setPlaying(false);
2969
+ };
2970
+ const handleEnded = () => {
2971
+ setPlaying(false);
2972
+ if (playbackType === "hls") {
2973
+ setHasStreamEnded(true);
2974
+ }
2975
+ if (!wasLiveStream) {
2976
+ setHasEnded(true);
2977
+ }
2978
+ };
2979
+ useEffect(() => {
2980
+ if (broadcast.durationSeconds && broadcast.durationSeconds > 0) {
2981
+ setDuration(broadcast.durationSeconds);
2982
+ }
2983
+ }, [broadcast.durationSeconds]);
2984
+ useEffect(() => {
2985
+ if (isLiveStream && !playing) {
2986
+ setPlaying(true);
2987
+ }
2988
+ }, [isLiveStream, playing]);
2989
+ useEffect(() => {
2990
+ if (currentPlaybackInfo && audioElement && !hasInitializedSession.current) {
2991
+ initializeTrackingSession();
2992
+ }
2993
+ }, [currentPlaybackInfo, audioElement, initializeTrackingSession]);
2994
+ useEffect(() => {
2995
+ if (playing && audienceId) {
2996
+ sendTrackingPing(1);
2997
+ heartbeatIntervalRef.current = setInterval(() => {
2998
+ sendTrackingPing(2);
2999
+ }, 15e3);
3000
+ return () => {
3001
+ if (heartbeatIntervalRef.current) {
3002
+ clearInterval(heartbeatIntervalRef.current);
3003
+ heartbeatIntervalRef.current = null;
3004
+ }
3005
+ };
3006
+ } else if (!playing && audienceId) {
3007
+ sendTrackingPing(0);
3008
+ if (heartbeatIntervalRef.current) {
3009
+ clearInterval(heartbeatIntervalRef.current);
3010
+ heartbeatIntervalRef.current = null;
3011
+ }
3012
+ }
3013
+ }, [playing, audienceId, sendTrackingPing]);
3014
+ useEffect(() => {
3015
+ return () => {
3016
+ if (audienceId && sessionId && sessionToken) {
3017
+ const payload = {
3018
+ audienceId,
3019
+ sessionId,
3020
+ eventType: 3,
3021
+ // UNMOUNT
3022
+ currentTime: Math.floor(audioElement?.currentTime || 0),
3023
+ duration: Math.floor(duration || 0)
3024
+ };
3025
+ const headers = {
3026
+ Authorization: `Bearer ${sessionToken}`,
3027
+ "Content-Type": "application/json"
3028
+ };
3029
+ fetch(ENDPOINTS.sessionPing, {
3030
+ method: "POST",
3031
+ headers,
3032
+ body: JSON.stringify(payload),
3033
+ keepalive: true
3034
+ }).catch(() => {
3035
+ });
3036
+ }
3037
+ };
3038
+ }, [audienceId, sessionId, sessionToken, audioElement, duration]);
3039
+ useEffect(() => {
3040
+ if (broadcast.transcriptUrl && broadcast.transcriptStatus === 2 && !transcriptData) {
3041
+ setIsLoadingTranscript(true);
3042
+ fetch(broadcast.transcriptUrl).then((res) => {
3043
+ if (!res.ok) {
3044
+ throw new Error(
3045
+ `Failed to fetch transcript: ${res.status} ${res.statusText}`
3046
+ );
3047
+ }
3048
+ return res.json();
3049
+ }).then((data) => {
3050
+ if (data.segments && data.words && !data.segments[0]?.words) {
3051
+ data.segments = data.segments.map((segment, index) => {
3052
+ const segmentWords = data.words.filter((word) => {
3053
+ if (index === data.segments.length - 1) {
3054
+ return word.start >= segment.start;
3055
+ }
3056
+ return word.start >= segment.start && word.start < segment.end;
3057
+ });
3058
+ return {
3059
+ ...segment,
3060
+ words: segmentWords
3061
+ };
3062
+ });
3063
+ }
3064
+ setTranscriptData(data);
3065
+ setIsLoadingTranscript(false);
3066
+ }).catch((error) => {
3067
+ debug.error("[Transcript] Failed to load transcript:", error);
3068
+ setIsLoadingTranscript(false);
3069
+ });
3070
+ }
3071
+ }, [broadcast.transcriptUrl, broadcast.transcriptStatus, transcriptData]);
3072
+ useEffect(() => {
3073
+ if (!audioElement) return;
3074
+ const handleTimeUpdate2 = () => {
3075
+ setCurrentTime(audioElement.currentTime);
3076
+ };
3077
+ audioElement.addEventListener("timeupdate", handleTimeUpdate2);
3078
+ return () => audioElement.removeEventListener("timeupdate", handleTimeUpdate2);
3079
+ }, [audioElement]);
3080
+ useEffect(() => {
3081
+ if (showTranscript && autoScrollEnabled && activeWordRef.current && transcriptContainerRef.current) {
3082
+ const container = transcriptContainerRef.current;
3083
+ const activeWord = activeWordRef.current;
3084
+ const containerRect = container.getBoundingClientRect();
3085
+ const wordRect = activeWord.getBoundingClientRect();
3086
+ if (wordRect.top < containerRect.top || wordRect.bottom > containerRect.bottom) {
3087
+ isScrollingProgrammatically.current = true;
3088
+ activeWord.scrollIntoView({ behavior: "smooth", block: "center" });
3089
+ setTimeout(() => {
3090
+ isScrollingProgrammatically.current = false;
3091
+ }, 500);
3092
+ }
3093
+ }
3094
+ }, [currentTime, showTranscript, autoScrollEnabled]);
3095
+ useEffect(() => {
3096
+ if (!showTranscript || !transcriptContainerRef.current) return;
3097
+ const container = transcriptContainerRef.current;
3098
+ const handleScroll = () => {
3099
+ if (!isScrollingProgrammatically.current && autoScrollEnabled) {
3100
+ setAutoScrollEnabled(false);
3101
+ }
3102
+ };
3103
+ container.addEventListener("scroll", handleScroll, { passive: true });
3104
+ return () => container.removeEventListener("scroll", handleScroll);
3105
+ }, [showTranscript, autoScrollEnabled]);
3106
+ const handlePlayPause = () => {
3107
+ if (hasEnded) {
3108
+ if (audioElement) {
3109
+ audioElement.currentTime = 0;
3110
+ }
3111
+ setHasEnded(false);
3112
+ }
3113
+ setPlaying(!playing);
3114
+ };
3115
+ const handleRestart = () => {
3116
+ if (audioElement) {
3117
+ audioElement.currentTime = 0;
3118
+ }
3119
+ setHasEnded(false);
3120
+ setPlaying(true);
3121
+ };
3122
+ const handleVideoClick = () => {
3123
+ if (!isLiveStream) {
3124
+ handlePlayPause();
3125
+ }
3126
+ };
3127
+ const handleSeekChange = (e) => {
3128
+ const newValue = parseFloat(e.target.value);
3129
+ setPlayed(newValue);
3130
+ };
3131
+ const handleSeekMouseDown = () => {
3132
+ setSeeking(true);
3133
+ };
3134
+ const handleSeekMouseUp = (e) => {
3135
+ const seekValue = parseFloat(e.target.value);
3136
+ setSeeking(false);
3137
+ if (audioElement && duration > 0) {
3138
+ const seekTime = seekValue * duration;
3139
+ audioElement.currentTime = seekTime;
3140
+ setHasEnded(false);
3141
+ }
3142
+ };
3143
+ const handleSeekTouchStart = () => {
3144
+ setSeeking(true);
3145
+ };
3146
+ const handleSeekTouchEnd = (e) => {
3147
+ const seekValue = parseFloat(e.target.value);
3148
+ setSeeking(false);
3149
+ if (audioElement && duration > 0) {
3150
+ const seekTime = seekValue * duration;
3151
+ audioElement.currentTime = seekTime;
3152
+ setHasEnded(false);
3153
+ }
3154
+ };
3155
+ const handleTimeUpdate = (e) => {
3156
+ if (!seeking) {
3157
+ const video = e.currentTarget;
3158
+ const playedFraction = video.duration > 0 ? video.currentTime / video.duration : 0;
3159
+ setPlayed(playedFraction);
3160
+ }
3161
+ };
3162
+ const handleLoadedMetadata = (e) => {
3163
+ const video = e.currentTarget;
3164
+ setAudioElement(video);
3165
+ if (video.duration && !isNaN(video.duration) && video.duration > 0) {
3166
+ setDuration(video.duration);
3167
+ } else if (broadcast.durationSeconds && broadcast.durationSeconds > 0) {
3168
+ setDuration(broadcast.durationSeconds);
3169
+ }
3170
+ };
3171
+ const handlePlayerReady = (player) => {
3172
+ try {
3173
+ const internalPlayer = player?.getInternalPlayer?.();
3174
+ if (internalPlayer && internalPlayer instanceof HTMLMediaElement) {
3175
+ setAudioElement(internalPlayer);
3176
+ }
3177
+ } catch (error) {
3178
+ debug.error("[DialtribePlayer] Error getting internal player:", error);
3179
+ }
3180
+ };
3181
+ useEffect(() => {
3182
+ const findAudioElement = () => {
3183
+ const videoElements = document.querySelectorAll("video, audio");
3184
+ if (videoElements.length > 0) {
3185
+ const element = videoElements[0];
3186
+ setAudioElement(element);
3187
+ return true;
3188
+ }
3189
+ return false;
3190
+ };
3191
+ if (!findAudioElement()) {
3192
+ const retryIntervals = [
3193
+ 100,
3194
+ 300,
3195
+ 500,
3196
+ 1e3,
3197
+ 1500,
3198
+ 2e3,
3199
+ 3e3,
3200
+ 4e3,
3201
+ 5e3
3202
+ ];
3203
+ const timeouts = retryIntervals.map(
3204
+ (delay) => setTimeout(() => {
3205
+ findAudioElement();
3206
+ }, delay)
3207
+ );
3208
+ return () => timeouts.forEach(clearTimeout);
3209
+ }
3210
+ }, [playbackUrl]);
3211
+ useEffect(() => {
3212
+ if (playing && !audioElement) {
3213
+ const videoElements = document.querySelectorAll("video, audio");
3214
+ if (videoElements.length > 0) {
3215
+ const element = videoElements[0];
3216
+ setAudioElement(element);
3217
+ }
3218
+ }
3219
+ }, [playing, audioElement]);
3220
+ const handleVolumeChange = (e) => {
3221
+ setVolume(parseFloat(e.target.value));
3222
+ };
3223
+ const toggleMute = () => {
3224
+ setMuted(!muted);
3225
+ };
3226
+ const toggleFullscreen = async () => {
3227
+ try {
3228
+ if (document.fullscreenElement) {
3229
+ await document.exitFullscreen();
3230
+ return;
3231
+ }
3232
+ let videoElement = null;
3233
+ if (playerRef.current && typeof playerRef.current.getInternalPlayer === "function") {
3234
+ videoElement = playerRef.current.getInternalPlayer();
3235
+ }
3236
+ if (!videoElement || typeof videoElement.requestFullscreen !== "function") {
3237
+ const videoElements = document.querySelectorAll("video");
3238
+ if (videoElements.length > 0) {
3239
+ videoElement = videoElements[0];
3240
+ }
3241
+ }
3242
+ if (!videoElement || typeof videoElement.requestFullscreen !== "function") {
3243
+ const modalElement = document.querySelector(".aspect-video");
3244
+ if (modalElement && typeof modalElement.requestFullscreen === "function") {
3245
+ await modalElement.requestFullscreen();
3246
+ return;
3247
+ }
3248
+ }
3249
+ if (videoElement && typeof videoElement.requestFullscreen === "function") {
3250
+ await videoElement.requestFullscreen();
3251
+ }
3252
+ } catch (error) {
3253
+ debug.error("Error toggling fullscreen:", error);
3254
+ }
3255
+ };
3256
+ const handleError = async (error) => {
3257
+ debug.error("Media playback error:", error);
3258
+ const isPotentialExpiration = error?.code === HTTP_STATUS.FORBIDDEN || error?.status === HTTP_STATUS.FORBIDDEN || error?.statusCode === HTTP_STATUS.FORBIDDEN || error?.code === HTTP_STATUS.NOT_FOUND || error?.status === HTTP_STATUS.NOT_FOUND || error?.statusCode === HTTP_STATUS.NOT_FOUND || error?.message?.includes("403") || error?.message?.includes("404") || error?.message?.includes("Forbidden") || error?.message?.toLowerCase().includes("network") || error?.type === "network" || error?.message?.includes("MEDIA_ERR_SRC_NOT_SUPPORTED");
3259
+ if (isPotentialExpiration && currentPlaybackInfo?.type && !isRefreshingUrl.current) {
3260
+ debug.log(
3261
+ "[Player Error] Detected potential URL expiration, attempting refresh..."
3262
+ );
3263
+ const currentPosition = audioElement?.currentTime || 0;
3264
+ const wasPlaying = playing;
3265
+ const fileType = currentPlaybackInfo.type;
3266
+ if (fileType !== "mp4" && fileType !== "mp3" && fileType !== "hls") {
3267
+ debug.error(
3268
+ "[Player Error] Invalid file type, cannot refresh:",
3269
+ fileType
3270
+ );
3271
+ } else {
3272
+ const refreshed = await refreshPresignedUrl(fileType);
3273
+ if (refreshed) {
3274
+ debug.log(
3275
+ "[Player Error] URL refreshed successfully, resuming playback"
3276
+ );
3277
+ setTimeout(() => {
3278
+ if (audioElement && currentPosition > 0) {
3279
+ audioElement.currentTime = currentPosition;
3280
+ }
3281
+ if (wasPlaying) {
3282
+ setPlaying(true);
3283
+ }
3284
+ }, PLAYBACK_RESUME_DELAY_MS);
3285
+ return;
3286
+ }
3287
+ }
3288
+ }
3289
+ setHasError(true);
3290
+ setPlaying(false);
3291
+ setIsLoadingVideo(false);
3292
+ setErrorMessage(getErrorMessage(error));
3293
+ if (onError && error instanceof Error) {
3294
+ onError(error);
3295
+ }
3296
+ };
3297
+ const handleRetry = useCallback(() => {
3298
+ setHasError(false);
3299
+ setErrorMessage("");
3300
+ setIsLoadingVideo(true);
3301
+ const info = getPlaybackInfo();
3302
+ if (info) {
3303
+ setCurrentPlaybackInfo(null);
3304
+ setTimeout(() => {
3305
+ setCurrentPlaybackInfo(info);
3306
+ setPlaying(true);
3307
+ }, 100);
3308
+ }
3309
+ }, [broadcast]);
3310
+ const handleWordClick = (startTime) => {
3311
+ if (audioElement) {
3312
+ audioElement.currentTime = startTime;
3313
+ setPlayed(startTime / duration);
3314
+ setHasEnded(false);
3315
+ if (!playing) {
3316
+ setPlaying(true);
3317
+ }
3318
+ }
3319
+ };
3320
+ useEffect(() => {
3321
+ if (!enableKeyboardShortcuts) return;
3322
+ const seekBy = (seconds) => {
3323
+ if (!audioElement || duration <= 0) return;
3324
+ const newTime = Math.max(
3325
+ 0,
3326
+ Math.min(duration, audioElement.currentTime + seconds)
3327
+ );
3328
+ audioElement.currentTime = newTime;
3329
+ setPlayed(newTime / duration);
3330
+ };
3331
+ const handleKeyDown = (e) => {
3332
+ const target = e.target;
3333
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.contentEditable === "true" || target.getAttribute("role") === "textbox") {
3334
+ return;
3335
+ }
3336
+ if (e.ctrlKey || e.metaKey || e.altKey) {
3337
+ return;
3338
+ }
3339
+ switch (e.key) {
3340
+ case " ":
3341
+ case "k":
3342
+ case "K":
3343
+ e.preventDefault();
3344
+ handlePlayPause();
3345
+ break;
3346
+ case "ArrowLeft":
3347
+ e.preventDefault();
3348
+ seekBy(-5);
3349
+ break;
3350
+ case "ArrowRight":
3351
+ e.preventDefault();
3352
+ seekBy(5);
3353
+ break;
3354
+ case "ArrowUp":
3355
+ e.preventDefault();
3356
+ setVolume((prev) => Math.min(1, prev + 0.1));
3357
+ if (muted) setMuted(false);
3358
+ break;
3359
+ case "ArrowDown":
3360
+ e.preventDefault();
3361
+ setVolume((prev) => Math.max(0, prev - 0.1));
3362
+ break;
3363
+ case "m":
3364
+ case "M":
3365
+ e.preventDefault();
3366
+ toggleMute();
3367
+ break;
3368
+ case "f":
3369
+ case "F":
3370
+ if (!isAudioOnly) {
3371
+ e.preventDefault();
3372
+ toggleFullscreen();
3373
+ }
3374
+ break;
3375
+ case "0":
3376
+ case "Home":
3377
+ e.preventDefault();
3378
+ if (audioElement && duration > 0) {
3379
+ audioElement.currentTime = 0;
3380
+ setPlayed(0);
3381
+ setHasEnded(false);
3382
+ }
3383
+ break;
3384
+ case "End":
3385
+ e.preventDefault();
3386
+ if (audioElement && duration > 0) {
3387
+ audioElement.currentTime = duration - 1;
3388
+ setPlayed((duration - 1) / duration);
3389
+ }
3390
+ break;
3391
+ }
3392
+ };
3393
+ window.addEventListener("keydown", handleKeyDown);
3394
+ return () => window.removeEventListener("keydown", handleKeyDown);
3395
+ }, [
3396
+ enableKeyboardShortcuts,
3397
+ audioElement,
3398
+ duration,
3399
+ playing,
3400
+ muted,
3401
+ isAudioOnly,
3402
+ handlePlayPause,
3403
+ toggleMute,
3404
+ toggleFullscreen
3405
+ ]);
3406
+ if (currentPlaybackInfo !== null && !playbackUrl) {
3407
+ return /* @__PURE__ */ jsxs("div", { className: "bg-white dark:bg-zinc-900 rounded-lg p-6 max-w-md w-full mx-4 border border-gray-200 dark:border-zinc-800", children: [
3408
+ /* @__PURE__ */ jsx("h3", { className: "text-lg font-bold text-black dark:text-white mb-4", children: "Broadcast Unavailable" }),
3409
+ /* @__PURE__ */ jsx("p", { className: "text-gray-600 dark:text-gray-400", children: "No playback URL available for this broadcast. The recording may still be processing." })
3410
+ ] });
3411
+ }
3412
+ if (!playbackUrl) {
3413
+ return /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center p-8", children: /* @__PURE__ */ jsx(LoadingSpinner, { variant: "white", text: "Loading..." }) });
3414
+ }
3415
+ const hasTranscript = broadcast.transcriptStatus === 2 && transcriptData && (transcriptData.segments && transcriptData.segments.some((s) => s.words && s.words.length > 0) || transcriptData.words && transcriptData.words.length > 0);
3416
+ const playerContent = /* @__PURE__ */ jsxs(
3417
+ "div",
3418
+ {
3419
+ className: `bg-black rounded-lg shadow-2xl w-full max-h-full flex flex-col overflow-hidden ${className}`,
3420
+ children: [
3421
+ /* @__PURE__ */ jsxs("div", { className: "bg-zinc-900/50 backdrop-blur-sm border-b border-zinc-800 px-3 sm:px-4 md:px-6 py-2 sm:py-3 md:py-4 flex justify-between items-center rounded-t-lg shrink-0", children: [
3422
+ /* @__PURE__ */ jsxs("div", { children: [
3423
+ /* @__PURE__ */ jsx("h3", { className: "text-lg font-semibold text-white", children: broadcast.streamKeyRecord?.foreignName || "Broadcast" }),
3424
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-gray-400", children: [
3425
+ broadcast.isVideo ? "Video" : "Audio",
3426
+ " \u2022",
3427
+ " ",
3428
+ broadcast.broadcastStatus === 1 ? /* @__PURE__ */ jsx("span", { className: "text-red-500 font-semibold", children: "\u{1F534} LIVE" }) : playbackType === "hls" ? /* @__PURE__ */ jsx("span", { className: "text-gray-500 font-semibold", children: "OFFLINE" }) : formatTime(duration)
3429
+ ] })
3430
+ ] }),
3431
+ /* @__PURE__ */ jsx("div", { className: "flex items-center gap-3", children: renderClipCreator && playbackType !== "hls" && appId && contentId && duration > 0 && /* @__PURE__ */ jsxs(
3432
+ "button",
3433
+ {
3434
+ onClick: () => setShowClipCreator(true),
3435
+ className: "px-3 md:px-4 py-1.5 md:py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs md:text-sm font-medium rounded-lg transition-colors flex items-center gap-1 md:gap-2",
3436
+ title: "Create a clip from this broadcast",
3437
+ "aria-label": "Create clip from broadcast",
3438
+ children: [
3439
+ /* @__PURE__ */ jsxs(
3440
+ "svg",
3441
+ {
3442
+ className: "w-3 h-3 md:w-4 md:h-4",
3443
+ fill: "none",
3444
+ stroke: "currentColor",
3445
+ viewBox: "0 0 24 24",
3446
+ children: [
3447
+ /* @__PURE__ */ jsx(
3448
+ "path",
3449
+ {
3450
+ strokeLinecap: "round",
3451
+ strokeLinejoin: "round",
3452
+ strokeWidth: 2,
3453
+ d: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
3454
+ }
3455
+ ),
3456
+ /* @__PURE__ */ jsx(
3457
+ "path",
3458
+ {
3459
+ strokeLinecap: "round",
3460
+ strokeLinejoin: "round",
3461
+ strokeWidth: 2,
3462
+ d: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
3463
+ }
3464
+ )
3465
+ ]
3466
+ }
3467
+ ),
3468
+ /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "Create Clip" }),
3469
+ /* @__PURE__ */ jsx("span", { className: "sm:hidden", children: "Clip" })
3470
+ ]
3471
+ }
3472
+ ) })
3473
+ ] }),
3474
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col md:flex-row flex-1 min-h-0 overflow-hidden", children: [
3475
+ /* @__PURE__ */ jsxs("div", { className: "shrink-0 md:shrink md:flex-1 flex flex-col overflow-hidden", children: [
3476
+ /* @__PURE__ */ jsxs(
3477
+ "div",
3478
+ {
3479
+ className: `relative ${isAudioOnly ? "bg-linear-to-br from-zinc-900 via-zinc-800 to-zinc-900 flex items-stretch" : "bg-black"}`,
3480
+ children: [
3481
+ isAudioOnly ? /* @__PURE__ */ jsx(
3482
+ "div",
3483
+ {
3484
+ className: "relative cursor-pointer w-full flex flex-col",
3485
+ onClick: handleVideoClick,
3486
+ children: !hasError ? /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("div", { className: "w-full h-full relative", children: [
3487
+ /* @__PURE__ */ jsx(
3488
+ AudioWaveform,
3489
+ {
3490
+ audioElement,
3491
+ isPlaying: isLiveStream ? true : playing,
3492
+ isLive: isLiveStream
3493
+ }
3494
+ ),
3495
+ isLoadingVideo && !hasError && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-black/90 flex items-center justify-center z-20", children: /* @__PURE__ */ jsx(LoadingSpinner, { variant: "white" }) }),
3496
+ hasEnded && !wasLiveStream && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-black/50 flex items-center justify-center z-20 pointer-events-auto", children: /* @__PURE__ */ jsxs(
3497
+ "button",
3498
+ {
3499
+ onClick: (e) => {
3500
+ e.stopPropagation();
3501
+ handleRestart();
3502
+ },
3503
+ className: "bg-white hover:bg-blue-500 text-black hover:text-white font-semibold py-4 px-8 rounded-full transition-all transform hover:scale-105 flex items-center gap-3",
3504
+ "aria-label": "Restart playback from beginning",
3505
+ children: [
3506
+ /* @__PURE__ */ jsx(
3507
+ "svg",
3508
+ {
3509
+ className: "w-6 h-6",
3510
+ fill: "currentColor",
3511
+ viewBox: "0 0 20 20",
3512
+ children: /* @__PURE__ */ jsx(
3513
+ "path",
3514
+ {
3515
+ fillRule: "evenodd",
3516
+ d: "M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z",
3517
+ clipRule: "evenodd"
3518
+ }
3519
+ )
3520
+ }
3521
+ ),
3522
+ "Restart"
3523
+ ]
3524
+ }
3525
+ ) })
3526
+ ] }) }) : /* @__PURE__ */ jsx("div", { className: "w-full h-full flex items-center justify-center", children: /* @__PURE__ */ jsxs("div", { className: "text-center max-w-md px-4", children: [
3527
+ /* @__PURE__ */ jsx("div", { className: "text-6xl mb-4", children: "\u26A0\uFE0F" }),
3528
+ /* @__PURE__ */ jsx("h3", { className: "text-xl font-semibold text-white mb-3", children: "Playback Error" }),
3529
+ /* @__PURE__ */ jsx("p", { className: "text-gray-300 text-sm mb-6", children: errorMessage }),
3530
+ /* @__PURE__ */ jsxs(
3531
+ "button",
3532
+ {
3533
+ onClick: handleRetry,
3534
+ className: "px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors inline-flex items-center gap-2",
3535
+ "aria-label": "Retry playback",
3536
+ children: [
3537
+ /* @__PURE__ */ jsx(
3538
+ "svg",
3539
+ {
3540
+ className: "w-5 h-5",
3541
+ fill: "none",
3542
+ stroke: "currentColor",
3543
+ viewBox: "0 0 24 24",
3544
+ children: /* @__PURE__ */ jsx(
3545
+ "path",
3546
+ {
3547
+ strokeLinecap: "round",
3548
+ strokeLinejoin: "round",
3549
+ strokeWidth: 2,
3550
+ d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
3551
+ }
3552
+ )
3553
+ }
3554
+ ),
3555
+ "Retry"
3556
+ ]
3557
+ }
3558
+ )
3559
+ ] }) })
3560
+ }
3561
+ ) : /* @__PURE__ */ jsxs("div", { className: "aspect-video relative", children: [
3562
+ /* @__PURE__ */ jsx("div", { onClick: handleVideoClick, className: "cursor-pointer", children: /* @__PURE__ */ jsx(
3563
+ ReactPlayer,
3564
+ {
3565
+ ref: playerRef,
3566
+ src: playbackUrl || void 0,
3567
+ playing,
3568
+ volume,
3569
+ muted,
3570
+ width: "100%",
3571
+ height: "100%",
3572
+ crossOrigin: "anonymous",
3573
+ config: playerConfig,
3574
+ onReady: handlePlayerReady,
3575
+ onTimeUpdate: handleTimeUpdate,
3576
+ onLoadedMetadata: handleLoadedMetadata,
3577
+ onPlay: handlePlay,
3578
+ onPause: handlePause,
3579
+ onEnded: handleEnded,
3580
+ onError: handleError,
3581
+ style: { backgroundColor: "#000" }
3582
+ },
3583
+ playbackUrl || "no-url"
3584
+ ) }),
3585
+ isLoadingVideo && !hasError && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-black/90 flex items-center justify-center", children: /* @__PURE__ */ jsx(LoadingSpinner, { variant: "white" }) }),
3586
+ hasEnded && !hasError && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-black/50 flex items-center justify-center", children: /* @__PURE__ */ jsxs(
3587
+ "button",
3588
+ {
3589
+ onClick: (e) => {
3590
+ e.stopPropagation();
3591
+ handleRestart();
3592
+ },
3593
+ className: "bg-white hover:bg-blue-500 text-black hover:text-white font-semibold py-4 px-8 rounded-full transition-all transform hover:scale-105 flex items-center gap-3",
3594
+ "aria-label": "Restart playback from beginning",
3595
+ children: [
3596
+ /* @__PURE__ */ jsx(
3597
+ "svg",
3598
+ {
3599
+ className: "w-6 h-6",
3600
+ fill: "currentColor",
3601
+ viewBox: "0 0 20 20",
3602
+ "aria-hidden": "true",
3603
+ children: /* @__PURE__ */ jsx(
3604
+ "path",
3605
+ {
3606
+ fillRule: "evenodd",
3607
+ d: "M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z",
3608
+ clipRule: "evenodd"
3609
+ }
3610
+ )
3611
+ }
3612
+ ),
3613
+ "Restart"
3614
+ ]
3615
+ }
3616
+ ) }),
3617
+ hasError && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-black/90 flex items-center justify-center p-8", children: /* @__PURE__ */ jsxs("div", { className: "text-center max-w-md", children: [
3618
+ /* @__PURE__ */ jsx("div", { className: "text-6xl mb-4", children: "\u26A0\uFE0F" }),
3619
+ /* @__PURE__ */ jsx("h3", { className: "text-xl font-semibold text-white mb-3", children: "Playback Error" }),
3620
+ /* @__PURE__ */ jsx("p", { className: "text-gray-300 text-sm mb-6", children: errorMessage }),
3621
+ /* @__PURE__ */ jsxs(
3622
+ "button",
3623
+ {
3624
+ onClick: handleRetry,
3625
+ className: "px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors inline-flex items-center gap-2",
3626
+ "aria-label": "Retry playback",
3627
+ children: [
3628
+ /* @__PURE__ */ jsx(
3629
+ "svg",
3630
+ {
3631
+ className: "w-5 h-5",
3632
+ fill: "none",
3633
+ stroke: "currentColor",
3634
+ viewBox: "0 0 24 24",
3635
+ children: /* @__PURE__ */ jsx(
3636
+ "path",
3637
+ {
3638
+ strokeLinecap: "round",
3639
+ strokeLinejoin: "round",
3640
+ strokeWidth: 2,
3641
+ d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
3642
+ }
3643
+ )
3644
+ }
3645
+ ),
3646
+ "Retry"
3647
+ ]
3648
+ }
3649
+ )
3650
+ ] }) })
3651
+ ] }),
3652
+ isAudioOnly && /* @__PURE__ */ jsx("div", { className: "hidden", children: /* @__PURE__ */ jsx(
3653
+ ReactPlayer,
3654
+ {
3655
+ ref: playerRef,
3656
+ src: playbackUrl || void 0,
3657
+ playing,
3658
+ volume,
3659
+ muted,
3660
+ width: "0",
3661
+ height: "0",
3662
+ crossOrigin: "anonymous",
3663
+ config: playerConfig,
3664
+ onReady: handlePlayerReady,
3665
+ onTimeUpdate: handleTimeUpdate,
3666
+ onLoadedMetadata: handleLoadedMetadata,
3667
+ onPlay: handlePlay,
3668
+ onPause: handlePause,
3669
+ onEnded: handleEnded,
3670
+ onError: handleError
3671
+ },
3672
+ playbackUrl || "no-url"
3673
+ ) })
3674
+ ]
3675
+ }
3676
+ ),
3677
+ !hasError && !isLiveStream && (wasLiveStream ? parseInt(broadcast.mp3Size || "0") > 0 || parseInt(broadcast.mp4Size || "0") > 0 ? /* @__PURE__ */ jsxs("div", { className: "bg-zinc-900/50 backdrop-blur-sm px-4 md:px-6 py-3 md:py-4 rounded-b-lg", children: [
3678
+ /* @__PURE__ */ jsxs("div", { className: "mb-4", children: [
3679
+ /* @__PURE__ */ jsx(
3680
+ "input",
3681
+ {
3682
+ type: "range",
3683
+ min: 0,
3684
+ max: 0.999999,
3685
+ step: "any",
3686
+ value: played || 0,
3687
+ onMouseDown: handleSeekMouseDown,
3688
+ onMouseUp: handleSeekMouseUp,
3689
+ onTouchStart: handleSeekTouchStart,
3690
+ onTouchEnd: handleSeekTouchEnd,
3691
+ onChange: handleSeekChange,
3692
+ className: "w-full h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer slider",
3693
+ "aria-label": "Seek position",
3694
+ "aria-valuemin": 0,
3695
+ "aria-valuemax": duration,
3696
+ "aria-valuenow": played * duration,
3697
+ "aria-valuetext": `${formatTime(
3698
+ played * duration
3699
+ )} of ${formatTime(duration)}`,
3700
+ role: "slider"
3701
+ }
3702
+ ),
3703
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-between text-xs text-gray-400 mt-1", children: [
3704
+ /* @__PURE__ */ jsx("span", { children: formatTime((played || 0) * duration) }),
3705
+ /* @__PURE__ */ jsx("span", { children: formatTime(duration) })
3706
+ ] })
3707
+ ] }),
3708
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
3709
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
3710
+ /* @__PURE__ */ jsx(
3711
+ "button",
3712
+ {
3713
+ onClick: handlePlayPause,
3714
+ className: "text-white hover:text-blue-400 transition-colors",
3715
+ title: playing ? "Pause" : "Play",
3716
+ "aria-label": playing ? "Pause" : "Play",
3717
+ "aria-pressed": playing,
3718
+ children: playing ? /* @__PURE__ */ jsx(
3719
+ "svg",
3720
+ {
3721
+ className: "w-8 h-8",
3722
+ fill: "currentColor",
3723
+ viewBox: "0 0 20 20",
3724
+ "aria-hidden": "true",
3725
+ children: /* @__PURE__ */ jsx(
3726
+ "path",
3727
+ {
3728
+ fillRule: "evenodd",
3729
+ d: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z",
3730
+ clipRule: "evenodd"
3731
+ }
3732
+ )
3733
+ }
3734
+ ) : /* @__PURE__ */ jsx(
3735
+ "svg",
3736
+ {
3737
+ className: "w-8 h-8",
3738
+ fill: "currentColor",
3739
+ viewBox: "0 0 20 20",
3740
+ "aria-hidden": "true",
3741
+ children: /* @__PURE__ */ jsx(
3742
+ "path",
3743
+ {
3744
+ fillRule: "evenodd",
3745
+ d: "M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z",
3746
+ clipRule: "evenodd"
3747
+ }
3748
+ )
3749
+ }
3750
+ )
3751
+ }
3752
+ ),
3753
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
3754
+ /* @__PURE__ */ jsx(
3755
+ "button",
3756
+ {
3757
+ onClick: toggleMute,
3758
+ className: "text-white hover:text-blue-400 transition-colors",
3759
+ title: muted ? "Unmute" : "Mute",
3760
+ "aria-label": muted ? "Unmute" : "Mute",
3761
+ "aria-pressed": muted,
3762
+ children: muted || volume === 0 ? /* @__PURE__ */ jsx(
3763
+ "svg",
3764
+ {
3765
+ className: "w-5 h-5",
3766
+ fill: "currentColor",
3767
+ viewBox: "0 0 20 20",
3768
+ children: /* @__PURE__ */ jsx(
3769
+ "path",
3770
+ {
3771
+ fillRule: "evenodd",
3772
+ d: "M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z",
3773
+ clipRule: "evenodd"
3774
+ }
3775
+ )
3776
+ }
3777
+ ) : /* @__PURE__ */ jsx(
3778
+ "svg",
3779
+ {
3780
+ className: "w-5 h-5",
3781
+ fill: "currentColor",
3782
+ viewBox: "0 0 20 20",
3783
+ children: /* @__PURE__ */ jsx(
3784
+ "path",
3785
+ {
3786
+ fillRule: "evenodd",
3787
+ d: "M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z",
3788
+ clipRule: "evenodd"
3789
+ }
3790
+ )
3791
+ }
3792
+ )
3793
+ }
3794
+ ),
3795
+ /* @__PURE__ */ jsx(
3796
+ "input",
3797
+ {
3798
+ type: "range",
3799
+ min: 0,
3800
+ max: 1,
3801
+ step: 0.01,
3802
+ value: muted ? 0 : volume || 1,
3803
+ onChange: handleVolumeChange,
3804
+ className: "w-20 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer slider",
3805
+ "aria-label": "Volume",
3806
+ "aria-valuemin": 0,
3807
+ "aria-valuemax": 100,
3808
+ "aria-valuenow": muted ? 0 : Math.round(volume * 100),
3809
+ "aria-valuetext": muted ? "Muted" : `${Math.round(volume * 100)}%`,
3810
+ role: "slider"
3811
+ }
3812
+ )
3813
+ ] })
3814
+ ] }),
3815
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
3816
+ !isLiveStream && broadcast.hash && (broadcast.recordingMp4Url || broadcast.recordingMp3Url) && /* @__PURE__ */ jsx(
3817
+ "button",
3818
+ {
3819
+ onClick: () => {
3820
+ const downloadUrl = buildPlaybackUrl(
3821
+ broadcast.id,
3822
+ broadcast.hash,
3823
+ "download"
3824
+ );
3825
+ window.open(downloadUrl, "_blank");
3826
+ },
3827
+ className: "text-white hover:text-blue-400 transition-colors",
3828
+ title: "Download Recording",
3829
+ "aria-label": "Download recording",
3830
+ children: /* @__PURE__ */ jsx(
3831
+ "svg",
3832
+ {
3833
+ className: "w-5 h-5",
3834
+ fill: "none",
3835
+ stroke: "currentColor",
3836
+ viewBox: "0 0 24 24",
3837
+ children: /* @__PURE__ */ jsx(
3838
+ "path",
3839
+ {
3840
+ strokeLinecap: "round",
3841
+ strokeLinejoin: "round",
3842
+ strokeWidth: 2,
3843
+ d: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
3844
+ }
3845
+ )
3846
+ }
3847
+ )
3848
+ }
3849
+ ),
3850
+ !isAudioOnly && /* @__PURE__ */ jsx(
3851
+ "button",
3852
+ {
3853
+ onClick: toggleFullscreen,
3854
+ className: "text-white hover:text-blue-400 transition-colors",
3855
+ title: "Fullscreen",
3856
+ "aria-label": "Toggle fullscreen",
3857
+ children: /* @__PURE__ */ jsx(
3858
+ "svg",
3859
+ {
3860
+ className: "w-5 h-5",
3861
+ fill: "currentColor",
3862
+ viewBox: "0 0 20 20",
3863
+ children: /* @__PURE__ */ jsx(
3864
+ "path",
3865
+ {
3866
+ fillRule: "evenodd",
3867
+ d: "M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z",
3868
+ clipRule: "evenodd"
3869
+ }
3870
+ )
3871
+ }
3872
+ )
3873
+ }
3874
+ ),
3875
+ hasTranscript && /* @__PURE__ */ jsx(
3876
+ "button",
3877
+ {
3878
+ onClick: () => setShowTranscript(!showTranscript),
3879
+ className: `transition-colors ${showTranscript ? "text-blue-400" : "text-white hover:text-blue-400"}`,
3880
+ title: showTranscript ? "Hide Transcript" : "Show Transcript",
3881
+ "aria-label": showTranscript ? "Hide transcript" : "Show transcript",
3882
+ "aria-pressed": showTranscript,
3883
+ children: /* @__PURE__ */ jsx(
3884
+ "svg",
3885
+ {
3886
+ className: "w-5 h-5",
3887
+ fill: "none",
3888
+ stroke: "currentColor",
3889
+ viewBox: "0 0 24 24",
3890
+ children: /* @__PURE__ */ jsx(
3891
+ "path",
3892
+ {
3893
+ strokeLinecap: "round",
3894
+ strokeLinejoin: "round",
3895
+ strokeWidth: 2,
3896
+ d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
3897
+ }
3898
+ )
3899
+ }
3900
+ )
3901
+ }
3902
+ )
3903
+ ] })
3904
+ ] })
3905
+ ] }) : null : /* @__PURE__ */ jsxs("div", { className: "bg-zinc-900/50 backdrop-blur-sm px-4 md:px-6 py-3 md:py-4 rounded-b-lg", children: [
3906
+ /* @__PURE__ */ jsxs("div", { className: "mb-4", children: [
3907
+ /* @__PURE__ */ jsx(
3908
+ "input",
3909
+ {
3910
+ type: "range",
3911
+ min: 0,
3912
+ max: 0.999999,
3913
+ step: "any",
3914
+ value: played || 0,
3915
+ onMouseDown: handleSeekMouseDown,
3916
+ onMouseUp: handleSeekMouseUp,
3917
+ onTouchStart: handleSeekTouchStart,
3918
+ onTouchEnd: handleSeekTouchEnd,
3919
+ onChange: handleSeekChange,
3920
+ className: "w-full h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer slider",
3921
+ "aria-label": "Seek position",
3922
+ "aria-valuemin": 0,
3923
+ "aria-valuemax": duration,
3924
+ "aria-valuenow": played * duration,
3925
+ "aria-valuetext": `${formatTime(
3926
+ played * duration
3927
+ )} of ${formatTime(duration)}`,
3928
+ role: "slider"
3929
+ }
3930
+ ),
3931
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-between text-xs text-gray-400 mt-1", children: [
3932
+ /* @__PURE__ */ jsx("span", { children: formatTime((played || 0) * duration) }),
3933
+ /* @__PURE__ */ jsx("span", { children: formatTime(duration) })
3934
+ ] })
3935
+ ] }),
3936
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
3937
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
3938
+ /* @__PURE__ */ jsx(
3939
+ "button",
3940
+ {
3941
+ onClick: handlePlayPause,
3942
+ className: "text-white hover:text-blue-400 transition-colors",
3943
+ title: playing ? "Pause" : "Play",
3944
+ "aria-label": playing ? "Pause" : "Play",
3945
+ "aria-pressed": playing,
3946
+ children: playing ? /* @__PURE__ */ jsx(
3947
+ "svg",
3948
+ {
3949
+ className: "w-8 h-8",
3950
+ fill: "currentColor",
3951
+ viewBox: "0 0 20 20",
3952
+ "aria-hidden": "true",
3953
+ children: /* @__PURE__ */ jsx(
3954
+ "path",
3955
+ {
3956
+ fillRule: "evenodd",
3957
+ d: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z",
3958
+ clipRule: "evenodd"
3959
+ }
3960
+ )
3961
+ }
3962
+ ) : /* @__PURE__ */ jsx(
3963
+ "svg",
3964
+ {
3965
+ className: "w-8 h-8",
3966
+ fill: "currentColor",
3967
+ viewBox: "0 0 20 20",
3968
+ "aria-hidden": "true",
3969
+ children: /* @__PURE__ */ jsx(
3970
+ "path",
3971
+ {
3972
+ fillRule: "evenodd",
3973
+ d: "M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z",
3974
+ clipRule: "evenodd"
3975
+ }
3976
+ )
3977
+ }
3978
+ )
3979
+ }
3980
+ ),
3981
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
3982
+ /* @__PURE__ */ jsx(
3983
+ "button",
3984
+ {
3985
+ onClick: toggleMute,
3986
+ className: "text-white hover:text-blue-400 transition-colors",
3987
+ title: muted ? "Unmute" : "Mute",
3988
+ "aria-label": muted ? "Unmute" : "Mute",
3989
+ "aria-pressed": muted,
3990
+ children: muted || volume === 0 ? /* @__PURE__ */ jsx(
3991
+ "svg",
3992
+ {
3993
+ className: "w-5 h-5",
3994
+ fill: "currentColor",
3995
+ viewBox: "0 0 20 20",
3996
+ "aria-hidden": "true",
3997
+ children: /* @__PURE__ */ jsx(
3998
+ "path",
3999
+ {
4000
+ fillRule: "evenodd",
4001
+ d: "M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z",
4002
+ clipRule: "evenodd"
4003
+ }
4004
+ )
4005
+ }
4006
+ ) : /* @__PURE__ */ jsx(
4007
+ "svg",
4008
+ {
4009
+ className: "w-5 h-5",
4010
+ fill: "currentColor",
4011
+ viewBox: "0 0 20 20",
4012
+ "aria-hidden": "true",
4013
+ children: /* @__PURE__ */ jsx(
4014
+ "path",
4015
+ {
4016
+ fillRule: "evenodd",
4017
+ d: "M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z",
4018
+ clipRule: "evenodd"
4019
+ }
4020
+ )
4021
+ }
4022
+ )
4023
+ }
4024
+ ),
4025
+ /* @__PURE__ */ jsx(
4026
+ "input",
4027
+ {
4028
+ type: "range",
4029
+ min: 0,
4030
+ max: 1,
4031
+ step: 0.01,
4032
+ value: muted ? 0 : volume || 1,
4033
+ onChange: handleVolumeChange,
4034
+ className: "w-20 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer slider",
4035
+ "aria-label": "Volume",
4036
+ "aria-valuemin": 0,
4037
+ "aria-valuemax": 100,
4038
+ "aria-valuenow": muted ? 0 : Math.round(volume * 100),
4039
+ "aria-valuetext": muted ? "Muted" : `${Math.round(volume * 100)}%`,
4040
+ role: "slider"
4041
+ }
4042
+ )
4043
+ ] })
4044
+ ] }),
4045
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
4046
+ !isAudioOnly && /* @__PURE__ */ jsx(
4047
+ "button",
4048
+ {
4049
+ onClick: toggleFullscreen,
4050
+ className: "text-white hover:text-blue-400 transition-colors",
4051
+ title: "Toggle fullscreen",
4052
+ "aria-label": "Toggle fullscreen",
4053
+ children: /* @__PURE__ */ jsx(
4054
+ "svg",
4055
+ {
4056
+ className: "w-5 h-5",
4057
+ fill: "currentColor",
4058
+ viewBox: "0 0 20 20",
4059
+ "aria-hidden": "true",
4060
+ children: /* @__PURE__ */ jsx(
4061
+ "path",
4062
+ {
4063
+ fillRule: "evenodd",
4064
+ d: "M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z",
4065
+ clipRule: "evenodd"
4066
+ }
4067
+ )
4068
+ }
4069
+ )
4070
+ }
4071
+ ),
4072
+ hasTranscript && /* @__PURE__ */ jsx(
4073
+ "button",
4074
+ {
4075
+ onClick: () => setShowTranscript(!showTranscript),
4076
+ className: `transition-colors ${showTranscript ? "text-blue-400" : "text-white hover:text-blue-400"}`,
4077
+ title: showTranscript ? "Hide transcript" : "Show transcript",
4078
+ "aria-label": showTranscript ? "Hide transcript" : "Show transcript",
4079
+ "aria-pressed": showTranscript,
4080
+ children: /* @__PURE__ */ jsx(
4081
+ "svg",
4082
+ {
4083
+ className: "w-5 h-5",
4084
+ fill: "none",
4085
+ stroke: "currentColor",
4086
+ viewBox: "0 0 24 24",
4087
+ "aria-hidden": "true",
4088
+ children: /* @__PURE__ */ jsx(
4089
+ "path",
4090
+ {
4091
+ strokeLinecap: "round",
4092
+ strokeLinejoin: "round",
4093
+ strokeWidth: 2,
4094
+ d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
4095
+ }
4096
+ )
4097
+ }
4098
+ )
4099
+ }
4100
+ )
4101
+ ] })
4102
+ ] })
4103
+ ] }))
4104
+ ] }),
4105
+ showTranscript && hasTranscript && /* @__PURE__ */ jsxs("div", { className: "flex-1 md:flex-none min-h-0 w-full md:w-96 bg-zinc-900 border-t md:border-t-0 border-l border-zinc-800 flex flex-col overflow-hidden", children: [
4106
+ /* @__PURE__ */ jsx("div", { className: "px-4 py-3 border-b border-zinc-800 bg-zinc-900/50 shrink-0", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
4107
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
4108
+ /* @__PURE__ */ jsx(
4109
+ "svg",
4110
+ {
4111
+ className: "w-5 h-5 text-green-400",
4112
+ fill: "none",
4113
+ stroke: "currentColor",
4114
+ viewBox: "0 0 24 24",
4115
+ children: /* @__PURE__ */ jsx(
4116
+ "path",
4117
+ {
4118
+ strokeLinecap: "round",
4119
+ strokeLinejoin: "round",
4120
+ strokeWidth: 2,
4121
+ d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
4122
+ }
4123
+ )
4124
+ }
4125
+ ),
4126
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-white", children: "Transcript" }),
4127
+ broadcast.transcriptLanguage && /* @__PURE__ */ jsx("span", { className: "text-xs text-gray-400 px-2 py-0.5 bg-zinc-800 rounded", children: broadcast.transcriptLanguage.toUpperCase() })
4128
+ ] }),
4129
+ broadcast.transcriptUrl && /* @__PURE__ */ jsx(
4130
+ "a",
4131
+ {
4132
+ href: broadcast.transcriptUrl,
4133
+ download: `${broadcast.hash || broadcast.id}-transcript.json`,
4134
+ className: "text-gray-400 hover:text-white transition-colors",
4135
+ title: "Download transcript",
4136
+ "aria-label": "Download transcript as JSON file",
4137
+ children: /* @__PURE__ */ jsx(
4138
+ "svg",
4139
+ {
4140
+ className: "w-5 h-5",
4141
+ fill: "none",
4142
+ stroke: "currentColor",
4143
+ viewBox: "0 0 24 24",
4144
+ "aria-hidden": "true",
4145
+ children: /* @__PURE__ */ jsx(
4146
+ "path",
4147
+ {
4148
+ strokeLinecap: "round",
4149
+ strokeLinejoin: "round",
4150
+ strokeWidth: 2,
4151
+ d: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
4152
+ }
4153
+ )
4154
+ }
4155
+ )
4156
+ }
4157
+ )
4158
+ ] }) }),
4159
+ !autoScrollEnabled && /* @__PURE__ */ jsx("div", { className: "px-4 py-2 bg-zinc-800/50 border-b border-zinc-700 shrink-0", children: /* @__PURE__ */ jsxs(
4160
+ "button",
4161
+ {
4162
+ onClick: () => setAutoScrollEnabled(true),
4163
+ className: "w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors flex items-center justify-center gap-2 text-sm font-medium",
4164
+ "aria-label": "Resume automatic scrolling of transcript",
4165
+ children: [
4166
+ /* @__PURE__ */ jsx(
4167
+ "svg",
4168
+ {
4169
+ className: "w-4 h-4",
4170
+ fill: "none",
4171
+ stroke: "currentColor",
4172
+ viewBox: "0 0 24 24",
4173
+ "aria-hidden": "true",
4174
+ children: /* @__PURE__ */ jsx(
4175
+ "path",
4176
+ {
4177
+ strokeLinecap: "round",
4178
+ strokeLinejoin: "round",
4179
+ strokeWidth: 2,
4180
+ d: "M19 14l-7 7m0 0l-7-7m7 7V3"
4181
+ }
4182
+ )
4183
+ }
4184
+ ),
4185
+ "Resume Auto-Scroll"
4186
+ ]
4187
+ }
4188
+ ) }),
4189
+ /* @__PURE__ */ jsx(
4190
+ "div",
4191
+ {
4192
+ ref: transcriptContainerRef,
4193
+ className: "flex-1 min-h-0 overflow-y-auto px-4 py-4 text-gray-300 leading-relaxed",
4194
+ children: isLoadingTranscript ? /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center py-8", children: /* @__PURE__ */ jsx("div", { className: "h-6 w-6 border-2 border-gray-600 border-t-blue-500 rounded-full animate-spin" }) }) : transcriptData?.segments && transcriptData.segments.length > 0 ? /* @__PURE__ */ jsx("div", { className: "space-y-4", children: (() => {
4195
+ const filteredSegments = transcriptData.segments.filter(
4196
+ (s) => s.words && s.words.length > 0
4197
+ );
4198
+ let globalWordIndex = 0;
4199
+ const wordMap = /* @__PURE__ */ new Map();
4200
+ filteredSegments.forEach((segment) => {
4201
+ segment.words.forEach((_word, wordIndex) => {
4202
+ wordMap.set(
4203
+ `${segment.id}-${wordIndex}`,
4204
+ globalWordIndex++
4205
+ );
4206
+ });
4207
+ });
4208
+ let currentWordIndex = -1;
4209
+ filteredSegments.forEach((segment) => {
4210
+ segment.words.forEach((word, wordIndex) => {
4211
+ const globalIdx = wordMap.get(`${segment.id}-${wordIndex}`) || -1;
4212
+ if (currentTime >= word.start && globalIdx > currentWordIndex) {
4213
+ currentWordIndex = globalIdx;
4214
+ }
4215
+ });
4216
+ });
4217
+ const previousWordIndex = lastActiveWordIndex.current;
4218
+ let minHighlightIndex = -1;
4219
+ let maxHighlightIndex = -1;
4220
+ if (currentWordIndex >= 0) {
4221
+ minHighlightIndex = Math.max(
4222
+ 0,
4223
+ currentWordIndex - TRAILING_WORDS
4224
+ );
4225
+ maxHighlightIndex = currentWordIndex;
4226
+ if (currentWordIndex <= TRAILING_WORDS) {
4227
+ minHighlightIndex = 0;
4228
+ }
4229
+ lastActiveWordIndex.current = currentWordIndex;
4230
+ } else if (currentWordIndex === -1) {
4231
+ minHighlightIndex = 0;
4232
+ maxHighlightIndex = 0;
4233
+ } else if (previousWordIndex >= 0) {
4234
+ minHighlightIndex = Math.max(
4235
+ 0,
4236
+ previousWordIndex - TRAILING_WORDS
4237
+ );
4238
+ maxHighlightIndex = previousWordIndex;
4239
+ }
4240
+ return filteredSegments.map((segment, _segmentIndex) => {
4241
+ const isSegmentActive = currentTime >= segment.start && currentTime < segment.end;
4242
+ return /* @__PURE__ */ jsxs(
4243
+ "div",
4244
+ {
4245
+ ref: isSegmentActive ? transcriptContainerRef : null,
4246
+ className: "flex gap-3 items-start leading-relaxed",
4247
+ children: [
4248
+ /* @__PURE__ */ jsx(
4249
+ "button",
4250
+ {
4251
+ onClick: () => handleWordClick(segment.start),
4252
+ className: "text-xs text-gray-500 hover:text-gray-300 transition-colors shrink-0 pt-0.5 font-mono",
4253
+ title: `Jump to ${formatTimestamp(segment.start)}`,
4254
+ children: formatTimestamp(segment.start)
4255
+ }
4256
+ ),
4257
+ /* @__PURE__ */ jsx("div", { className: "flex-1", children: segment.words.map((word, wordIndex) => {
4258
+ const thisGlobalIndex = wordMap.get(`${segment.id}-${wordIndex}`) ?? -1;
4259
+ const isTimestampActive = currentTime >= word.start && currentTime < word.end;
4260
+ const isInGapFill = minHighlightIndex >= 0 && thisGlobalIndex >= minHighlightIndex && thisGlobalIndex <= maxHighlightIndex;
4261
+ const isWordActive = isInGapFill;
4262
+ return /* @__PURE__ */ jsxs(
4263
+ "span",
4264
+ {
4265
+ ref: isTimestampActive ? activeWordRef : null,
4266
+ onClick: () => handleWordClick(word.start),
4267
+ className: `cursor-pointer ${isWordActive ? "text-blue-400 font-medium active-word" : isSegmentActive ? "text-gray-200 segment-word" : "text-gray-400 hover:text-gray-200 inactive-word"}`,
4268
+ title: `${formatTime(
4269
+ word.start
4270
+ )} - ${formatTime(word.end)}`,
4271
+ children: [
4272
+ word.word,
4273
+ " "
4274
+ ]
4275
+ },
4276
+ `${segment.id}-${wordIndex}`
4277
+ );
4278
+ }) })
4279
+ ]
4280
+ },
4281
+ segment.id
4282
+ );
4283
+ });
4284
+ })() }) : transcriptData?.words && transcriptData.words.length > 0 ? /* @__PURE__ */ jsx("div", { className: "space-y-1", children: transcriptData.words.map((word, index) => {
4285
+ const isActive = currentTime >= word.start && currentTime < word.end;
4286
+ return /* @__PURE__ */ jsxs(
4287
+ "span",
4288
+ {
4289
+ ref: isActive ? activeWordRef : null,
4290
+ onClick: () => handleWordClick(word.start),
4291
+ className: `inline-block cursor-pointer transition-all ${isActive ? "text-blue-400 underline decoration-blue-400 decoration-2 font-medium" : "text-gray-400 hover:text-gray-200"}`,
4292
+ title: `${formatTime(word.start)} - ${formatTime(
4293
+ word.end
4294
+ )}`,
4295
+ children: [
4296
+ word.word,
4297
+ " "
4298
+ ]
4299
+ },
4300
+ index
4301
+ );
4302
+ }) }) : /* @__PURE__ */ jsxs("div", { className: "text-center text-gray-500 py-8", children: [
4303
+ /* @__PURE__ */ jsx("p", { className: "mb-2", children: "Transcript data not available" }),
4304
+ transcriptData && /* @__PURE__ */ jsxs("p", { className: "text-xs text-gray-600", children: [
4305
+ "Debug: segments=",
4306
+ transcriptData.segments ? transcriptData.segments.length : 0,
4307
+ ", words=",
4308
+ transcriptData.words ? transcriptData.words.length : 0
4309
+ ] })
4310
+ ] })
4311
+ }
4312
+ )
4313
+ ] })
4314
+ ] }),
4315
+ renderClipCreator && renderClipCreator({
4316
+ isOpen: showClipCreator,
4317
+ onClose: () => setShowClipCreator(false),
4318
+ sourceVideoUrl: playbackType === "mp4" ? playbackUrl || void 0 : void 0,
4319
+ sourceAudioUrl: playbackType === "mp3" ? playbackUrl || void 0 : void 0,
4320
+ sourceDuration: duration,
4321
+ onPauseParent: () => setPlaying(false)
4322
+ }),
4323
+ /* @__PURE__ */ jsx("style", { children: `
4324
+ .slider::-webkit-slider-thumb {
4325
+ appearance: none;
4326
+ width: 14px;
4327
+ height: 14px;
4328
+ border-radius: 50%;
4329
+ background: white;
4330
+ cursor: pointer;
4331
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
4332
+ }
4333
+ .slider::-webkit-slider-thumb:hover {
4334
+ background: #60a5fa;
4335
+ }
4336
+ .slider::-moz-range-thumb {
4337
+ width: 14px;
4338
+ height: 14px;
4339
+ border-radius: 50%;
4340
+ background: white;
4341
+ cursor: pointer;
4342
+ border: none;
4343
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
4344
+ }
4345
+ .slider::-moz-range-thumb:hover {
4346
+ background: #60a5fa;
4347
+ }
4348
+
4349
+ .active-word {
4350
+ transition: color 0s, text-shadow 0s;
4351
+ text-shadow: 0 0 8px rgba(96, 165, 250, 0.6), 0 0 12px rgba(96, 165, 250, 0.4);
4352
+ }
4353
+ .segment-word,
4354
+ .inactive-word {
4355
+ transition: color 0.4s ease-out, text-shadow 0.4s ease-out;
4356
+ text-shadow: none;
4357
+ }
4358
+ .segment-word:hover,
4359
+ .inactive-word:hover {
4360
+ transition: color 0.15s ease-in;
4361
+ }
4362
+ ` })
4363
+ ]
4364
+ }
4365
+ );
4366
+ return playerContent;
4367
+ }
4368
+ var DialtribePlayerErrorBoundary = class extends Component {
4369
+ constructor(props) {
4370
+ super(props);
4371
+ this.handleReset = () => {
4372
+ this.setState({
4373
+ hasError: false,
4374
+ error: null,
4375
+ errorInfo: null
4376
+ });
4377
+ };
4378
+ this.handleClose = () => {
4379
+ this.setState({
4380
+ hasError: false,
4381
+ error: null,
4382
+ errorInfo: null
4383
+ });
4384
+ this.props.onClose?.();
4385
+ };
4386
+ this.state = {
4387
+ hasError: false,
4388
+ error: null,
4389
+ errorInfo: null
4390
+ };
4391
+ }
4392
+ static getDerivedStateFromError(error) {
4393
+ return { hasError: true, error };
4394
+ }
4395
+ componentDidCatch(error, errorInfo) {
4396
+ console.error("[Player Error Boundary] Caught error:", {
4397
+ error: error.message,
4398
+ stack: error.stack,
4399
+ componentStack: errorInfo.componentStack,
4400
+ broadcastId: this.props.broadcastId,
4401
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4402
+ });
4403
+ this.setState({
4404
+ error,
4405
+ errorInfo
4406
+ });
4407
+ }
4408
+ render() {
4409
+ if (this.state.hasError) {
4410
+ return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/80", children: /* @__PURE__ */ jsxs("div", { className: "bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6", children: [
4411
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-4", children: [
4412
+ /* @__PURE__ */ jsx("h2", { className: "text-2xl font-bold text-gray-900 dark:text-white", children: "Player Error" }),
4413
+ /* @__PURE__ */ jsx(
4414
+ "button",
4415
+ {
4416
+ onClick: this.handleClose,
4417
+ className: "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300",
4418
+ "aria-label": "Close",
4419
+ children: /* @__PURE__ */ jsx(
4420
+ "svg",
4421
+ {
4422
+ className: "w-6 h-6",
4423
+ fill: "none",
4424
+ stroke: "currentColor",
4425
+ viewBox: "0 0 24 24",
4426
+ children: /* @__PURE__ */ jsx(
4427
+ "path",
4428
+ {
4429
+ strokeLinecap: "round",
4430
+ strokeLinejoin: "round",
4431
+ strokeWidth: 2,
4432
+ d: "M6 18L18 6M6 6l12 12"
4433
+ }
4434
+ )
4435
+ }
4436
+ )
4437
+ }
4438
+ )
4439
+ ] }),
4440
+ /* @__PURE__ */ jsx("div", { className: "flex justify-center mb-4", children: /* @__PURE__ */ jsx("div", { className: "rounded-full bg-red-100 dark:bg-red-900/30 p-3", children: /* @__PURE__ */ jsx(
4441
+ "svg",
4442
+ {
4443
+ className: "w-12 h-12 text-red-600 dark:text-red-400",
4444
+ fill: "none",
4445
+ stroke: "currentColor",
4446
+ viewBox: "0 0 24 24",
4447
+ children: /* @__PURE__ */ jsx(
4448
+ "path",
4449
+ {
4450
+ strokeLinecap: "round",
4451
+ strokeLinejoin: "round",
4452
+ strokeWidth: 2,
4453
+ d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
4454
+ }
4455
+ )
4456
+ }
4457
+ ) }) }),
4458
+ /* @__PURE__ */ jsxs("div", { className: "text-center mb-6", children: [
4459
+ /* @__PURE__ */ jsx("p", { className: "text-lg text-gray-900 dark:text-white mb-2", children: "Something went wrong with the media player" }),
4460
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-600 dark:text-gray-400", children: this.state.error?.message || "An unexpected error occurred" })
4461
+ ] }),
4462
+ process.env.NODE_ENV === "development" && this.state.errorInfo && /* @__PURE__ */ jsxs("details", { className: "mb-6 p-4 bg-gray-100 dark:bg-gray-900 rounded text-xs", children: [
4463
+ /* @__PURE__ */ jsx("summary", { className: "cursor-pointer font-semibold text-gray-700 dark:text-gray-300 mb-2", children: "Error Details (Development Only)" }),
4464
+ /* @__PURE__ */ jsxs("pre", { className: "overflow-auto text-gray-600 dark:text-gray-400 whitespace-pre-wrap", children: [
4465
+ this.state.error?.stack,
4466
+ "\n\nComponent Stack:",
4467
+ this.state.errorInfo.componentStack
4468
+ ] })
4469
+ ] }),
4470
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-3 justify-center", children: [
4471
+ /* @__PURE__ */ jsx(
4472
+ "button",
4473
+ {
4474
+ onClick: this.handleReset,
4475
+ className: "px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors",
4476
+ children: "Try Again"
4477
+ }
4478
+ ),
4479
+ /* @__PURE__ */ jsx(
4480
+ "button",
4481
+ {
4482
+ onClick: this.handleClose,
4483
+ className: "px-6 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-lg font-medium transition-colors",
4484
+ children: "Close Player"
4485
+ }
4486
+ )
4487
+ ] }),
4488
+ /* @__PURE__ */ jsx("p", { className: "text-center text-sm text-gray-500 dark:text-gray-400 mt-6", children: "If this problem persists, try refreshing the page or contact support." })
4489
+ ] }) });
4490
+ }
4491
+ return this.props.children;
4492
+ }
4493
+ };
4494
+ var overlayStyles = {
4495
+ modal: {
4496
+ backdrop: "bg-black/70 backdrop-blur-xl p-2 sm:p-4",
4497
+ container: "max-w-7xl max-h-[95vh] sm:max-h-[90vh]"
4498
+ },
4499
+ fullscreen: {
4500
+ backdrop: "bg-black",
4501
+ container: "h-full"
4502
+ }
4503
+ };
4504
+ function DialtribeOverlay({
4505
+ isOpen,
4506
+ onClose,
4507
+ mode = "modal",
4508
+ children,
4509
+ ariaLabel = "Dialog",
4510
+ showCloseButton = true,
4511
+ closeOnBackdropClick = true,
4512
+ closeOnEsc = true
4513
+ }) {
4514
+ const closeButtonRef = useRef(null);
4515
+ const previousActiveElement = useRef(null);
4516
+ useEffect(() => {
4517
+ if (!isOpen) return;
4518
+ previousActiveElement.current = document.activeElement;
4519
+ setTimeout(() => {
4520
+ closeButtonRef.current?.focus();
4521
+ }, 100);
4522
+ return () => {
4523
+ if (previousActiveElement.current) {
4524
+ previousActiveElement.current.focus();
4525
+ }
4526
+ };
4527
+ }, [isOpen]);
4528
+ useEffect(() => {
4529
+ if (!isOpen || !closeOnEsc) return;
4530
+ const handleKeyDown = (e) => {
4531
+ if (e.key === "Escape") {
4532
+ onClose();
4533
+ }
4534
+ };
4535
+ document.addEventListener("keydown", handleKeyDown);
4536
+ return () => document.removeEventListener("keydown", handleKeyDown);
4537
+ }, [isOpen, onClose, closeOnEsc]);
4538
+ if (!isOpen) {
4539
+ return null;
4540
+ }
4541
+ const handleBackdropClick = (e) => {
4542
+ if (closeOnBackdropClick && e.target === e.currentTarget) {
4543
+ onClose();
4544
+ }
4545
+ };
4546
+ const styles = overlayStyles[mode];
4547
+ return /* @__PURE__ */ jsx(
4548
+ "div",
4549
+ {
4550
+ className: `fixed inset-0 flex items-center justify-center z-50 ${styles.backdrop}`,
4551
+ onClick: handleBackdropClick,
4552
+ role: "dialog",
4553
+ "aria-modal": "true",
4554
+ "aria-label": ariaLabel,
4555
+ children: /* @__PURE__ */ jsxs("div", { className: `relative w-full overflow-hidden ${styles.container}`, children: [
4556
+ showCloseButton && /* @__PURE__ */ jsx(
4557
+ "button",
4558
+ {
4559
+ ref: closeButtonRef,
4560
+ onClick: onClose,
4561
+ className: "absolute top-2 right-2 sm:top-4 sm:right-4 z-10 text-gray-400 hover:text-white text-2xl leading-none transition-colors w-8 h-8 flex items-center justify-center bg-black/50 rounded-full",
4562
+ title: "Close (ESC)",
4563
+ "aria-label": "Close",
4564
+ children: "\xD7"
4565
+ }
4566
+ ),
4567
+ children
4568
+ ] })
4569
+ }
4570
+ );
4571
+ }
4572
+
4573
+ // src/utils/dialtribe-popup.ts
4574
+ function calculatePopupDimensions() {
4575
+ const screenWidth = window.screen.width;
4576
+ const screenHeight = window.screen.height;
4577
+ const screenAspectRatio = screenWidth / screenHeight;
4578
+ let width;
4579
+ let height;
4580
+ if (screenAspectRatio > 1.2) {
4581
+ height = Math.min(720, Math.floor(screenHeight * 0.85));
4582
+ width = Math.floor(height * (16 / 9));
4583
+ } else {
4584
+ width = Math.min(720, Math.floor(screenWidth * 0.9));
4585
+ height = Math.floor(width * (16 / 9));
4586
+ }
4587
+ const left = Math.floor((screenWidth - width) / 2);
4588
+ const top = Math.floor((screenHeight - height) / 2);
4589
+ return { width, height, left, top };
4590
+ }
4591
+ function openDialtribeStreamerPopup(options) {
4592
+ const {
4593
+ sessionToken,
4594
+ streamKey,
4595
+ streamerUrl,
4596
+ appId,
4597
+ additionalParams
4598
+ } = options;
4599
+ const { width, height, left, top } = calculatePopupDimensions();
4600
+ const params = new URLSearchParams();
4601
+ if (additionalParams) {
4602
+ Object.entries(additionalParams).forEach(([key, value]) => {
4603
+ params.append(key, value);
4604
+ });
4605
+ }
4606
+ const url = `${streamerUrl}${params.toString() ? `?${params.toString()}` : ""}`;
4607
+ const popup = window.open(
4608
+ url,
4609
+ "_blank",
4610
+ `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
4611
+ );
4612
+ if (!popup) {
4613
+ console.error("Failed to open popup window - popup may be blocked");
4614
+ return null;
4615
+ }
4616
+ const sendMessage = () => {
4617
+ try {
4618
+ popup.postMessage(
4619
+ {
4620
+ type: "STREAM_KEY",
4621
+ sessionToken,
4622
+ streamKey,
4623
+ appId
4624
+ },
4625
+ window.location.origin
4626
+ );
4627
+ } catch (error) {
4628
+ console.error("Failed to send credentials to popup:", error);
4629
+ }
4630
+ };
4631
+ sendMessage();
4632
+ setTimeout(sendMessage, 100);
4633
+ setTimeout(sendMessage, 500);
4634
+ return popup;
4635
+ }
4636
+ var openBroadcastPopup = openDialtribeStreamerPopup;
4637
+ function useDialtribeStreamerPopup() {
4638
+ const [sessionToken, setSessionToken] = useState(null);
4639
+ const [streamKey, setStreamKey] = useState(null);
4640
+ const [apiBaseUrl, setApiBaseUrl] = useState("");
4641
+ const receivedDataRef = useRef(false);
4642
+ useEffect(() => {
4643
+ const handleMessage = (event) => {
4644
+ if (event.data?.type !== "STREAM_KEY") return;
4645
+ const { sessionToken: token, streamKey: key, apiBaseUrl: url } = event.data;
4646
+ if (token && key) {
4647
+ receivedDataRef.current = true;
4648
+ setSessionToken(token);
4649
+ setStreamKey(key);
4650
+ if (url) {
4651
+ setApiBaseUrl(url);
4652
+ }
4653
+ } else if (key) {
4654
+ receivedDataRef.current = true;
4655
+ setStreamKey(key);
4656
+ }
4657
+ };
4658
+ window.addEventListener("message", handleMessage);
4659
+ const requestCredentials = () => {
4660
+ if (window.opener && !receivedDataRef.current) {
4661
+ window.opener.postMessage({ type: "POPUP_READY" }, "*");
4662
+ }
4663
+ };
4664
+ requestCredentials();
4665
+ const pollInterval = setInterval(() => {
4666
+ if (!receivedDataRef.current) {
4667
+ requestCredentials();
4668
+ } else {
4669
+ clearInterval(pollInterval);
4670
+ }
4671
+ }, 200);
4672
+ const timeout = setTimeout(() => {
4673
+ clearInterval(pollInterval);
4674
+ }, 1e4);
4675
+ return () => {
4676
+ window.removeEventListener("message", handleMessage);
4677
+ clearInterval(pollInterval);
4678
+ clearTimeout(timeout);
4679
+ };
4680
+ }, []);
4681
+ return {
4682
+ sessionToken,
4683
+ streamKey,
4684
+ apiBaseUrl,
4685
+ setStreamKey,
4686
+ isReady: receivedDataRef.current
4687
+ };
4688
+ }
4689
+ function useDialtribeStreamerLauncher(options) {
4690
+ const {
4691
+ sessionToken,
4692
+ streamKey,
4693
+ streamerUrl,
4694
+ apiBaseUrl,
4695
+ fallback = "fullscreen",
4696
+ onPopupBlocked,
4697
+ onDone,
4698
+ onStreamKeyChange
4699
+ } = options;
4700
+ const [showFallback, setShowFallback] = useState(false);
4701
+ const [wasBlocked, setWasBlocked] = useState(false);
4702
+ const popupRef = useRef(null);
4703
+ const sessionTokenRef = useRef(sessionToken);
4704
+ const streamKeyRef = useRef(streamKey);
4705
+ const apiBaseUrlRef = useRef(apiBaseUrl);
4706
+ useEffect(() => {
4707
+ sessionTokenRef.current = sessionToken;
4708
+ }, [sessionToken]);
4709
+ useEffect(() => {
4710
+ streamKeyRef.current = streamKey;
4711
+ }, [streamKey]);
4712
+ useEffect(() => {
4713
+ apiBaseUrlRef.current = apiBaseUrl;
4714
+ }, [apiBaseUrl]);
4715
+ useEffect(() => {
4716
+ const handleMessage = (event) => {
4717
+ if (event.data?.type === "POPUP_READY" && popupRef.current) {
4718
+ popupRef.current.postMessage(
4719
+ {
4720
+ type: "STREAM_KEY",
4721
+ sessionToken: sessionTokenRef.current,
4722
+ streamKey: streamKeyRef.current,
4723
+ apiBaseUrl: apiBaseUrlRef.current
4724
+ },
4725
+ "*"
4726
+ );
4727
+ }
4728
+ };
4729
+ window.addEventListener("message", handleMessage);
4730
+ return () => window.removeEventListener("message", handleMessage);
4731
+ }, []);
4732
+ const launch = useCallback(() => {
4733
+ if (!sessionToken) {
4734
+ console.warn("Cannot launch streamer: no session token");
4735
+ return;
4736
+ }
4737
+ setWasBlocked(false);
4738
+ const popup = openDialtribeStreamerPopup({
4739
+ sessionToken,
4740
+ streamKey,
4741
+ streamerUrl
4742
+ });
4743
+ if (popup) {
4744
+ popupRef.current = popup;
4745
+ return;
4746
+ }
4747
+ setWasBlocked(true);
4748
+ onPopupBlocked?.();
4749
+ switch (fallback) {
4750
+ case "fullscreen":
4751
+ setShowFallback(true);
4752
+ break;
4753
+ case "newTab":
4754
+ window.open(streamerUrl, "_blank");
4755
+ break;
4756
+ }
4757
+ }, [sessionToken, streamKey, streamerUrl, fallback, onPopupBlocked]);
4758
+ const closeFallback = useCallback(() => {
4759
+ setShowFallback(false);
4760
+ onDone?.();
4761
+ }, [onDone]);
4762
+ const Fallback = useCallback(() => {
4763
+ if (fallback !== "fullscreen" || !showFallback) {
4764
+ return null;
4765
+ }
4766
+ if (typeof document === "undefined") {
4767
+ return null;
4768
+ }
4769
+ const streamerElement = React2.createElement(DialtribeStreamer, {
4770
+ sessionToken: sessionToken || void 0,
4771
+ streamKey: streamKey || void 0,
4772
+ apiBaseUrl,
4773
+ onDone: closeFallback,
4774
+ onStreamKeyChange
4775
+ });
4776
+ const overlayElement = React2.createElement(
4777
+ DialtribeOverlay,
4778
+ {
4779
+ mode: "fullscreen",
4780
+ isOpen: true,
4781
+ onClose: closeFallback,
4782
+ children: streamerElement
4783
+ }
4784
+ );
4785
+ return createPortal(overlayElement, document.body);
4786
+ }, [fallback, showFallback, closeFallback, sessionToken, streamKey, apiBaseUrl, onStreamKeyChange]);
4787
+ return {
4788
+ launch,
4789
+ Fallback,
4790
+ showFallback,
4791
+ closeFallback,
4792
+ popupRef: popupRef.current,
4793
+ wasBlocked
4794
+ };
4795
+ }
4796
+
4797
+ export { AudioWaveform, CDN_DOMAIN, DEFAULT_ENCODER_SERVER_URL, DIALTRIBE_API_BASE, DialtribeClient, DialtribeOverlay, DialtribePlayer, DialtribePlayerErrorBoundary, DialtribeProvider, DialtribeStreamer, ENDPOINTS, HTTP_STATUS, LoadingSpinner, StreamKeyDisplay, StreamKeyInput, StreamingControls, StreamingPreview, WebSocketStreamer, buildBroadcastCdnUrl, buildBroadcastS3KeyPrefix, calculatePopupDimensions, checkBrowserCompatibility, formatTime, getMediaConstraints, getMediaRecorderOptions, openBroadcastPopup, openDialtribeStreamerPopup, useDialtribe, useDialtribeOptional, useDialtribeStreamerLauncher, useDialtribeStreamerPopup };
4798
+ //# sourceMappingURL=dialtribe-streamer.mjs.map
4799
+ //# sourceMappingURL=dialtribe-streamer.mjs.map