@livo-tv/sdk 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { createContext, useContext, useState, useRef, useEffect, useCallback, useMemo } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
- import { Loader2, MicOff, Mic, Video, VideoOff, X, EyeOff, Eye, Users, MonitorUp, MessageSquare, Share2, Link2, Maximize, PictureInPicture2, Settings, PhoneOff, Undo2, Volume2, XIcon, Pin, MessageSquareOff, MoreVertical } from 'lucide-react';
4
+ import { Loader2, Share2, MicOff, Mic, Video, VideoOff, Settings, X, Link2, EyeOff, Eye, Users, MonitorUp, MessageSquare, Maximize, PictureInPicture2, PhoneOff, Undo2, Volume2, XIcon, Pin, MessageSquareOff, MoreVertical } from 'lucide-react';
5
5
  import { toast } from 'sonner';
6
6
  import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
7
7
  import { clsx } from 'clsx';
@@ -9,11 +9,13 @@ import { twMerge } from 'tailwind-merge';
9
9
  import { Button as Button$1 } from '@base-ui/react/button';
10
10
  import { cva } from 'class-variance-authority';
11
11
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
12
- import { Menu } from '@base-ui/react/menu';
13
12
  import { Tooltip as Tooltip$1 } from '@base-ui/react/tooltip';
14
13
  import * as DialogPrimitive from '@radix-ui/react-dialog';
15
14
  import { Input as Input$1 } from '@base-ui/react/input';
16
15
  import * as LabelPrimitive from '@radix-ui/react-label';
16
+ import { mergeProps } from '@base-ui/react/merge-props';
17
+ import { useRender } from '@base-ui/react/use-render';
18
+ import { Menu } from '@base-ui/react/menu';
17
19
 
18
20
  // src/public-api.ts
19
21
  function defaultApiUrl() {
@@ -752,6 +754,58 @@ function postLivoEvent(type, payload) {
752
754
  window.parent.postMessage({ source: "livo", type, payload }, "*");
753
755
  }
754
756
 
757
+ // src/studio/lib/embed-urls.ts
758
+ function playerWatchUrl(streamId, origin = "https://player.livo.tv") {
759
+ return `${origin.replace(/\/$/, "")}/${streamId}`;
760
+ }
761
+
762
+ // src/studio/lib/clipboard.ts
763
+ async function copyText(value, clipboard = globalThis.navigator?.clipboard ?? null) {
764
+ if (!clipboard) return false;
765
+ try {
766
+ await clipboard.writeText(value);
767
+ return true;
768
+ } catch {
769
+ return false;
770
+ }
771
+ }
772
+
773
+ // src/studio/lib/share.ts
774
+ function isAbort(error) {
775
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
776
+ }
777
+ function resolveShareApi(share = typeof navigator === "undefined" ? null : typeof navigator.share === "function" ? navigator : null) {
778
+ return share ?? null;
779
+ }
780
+ function canNativeShare(data, share = resolveShareApi()) {
781
+ if (!share) return false;
782
+ if (typeof share.canShare !== "function") return true;
783
+ try {
784
+ return share.canShare(data);
785
+ } catch {
786
+ return false;
787
+ }
788
+ }
789
+ async function shareOrCopyLink(options, deps) {
790
+ const data = {
791
+ url: options.url,
792
+ ...options.title ? { title: options.title } : {},
793
+ ...options.text ? { text: options.text } : {}
794
+ };
795
+ const share = resolveShareApi(deps?.share);
796
+ if (canNativeShare(data, share) && share) {
797
+ try {
798
+ await share.share(data);
799
+ return "shared";
800
+ } catch (error) {
801
+ if (isAbort(error)) return "cancelled";
802
+ }
803
+ }
804
+ const copy = copyText;
805
+ const ok = await copy(options.url);
806
+ return ok ? "copied" : "failed";
807
+ }
808
+
755
809
  // src/studio/lib/status.ts
756
810
  function canPublish(status) {
757
811
  return status === "preview";
@@ -1008,6 +1062,93 @@ function StudioWindowGate({
1008
1062
  }
1009
1063
  return /* @__PURE__ */ jsx(StudioWindowGateContext.Provider, { value: { reportDuplicate }, children: /* @__PURE__ */ jsx("div", { children }, generation) });
1010
1064
  }
1065
+
1066
+ // src/studio/lib/devices.ts
1067
+ var STUDIO_DEVICE_CHANGE_DEBOUNCE_MS = 250;
1068
+ var STORAGE_KEYS = {
1069
+ audio: "livo-studio-device-audio",
1070
+ video: "livo-studio-device-video",
1071
+ speaker: "livo-studio-device-speaker"
1072
+ };
1073
+ var DEVICE_KINDS = ["audio", "video", "speaker"];
1074
+ function readStudioDeviceSelection(storage = defaultStorage()) {
1075
+ return {
1076
+ audio: storage?.getItem(STORAGE_KEYS.audio)?.trim() ?? "",
1077
+ video: storage?.getItem(STORAGE_KEYS.video)?.trim() ?? "",
1078
+ speaker: storage?.getItem(STORAGE_KEYS.speaker)?.trim() ?? ""
1079
+ };
1080
+ }
1081
+ function persistStudioDeviceSelection(kind, deviceId, storage = defaultStorage()) {
1082
+ storage?.setItem(STORAGE_KEYS[kind], deviceId.trim());
1083
+ }
1084
+ function mapMediaDevices(devices) {
1085
+ const next = { audio: [], video: [], speaker: [] };
1086
+ for (const device of devices) {
1087
+ const kind = mediaKindToStudio(device.kind);
1088
+ if (!kind || !device.deviceId) continue;
1089
+ next[kind].push({
1090
+ deviceId: device.deviceId,
1091
+ label: device.label.trim() || `Device ${next[kind].length + 1}`
1092
+ });
1093
+ }
1094
+ return next;
1095
+ }
1096
+ function resolveDeviceSelection(saved, devices) {
1097
+ const effective = {
1098
+ audio: "",
1099
+ video: "",
1100
+ speaker: ""
1101
+ };
1102
+ const missing = {
1103
+ audio: false,
1104
+ video: false,
1105
+ speaker: false
1106
+ };
1107
+ for (const kind of DEVICE_KINDS) {
1108
+ const id = saved[kind];
1109
+ if (!id) continue;
1110
+ if (devices[kind].some((option) => option.deviceId === id)) {
1111
+ effective[kind] = id;
1112
+ continue;
1113
+ }
1114
+ missing[kind] = true;
1115
+ }
1116
+ return { effective, missing };
1117
+ }
1118
+ function displayDeviceId(value, options) {
1119
+ if (options.some((option) => option.deviceId === value)) return value;
1120
+ return options[0]?.deviceId ?? "";
1121
+ }
1122
+ async function applyStudioDeviceSelection(setDevice, saved, listed) {
1123
+ const { effective } = resolveDeviceSelection(saved, listed);
1124
+ await Promise.allSettled(
1125
+ DEVICE_KINDS.filter((kind) => effective[kind]).map(
1126
+ (kind) => setDevice(kind, effective[kind])
1127
+ )
1128
+ );
1129
+ }
1130
+ function mediaKindToStudio(kind) {
1131
+ switch (kind) {
1132
+ case "audioinput":
1133
+ return "audio";
1134
+ case "videoinput":
1135
+ return "video";
1136
+ case "audiooutput":
1137
+ return "speaker";
1138
+ default: {
1139
+ const _exhaustive = kind;
1140
+ return _exhaustive;
1141
+ }
1142
+ }
1143
+ }
1144
+ function defaultStorage() {
1145
+ if (typeof window === "undefined") return null;
1146
+ try {
1147
+ return window.localStorage;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1011
1152
  function AudioWaveform({
1012
1153
  track,
1013
1154
  reducedMotion,
@@ -1081,1695 +1222,1932 @@ function AudioWaveform({
1081
1222
  }
1082
1223
  );
1083
1224
  }
1084
- function StudioDevicePreflight({
1085
- reducedMotion,
1086
- cameraLabel,
1087
- micLabel,
1088
- cameraOn = true,
1089
- muted = false
1225
+ function TooltipProvider({
1226
+ delay = 0,
1227
+ ...props
1090
1228
  }) {
1091
- const videoRef = useRef(null);
1092
- const streamRef = useRef(null);
1093
- const [audioTrack, setAudioTrack] = useState(null);
1094
- const [cameraOk, setCameraOk] = useState(false);
1095
- const [micOk, setMicOk] = useState(false);
1096
- useEffect(() => {
1097
- let cancelled = false;
1098
- void navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then((next) => {
1099
- if (cancelled) {
1100
- for (const track of next.getTracks()) track.stop();
1101
- return;
1102
- }
1103
- streamRef.current = next;
1104
- const video = next.getVideoTracks()[0] ?? null;
1105
- const audio = next.getAudioTracks()[0] ?? null;
1106
- setCameraOk(Boolean(video));
1107
- setMicOk(Boolean(audio));
1108
- setAudioTrack(audio);
1109
- if (videoRef.current && video) {
1110
- videoRef.current.srcObject = next;
1111
- }
1112
- }).catch(() => {
1113
- if (!cancelled) {
1114
- setCameraOk(false);
1115
- setMicOk(false);
1116
- }
1117
- });
1118
- return () => {
1119
- cancelled = true;
1120
- if (streamRef.current) {
1121
- for (const track of streamRef.current.getTracks()) track.stop();
1122
- streamRef.current = null;
1123
- }
1124
- };
1125
- }, []);
1126
- useEffect(() => {
1127
- const stream = streamRef.current;
1128
- if (!stream) return;
1129
- for (const track of stream.getVideoTracks()) track.enabled = cameraOn;
1130
- for (const track of stream.getAudioTracks()) track.enabled = !muted;
1131
- }, [cameraOn, muted]);
1132
- return /* @__PURE__ */ jsxs("div", { className: "flex w-full flex-col items-center gap-3", children: [
1133
- /* @__PURE__ */ jsx(
1134
- "video",
1135
- {
1136
- ref: videoRef,
1137
- autoPlay: true,
1138
- muted: true,
1139
- playsInline: true,
1140
- className: "bg-muted aspect-video w-full rounded-xl object-cover"
1141
- }
1142
- ),
1229
+ return /* @__PURE__ */ jsx(
1230
+ Tooltip$1.Provider,
1231
+ {
1232
+ "data-slot": "tooltip-provider",
1233
+ delay,
1234
+ ...props
1235
+ }
1236
+ );
1237
+ }
1238
+ function Tooltip({ ...props }) {
1239
+ return /* @__PURE__ */ jsx(Tooltip$1.Root, { "data-slot": "tooltip", ...props });
1240
+ }
1241
+ function TooltipTrigger({ ...props }) {
1242
+ return /* @__PURE__ */ jsx(Tooltip$1.Trigger, { "data-slot": "tooltip-trigger", ...props });
1243
+ }
1244
+ function TooltipContent({
1245
+ className,
1246
+ side = "top",
1247
+ sideOffset = 4,
1248
+ align = "center",
1249
+ alignOffset = 0,
1250
+ children,
1251
+ ...props
1252
+ }) {
1253
+ return /* @__PURE__ */ jsx(Tooltip$1.Portal, { children: /* @__PURE__ */ jsx(
1254
+ Tooltip$1.Positioner,
1255
+ {
1256
+ align,
1257
+ alignOffset,
1258
+ side,
1259
+ sideOffset,
1260
+ className: "isolate z-50",
1261
+ children: /* @__PURE__ */ jsxs(
1262
+ Tooltip$1.Popup,
1263
+ {
1264
+ "data-slot": "tooltip-content",
1265
+ className: cn(
1266
+ "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
1267
+ className
1268
+ ),
1269
+ ...props,
1270
+ children: [
1271
+ children,
1272
+ /* @__PURE__ */ jsx(Tooltip$1.Arrow, { className: "z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" })
1273
+ ]
1274
+ }
1275
+ )
1276
+ }
1277
+ ) });
1278
+ }
1279
+ function ControlIcon({
1280
+ label,
1281
+ pressed = false,
1282
+ disabled = false,
1283
+ pending = false,
1284
+ tone = "default",
1285
+ badge,
1286
+ onClick,
1287
+ children
1288
+ }) {
1289
+ return /* @__PURE__ */ jsxs(Tooltip, { children: [
1143
1290
  /* @__PURE__ */ jsx(
1144
- AudioWaveform,
1291
+ TooltipTrigger,
1145
1292
  {
1146
- track: muted ? null : audioTrack,
1147
- reducedMotion
1293
+ render: /* @__PURE__ */ jsx(
1294
+ Button,
1295
+ {
1296
+ type: "button",
1297
+ size: "icon-lg",
1298
+ variant: tone === "danger" ? "destructive" : pressed ? "default" : "secondary",
1299
+ className: "overflow-visible",
1300
+ "aria-label": label,
1301
+ "aria-pressed": pressed,
1302
+ disabled,
1303
+ onClick
1304
+ }
1305
+ ),
1306
+ children: /* @__PURE__ */ jsxs("span", { className: "relative overflow-visible", children: [
1307
+ pending ? /* @__PURE__ */ jsx(Loader2, { className: "animate-spin motion-reduce:animate-none" }) : children,
1308
+ badge != null && badge > 0 ? /* @__PURE__ */ jsx("span", { className: "bg-primary text-primary-foreground absolute -top-2 -right-2 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.65rem]", children: badge }) : null
1309
+ ] })
1148
1310
  }
1149
1311
  ),
1150
- /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground text-sm", role: "status", children: [
1151
- cameraLabel,
1152
- ": ",
1153
- cameraOk && cameraOn ? "OK" : "\u2014",
1154
- " \xB7 ",
1155
- micLabel,
1156
- ":",
1157
- " ",
1158
- micOk && !muted ? "OK" : "\u2014"
1159
- ] })
1312
+ /* @__PURE__ */ jsx(TooltipContent, { children: label })
1160
1313
  ] });
1161
1314
  }
1162
- var badgeVariants = cva(
1163
- "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
1164
- {
1165
- variants: {
1166
- variant: {
1167
- default: "border-transparent bg-primary text-primary-foreground",
1168
- secondary: "border-transparent bg-secondary text-secondary-foreground",
1169
- destructive: "border-transparent bg-destructive text-destructive-foreground",
1170
- outline: "text-foreground",
1171
- success: "border-transparent bg-chart-5/15 text-chart-5 dark:bg-chart-5/25",
1172
- warning: "border-transparent bg-chart-4/15 text-chart-4 dark:bg-chart-4/25"
1173
- }
1174
- },
1175
- defaultVariants: { variant: "default" }
1176
- }
1177
- );
1178
- function Badge({ className, variant, ...props }) {
1179
- return /* @__PURE__ */ jsx("span", { className: cn(badgeVariants({ variant }), className), ...props });
1315
+ function Dialog({ ...props }) {
1316
+ return /* @__PURE__ */ jsx(DialogPrimitive.Root, { "data-slot": "dialog", ...props });
1180
1317
  }
1181
- function StudioPipPanel({
1182
- meeting,
1183
- labels,
1184
- cameraOn,
1185
- muted,
1186
- screenOn,
1187
- showMediaControls = true,
1188
- showScreenControl = false,
1189
- canShareFreely = false,
1190
- screenAvailable = true,
1191
- screenPending = false,
1192
- shareGrant = "idle",
1193
- onAir,
1194
- elapsed,
1195
- unread = 0,
1196
- onToggleMute,
1197
- onToggleCamera,
1198
- onToggleScreen,
1199
- onAskToShare,
1200
- onCancelShareRequest,
1201
- onBack
1318
+ function DialogPortal({
1319
+ ...props
1202
1320
  }) {
1203
- const tiles = meeting.participants();
1204
- const spotlight = resolveStudioPipSpotlight(tiles, meeting.activeSpeakerId());
1205
- const selfView = resolveStudioPipSelfView(tiles, spotlight);
1206
- const speaking = spotlight !== null && spotlight.participantId === meeting.activeSpeakerId();
1207
- const backLabel = unread > 0 ? `${labels.backToStudio} (${unread})` : labels.backToStudio;
1208
- return /* @__PURE__ */ jsxs(
1209
- "div",
1321
+ return /* @__PURE__ */ jsx(DialogPrimitive.Portal, { "data-slot": "dialog-portal", ...props });
1322
+ }
1323
+ function DialogOverlay({
1324
+ className,
1325
+ ...props
1326
+ }) {
1327
+ return /* @__PURE__ */ jsx(
1328
+ DialogPrimitive.Overlay,
1210
1329
  {
1211
- role: "region",
1212
- "aria-label": labels.pipTitle,
1213
- className: "bg-background flex h-full min-h-0 flex-col gap-2 p-2 text-foreground",
1214
- children: [
1215
- /* @__PURE__ */ jsxs(
1216
- "div",
1217
- {
1218
- className: cn(
1219
- "bg-muted relative min-h-0 flex-1 overflow-hidden rounded-xl",
1220
- speaking && "ring-2 ring-primary"
1221
- ),
1222
- children: [
1223
- /* @__PURE__ */ jsx(
1224
- PipSpotlight,
1225
- {
1226
- meeting,
1227
- spotlight,
1228
- cameraOffLabel: labels.cameraOff
1229
- }
1230
- ),
1231
- onAir ? /* @__PURE__ */ jsxs("div", { className: "absolute top-2 left-2 flex items-center gap-2", children: [
1232
- /* @__PURE__ */ jsx("span", { className: "rounded-full border bg-background/80 px-2 py-0.5 text-xs backdrop-blur-sm", children: labels.live }),
1233
- elapsed ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: elapsed }) : null
1234
- ] }) : null,
1235
- selfView ? /* @__PURE__ */ jsx(
1236
- "div",
1237
- {
1238
- "data-testid": "pip-self-view",
1239
- className: "absolute right-2 bottom-2 aspect-video w-[30%] overflow-hidden rounded-lg ring-1 ring-white/40",
1240
- children: /* @__PURE__ */ jsx(PipTrackVideo, { track: meeting.localVideoTrack(), fit: "cover" })
1241
- }
1242
- ) : null
1243
- ]
1244
- }
1245
- ),
1246
- /* @__PURE__ */ jsxs("footer", { className: "flex flex-wrap items-center justify-center gap-2", children: [
1247
- showMediaControls ? /* @__PURE__ */ jsxs(Fragment, { children: [
1248
- /* @__PURE__ */ jsx(
1249
- PipControl,
1250
- {
1251
- label: muted ? labels.unmute : labels.mute,
1252
- off: muted,
1253
- onClick: onToggleMute,
1254
- children: muted ? /* @__PURE__ */ jsx(MicOff, {}) : /* @__PURE__ */ jsx(Mic, {})
1255
- }
1256
- ),
1257
- /* @__PURE__ */ jsx(
1258
- PipControl,
1259
- {
1260
- label: labels.camera,
1261
- off: !cameraOn,
1262
- onClick: onToggleCamera,
1263
- children: cameraOn ? /* @__PURE__ */ jsx(Video, {}) : /* @__PURE__ */ jsx(VideoOff, {})
1264
- }
1265
- )
1266
- ] }) : null,
1267
- showMediaControls && (showScreenControl || screenOn) ? pipScreenControls({
1268
- labels,
1269
- screenOn,
1270
- canShareFreely,
1271
- screenAvailable,
1272
- screenPending,
1273
- shareGrant,
1274
- onToggleScreen,
1275
- onAskToShare,
1276
- onCancelShareRequest
1277
- }) : null,
1278
- /* @__PURE__ */ jsx(PipControl, { label: backLabel, onClick: onBack, children: /* @__PURE__ */ jsxs("span", { className: "relative", children: [
1279
- /* @__PURE__ */ jsx(Undo2, {}),
1280
- unread > 0 ? /* @__PURE__ */ jsx(Badge, { className: "absolute -top-2 -right-2 h-4 min-w-4 px-1 text-[0.65rem]", children: unread }) : null
1281
- ] }) })
1282
- ] })
1283
- ]
1330
+ "data-slot": "dialog-overlay",
1331
+ className: cn(
1332
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
1333
+ className
1334
+ ),
1335
+ ...props
1284
1336
  }
1285
1337
  );
1286
1338
  }
1287
- function PipSpotlight({
1288
- meeting,
1289
- spotlight,
1290
- cameraOffLabel
1339
+ function DialogContent({
1340
+ className,
1341
+ children,
1342
+ showCloseButton = true,
1343
+ ...props
1291
1344
  }) {
1292
- if (!spotlight || spotlight.kind === "idle") {
1293
- return /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2", children: [
1294
- spotlight?.picture ? /* @__PURE__ */ jsx(
1295
- "img",
1296
- {
1297
- src: spotlight.picture,
1298
- alt: "",
1299
- className: "size-14 rounded-full object-cover"
1300
- }
1301
- ) : /* @__PURE__ */ jsx("div", { className: "bg-background flex size-14 items-center justify-center rounded-full text-lg", children: (spotlight?.name ?? "?").slice(0, 1).toUpperCase() }),
1302
- /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: spotlight?.name ?? cameraOffLabel })
1303
- ] });
1304
- }
1305
- const track = spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId);
1306
- return /* @__PURE__ */ jsxs(Fragment, { children: [
1307
- /* @__PURE__ */ jsx(
1308
- PipTrackVideo,
1345
+ return /* @__PURE__ */ jsxs(DialogPortal, { children: [
1346
+ /* @__PURE__ */ jsx(DialogOverlay, {}),
1347
+ /* @__PURE__ */ jsxs(
1348
+ DialogPrimitive.Content,
1309
1349
  {
1310
- track,
1311
- fit: spotlight.kind === "screen" ? "contain" : "cover"
1350
+ "data-slot": "dialog-content",
1351
+ className: cn(
1352
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
1353
+ className
1354
+ ),
1355
+ ...props,
1356
+ children: [
1357
+ children,
1358
+ showCloseButton ? /* @__PURE__ */ jsxs(DialogPrimitive.Close, { className: "ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none", children: [
1359
+ /* @__PURE__ */ jsx(XIcon, { className: "size-4" }),
1360
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Close" })
1361
+ ] }) : null
1362
+ ]
1312
1363
  }
1313
- ),
1314
- /* @__PURE__ */ jsx("p", { className: "absolute inset-x-0 bottom-0 bg-linear-to-t from-black/70 to-transparent p-2 text-xs font-medium text-white", children: spotlight.name })
1364
+ )
1315
1365
  ] });
1316
1366
  }
1317
- function PipTrackVideo({
1318
- track,
1319
- fit
1320
- }) {
1321
- const ref = useRef(null);
1322
- useEffect(() => {
1323
- const el = ref.current;
1324
- if (!el) return;
1325
- if (!track) {
1326
- el.srcObject = null;
1327
- return;
1328
- }
1329
- el.srcObject = new MediaStream([track]);
1330
- void el.play().catch(() => void 0);
1331
- return () => {
1332
- el.srcObject = null;
1333
- };
1334
- }, [track]);
1367
+ function DialogHeader({ className, ...props }) {
1335
1368
  return /* @__PURE__ */ jsx(
1336
- "video",
1369
+ "div",
1337
1370
  {
1338
- ref,
1339
- autoPlay: true,
1340
- playsInline: true,
1341
- muted: true,
1342
- className: cn(
1343
- "absolute inset-0 size-full",
1344
- fit === "contain" ? "object-contain" : "object-cover"
1345
- )
1371
+ "data-slot": "dialog-header",
1372
+ className: cn("flex flex-col gap-2 text-center sm:text-left", className),
1373
+ ...props
1346
1374
  }
1347
1375
  );
1348
1376
  }
1349
- function pipScreenControls({
1350
- labels,
1351
- screenOn,
1352
- canShareFreely,
1353
- screenAvailable,
1354
- screenPending,
1355
- shareGrant,
1356
- onToggleScreen,
1357
- onAskToShare,
1358
- onCancelShareRequest
1359
- }) {
1360
- if (canShareFreely || screenOn) {
1361
- return /* @__PURE__ */ jsx(
1362
- PipControl,
1363
- {
1364
- label: screenOn ? labels.stopScreen : labels.screen,
1365
- off: screenOn,
1366
- disabled: screenPending || !screenAvailable && !screenOn,
1367
- onClick: onToggleScreen,
1368
- children: /* @__PURE__ */ jsx(MonitorUp, {})
1369
- }
1370
- );
1371
- }
1372
- if (shareGrant === "requested") {
1373
- return /* @__PURE__ */ jsxs(Fragment, { children: [
1374
- /* @__PURE__ */ jsx(
1375
- PipControl,
1376
- {
1377
- label: labels.shareRequested,
1378
- off: true,
1379
- disabled: true,
1380
- onClick: () => void 0,
1381
- children: /* @__PURE__ */ jsx(MonitorUp, {})
1382
- }
1383
- ),
1384
- /* @__PURE__ */ jsx(
1385
- PipControl,
1386
- {
1387
- label: labels.cancelShareRequest,
1388
- onClick: () => onCancelShareRequest?.(),
1389
- children: /* @__PURE__ */ jsx(X, {})
1390
- }
1391
- )
1392
- ] });
1393
- }
1394
- return /* @__PURE__ */ jsx(PipControl, { label: labels.askToShare, onClick: () => onAskToShare?.(), children: /* @__PURE__ */ jsx(MonitorUp, {}) });
1395
- }
1396
- function PipControl({
1397
- label,
1398
- onClick,
1399
- off = false,
1400
- disabled = false,
1401
- children
1377
+ function DialogTitle({
1378
+ className,
1379
+ ...props
1402
1380
  }) {
1403
1381
  return /* @__PURE__ */ jsx(
1404
- Button,
1382
+ DialogPrimitive.Title,
1405
1383
  {
1406
- type: "button",
1407
- size: "icon-lg",
1408
- variant: off ? "destructive" : "secondary",
1409
- className: "size-10 rounded-full",
1410
- "aria-label": label,
1411
- disabled,
1412
- onClick,
1413
- children
1384
+ "data-slot": "dialog-title",
1385
+ className: cn("text-lg leading-none font-semibold", className),
1386
+ ...props
1414
1387
  }
1415
1388
  );
1416
1389
  }
1417
- function StudioRemoteAudio({
1418
- meeting,
1419
- participantId
1390
+ function DialogDescription({
1391
+ className,
1392
+ ...props
1420
1393
  }) {
1421
- const ref = useRef(null);
1422
- useEffect(() => {
1423
- meeting.attachAudio(participantId, ref.current);
1424
- return () => meeting.attachAudio(participantId, null);
1425
- }, [meeting, participantId]);
1426
- return /* @__PURE__ */ jsx("audio", { ref, autoPlay: true, className: "sr-only" });
1394
+ return /* @__PURE__ */ jsx(
1395
+ DialogPrimitive.Description,
1396
+ {
1397
+ "data-slot": "dialog-description",
1398
+ className: cn("text-muted-foreground text-sm", className),
1399
+ ...props
1400
+ }
1401
+ );
1427
1402
  }
1428
- function StudioChatPanel({
1429
- meeting,
1430
- selfId,
1431
- isModerator: isModerator2,
1432
- labels
1433
- }) {
1434
- const [tick, setTick] = useState(0);
1435
- const [draft, setDraft] = useState("");
1436
- const [editingId, setEditingId] = useState(null);
1437
- const listRef = useRef(null);
1438
- useEffect(
1439
- () => meeting.subscribeChat(() => setTick((n) => n + 1)),
1440
- [meeting]
1403
+ function Input({ className, type, ...props }) {
1404
+ return /* @__PURE__ */ jsx(
1405
+ Input$1,
1406
+ {
1407
+ type,
1408
+ "data-slot": "input",
1409
+ className: cn(
1410
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
1411
+ className
1412
+ ),
1413
+ ...props
1414
+ }
1441
1415
  );
1442
- const messages = useMemo(
1443
- () => meeting.chatMessages().filter((row) => !row.targetUserIds?.length),
1444
- [meeting, tick]
1416
+ }
1417
+ function Label({
1418
+ className,
1419
+ ...props
1420
+ }) {
1421
+ return /* @__PURE__ */ jsx(
1422
+ LabelPrimitive.Root,
1423
+ {
1424
+ "data-slot": "label",
1425
+ className: cn(
1426
+ "flex items-center gap-2 text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
1427
+ className
1428
+ ),
1429
+ ...props
1430
+ }
1445
1431
  );
1446
- const pinned = useMemo(() => meeting.pinnedChatMessages(), [meeting, tick]);
1447
- const limit = meeting.chatMaxTextLimit();
1432
+ }
1433
+ var EMPTY_DEVICES = {
1434
+ audio: [],
1435
+ video: [],
1436
+ speaker: []
1437
+ };
1438
+ function useMediaDevices(refreshKey = 0, enabled = true) {
1439
+ const [devices, setDevices] = useState(EMPTY_DEVICES);
1448
1440
  useEffect(() => {
1449
- const list = listRef.current;
1450
- if (!list) return;
1451
- list.scrollTop = list.scrollHeight;
1452
- }, [messages.length]);
1453
- async function onSend() {
1454
- const text = draft.trim();
1455
- if (!text) return;
1456
- setDraft("");
1457
- try {
1458
- if (editingId) {
1459
- await meeting.editChatMessage(editingId, text);
1460
- setEditingId(null);
1461
- return;
1441
+ if (!enabled) return;
1442
+ if (typeof navigator === "undefined" || !navigator.mediaDevices) {
1443
+ return;
1444
+ }
1445
+ let cancelled = false;
1446
+ let timer;
1447
+ const load = async () => {
1448
+ try {
1449
+ const list = await navigator.mediaDevices.enumerateDevices();
1450
+ if (!cancelled) setDevices(mapMediaDevices(list));
1451
+ } catch {
1452
+ if (!cancelled) setDevices(EMPTY_DEVICES);
1462
1453
  }
1463
- await meeting.sendChatMessage(text);
1464
- } catch (error) {
1465
- const message = error instanceof Error ? error.message.toLowerCase() : "";
1466
- toast.error(
1467
- message.includes("rate") ? labels.chatRateLimited : labels.chatError
1468
- );
1454
+ };
1455
+ const onChange = () => {
1456
+ window.clearTimeout(timer);
1457
+ timer = window.setTimeout(() => {
1458
+ void load();
1459
+ }, STUDIO_DEVICE_CHANGE_DEBOUNCE_MS);
1460
+ };
1461
+ void load();
1462
+ navigator.mediaDevices.addEventListener("devicechange", onChange);
1463
+ return () => {
1464
+ cancelled = true;
1465
+ window.clearTimeout(timer);
1466
+ navigator.mediaDevices.removeEventListener("devicechange", onChange);
1467
+ };
1468
+ }, [enabled, refreshKey]);
1469
+ return devices;
1470
+ }
1471
+
1472
+ // src/studio/lib/speaker-test.ts
1473
+ var TEST_MS = 400;
1474
+ var TEST_HZ = 440;
1475
+ async function playSpeakerTest(deviceId) {
1476
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
1477
+ if (!AudioCtx) {
1478
+ throw new Error("AudioContext unavailable");
1479
+ }
1480
+ const ctx = new AudioCtx();
1481
+ void ctx.resume();
1482
+ const oscillator = ctx.createOscillator();
1483
+ const gain = ctx.createGain();
1484
+ oscillator.type = "sine";
1485
+ oscillator.frequency.value = TEST_HZ;
1486
+ gain.gain.value = 0.18;
1487
+ oscillator.connect(gain);
1488
+ let audio = null;
1489
+ try {
1490
+ const probe = document.createElement("audio");
1491
+ const canRoute = Boolean(deviceId) && typeof probe.setSinkId === "function";
1492
+ if (canRoute && deviceId) {
1493
+ const destination = ctx.createMediaStreamDestination();
1494
+ gain.connect(destination);
1495
+ audio = probe;
1496
+ audio.autoplay = true;
1497
+ audio.playsInline = true;
1498
+ audio.srcObject = destination.stream;
1499
+ try {
1500
+ await audio.setSinkId?.(deviceId);
1501
+ await audio.play();
1502
+ } catch {
1503
+ gain.disconnect();
1504
+ gain.connect(ctx.destination);
1505
+ audio.srcObject = null;
1506
+ audio.remove();
1507
+ audio = null;
1508
+ }
1509
+ } else {
1510
+ gain.connect(ctx.destination);
1511
+ }
1512
+ oscillator.start();
1513
+ await new Promise((resolve) => {
1514
+ window.setTimeout(resolve, TEST_MS);
1515
+ });
1516
+ oscillator.stop();
1517
+ } finally {
1518
+ if (audio) {
1519
+ audio.srcObject = null;
1520
+ audio.remove();
1469
1521
  }
1522
+ await ctx.close();
1470
1523
  }
1471
- return /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [
1472
- pinned[0] ? /* @__PURE__ */ jsxs("div", { role: "status", className: "bg-muted border-b px-3 py-2 text-sm", children: [
1473
- /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: labels.chatPinned }),
1474
- /* @__PURE__ */ jsx("p", { children: pinned[0].text })
1475
- ] }) : null,
1476
- /* @__PURE__ */ jsxs(
1477
- "div",
1524
+ }
1525
+ function StudioDeviceSelectGroup({
1526
+ devices,
1527
+ selected,
1528
+ labels,
1529
+ onChange,
1530
+ speakerAction
1531
+ }) {
1532
+ return /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-[8.5rem_minmax(0,1fr)] items-center gap-x-3 gap-y-3", children: [
1533
+ /* @__PURE__ */ jsx(
1534
+ DeviceRow,
1478
1535
  {
1479
- ref: listRef,
1480
- role: "log",
1481
- "aria-live": "polite",
1482
- className: "flex min-h-0 flex-1 flex-col gap-2 overflow-auto p-3",
1483
- children: [
1484
- /* @__PURE__ */ jsx(
1485
- Button,
1486
- {
1487
- type: "button",
1488
- size: "xs",
1489
- variant: "ghost",
1490
- className: "self-center",
1491
- onClick: () => {
1492
- const oldest = messages[0]?.time;
1493
- void meeting.fetchChatHistory({
1494
- timestamp: oldest
1495
- });
1496
- },
1497
- children: labels.chatLoadOlder
1498
- }
1499
- ),
1500
- messages.map((row) => {
1501
- const mine = Boolean(selfId) && row.userId === selfId;
1502
- return /* @__PURE__ */ jsxs(
1503
- "div",
1504
- {
1505
- className: mine ? "self-end text-right" : "self-start",
1506
- children: [
1507
- /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: row.name }),
1508
- /* @__PURE__ */ jsx("p", { className: "bg-muted max-w-64 rounded-lg px-2 py-1 text-sm", children: row.text }),
1509
- /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground flex flex-wrap justify-end gap-2 text-xs", children: [
1510
- row.isEdited ? /* @__PURE__ */ jsx("span", { children: labels.chatEdited }) : null,
1511
- mine ? /* @__PURE__ */ jsx(
1512
- "button",
1513
- {
1514
- type: "button",
1515
- onClick: () => {
1516
- setEditingId(row.id);
1517
- setDraft(row.text);
1518
- },
1519
- children: labels.chatEdit
1520
- }
1521
- ) : null,
1522
- isModerator2 || mine ? /* @__PURE__ */ jsx(
1523
- "button",
1524
- {
1525
- type: "button",
1526
- onClick: () => void meeting.deleteChatMessage(row.id).catch(() => toast.error(labels.chatError)),
1527
- children: labels.chatDelete
1528
- }
1529
- ) : null,
1530
- isModerator2 ? /* @__PURE__ */ jsx(
1531
- "button",
1532
- {
1533
- type: "button",
1534
- onClick: () => void meeting.pinChatMessage(row.id, row.pinned !== true).catch(() => toast.error(labels.chatError)),
1535
- children: row.pinned ? labels.chatUnpin : labels.chatPin
1536
- }
1537
- ) : null
1538
- ] })
1539
- ]
1540
- },
1541
- row.id
1542
- );
1543
- })
1544
- ]
1536
+ icon: /* @__PURE__ */ jsx(Video, {}),
1537
+ label: labels.cameraDevice,
1538
+ options: devices.video,
1539
+ value: selected.video,
1540
+ onChange: (id) => onChange("video", id)
1545
1541
  }
1546
1542
  ),
1547
- /* @__PURE__ */ jsxs(
1548
- "form",
1543
+ /* @__PURE__ */ jsx(
1544
+ DeviceRow,
1549
1545
  {
1550
- className: "flex flex-col gap-2 border-t p-3",
1551
- onSubmit: (event) => {
1552
- event.preventDefault();
1553
- void onSend();
1554
- },
1555
- children: [
1556
- /* @__PURE__ */ jsx(
1557
- "input",
1558
- {
1559
- className: "border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm",
1560
- value: draft,
1561
- maxLength: limit,
1562
- onChange: (event) => setDraft(event.target.value),
1563
- placeholder: labels.chatPlaceholder,
1564
- "aria-label": labels.chatPlaceholder
1565
- }
1566
- ),
1567
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
1568
- /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground text-xs", children: [
1569
- draft.length,
1570
- "/",
1571
- limit
1572
- ] }),
1573
- /* @__PURE__ */ jsx(Button, { type: "submit", size: "sm", children: editingId ? labels.chatSave : labels.chatSend })
1574
- ] })
1575
- ]
1546
+ icon: /* @__PURE__ */ jsx(Mic, {}),
1547
+ label: labels.micDevice,
1548
+ options: devices.audio,
1549
+ value: selected.audio,
1550
+ onChange: (id) => onChange("audio", id)
1551
+ }
1552
+ ),
1553
+ /* @__PURE__ */ jsx(
1554
+ DeviceRow,
1555
+ {
1556
+ icon: /* @__PURE__ */ jsx(Volume2, {}),
1557
+ label: labels.speakerDevice,
1558
+ options: devices.speaker,
1559
+ value: selected.speaker,
1560
+ onChange: (id) => onChange("speaker", id),
1561
+ action: speakerAction
1576
1562
  }
1577
1563
  )
1578
1564
  ] });
1579
1565
  }
1580
- function StudioWaitingRoom({
1581
- waitlisted,
1582
- labels,
1583
- onAdmitAs,
1584
- onAdmitAll,
1585
- onDeny
1566
+ function DeviceRow({
1567
+ icon,
1568
+ label,
1569
+ options,
1570
+ value,
1571
+ onChange,
1572
+ action
1586
1573
  }) {
1587
- return /* @__PURE__ */ jsxs("section", { "aria-label": labels.waiting, className: "space-y-3", children: [
1588
- /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.waiting }),
1589
- waitlisted.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.noWaiting }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1590
- /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
1591
- /* @__PURE__ */ jsx(
1592
- Button,
1593
- {
1594
- type: "button",
1595
- size: "xs",
1596
- className: "w-full",
1597
- onClick: () => onAdmitAll("panelist"),
1598
- children: labels.admitAllPanelists
1599
- }
1600
- ),
1601
- /* @__PURE__ */ jsx(
1602
- Button,
1603
- {
1604
- type: "button",
1605
- size: "xs",
1606
- variant: "outline",
1607
- className: "w-full",
1608
- onClick: () => onAdmitAll("audience"),
1609
- children: labels.admitAllAudience
1610
- }
1611
- )
1612
- ] }),
1613
- /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: waitlisted.map((guest) => /* @__PURE__ */ jsxs("li", { className: "space-y-2 rounded-md border p-2", children: [
1614
- /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: guest.name }),
1615
- /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
1616
- /* @__PURE__ */ jsx(
1617
- Button,
1618
- {
1619
- type: "button",
1620
- size: "xs",
1621
- className: "w-full",
1622
- onClick: () => onAdmitAs(guest.id, "panelist"),
1623
- children: labels.admitAsPanelist
1624
- }
1574
+ const display = displayDeviceId(value, options);
1575
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1576
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground flex min-w-0 items-center gap-2 text-sm", children: [
1577
+ /* @__PURE__ */ jsx("span", { className: "[&_svg]:size-4", "aria-hidden": true, children: icon }),
1578
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: label })
1579
+ ] }),
1580
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [
1581
+ options.length > 0 ? /* @__PURE__ */ jsx(
1582
+ "select",
1583
+ {
1584
+ "aria-label": label,
1585
+ className: cn(
1586
+ "border-input bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-base md:text-sm"
1625
1587
  ),
1626
- /* @__PURE__ */ jsx(
1627
- Button,
1628
- {
1629
- type: "button",
1630
- size: "xs",
1631
- variant: "outline",
1632
- className: "w-full",
1633
- onClick: () => onAdmitAs(guest.id, "audience"),
1634
- children: labels.admitAsAudience
1635
- }
1636
- ),
1637
- /* @__PURE__ */ jsx(
1638
- Button,
1639
- {
1640
- type: "button",
1641
- size: "xs",
1642
- variant: "ghost",
1643
- className: "w-full",
1644
- onClick: () => onDeny(guest.id),
1645
- children: labels.deny
1646
- }
1647
- )
1648
- ] })
1649
- ] }, guest.id)) })
1588
+ value: display,
1589
+ onChange: (event) => onChange(event.target.value),
1590
+ children: options.map((option) => /* @__PURE__ */ jsx("option", { value: option.deviceId, children: option.label }, option.deviceId))
1591
+ }
1592
+ ) : null,
1593
+ action
1650
1594
  ] })
1651
1595
  ] });
1652
1596
  }
1653
- function StudioSidePanel({
1654
- tab,
1655
- onTabChange,
1656
- onClose,
1597
+ function StudioSettingsDialog({
1598
+ open,
1599
+ onOpenChange,
1657
1600
  meeting,
1658
- selfId,
1659
- participants,
1660
- waitlisted,
1661
- stageRequests,
1662
- isModerator: isModerator2,
1663
- chatBadge,
1664
- peopleBadge,
1665
1601
  labels,
1666
- onAdmitAs,
1667
- onAdmitAll,
1668
- onDeny,
1669
- onGrantStage,
1670
- onDenyStage,
1671
- onTakeOffAir
1602
+ reducedMotion,
1603
+ cameraOn
1672
1604
  }) {
1673
- const panelists = participants.filter((row) => isOnStage(row.stageStatus));
1674
- const audience = participants.filter((row) => !isOnStage(row.stageStatus));
1675
- return /* @__PURE__ */ jsxs("aside", { className: "bg-background flex w-full flex-col border-t md:w-80 md:border-l md:border-t-0", children: [
1676
- /* @__PURE__ */ jsxs("div", { className: "flex border-b", children: [
1677
- /* @__PURE__ */ jsxs(
1678
- Button,
1679
- {
1680
- type: "button",
1681
- size: "sm",
1682
- variant: tab === "chat" ? "secondary" : "ghost",
1683
- className: "relative flex-1 rounded-none",
1684
- onClick: () => onTabChange("chat"),
1685
- children: [
1686
- labels.chat,
1687
- chatBadge > 0 ? /* @__PURE__ */ jsx("span", { className: "bg-primary text-primary-foreground ml-2 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.65rem]", children: chatBadge }) : null
1688
- ]
1689
- }
1690
- ),
1691
- isModerator2 ? /* @__PURE__ */ jsxs(
1692
- Button,
1693
- {
1694
- type: "button",
1695
- size: "sm",
1696
- variant: tab === "people" ? "secondary" : "ghost",
1697
- className: "relative flex-1 rounded-none",
1698
- onClick: () => onTabChange("people"),
1699
- children: [
1700
- labels.people,
1701
- peopleBadge > 0 ? /* @__PURE__ */ jsx("span", { className: "bg-primary text-primary-foreground ml-2 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.65rem]", children: peopleBadge }) : null
1702
- ]
1703
- }
1704
- ) : null,
1705
- /* @__PURE__ */ jsx(
1706
- Button,
1707
- {
1708
- type: "button",
1709
- size: "icon-xs",
1710
- variant: "ghost",
1711
- className: "m-1 shrink-0",
1712
- "aria-label": labels.closePanel,
1713
- onClick: onClose,
1714
- children: /* @__PURE__ */ jsx(X, {})
1715
- }
1716
- )
1605
+ const devices = useMediaDevices(open ? 1 : 0, open);
1606
+ const [saved, setSaved] = useState(
1607
+ () => readStudioDeviceSelection()
1608
+ );
1609
+ const [audioTrack, setAudioTrack] = useState(null);
1610
+ const [videoTrack, setVideoTrack] = useState(null);
1611
+ const [testingSpeaker, setTestingSpeaker] = useState(false);
1612
+ const [displayName, setDisplayName] = useState("");
1613
+ const [savedDisplayName, setSavedDisplayName] = useState("");
1614
+ const [savingDisplayName, setSavingDisplayName] = useState(false);
1615
+ const { effective } = resolveDeviceSelection(saved, devices);
1616
+ useEffect(() => {
1617
+ if (!open) return;
1618
+ setSaved(readStudioDeviceSelection());
1619
+ }, [open]);
1620
+ useEffect(() => {
1621
+ if (!open) return;
1622
+ const selfName = meeting.participants().find((row) => row.isSelf)?.name?.trim() ?? "";
1623
+ setDisplayName(selfName);
1624
+ setSavedDisplayName(selfName);
1625
+ }, [meeting, open]);
1626
+ useEffect(() => {
1627
+ if (!open) return;
1628
+ const sync = () => {
1629
+ setAudioTrack(meeting.localAudioTrack());
1630
+ setVideoTrack(meeting.localVideoTrack());
1631
+ };
1632
+ sync();
1633
+ return meeting.subscribeMedia(sync);
1634
+ }, [meeting, open]);
1635
+ const trimmedDisplayName = displayName.trim();
1636
+ const displayNameUnchanged = trimmedDisplayName === savedDisplayName;
1637
+ const displayNameSaveDisabled = !trimmedDisplayName || displayNameUnchanged || savingDisplayName;
1638
+ async function onChange(kind, deviceId) {
1639
+ persistStudioDeviceSelection(kind, deviceId);
1640
+ setSaved((prev) => ({ ...prev, [kind]: deviceId }));
1641
+ try {
1642
+ await meeting.setDevice(kind, deviceId);
1643
+ } catch {
1644
+ }
1645
+ }
1646
+ async function onSaveDisplayName() {
1647
+ if (displayNameSaveDisabled) return;
1648
+ setSavingDisplayName(true);
1649
+ try {
1650
+ await meeting.setDisplayName(trimmedDisplayName);
1651
+ setSavedDisplayName(trimmedDisplayName);
1652
+ setDisplayName(trimmedDisplayName);
1653
+ toast.success(labels.displayNameSaved);
1654
+ } catch {
1655
+ toast.error(labels.displayNameError);
1656
+ } finally {
1657
+ setSavingDisplayName(false);
1658
+ }
1659
+ }
1660
+ async function onTestSpeaker() {
1661
+ if (testingSpeaker) return;
1662
+ const speakerId = effective.speaker || devices.speaker[0]?.deviceId;
1663
+ setTestingSpeaker(true);
1664
+ try {
1665
+ await playSpeakerTest(speakerId || void 0);
1666
+ } catch {
1667
+ toast.error(labels.testSpeakerError);
1668
+ } finally {
1669
+ setTestingSpeaker(false);
1670
+ }
1671
+ }
1672
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-md", children: [
1673
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
1674
+ /* @__PURE__ */ jsx(DialogTitle, { children: labels.settingsTitle }),
1675
+ /* @__PURE__ */ jsx(DialogDescription, { children: labels.settingsDescription })
1717
1676
  ] }),
1718
- tab === "chat" ? /* @__PURE__ */ jsx(
1719
- StudioChatPanel,
1677
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
1678
+ /* @__PURE__ */ jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
1679
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
1680
+ /* @__PURE__ */ jsx(
1681
+ Input,
1682
+ {
1683
+ id: "studio-display-name",
1684
+ value: displayName,
1685
+ maxLength: STUDIO_DISPLAY_NAME_MAX,
1686
+ onChange: (event) => setDisplayName(event.target.value)
1687
+ }
1688
+ ),
1689
+ /* @__PURE__ */ jsx(
1690
+ Button,
1691
+ {
1692
+ type: "button",
1693
+ variant: "outline",
1694
+ disabled: displayNameSaveDisabled,
1695
+ onClick: () => void onSaveDisplayName(),
1696
+ children: labels.displayNameSave
1697
+ }
1698
+ )
1699
+ ] })
1700
+ ] }),
1701
+ /* @__PURE__ */ jsx(
1702
+ SettingsCameraPreview,
1720
1703
  {
1721
- meeting,
1722
- selfId,
1723
- participants,
1724
- isModerator: isModerator2,
1725
- labels
1704
+ track: videoTrack,
1705
+ open,
1706
+ cameraOn,
1707
+ cameraOffLabel: labels.cameraOff
1726
1708
  }
1727
- ) : /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3", children: [
1709
+ ),
1710
+ /* @__PURE__ */ jsxs("div", { children: [
1711
+ /* @__PURE__ */ jsx("p", { className: "sr-only", children: labels.micLevel }),
1728
1712
  /* @__PURE__ */ jsx(
1729
- StudioWaitingRoom,
1713
+ AudioWaveform,
1730
1714
  {
1731
- waitlisted,
1732
- labels,
1733
- onAdmitAs,
1734
- onAdmitAll,
1735
- onDeny
1715
+ track: audioTrack,
1716
+ reducedMotion,
1717
+ className: "w-full"
1736
1718
  }
1737
- ),
1738
- stageRequests.length > 0 ? /* @__PURE__ */ jsxs("section", { "aria-label": labels.stageRequests, className: "space-y-2", children: [
1739
- /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.stageRequests }),
1740
- /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: stageRequests.map((request) => /* @__PURE__ */ jsxs(
1741
- "li",
1719
+ )
1720
+ ] }),
1721
+ /* @__PURE__ */ jsx(
1722
+ StudioDeviceSelectGroup,
1723
+ {
1724
+ devices,
1725
+ selected: effective,
1726
+ labels,
1727
+ onChange: (kind, id) => void onChange(kind, id),
1728
+ speakerAction: /* @__PURE__ */ jsx(
1729
+ Button,
1742
1730
  {
1743
- className: "space-y-2 rounded-md border p-2",
1744
- children: [
1745
- /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: request.name }),
1746
- /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
1747
- /* @__PURE__ */ jsx(
1748
- Button,
1749
- {
1750
- type: "button",
1751
- size: "xs",
1752
- className: "w-full",
1753
- onClick: () => onGrantStage(request.userId),
1754
- children: labels.bringOnAir
1755
- }
1756
- ),
1757
- /* @__PURE__ */ jsx(
1758
- Button,
1759
- {
1760
- type: "button",
1761
- size: "xs",
1762
- variant: "outline",
1763
- className: "w-full",
1764
- onClick: () => onDenyStage(request.userId),
1765
- children: labels.deny
1766
- }
1767
- )
1768
- ] })
1769
- ]
1770
- },
1771
- request.userId
1772
- )) })
1773
- ] }) : null,
1774
- /* @__PURE__ */ jsxs("section", { "aria-label": labels.panelists, className: "space-y-2", children: [
1775
- /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.panelists }),
1776
- panelists.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.noPanelists }) : /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: panelists.map((row) => {
1777
- const userId = row.userId;
1778
- return /* @__PURE__ */ jsxs(
1779
- "li",
1780
- {
1781
- className: "space-y-2 rounded-md border p-2",
1782
- children: [
1783
- /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: row.name }),
1784
- !row.isSelf && userId ? /* @__PURE__ */ jsx(
1785
- Button,
1786
- {
1787
- type: "button",
1788
- size: "xs",
1789
- variant: "outline",
1790
- className: "w-full",
1791
- onClick: () => onTakeOffAir(userId),
1792
- children: labels.offAir
1793
- }
1794
- ) : null
1795
- ]
1796
- },
1797
- row.id
1798
- );
1799
- }) })
1800
- ] }),
1801
- /* @__PURE__ */ jsxs("section", { "aria-label": labels.audience, className: "space-y-2", children: [
1802
- /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.audience }),
1803
- audience.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.noAudience }) : /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: audience.map((row) => {
1804
- const userId = row.userId;
1805
- return /* @__PURE__ */ jsxs(
1806
- "li",
1807
- {
1808
- className: "space-y-2 rounded-md border p-2",
1809
- children: [
1810
- /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: row.name }),
1811
- !row.isSelf && userId ? /* @__PURE__ */ jsx(
1812
- Button,
1813
- {
1814
- type: "button",
1815
- size: "xs",
1816
- variant: "outline",
1817
- className: "w-full",
1818
- onClick: () => onGrantStage(userId),
1819
- children: labels.bringOnAir
1820
- }
1821
- ) : null
1822
- ]
1823
- },
1824
- row.id
1825
- );
1826
- }) })
1827
- ] })
1828
- ] })
1829
- ] });
1830
- }
1831
- function DropdownMenu({ ...props }) {
1832
- return /* @__PURE__ */ jsx(Menu.Root, { "data-slot": "dropdown-menu", ...props });
1833
- }
1834
- function DropdownMenuTrigger({ ...props }) {
1835
- return /* @__PURE__ */ jsx(Menu.Trigger, { "data-slot": "dropdown-menu-trigger", ...props });
1836
- }
1837
- function DropdownMenuContent({
1838
- align = "start",
1839
- alignOffset = 0,
1840
- side = "bottom",
1841
- sideOffset = 4,
1842
- className,
1843
- ...props
1844
- }) {
1845
- return /* @__PURE__ */ jsx(Menu.Portal, { children: /* @__PURE__ */ jsx(
1846
- Menu.Positioner,
1847
- {
1848
- className: "isolate z-50 outline-none",
1849
- align,
1850
- alignOffset,
1851
- side,
1852
- sideOffset,
1853
- children: /* @__PURE__ */ jsx(
1854
- Menu.Popup,
1855
- {
1856
- "data-slot": "dropdown-menu-content",
1857
- className: cn(
1858
- "z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
1859
- className
1860
- ),
1861
- ...props
1862
- }
1863
- )
1864
- }
1865
- ) });
1731
+ type: "button",
1732
+ size: "sm",
1733
+ variant: "outline",
1734
+ disabled: testingSpeaker,
1735
+ onClick: () => void onTestSpeaker(),
1736
+ children: labels.testSpeaker
1737
+ }
1738
+ )
1739
+ }
1740
+ )
1741
+ ] }) });
1866
1742
  }
1867
- function DropdownMenuItem({
1868
- className,
1869
- inset,
1870
- variant = "default",
1871
- ...props
1743
+ function SettingsCameraPreview({
1744
+ track,
1745
+ open,
1746
+ cameraOn,
1747
+ cameraOffLabel
1872
1748
  }) {
1873
- return /* @__PURE__ */ jsx(
1874
- Menu.Item,
1875
- {
1876
- "data-slot": "dropdown-menu-item",
1877
- "data-inset": inset,
1878
- "data-variant": variant,
1879
- className: cn(
1880
- "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
1881
- className
1882
- ),
1883
- ...props
1749
+ const ref = useRef(null);
1750
+ useEffect(() => {
1751
+ const el = ref.current;
1752
+ if (!el) return;
1753
+ if (!open || !cameraOn || !track) {
1754
+ el.srcObject = null;
1755
+ return;
1884
1756
  }
1885
- );
1886
- }
1887
- function DropdownMenuSeparator({
1888
- className,
1889
- ...props
1890
- }) {
1891
- return /* @__PURE__ */ jsx(
1892
- Menu.Separator,
1757
+ el.srcObject = new MediaStream([track]);
1758
+ void el.play().catch(() => void 0);
1759
+ return () => {
1760
+ el.srcObject = null;
1761
+ };
1762
+ }, [cameraOn, open, track]);
1763
+ if (!cameraOn) {
1764
+ return /* @__PURE__ */ jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) });
1765
+ }
1766
+ if (!track) {
1767
+ return /* @__PURE__ */ jsx("div", { className: "bg-muted aspect-video w-full rounded-xl", "aria-hidden": true });
1768
+ }
1769
+ return /* @__PURE__ */ jsx("div", { className: "bg-muted relative aspect-video w-full overflow-hidden rounded-xl", children: /* @__PURE__ */ jsx(
1770
+ "video",
1893
1771
  {
1894
- "data-slot": "dropdown-menu-separator",
1895
- className: cn("-mx-1 my-1 h-px bg-border", className),
1896
- ...props
1772
+ ref,
1773
+ autoPlay: true,
1774
+ muted: true,
1775
+ playsInline: true,
1776
+ className: "absolute inset-0 size-full -scale-x-100 object-cover"
1897
1777
  }
1898
- );
1778
+ ) });
1899
1779
  }
1900
- function StudioTileMenu({
1901
- tile,
1902
- canModerate,
1903
- canManageStage,
1904
- takeOffBlocked,
1780
+ function StudioGuestWaiting({
1781
+ meeting,
1782
+ reducedMotion,
1783
+ cameraOn,
1784
+ muted,
1905
1785
  labels,
1906
- onKick,
1907
- onMute,
1908
- onStopCamera,
1909
- onStopScreen,
1910
- onToggleStage,
1911
- onTogglePin,
1912
- onToggleChat
1786
+ settingsOpen,
1787
+ onToggleCamera,
1788
+ onToggleMute,
1789
+ onOpenSettings,
1790
+ onCloseSettings
1913
1791
  }) {
1914
- const onStage = isOnStage(tile.stageStatus);
1915
- const showPersonItems = canModerate && tile.kind !== "screen";
1916
- const showScreenItems = canModerate && tile.kind === "screen";
1917
- const showStageItems = canManageStage && tile.kind !== "screen";
1918
- if (!showPersonItems && !showScreenItems && !showStageItems) return null;
1919
- return /* @__PURE__ */ jsxs(DropdownMenu, { children: [
1792
+ const [audioTrack, setAudioTrack] = useState(null);
1793
+ const [videoTrack, setVideoTrack] = useState(null);
1794
+ useEffect(() => {
1795
+ const sync = () => {
1796
+ setAudioTrack(meeting.localAudioTrack());
1797
+ setVideoTrack(meeting.localVideoTrack());
1798
+ };
1799
+ sync();
1800
+ return meeting.subscribeMedia(sync);
1801
+ }, [meeting]);
1802
+ return /* @__PURE__ */ jsxs("div", { className: "mx-auto flex min-h-dvh w-full max-w-lg flex-col justify-center gap-6 p-6", children: [
1920
1803
  /* @__PURE__ */ jsx(
1921
- DropdownMenuTrigger,
1804
+ WaitingCameraPreview,
1922
1805
  {
1923
- render: /* @__PURE__ */ jsx(
1924
- Button,
1925
- {
1926
- type: "button",
1927
- size: "icon-xs",
1928
- variant: "ghost",
1929
- className: "text-white hover:text-white",
1930
- "aria-label": labels.tileMenu
1931
- }
1932
- ),
1933
- children: /* @__PURE__ */ jsx(MoreVertical, { className: "size-3.5" })
1806
+ track: videoTrack,
1807
+ cameraOn,
1808
+ cameraOffLabel: labels.cameraOff
1934
1809
  }
1935
1810
  ),
1936
- /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", className: "min-w-44", children: [
1937
- showScreenItems ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onStopScreen, children: labels.stopScreen }) : null,
1938
- showPersonItems && tile.audioEnabled ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onMute, children: labels.mute }) : null,
1939
- showPersonItems && tile.kind === "camera" && tile.videoEnabled ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onStopCamera, children: labels.stopCamera }) : null,
1940
- showPersonItems || showScreenItems ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onTogglePin, children: tile.pinned ? labels.unpin : labels.pin }) : null,
1941
- showPersonItems ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onToggleChat, children: tile.chatCanSend === false ? labels.allowChat : labels.muteChat }) : null,
1942
- showStageItems ? /* @__PURE__ */ jsx(
1943
- DropdownMenuItem,
1811
+ /* @__PURE__ */ jsx(
1812
+ AudioWaveform,
1813
+ {
1814
+ track: muted ? null : audioTrack,
1815
+ reducedMotion
1816
+ }
1817
+ ),
1818
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-center text-sm", role: "status", children: labels.waitingHost }),
1819
+ /* @__PURE__ */ jsx(TooltipProvider, { children: /* @__PURE__ */ jsxs("div", { className: "flex justify-center gap-2", children: [
1820
+ /* @__PURE__ */ jsx(
1821
+ ControlIcon,
1944
1822
  {
1945
- disabled: onStage && takeOffBlocked,
1946
- onClick: onToggleStage,
1947
- children: onStage ? takeOffBlocked ? labels.offAirLastVisible : labels.takeOffAir : labels.bringOnAir
1823
+ label: muted ? labels.unmute : labels.mute,
1824
+ pressed: muted,
1825
+ tone: muted ? "danger" : "default",
1826
+ onClick: onToggleMute,
1827
+ children: muted ? /* @__PURE__ */ jsx(MicOff, {}) : /* @__PURE__ */ jsx(Mic, {})
1948
1828
  }
1949
- ) : null,
1950
- showPersonItems ? /* @__PURE__ */ jsxs(Fragment, { children: [
1951
- /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
1952
- /* @__PURE__ */ jsx(DropdownMenuItem, { variant: "destructive", onClick: onKick, children: labels.kick })
1953
- ] }) : null
1954
- ] })
1829
+ ),
1830
+ /* @__PURE__ */ jsx(
1831
+ ControlIcon,
1832
+ {
1833
+ label: labels.camera,
1834
+ pressed: cameraOn,
1835
+ onClick: onToggleCamera,
1836
+ children: cameraOn ? /* @__PURE__ */ jsx(Video, {}) : /* @__PURE__ */ jsx(VideoOff, {})
1837
+ }
1838
+ ),
1839
+ /* @__PURE__ */ jsx(
1840
+ ControlIcon,
1841
+ {
1842
+ label: labels.settings,
1843
+ pressed: settingsOpen,
1844
+ onClick: onOpenSettings,
1845
+ children: /* @__PURE__ */ jsx(Settings, {})
1846
+ }
1847
+ )
1848
+ ] }) }),
1849
+ /* @__PURE__ */ jsx(
1850
+ StudioSettingsDialog,
1851
+ {
1852
+ open: settingsOpen,
1853
+ onOpenChange: onCloseSettings,
1854
+ meeting,
1855
+ labels,
1856
+ reducedMotion,
1857
+ cameraOn
1858
+ }
1859
+ )
1955
1860
  ] });
1956
1861
  }
1957
- function StudioTile({
1958
- tile,
1959
- meeting,
1960
- reducedMotion,
1961
- compact,
1962
- canModerate,
1963
- canManageStage,
1964
- takeOffBlocked,
1965
- confirmTakeOff,
1966
- labels,
1967
- onKick,
1968
- onMute,
1969
- onStopCamera,
1970
- onStopScreen,
1971
- onToggleStage,
1972
- onTogglePin,
1973
- onToggleChat
1974
- }) {
1975
- const onStage = isOnStage(tile.stageStatus);
1976
- const [confirm, setConfirm] = useState(null);
1977
- const showMenu = (canModerate || canManageStage) && !tile.isSelf;
1978
- return /* @__PURE__ */ jsxs(
1979
- "article",
1980
- {
1981
- "aria-label": tile.name,
1982
- className: cn(
1983
- "bg-muted relative overflow-hidden rounded-xl",
1984
- compact ? "aspect-video w-40 shrink-0 md:w-full" : tile.kind === "screen" ? "min-h-48 flex-1" : "aspect-video"
1985
- ),
1986
- children: [
1987
- tile.kind === "idle" ? /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
1988
- tile.picture ? /* @__PURE__ */ jsx(
1989
- "img",
1990
- {
1991
- src: tile.picture,
1992
- alt: "",
1993
- className: "size-16 rounded-full object-cover"
1994
- }
1995
- ) : /* @__PURE__ */ jsx("div", { className: "bg-background flex size-16 items-center justify-center rounded-full text-xl", children: tile.name.slice(0, 1).toUpperCase() }),
1996
- /* @__PURE__ */ jsx(
1997
- AudioWaveform,
1998
- {
1999
- track: tile.isSelf ? meeting.localAudioTrack() : null,
2000
- reducedMotion
2001
- }
2002
- )
2003
- ] }) : /* @__PURE__ */ jsx(
2004
- StudioTileVideo,
2005
- {
2006
- meeting,
2007
- participantId: tile.id,
2008
- kind: tile.kind,
2009
- mirror: tile.kind === "camera" && tile.isSelf,
2010
- contain: tile.kind === "screen"
2011
- }
2012
- ),
2013
- /* @__PURE__ */ jsxs("div", { className: "absolute inset-x-0 bottom-0 z-10 flex items-end justify-between gap-2 bg-linear-to-t from-black/70 to-transparent p-2", children: [
2014
- /* @__PURE__ */ jsxs("p", { className: "min-w-0 truncate text-xs font-medium text-white", children: [
2015
- tile.name,
2016
- !onStage ? /* @__PURE__ */ jsx("span", { className: "ml-1 rounded-full border border-white/40 px-1.5 py-0.5 text-[0.65rem]", children: labels.offAir }) : null
2017
- ] }),
2018
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
2019
- /* @__PURE__ */ jsx(TileStatusIcons, { tile, labels }),
2020
- showMenu ? /* @__PURE__ */ jsx(
2021
- StudioTileMenu,
2022
- {
2023
- tile,
2024
- canModerate,
2025
- canManageStage,
2026
- takeOffBlocked,
2027
- labels,
2028
- onKick: () => setConfirm("kick"),
2029
- onMute,
2030
- onStopCamera,
2031
- onStopScreen,
2032
- onToggleStage: () => {
2033
- if (onStage && confirmTakeOff && !takeOffBlocked) {
2034
- setConfirm("takeOff");
2035
- return;
2036
- }
2037
- onToggleStage();
2038
- },
2039
- onTogglePin,
2040
- onToggleChat
2041
- }
2042
- ) : null
2043
- ] })
2044
- ] }),
2045
- /* @__PURE__ */ jsx(
2046
- ConfirmAction,
2047
- {
2048
- open: confirm === "kick",
2049
- onOpenChange: (open) => {
2050
- if (!open) setConfirm(null);
2051
- },
2052
- triggerLabel: labels.kick,
2053
- title: labels.kickTitle,
2054
- description: labels.kickDescription,
2055
- confirmLabel: labels.kickConfirm,
2056
- onConfirm: () => {
2057
- setConfirm(null);
2058
- onKick();
2059
- }
2060
- }
2061
- ),
2062
- /* @__PURE__ */ jsx(
2063
- ConfirmAction,
2064
- {
2065
- open: confirm === "takeOff",
2066
- onOpenChange: (open) => {
2067
- if (!open) setConfirm(null);
2068
- },
2069
- triggerLabel: labels.takeOffAir,
2070
- title: labels.takeOffAirTitle,
2071
- description: labels.takeOffAirDescription,
2072
- confirmLabel: labels.takeOffAirConfirm,
2073
- onConfirm: () => {
2074
- setConfirm(null);
2075
- onToggleStage();
2076
- }
2077
- }
2078
- )
2079
- ]
2080
- }
2081
- );
2082
- }
2083
- function TileStatusIcons({
2084
- tile,
2085
- labels
2086
- }) {
2087
- return /* @__PURE__ */ jsxs(Fragment, { children: [
2088
- tile.audioEnabled ? null : /* @__PURE__ */ jsx(StatusIcon, { label: labels.mute, children: /* @__PURE__ */ jsx(MicOff, {}) }),
2089
- tile.kind !== "screen" && !tile.videoEnabled ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.cameraOff, children: /* @__PURE__ */ jsx(VideoOff, {}) }) : null,
2090
- tile.kind !== "screen" && tile.screenShareEnabled ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.stopScreen, children: /* @__PURE__ */ jsx(MonitorUp, {}) }) : null,
2091
- tile.pinned ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.pin, children: /* @__PURE__ */ jsx(Pin, {}) }) : null,
2092
- tile.chatCanSend === false ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.muteChat, children: /* @__PURE__ */ jsx(MessageSquareOff, {}) }) : null
2093
- ] });
2094
- }
2095
- function StatusIcon({
2096
- label,
2097
- children
2098
- }) {
2099
- return /* @__PURE__ */ jsxs("span", { className: "inline-flex text-white", children: [
2100
- /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "[&_svg]:size-3.5", children }),
2101
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: label })
2102
- ] });
2103
- }
2104
- function StudioTileVideo({
2105
- meeting,
2106
- participantId,
2107
- kind,
2108
- mirror,
2109
- contain
1862
+ function WaitingCameraPreview({
1863
+ track,
1864
+ cameraOn,
1865
+ cameraOffLabel
2110
1866
  }) {
2111
1867
  const ref = useRef(null);
2112
1868
  useEffect(() => {
2113
- meeting.attachVideo(participantId, ref.current, kind);
2114
- return () => meeting.attachVideo(participantId, null, kind);
2115
- }, [kind, meeting, participantId]);
2116
- return /* @__PURE__ */ jsx(
1869
+ const el = ref.current;
1870
+ if (!el) return;
1871
+ if (!cameraOn || !track) {
1872
+ el.srcObject = null;
1873
+ return;
1874
+ }
1875
+ el.srcObject = new MediaStream([track]);
1876
+ void el.play().catch(() => void 0);
1877
+ return () => {
1878
+ el.srcObject = null;
1879
+ };
1880
+ }, [cameraOn, track]);
1881
+ if (!cameraOn) {
1882
+ return /* @__PURE__ */ jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) });
1883
+ }
1884
+ if (!track) {
1885
+ return /* @__PURE__ */ jsx("div", { className: "bg-muted aspect-video w-full rounded-xl", "aria-hidden": true });
1886
+ }
1887
+ return /* @__PURE__ */ jsx("div", { className: "bg-muted relative aspect-video w-full overflow-hidden rounded-xl", children: /* @__PURE__ */ jsx(
2117
1888
  "video",
2118
1889
  {
2119
1890
  ref,
2120
1891
  autoPlay: true,
2121
- playsInline: true,
2122
1892
  muted: true,
2123
- disablePictureInPicture: true,
2124
- controlsList: "nodownload noplaybackrate noremoteplayback",
2125
- className: cn(
2126
- "absolute inset-0 size-full",
2127
- "pointer-events-none",
2128
- contain ? "object-contain" : "object-cover",
2129
- mirror && "-scale-x-100"
2130
- )
1893
+ playsInline: true,
1894
+ className: "absolute inset-0 size-full -scale-x-100 object-cover"
1895
+ }
1896
+ ) });
1897
+ }
1898
+ function StudioLiveBadge({
1899
+ children,
1900
+ role
1901
+ }) {
1902
+ return /* @__PURE__ */ jsxs(
1903
+ "span",
1904
+ {
1905
+ role,
1906
+ className: "flex items-center gap-1.5 rounded bg-red-600 px-2 py-0.5 text-xs font-semibold tracking-wide text-white",
1907
+ children: [
1908
+ /* @__PURE__ */ jsx("span", { className: "size-1.5 animate-pulse rounded-full bg-white motion-reduce:animate-none" }),
1909
+ children
1910
+ ]
2131
1911
  }
2132
1912
  );
2133
1913
  }
2134
- function StudioStage({
2135
- participants,
1914
+ var badgeVariants = cva(
1915
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
1916
+ {
1917
+ variants: {
1918
+ variant: {
1919
+ default: "border-transparent bg-primary text-primary-foreground",
1920
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
1921
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
1922
+ outline: "text-foreground",
1923
+ success: "border-transparent bg-chart-5/15 text-chart-5 dark:bg-chart-5/25",
1924
+ warning: "border-transparent bg-chart-4/15 text-chart-4 dark:bg-chart-4/25"
1925
+ }
1926
+ },
1927
+ defaultVariants: { variant: "default" }
1928
+ }
1929
+ );
1930
+ function Badge({ className, variant, ...props }) {
1931
+ return /* @__PURE__ */ jsx("span", { className: cn(badgeVariants({ variant }), className), ...props });
1932
+ }
1933
+ function StudioPipPanel({
2136
1934
  meeting,
2137
- reducedMotion,
2138
- canModerate,
2139
- canManageStage,
2140
- onAir,
2141
1935
  labels,
2142
- onKick,
2143
- onMute,
2144
- onStopCamera,
2145
- onStopScreen,
2146
- onToggleStage,
2147
- onTogglePin,
2148
- onToggleChat,
2149
- canTakeOff,
2150
- onOpenPeople
1936
+ cameraOn,
1937
+ muted,
1938
+ screenOn,
1939
+ showMediaControls = true,
1940
+ showScreenControl = false,
1941
+ canShareFreely = false,
1942
+ screenAvailable = true,
1943
+ screenPending = false,
1944
+ shareGrant = "idle",
1945
+ onAir,
1946
+ elapsed,
1947
+ unread = 0,
1948
+ onToggleMute,
1949
+ onToggleCamera,
1950
+ onToggleScreen,
1951
+ onAskToShare,
1952
+ onCancelShareRequest,
1953
+ onBack
2151
1954
  }) {
2152
- const displayTiles = expandStudioTiles(participants);
2153
- const stage = studioStageLayout(displayTiles, {
2154
- activeSpeakerId: meeting.activeSpeakerId()
2155
- });
2156
- const sharing = stage.spotlight.length > 0;
2157
- const overflowLabel = labels.stageOverflow.replace(
2158
- "{count}",
2159
- String(stage.overflow)
2160
- );
2161
- function renderTile(tile, compact) {
2162
- const takeOffBlocked = !canTakeOff(tile.id);
2163
- return /* @__PURE__ */ jsx(
2164
- StudioTile,
2165
- {
2166
- tile,
2167
- meeting,
2168
- reducedMotion,
2169
- compact,
2170
- canModerate: canModerate && !tile.isSelf,
2171
- canManageStage: canManageStage && !tile.isSelf,
2172
- takeOffBlocked,
2173
- confirmTakeOff: onAir,
2174
- labels,
2175
- onKick: () => onKick(tile.id),
2176
- onMute: () => onMute(tile.id),
2177
- onStopCamera: () => onStopCamera(tile.id),
2178
- onStopScreen: () => onStopScreen(tile.id),
2179
- onToggleStage: () => onToggleStage(tile),
2180
- onTogglePin: () => onTogglePin(tile),
2181
- onToggleChat: () => onToggleChat(tile)
2182
- },
2183
- tile.tileId
2184
- );
2185
- }
2186
- function overflowChip(compact) {
2187
- if (stage.overflow <= 0) return null;
2188
- return /* @__PURE__ */ jsx(
2189
- Button,
2190
- {
2191
- type: "button",
2192
- variant: "secondary",
2193
- className: cn(
2194
- "shrink-0",
2195
- compact ? "aspect-video w-40 md:w-full" : "aspect-video min-h-24"
2196
- ),
2197
- onClick: onOpenPeople,
2198
- children: overflowLabel
2199
- }
2200
- );
2201
- }
2202
- if (sharing) {
2203
- return /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col md:flex-row", children: [
2204
- /* @__PURE__ */ jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col gap-2 bg-black p-3", children: stage.spotlight.map((tile) => renderTile(tile, false)) }),
2205
- /* @__PURE__ */ jsxs("div", { className: "flex gap-2 overflow-auto p-2 md:w-64 md:flex-col", children: [
2206
- stage.strip.map((tile) => renderTile(tile, true)),
2207
- overflowChip(true)
2208
- ] })
2209
- ] });
2210
- }
1955
+ const tiles = meeting.participants();
1956
+ const spotlight = resolveStudioPipSpotlight(tiles, meeting.activeSpeakerId());
1957
+ const selfView = resolveStudioPipSelfView(tiles, spotlight);
1958
+ const speaking = spotlight !== null && spotlight.participantId === meeting.activeSpeakerId();
1959
+ const backLabel = unread > 0 ? `${labels.backToStudio} (${unread})` : labels.backToStudio;
2211
1960
  return /* @__PURE__ */ jsxs(
2212
1961
  "div",
2213
1962
  {
2214
- className: cn(
2215
- "mx-auto grid min-h-0 w-full flex-1 content-center gap-3 overflow-auto p-4",
2216
- stage.strip.length <= 1 ? "max-w-3xl grid-cols-1" : stage.strip.length <= 4 ? "max-w-5xl grid-cols-1 sm:grid-cols-2" : stage.strip.length <= 6 ? "max-w-6xl grid-cols-1 sm:grid-cols-2 xl:grid-cols-3" : "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
2217
- ),
1963
+ role: "region",
1964
+ "aria-label": labels.pipTitle,
1965
+ className: "bg-background flex h-full min-h-0 flex-col gap-2 p-2 text-foreground",
2218
1966
  children: [
2219
- stage.strip.map((tile) => renderTile(tile, false)),
2220
- overflowChip(false)
1967
+ /* @__PURE__ */ jsxs(
1968
+ "div",
1969
+ {
1970
+ className: cn(
1971
+ "bg-muted relative min-h-0 flex-1 overflow-hidden rounded-xl",
1972
+ speaking && "ring-2 ring-primary"
1973
+ ),
1974
+ children: [
1975
+ /* @__PURE__ */ jsx(
1976
+ PipSpotlight,
1977
+ {
1978
+ meeting,
1979
+ spotlight,
1980
+ cameraOffLabel: labels.cameraOff
1981
+ }
1982
+ ),
1983
+ onAir ? /* @__PURE__ */ jsxs("div", { className: "absolute top-2 left-2 flex items-center gap-2", children: [
1984
+ /* @__PURE__ */ jsx(StudioLiveBadge, { children: labels.live }),
1985
+ elapsed ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: elapsed }) : null
1986
+ ] }) : null,
1987
+ selfView ? /* @__PURE__ */ jsx(
1988
+ "div",
1989
+ {
1990
+ "data-testid": "pip-self-view",
1991
+ className: "absolute right-2 bottom-2 aspect-video w-[30%] overflow-hidden rounded-lg ring-1 ring-white/40",
1992
+ children: /* @__PURE__ */ jsx(PipTrackVideo, { track: meeting.localVideoTrack(), fit: "cover" })
1993
+ }
1994
+ ) : null
1995
+ ]
1996
+ }
1997
+ ),
1998
+ /* @__PURE__ */ jsxs("footer", { className: "flex flex-wrap items-center justify-center gap-2", children: [
1999
+ showMediaControls ? /* @__PURE__ */ jsxs(Fragment, { children: [
2000
+ /* @__PURE__ */ jsx(
2001
+ PipControl,
2002
+ {
2003
+ label: muted ? labels.unmute : labels.mute,
2004
+ off: muted,
2005
+ onClick: onToggleMute,
2006
+ children: muted ? /* @__PURE__ */ jsx(MicOff, {}) : /* @__PURE__ */ jsx(Mic, {})
2007
+ }
2008
+ ),
2009
+ /* @__PURE__ */ jsx(
2010
+ PipControl,
2011
+ {
2012
+ label: labels.camera,
2013
+ off: !cameraOn,
2014
+ onClick: onToggleCamera,
2015
+ children: cameraOn ? /* @__PURE__ */ jsx(Video, {}) : /* @__PURE__ */ jsx(VideoOff, {})
2016
+ }
2017
+ )
2018
+ ] }) : null,
2019
+ showMediaControls && (showScreenControl || screenOn) ? pipScreenControls({
2020
+ labels,
2021
+ screenOn,
2022
+ canShareFreely,
2023
+ screenAvailable,
2024
+ screenPending,
2025
+ shareGrant,
2026
+ onToggleScreen,
2027
+ onAskToShare,
2028
+ onCancelShareRequest
2029
+ }) : null,
2030
+ /* @__PURE__ */ jsx(PipControl, { label: backLabel, onClick: onBack, children: /* @__PURE__ */ jsxs("span", { className: "relative", children: [
2031
+ /* @__PURE__ */ jsx(Undo2, {}),
2032
+ unread > 0 ? /* @__PURE__ */ jsx(Badge, { className: "absolute -top-2 -right-2 h-4 min-w-4 px-1 text-[0.65rem]", children: unread }) : null
2033
+ ] }) })
2034
+ ] })
2221
2035
  ]
2222
2036
  }
2223
2037
  );
2224
2038
  }
2225
- function TooltipProvider({
2226
- delay = 0,
2227
- ...props
2039
+ function PipSpotlight({
2040
+ meeting,
2041
+ spotlight,
2042
+ cameraOffLabel
2228
2043
  }) {
2044
+ if (!spotlight || spotlight.kind === "idle") {
2045
+ return /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2", children: [
2046
+ spotlight?.picture ? /* @__PURE__ */ jsx(
2047
+ "img",
2048
+ {
2049
+ src: spotlight.picture,
2050
+ alt: "",
2051
+ className: "size-14 rounded-full object-cover"
2052
+ }
2053
+ ) : /* @__PURE__ */ jsx("div", { className: "bg-background flex size-14 items-center justify-center rounded-full text-lg", children: (spotlight?.name ?? "?").slice(0, 1).toUpperCase() }),
2054
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: spotlight?.name ?? cameraOffLabel })
2055
+ ] });
2056
+ }
2057
+ const track = spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId);
2058
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2059
+ /* @__PURE__ */ jsx(
2060
+ PipTrackVideo,
2061
+ {
2062
+ track,
2063
+ fit: spotlight.kind === "screen" ? "contain" : "cover"
2064
+ }
2065
+ ),
2066
+ /* @__PURE__ */ jsx("p", { className: "absolute inset-x-0 bottom-0 bg-linear-to-t from-black/70 to-transparent p-2 text-xs font-medium text-white", children: spotlight.name })
2067
+ ] });
2068
+ }
2069
+ function PipTrackVideo({
2070
+ track,
2071
+ fit
2072
+ }) {
2073
+ const ref = useRef(null);
2074
+ useEffect(() => {
2075
+ const el = ref.current;
2076
+ if (!el) return;
2077
+ if (!track) {
2078
+ el.srcObject = null;
2079
+ return;
2080
+ }
2081
+ el.srcObject = new MediaStream([track]);
2082
+ void el.play().catch(() => void 0);
2083
+ return () => {
2084
+ el.srcObject = null;
2085
+ };
2086
+ }, [track]);
2229
2087
  return /* @__PURE__ */ jsx(
2230
- Tooltip$1.Provider,
2088
+ "video",
2231
2089
  {
2232
- "data-slot": "tooltip-provider",
2233
- delay,
2234
- ...props
2090
+ ref,
2091
+ autoPlay: true,
2092
+ playsInline: true,
2093
+ muted: true,
2094
+ className: cn(
2095
+ "absolute inset-0 size-full",
2096
+ fit === "contain" ? "object-contain" : "object-cover"
2097
+ )
2235
2098
  }
2236
2099
  );
2237
2100
  }
2238
- function Tooltip({ ...props }) {
2239
- return /* @__PURE__ */ jsx(Tooltip$1.Root, { "data-slot": "tooltip", ...props });
2240
- }
2241
- function TooltipTrigger({ ...props }) {
2242
- return /* @__PURE__ */ jsx(Tooltip$1.Trigger, { "data-slot": "tooltip-trigger", ...props });
2243
- }
2244
- function TooltipContent({
2245
- className,
2246
- side = "top",
2247
- sideOffset = 4,
2248
- align = "center",
2249
- alignOffset = 0,
2250
- children,
2251
- ...props
2101
+ function pipScreenControls({
2102
+ labels,
2103
+ screenOn,
2104
+ canShareFreely,
2105
+ screenAvailable,
2106
+ screenPending,
2107
+ shareGrant,
2108
+ onToggleScreen,
2109
+ onAskToShare,
2110
+ onCancelShareRequest
2252
2111
  }) {
2253
- return /* @__PURE__ */ jsx(Tooltip$1.Portal, { children: /* @__PURE__ */ jsx(
2254
- Tooltip$1.Positioner,
2255
- {
2256
- align,
2257
- alignOffset,
2258
- side,
2259
- sideOffset,
2260
- className: "isolate z-50",
2261
- children: /* @__PURE__ */ jsxs(
2262
- Tooltip$1.Popup,
2112
+ if (canShareFreely || screenOn) {
2113
+ return /* @__PURE__ */ jsx(
2114
+ PipControl,
2115
+ {
2116
+ label: screenOn ? labels.stopScreen : labels.screen,
2117
+ off: screenOn,
2118
+ disabled: screenPending || !screenAvailable && !screenOn,
2119
+ onClick: onToggleScreen,
2120
+ children: /* @__PURE__ */ jsx(MonitorUp, {})
2121
+ }
2122
+ );
2123
+ }
2124
+ if (shareGrant === "requested") {
2125
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2126
+ /* @__PURE__ */ jsx(
2127
+ PipControl,
2263
2128
  {
2264
- "data-slot": "tooltip-content",
2265
- className: cn(
2266
- "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
2267
- className
2268
- ),
2269
- ...props,
2270
- children: [
2271
- children,
2272
- /* @__PURE__ */ jsx(Tooltip$1.Arrow, { className: "z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" })
2273
- ]
2129
+ label: labels.shareRequested,
2130
+ off: true,
2131
+ disabled: true,
2132
+ onClick: () => void 0,
2133
+ children: /* @__PURE__ */ jsx(MonitorUp, {})
2274
2134
  }
2275
- )
2276
- }
2277
- ) });
2278
- }
2279
-
2280
- // src/studio/lib/clipboard.ts
2281
- async function copyText(value, clipboard = globalThis.navigator?.clipboard ?? null) {
2282
- if (!clipboard) return false;
2283
- try {
2284
- await clipboard.writeText(value);
2285
- return true;
2286
- } catch {
2287
- return false;
2135
+ ),
2136
+ /* @__PURE__ */ jsx(
2137
+ PipControl,
2138
+ {
2139
+ label: labels.cancelShareRequest,
2140
+ onClick: () => onCancelShareRequest?.(),
2141
+ children: /* @__PURE__ */ jsx(X, {})
2142
+ }
2143
+ )
2144
+ ] });
2288
2145
  }
2146
+ return /* @__PURE__ */ jsx(PipControl, { label: labels.askToShare, onClick: () => onAskToShare?.(), children: /* @__PURE__ */ jsx(MonitorUp, {}) });
2289
2147
  }
2290
-
2291
- // src/studio/lib/share.ts
2292
- function isAbort(error) {
2293
- return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
2148
+ function PipControl({
2149
+ label,
2150
+ onClick,
2151
+ off = false,
2152
+ disabled = false,
2153
+ children
2154
+ }) {
2155
+ return /* @__PURE__ */ jsx(
2156
+ Button,
2157
+ {
2158
+ type: "button",
2159
+ size: "icon-lg",
2160
+ variant: off ? "destructive" : "secondary",
2161
+ className: "size-10 rounded-full",
2162
+ "aria-label": label,
2163
+ disabled,
2164
+ onClick,
2165
+ children
2166
+ }
2167
+ );
2294
2168
  }
2295
- function resolveShareApi(share = typeof navigator === "undefined" ? null : typeof navigator.share === "function" ? navigator : null) {
2296
- return share ?? null;
2169
+ function StudioRemoteAudio({
2170
+ meeting,
2171
+ participantId
2172
+ }) {
2173
+ const ref = useRef(null);
2174
+ useEffect(() => {
2175
+ meeting.attachAudio(participantId, ref.current);
2176
+ return () => meeting.attachAudio(participantId, null);
2177
+ }, [meeting, participantId]);
2178
+ return /* @__PURE__ */ jsx("audio", { ref, autoPlay: true, className: "sr-only" });
2297
2179
  }
2298
- function canNativeShare(data, share = resolveShareApi()) {
2299
- if (!share) return false;
2300
- if (typeof share.canShare !== "function") return true;
2301
- try {
2302
- return share.canShare(data);
2303
- } catch {
2304
- return false;
2305
- }
2180
+ function BubbleGroup({ className, ...props }) {
2181
+ return /* @__PURE__ */ jsx(
2182
+ "div",
2183
+ {
2184
+ "data-slot": "bubble-group",
2185
+ className: cn("flex min-w-0 flex-col gap-2", className),
2186
+ ...props
2187
+ }
2188
+ );
2306
2189
  }
2307
- async function shareOrCopyLink(options, deps) {
2308
- const data = {
2309
- url: options.url,
2310
- ...options.title ? { title: options.title } : {},
2311
- ...options.text ? { text: options.text } : {}
2312
- };
2313
- const share = resolveShareApi(deps?.share);
2314
- if (canNativeShare(data, share) && share) {
2315
- try {
2316
- await share.share(data);
2317
- return "shared";
2318
- } catch (error) {
2319
- if (isAbort(error)) return "cancelled";
2190
+ var bubbleVariants = cva(
2191
+ "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
2192
+ {
2193
+ variants: {
2194
+ variant: {
2195
+ default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
2196
+ secondary: "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
2197
+ muted: "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
2198
+ tinted: "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
2199
+ outline: "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
2200
+ ghost: "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
2201
+ destructive: "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30"
2202
+ }
2203
+ },
2204
+ defaultVariants: {
2205
+ variant: "default"
2320
2206
  }
2321
2207
  }
2322
- const copy = copyText;
2323
- const ok = await copy(options.url);
2324
- return ok ? "copied" : "failed";
2325
- }
2326
-
2327
- // src/studio/lib/embed-urls.ts
2328
- function playerWatchUrl(streamId, origin = "https://player.livo.tv") {
2329
- return `${origin.replace(/\/$/, "")}/${streamId}`;
2330
- }
2331
- function Dialog({ ...props }) {
2332
- return /* @__PURE__ */ jsx(DialogPrimitive.Root, { "data-slot": "dialog", ...props });
2333
- }
2334
- function DialogPortal({
2335
- ...props
2336
- }) {
2337
- return /* @__PURE__ */ jsx(DialogPrimitive.Portal, { "data-slot": "dialog-portal", ...props });
2338
- }
2339
- function DialogOverlay({
2208
+ );
2209
+ function Bubble({
2210
+ variant = "default",
2211
+ align = "start",
2340
2212
  className,
2341
2213
  ...props
2342
2214
  }) {
2343
2215
  return /* @__PURE__ */ jsx(
2344
- DialogPrimitive.Overlay,
2216
+ "div",
2345
2217
  {
2346
- "data-slot": "dialog-overlay",
2347
- className: cn(
2348
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
2349
- className
2350
- ),
2218
+ "data-slot": "bubble",
2219
+ "data-variant": variant,
2220
+ "data-align": align,
2221
+ className: cn(bubbleVariants({ variant }), className),
2351
2222
  ...props
2352
2223
  }
2353
2224
  );
2354
2225
  }
2355
- function DialogContent({
2226
+ function BubbleContent({
2356
2227
  className,
2357
- children,
2358
- showCloseButton = true,
2228
+ render,
2359
2229
  ...props
2360
2230
  }) {
2361
- return /* @__PURE__ */ jsxs(DialogPortal, { children: [
2362
- /* @__PURE__ */ jsx(DialogOverlay, {}),
2363
- /* @__PURE__ */ jsxs(
2364
- DialogPrimitive.Content,
2231
+ return useRender({
2232
+ defaultTagName: "div",
2233
+ props: mergeProps(
2365
2234
  {
2366
- "data-slot": "dialog-content",
2367
2235
  className: cn(
2368
- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
2236
+ "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
2369
2237
  className
2370
- ),
2371
- ...props,
2372
- children: [
2373
- children,
2374
- showCloseButton ? /* @__PURE__ */ jsxs(DialogPrimitive.Close, { className: "ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none", children: [
2375
- /* @__PURE__ */ jsx(XIcon, { className: "size-4" }),
2376
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Close" })
2377
- ] }) : null
2378
- ]
2379
- }
2380
- )
2381
- ] });
2238
+ )
2239
+ },
2240
+ props
2241
+ ),
2242
+ render,
2243
+ state: {
2244
+ slot: "bubble-content"
2245
+ }
2246
+ });
2382
2247
  }
2383
- function DialogHeader({ className, ...props }) {
2384
- return /* @__PURE__ */ jsx(
2385
- "div",
2386
- {
2387
- "data-slot": "dialog-header",
2388
- className: cn("flex flex-col gap-2 text-center sm:text-left", className),
2389
- ...props
2248
+ cva(
2249
+ "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
2250
+ {
2251
+ variants: {
2252
+ side: {
2253
+ top: "top-0 -translate-y-3/4",
2254
+ bottom: "bottom-0 translate-y-3/4"
2255
+ },
2256
+ align: {
2257
+ start: "left-3",
2258
+ end: "right-3"
2259
+ }
2260
+ },
2261
+ defaultVariants: {
2262
+ side: "bottom",
2263
+ align: "end"
2390
2264
  }
2391
- );
2265
+ }
2266
+ );
2267
+
2268
+ // src/studio/lib/chat-groups.ts
2269
+ function groupChatMessages(messages, selfUserId) {
2270
+ const groups = [];
2271
+ for (const row of messages) {
2272
+ const mine = Boolean(selfUserId) && row.userId === selfUserId;
2273
+ const last = groups[groups.length - 1];
2274
+ if (last && last.userId === row.userId && last.mine === mine) {
2275
+ last.rows.push(row);
2276
+ continue;
2277
+ }
2278
+ groups.push({
2279
+ userId: row.userId,
2280
+ name: row.name,
2281
+ mine,
2282
+ rows: [row]
2283
+ });
2284
+ }
2285
+ return groups;
2392
2286
  }
2393
- function DialogTitle({
2394
- className,
2395
- ...props
2287
+ function senderInitials(name) {
2288
+ const parts = name.trim().split(/\s+/).filter((part) => part.length > 0);
2289
+ if (parts.length === 0) return "?";
2290
+ const first = parts[0];
2291
+ if (parts.length === 1) return first.slice(0, 2).toUpperCase();
2292
+ return `${first[0]}${parts[1][0]}`.toUpperCase();
2293
+ }
2294
+ function StudioChatPanel({
2295
+ meeting,
2296
+ selfUserId,
2297
+ participants,
2298
+ isModerator: isModerator2,
2299
+ labels
2396
2300
  }) {
2397
- return /* @__PURE__ */ jsx(
2398
- DialogPrimitive.Title,
2399
- {
2400
- "data-slot": "dialog-title",
2401
- className: cn("text-lg leading-none font-semibold", className),
2402
- ...props
2301
+ const [tick, setTick] = useState(0);
2302
+ const [draft, setDraft] = useState("");
2303
+ const [editingId, setEditingId] = useState(null);
2304
+ const listRef = useRef(null);
2305
+ useEffect(
2306
+ () => meeting.subscribeChat(() => setTick((n) => n + 1)),
2307
+ [meeting]
2308
+ );
2309
+ const messages = useMemo(
2310
+ () => meeting.chatMessages().filter((row) => !row.targetUserIds?.length),
2311
+ [meeting, tick]
2312
+ );
2313
+ const pinned = useMemo(() => meeting.pinnedChatMessages(), [meeting, tick]);
2314
+ const limit = meeting.chatMaxTextLimit();
2315
+ const pictures = useMemo(() => {
2316
+ const map = /* @__PURE__ */ new Map();
2317
+ for (const row of participants) {
2318
+ if (row.userId) map.set(row.userId, row.picture);
2403
2319
  }
2320
+ return map;
2321
+ }, [participants]);
2322
+ const groups = useMemo(
2323
+ () => groupChatMessages(messages, selfUserId),
2324
+ [messages, selfUserId]
2404
2325
  );
2405
- }
2406
- function DialogDescription({
2326
+ useEffect(() => {
2327
+ const list = listRef.current;
2328
+ if (!list) return;
2329
+ list.scrollTop = list.scrollHeight;
2330
+ }, [messages.length]);
2331
+ async function onSend() {
2332
+ const text = draft.trim();
2333
+ if (!text) return;
2334
+ setDraft("");
2335
+ try {
2336
+ if (editingId) {
2337
+ await meeting.editChatMessage(editingId, text);
2338
+ setEditingId(null);
2339
+ return;
2340
+ }
2341
+ await meeting.sendChatMessage(text);
2342
+ } catch (error) {
2343
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
2344
+ toast.error(
2345
+ message.includes("rate") ? labels.chatRateLimited : labels.chatError
2346
+ );
2347
+ }
2348
+ }
2349
+ function messageActions(row, mine) {
2350
+ return /* @__PURE__ */ jsxs(
2351
+ "p",
2352
+ {
2353
+ className: mine ? "text-muted-foreground flex flex-wrap justify-end gap-2 text-xs" : "text-muted-foreground flex flex-wrap gap-2 text-xs",
2354
+ children: [
2355
+ row.isEdited ? /* @__PURE__ */ jsx("span", { children: labels.chatEdited }) : null,
2356
+ mine ? /* @__PURE__ */ jsx(
2357
+ "button",
2358
+ {
2359
+ type: "button",
2360
+ onClick: () => {
2361
+ setEditingId(row.id);
2362
+ setDraft(row.text);
2363
+ },
2364
+ children: labels.chatEdit
2365
+ }
2366
+ ) : null,
2367
+ isModerator2 || mine ? /* @__PURE__ */ jsx(
2368
+ "button",
2369
+ {
2370
+ type: "button",
2371
+ onClick: () => void meeting.deleteChatMessage(row.id).catch(() => toast.error(labels.chatError)),
2372
+ children: labels.chatDelete
2373
+ }
2374
+ ) : null,
2375
+ isModerator2 ? /* @__PURE__ */ jsx(
2376
+ "button",
2377
+ {
2378
+ type: "button",
2379
+ onClick: () => void meeting.pinChatMessage(row.id, row.pinned !== true).catch(() => toast.error(labels.chatError)),
2380
+ children: row.pinned ? labels.chatUnpin : labels.chatPin
2381
+ }
2382
+ ) : null
2383
+ ]
2384
+ }
2385
+ );
2386
+ }
2387
+ return /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [
2388
+ pinned[0] ? /* @__PURE__ */ jsxs("div", { role: "status", className: "bg-muted border-b px-3 py-2 text-sm", children: [
2389
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: labels.chatPinned }),
2390
+ /* @__PURE__ */ jsx("p", { children: pinned[0].text })
2391
+ ] }) : null,
2392
+ /* @__PURE__ */ jsxs(
2393
+ "div",
2394
+ {
2395
+ ref: listRef,
2396
+ role: "log",
2397
+ "aria-live": "polite",
2398
+ className: "flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3",
2399
+ children: [
2400
+ /* @__PURE__ */ jsx(
2401
+ Button,
2402
+ {
2403
+ type: "button",
2404
+ size: "xs",
2405
+ variant: "ghost",
2406
+ className: "self-center",
2407
+ onClick: () => {
2408
+ const oldest = messages[0]?.time;
2409
+ void meeting.fetchChatHistory({
2410
+ timestamp: oldest
2411
+ });
2412
+ },
2413
+ children: labels.chatLoadOlder
2414
+ }
2415
+ ),
2416
+ groups.map((group) => {
2417
+ const bubbles = group.rows.map((row) => /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [
2418
+ /* @__PURE__ */ jsx(
2419
+ Bubble,
2420
+ {
2421
+ align: group.mine ? "end" : "start",
2422
+ variant: group.mine ? "default" : "secondary",
2423
+ children: /* @__PURE__ */ jsx(BubbleContent, { children: row.text })
2424
+ }
2425
+ ),
2426
+ messageActions(row, group.mine)
2427
+ ] }, row.id));
2428
+ if (group.mine) {
2429
+ return /* @__PURE__ */ jsx(BubbleGroup, { className: "items-end", children: bubbles }, group.rows[0].id);
2430
+ }
2431
+ const picture = pictures.get(group.userId) ?? null;
2432
+ return /* @__PURE__ */ jsxs(
2433
+ "div",
2434
+ {
2435
+ className: "flex max-w-full gap-2 self-start",
2436
+ children: [
2437
+ picture ? /* @__PURE__ */ jsx(
2438
+ "img",
2439
+ {
2440
+ src: picture,
2441
+ alt: "",
2442
+ className: "mt-0.5 size-7 shrink-0 rounded-full object-cover"
2443
+ }
2444
+ ) : /* @__PURE__ */ jsx("span", { className: "bg-muted text-muted-foreground mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-xs", children: senderInitials(group.name) }),
2445
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
2446
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground mb-1 text-xs", children: group.name }),
2447
+ /* @__PURE__ */ jsx(BubbleGroup, { children: bubbles })
2448
+ ] })
2449
+ ]
2450
+ },
2451
+ group.rows[0].id
2452
+ );
2453
+ })
2454
+ ]
2455
+ }
2456
+ ),
2457
+ /* @__PURE__ */ jsxs(
2458
+ "form",
2459
+ {
2460
+ className: "flex flex-col gap-2 border-t p-3",
2461
+ onSubmit: (event) => {
2462
+ event.preventDefault();
2463
+ void onSend();
2464
+ },
2465
+ children: [
2466
+ /* @__PURE__ */ jsx(
2467
+ Input,
2468
+ {
2469
+ className: "min-w-0 flex-1",
2470
+ value: draft,
2471
+ maxLength: limit,
2472
+ onChange: (event) => setDraft(event.target.value),
2473
+ placeholder: labels.chatPlaceholder,
2474
+ "aria-label": labels.chatPlaceholder
2475
+ }
2476
+ ),
2477
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
2478
+ /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground text-xs", children: [
2479
+ draft.length,
2480
+ "/",
2481
+ limit
2482
+ ] }),
2483
+ /* @__PURE__ */ jsx(Button, { type: "submit", size: "sm", children: editingId ? labels.chatSave : labels.chatSend })
2484
+ ] })
2485
+ ]
2486
+ }
2487
+ )
2488
+ ] });
2489
+ }
2490
+ function StudioWaitingRoom({
2491
+ waitlisted,
2492
+ labels,
2493
+ onAdmitAs,
2494
+ onAdmitAll,
2495
+ onDeny
2496
+ }) {
2497
+ return /* @__PURE__ */ jsxs("section", { "aria-label": labels.waiting, className: "space-y-3", children: [
2498
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.waiting }),
2499
+ waitlisted.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.noWaiting }) : /* @__PURE__ */ jsxs(Fragment, { children: [
2500
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
2501
+ /* @__PURE__ */ jsx(
2502
+ Button,
2503
+ {
2504
+ type: "button",
2505
+ size: "xs",
2506
+ className: "w-full",
2507
+ onClick: () => onAdmitAll("panelist"),
2508
+ children: labels.admitAllPanelists
2509
+ }
2510
+ ),
2511
+ /* @__PURE__ */ jsx(
2512
+ Button,
2513
+ {
2514
+ type: "button",
2515
+ size: "xs",
2516
+ variant: "outline",
2517
+ className: "w-full",
2518
+ onClick: () => onAdmitAll("audience"),
2519
+ children: labels.admitAllAudience
2520
+ }
2521
+ )
2522
+ ] }),
2523
+ /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: waitlisted.map((guest) => /* @__PURE__ */ jsxs("li", { className: "space-y-2 rounded-md border p-2", children: [
2524
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: guest.name }),
2525
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
2526
+ /* @__PURE__ */ jsx(
2527
+ Button,
2528
+ {
2529
+ type: "button",
2530
+ size: "xs",
2531
+ className: "w-full",
2532
+ onClick: () => onAdmitAs(guest.id, "panelist"),
2533
+ children: labels.admitAsPanelist
2534
+ }
2535
+ ),
2536
+ /* @__PURE__ */ jsx(
2537
+ Button,
2538
+ {
2539
+ type: "button",
2540
+ size: "xs",
2541
+ variant: "outline",
2542
+ className: "w-full",
2543
+ onClick: () => onAdmitAs(guest.id, "audience"),
2544
+ children: labels.admitAsAudience
2545
+ }
2546
+ ),
2547
+ /* @__PURE__ */ jsx(
2548
+ Button,
2549
+ {
2550
+ type: "button",
2551
+ size: "xs",
2552
+ variant: "ghost",
2553
+ className: "w-full",
2554
+ onClick: () => onDeny(guest.id),
2555
+ children: labels.deny
2556
+ }
2557
+ )
2558
+ ] })
2559
+ ] }, guest.id)) })
2560
+ ] })
2561
+ ] });
2562
+ }
2563
+ function StudioSidePanel({
2564
+ tab,
2565
+ onTabChange,
2566
+ onClose,
2567
+ meeting,
2568
+ selfUserId,
2569
+ participants,
2570
+ waitlisted,
2571
+ stageRequests,
2572
+ isModerator: isModerator2,
2573
+ chatBadge,
2574
+ peopleBadge,
2575
+ labels,
2576
+ onAdmitAs,
2577
+ onAdmitAll,
2578
+ onDeny,
2579
+ onGrantStage,
2580
+ onDenyStage,
2581
+ onTakeOffAir,
2582
+ guestUrl,
2583
+ onShareGuestLink
2584
+ }) {
2585
+ const panelists = participants.filter((row) => isOnStage(row.stageStatus));
2586
+ const audience = participants.filter((row) => !isOnStage(row.stageStatus));
2587
+ return /* @__PURE__ */ jsxs("aside", { className: "bg-background flex min-h-0 w-full flex-1 flex-col border-t md:w-80 md:flex-none md:border-l md:border-t-0", children: [
2588
+ /* @__PURE__ */ jsxs("div", { className: "flex border-b", children: [
2589
+ /* @__PURE__ */ jsxs(
2590
+ Button,
2591
+ {
2592
+ type: "button",
2593
+ size: "sm",
2594
+ variant: tab === "chat" ? "secondary" : "ghost",
2595
+ className: "relative flex-1 rounded-none",
2596
+ onClick: () => onTabChange("chat"),
2597
+ children: [
2598
+ labels.chat,
2599
+ chatBadge > 0 ? /* @__PURE__ */ jsx("span", { className: "bg-primary text-primary-foreground ml-2 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.65rem]", children: chatBadge }) : null
2600
+ ]
2601
+ }
2602
+ ),
2603
+ isModerator2 ? /* @__PURE__ */ jsxs(
2604
+ Button,
2605
+ {
2606
+ type: "button",
2607
+ size: "sm",
2608
+ variant: tab === "people" ? "secondary" : "ghost",
2609
+ className: "relative flex-1 rounded-none",
2610
+ onClick: () => onTabChange("people"),
2611
+ children: [
2612
+ labels.people,
2613
+ peopleBadge > 0 ? /* @__PURE__ */ jsx("span", { className: "bg-primary text-primary-foreground ml-2 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.65rem]", children: peopleBadge }) : null
2614
+ ]
2615
+ }
2616
+ ) : null,
2617
+ /* @__PURE__ */ jsx(
2618
+ Button,
2619
+ {
2620
+ type: "button",
2621
+ size: "icon-xs",
2622
+ variant: "ghost",
2623
+ className: "m-1 shrink-0",
2624
+ "aria-label": labels.closePanel,
2625
+ onClick: onClose,
2626
+ children: /* @__PURE__ */ jsx(X, {})
2627
+ }
2628
+ )
2629
+ ] }),
2630
+ tab === "chat" ? /* @__PURE__ */ jsx(
2631
+ StudioChatPanel,
2632
+ {
2633
+ meeting,
2634
+ selfUserId,
2635
+ participants,
2636
+ isModerator: isModerator2,
2637
+ labels
2638
+ }
2639
+ ) : /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3", children: [
2640
+ guestUrl ? /* @__PURE__ */ jsxs(
2641
+ Button,
2642
+ {
2643
+ type: "button",
2644
+ variant: "outline",
2645
+ className: "w-full",
2646
+ onClick: onShareGuestLink,
2647
+ children: [
2648
+ /* @__PURE__ */ jsx(Link2, {}),
2649
+ labels.shareLink
2650
+ ]
2651
+ }
2652
+ ) : null,
2653
+ /* @__PURE__ */ jsx(
2654
+ StudioWaitingRoom,
2655
+ {
2656
+ waitlisted,
2657
+ labels,
2658
+ onAdmitAs,
2659
+ onAdmitAll,
2660
+ onDeny
2661
+ }
2662
+ ),
2663
+ stageRequests.length > 0 ? /* @__PURE__ */ jsxs("section", { "aria-label": labels.stageRequests, className: "space-y-2", children: [
2664
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.stageRequests }),
2665
+ /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: stageRequests.map((request) => /* @__PURE__ */ jsxs(
2666
+ "li",
2667
+ {
2668
+ className: "space-y-2 rounded-md border p-2",
2669
+ children: [
2670
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: request.name }),
2671
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
2672
+ /* @__PURE__ */ jsx(
2673
+ Button,
2674
+ {
2675
+ type: "button",
2676
+ size: "xs",
2677
+ className: "w-full",
2678
+ onClick: () => onGrantStage(request.userId),
2679
+ children: labels.bringOnAir
2680
+ }
2681
+ ),
2682
+ /* @__PURE__ */ jsx(
2683
+ Button,
2684
+ {
2685
+ type: "button",
2686
+ size: "xs",
2687
+ variant: "outline",
2688
+ className: "w-full",
2689
+ onClick: () => onDenyStage(request.userId),
2690
+ children: labels.deny
2691
+ }
2692
+ )
2693
+ ] })
2694
+ ]
2695
+ },
2696
+ request.userId
2697
+ )) })
2698
+ ] }) : null,
2699
+ /* @__PURE__ */ jsxs("section", { "aria-label": labels.panelists, className: "space-y-2", children: [
2700
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.panelists }),
2701
+ panelists.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.noPanelists }) : /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: panelists.map((row) => {
2702
+ const userId = row.userId;
2703
+ return /* @__PURE__ */ jsxs(
2704
+ "li",
2705
+ {
2706
+ className: "space-y-2 rounded-md border p-2",
2707
+ children: [
2708
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: row.name }),
2709
+ !row.isSelf && userId ? /* @__PURE__ */ jsx(
2710
+ Button,
2711
+ {
2712
+ type: "button",
2713
+ size: "xs",
2714
+ variant: "outline",
2715
+ className: "w-full",
2716
+ onClick: () => onTakeOffAir(userId),
2717
+ children: labels.offAir
2718
+ }
2719
+ ) : null
2720
+ ]
2721
+ },
2722
+ row.id
2723
+ );
2724
+ }) })
2725
+ ] }),
2726
+ /* @__PURE__ */ jsxs("section", { "aria-label": labels.audience, className: "space-y-2", children: [
2727
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.audience }),
2728
+ audience.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.noAudience }) : /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: audience.map((row) => {
2729
+ const userId = row.userId;
2730
+ return /* @__PURE__ */ jsxs(
2731
+ "li",
2732
+ {
2733
+ className: "space-y-2 rounded-md border p-2",
2734
+ children: [
2735
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: row.name }),
2736
+ !row.isSelf && userId ? /* @__PURE__ */ jsx(
2737
+ Button,
2738
+ {
2739
+ type: "button",
2740
+ size: "xs",
2741
+ variant: "outline",
2742
+ className: "w-full",
2743
+ onClick: () => onGrantStage(userId),
2744
+ children: labels.bringOnAir
2745
+ }
2746
+ ) : null
2747
+ ]
2748
+ },
2749
+ row.id
2750
+ );
2751
+ }) })
2752
+ ] })
2753
+ ] })
2754
+ ] });
2755
+ }
2756
+ function DropdownMenu({ ...props }) {
2757
+ return /* @__PURE__ */ jsx(Menu.Root, { "data-slot": "dropdown-menu", ...props });
2758
+ }
2759
+ function DropdownMenuTrigger({ ...props }) {
2760
+ return /* @__PURE__ */ jsx(Menu.Trigger, { "data-slot": "dropdown-menu-trigger", ...props });
2761
+ }
2762
+ function DropdownMenuContent({
2763
+ align = "start",
2764
+ alignOffset = 0,
2765
+ side = "bottom",
2766
+ sideOffset = 4,
2407
2767
  className,
2408
2768
  ...props
2409
2769
  }) {
2410
- return /* @__PURE__ */ jsx(
2411
- DialogPrimitive.Description,
2770
+ return /* @__PURE__ */ jsx(Menu.Portal, { children: /* @__PURE__ */ jsx(
2771
+ Menu.Positioner,
2412
2772
  {
2413
- "data-slot": "dialog-description",
2414
- className: cn("text-muted-foreground text-sm", className),
2415
- ...props
2773
+ className: "isolate z-50 outline-none",
2774
+ align,
2775
+ alignOffset,
2776
+ side,
2777
+ sideOffset,
2778
+ children: /* @__PURE__ */ jsx(
2779
+ Menu.Popup,
2780
+ {
2781
+ "data-slot": "dropdown-menu-content",
2782
+ className: cn(
2783
+ "z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
2784
+ className
2785
+ ),
2786
+ ...props
2787
+ }
2788
+ )
2416
2789
  }
2417
- );
2790
+ ) });
2418
2791
  }
2419
- function Input({ className, type, ...props }) {
2792
+ function DropdownMenuItem({
2793
+ className,
2794
+ inset,
2795
+ variant = "default",
2796
+ ...props
2797
+ }) {
2420
2798
  return /* @__PURE__ */ jsx(
2421
- Input$1,
2799
+ Menu.Item,
2422
2800
  {
2423
- type,
2424
- "data-slot": "input",
2801
+ "data-slot": "dropdown-menu-item",
2802
+ "data-inset": inset,
2803
+ "data-variant": variant,
2425
2804
  className: cn(
2426
- "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
2805
+ "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
2427
2806
  className
2428
2807
  ),
2429
2808
  ...props
2430
2809
  }
2431
2810
  );
2432
2811
  }
2433
- function Label({
2812
+ function DropdownMenuSeparator({
2434
2813
  className,
2435
2814
  ...props
2436
2815
  }) {
2437
2816
  return /* @__PURE__ */ jsx(
2438
- LabelPrimitive.Root,
2817
+ Menu.Separator,
2439
2818
  {
2440
- "data-slot": "label",
2441
- className: cn(
2442
- "flex items-center gap-2 text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
2443
- className
2444
- ),
2819
+ "data-slot": "dropdown-menu-separator",
2820
+ className: cn("-mx-1 my-1 h-px bg-border", className),
2445
2821
  ...props
2446
2822
  }
2447
2823
  );
2448
2824
  }
2449
-
2450
- // src/studio/lib/speaker-test.ts
2451
- var TEST_MS = 400;
2452
- var TEST_HZ = 440;
2453
- async function playSpeakerTest(deviceId) {
2454
- const AudioCtx = window.AudioContext || window.webkitAudioContext;
2455
- if (!AudioCtx) {
2456
- throw new Error("AudioContext unavailable");
2457
- }
2458
- const ctx = new AudioCtx();
2459
- const destination = ctx.createMediaStreamDestination();
2460
- const oscillator = ctx.createOscillator();
2461
- const gain = ctx.createGain();
2462
- oscillator.type = "sine";
2463
- oscillator.frequency.value = TEST_HZ;
2464
- gain.gain.value = 0.18;
2465
- oscillator.connect(gain);
2466
- gain.connect(destination);
2467
- const audio = document.createElement("audio");
2468
- audio.autoplay = true;
2469
- audio.srcObject = destination.stream;
2470
- try {
2471
- const sink = audio;
2472
- if (typeof sink.setSinkId !== "function") {
2473
- throw new Error("setSinkId unsupported");
2474
- }
2475
- await sink.setSinkId(deviceId);
2476
- oscillator.start();
2477
- await ctx.resume();
2478
- await new Promise((resolve) => {
2479
- window.setTimeout(resolve, TEST_MS);
2480
- });
2481
- oscillator.stop();
2482
- } finally {
2483
- audio.srcObject = null;
2484
- audio.remove();
2485
- await ctx.close();
2486
- }
2825
+ function StudioTileMenu({
2826
+ tile,
2827
+ canModerate,
2828
+ canManageStage,
2829
+ takeOffBlocked,
2830
+ labels,
2831
+ onKick,
2832
+ onMute,
2833
+ onStopCamera,
2834
+ onStopScreen,
2835
+ onToggleStage,
2836
+ onTogglePin,
2837
+ onToggleChat
2838
+ }) {
2839
+ const onStage = isOnStage(tile.stageStatus);
2840
+ const showPersonItems = canModerate && tile.kind !== "screen";
2841
+ const showScreenItems = canModerate && tile.kind === "screen";
2842
+ const showStageItems = canManageStage && tile.kind !== "screen";
2843
+ if (!showPersonItems && !showScreenItems && !showStageItems) return null;
2844
+ return /* @__PURE__ */ jsxs(DropdownMenu, { children: [
2845
+ /* @__PURE__ */ jsx(
2846
+ DropdownMenuTrigger,
2847
+ {
2848
+ render: /* @__PURE__ */ jsx(
2849
+ Button,
2850
+ {
2851
+ type: "button",
2852
+ size: "icon-xs",
2853
+ variant: "ghost",
2854
+ className: "text-white hover:text-white",
2855
+ "aria-label": labels.tileMenu
2856
+ }
2857
+ ),
2858
+ children: /* @__PURE__ */ jsx(MoreVertical, { className: "size-3.5" })
2859
+ }
2860
+ ),
2861
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", className: "min-w-44", children: [
2862
+ showScreenItems ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onStopScreen, children: labels.stopScreen }) : null,
2863
+ showPersonItems && tile.audioEnabled ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onMute, children: labels.mute }) : null,
2864
+ showPersonItems && tile.kind === "camera" && tile.videoEnabled ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onStopCamera, children: labels.stopCamera }) : null,
2865
+ showPersonItems || showScreenItems ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onTogglePin, children: tile.pinned ? labels.unpin : labels.pin }) : null,
2866
+ showPersonItems ? /* @__PURE__ */ jsx(DropdownMenuItem, { onClick: onToggleChat, children: tile.chatCanSend === false ? labels.allowChat : labels.muteChat }) : null,
2867
+ showStageItems ? /* @__PURE__ */ jsx(
2868
+ DropdownMenuItem,
2869
+ {
2870
+ disabled: onStage && takeOffBlocked,
2871
+ onClick: onToggleStage,
2872
+ children: onStage ? takeOffBlocked ? labels.offAirLastVisible : labels.takeOffAir : labels.bringOnAir
2873
+ }
2874
+ ) : null,
2875
+ showPersonItems ? /* @__PURE__ */ jsxs(Fragment, { children: [
2876
+ /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
2877
+ /* @__PURE__ */ jsx(DropdownMenuItem, { variant: "destructive", onClick: onKick, children: labels.kick })
2878
+ ] }) : null
2879
+ ] })
2880
+ ] });
2487
2881
  }
2488
- function StudioSettingsDialog({
2489
- open,
2490
- onOpenChange,
2882
+ function StudioTile({
2883
+ tile,
2491
2884
  meeting,
2492
- labels,
2493
2885
  reducedMotion,
2494
- cameraOn
2886
+ compact,
2887
+ fill = false,
2888
+ canModerate,
2889
+ canManageStage,
2890
+ takeOffBlocked,
2891
+ confirmTakeOff,
2892
+ labels,
2893
+ onKick,
2894
+ onMute,
2895
+ onStopCamera,
2896
+ onStopScreen,
2897
+ onToggleStage,
2898
+ onTogglePin,
2899
+ onToggleChat
2495
2900
  }) {
2496
- const [devices, setDevices] = useState({
2497
- audio: [],
2498
- video: [],
2499
- speaker: []
2500
- });
2501
- const [selected, setSelected] = useState({
2502
- audio: "",
2503
- video: "",
2504
- speaker: ""
2505
- });
2506
- const [audioTrack, setAudioTrack] = useState(null);
2507
- const [videoTrack, setVideoTrack] = useState(null);
2508
- const [testingSpeaker, setTestingSpeaker] = useState(false);
2509
- const [displayName, setDisplayName] = useState("");
2510
- const [savedDisplayName, setSavedDisplayName] = useState("");
2511
- const [savingDisplayName, setSavingDisplayName] = useState(false);
2512
- useEffect(() => {
2513
- if (!open) return;
2514
- const selfName = meeting.participants().find((row) => row.isSelf)?.name?.trim() ?? "";
2515
- setDisplayName(selfName);
2516
- setSavedDisplayName(selfName);
2517
- }, [meeting, open]);
2518
- useEffect(() => {
2519
- if (!open) return;
2520
- const sync = () => {
2521
- setAudioTrack(meeting.localAudioTrack());
2522
- setVideoTrack(meeting.localVideoTrack());
2523
- };
2524
- sync();
2525
- return meeting.subscribeMedia(sync);
2526
- }, [meeting, open]);
2527
- useEffect(() => {
2528
- if (!open) return;
2529
- let cancelled = false;
2530
- void meeting.listDevices().then((next) => {
2531
- if (cancelled) return;
2532
- setDevices(next);
2533
- setSelected((prev) => ({
2534
- audio: prev.audio || next.audio[0]?.deviceId || "",
2535
- video: prev.video || next.video[0]?.deviceId || "",
2536
- speaker: prev.speaker || next.speaker[0]?.deviceId || ""
2537
- }));
2538
- }).catch(() => {
2539
- });
2540
- return () => {
2541
- cancelled = true;
2542
- };
2543
- }, [meeting, open]);
2544
- const trimmedDisplayName = displayName.trim();
2545
- const displayNameUnchanged = trimmedDisplayName === savedDisplayName;
2546
- const displayNameSaveDisabled = !trimmedDisplayName || displayNameUnchanged || savingDisplayName;
2547
- async function onChange(kind, deviceId) {
2548
- setSelected((prev) => ({ ...prev, [kind]: deviceId }));
2549
- await meeting.setDevice(kind, deviceId);
2550
- }
2551
- async function onSaveDisplayName() {
2552
- if (displayNameSaveDisabled) return;
2553
- setSavingDisplayName(true);
2554
- try {
2555
- await meeting.setDisplayName(trimmedDisplayName);
2556
- setSavedDisplayName(trimmedDisplayName);
2557
- setDisplayName(trimmedDisplayName);
2558
- toast.success(labels.displayNameSaved);
2559
- } catch {
2560
- toast.error(labels.displayNameError);
2561
- } finally {
2562
- setSavingDisplayName(false);
2563
- }
2564
- }
2565
- async function onTestSpeaker() {
2566
- if (!selected.speaker || testingSpeaker) return;
2567
- setTestingSpeaker(true);
2568
- try {
2569
- await playSpeakerTest(selected.speaker);
2570
- } catch {
2571
- toast.error(labels.testSpeakerError);
2572
- } finally {
2573
- setTestingSpeaker(false);
2574
- }
2575
- }
2576
- return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-md", children: [
2577
- /* @__PURE__ */ jsxs(DialogHeader, { children: [
2578
- /* @__PURE__ */ jsx(DialogTitle, { children: labels.settingsTitle }),
2579
- /* @__PURE__ */ jsx(DialogDescription, { children: labels.settingsDescription })
2580
- ] }),
2581
- /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
2582
- /* @__PURE__ */ jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
2583
- /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
2901
+ const onStage = isOnStage(tile.stageStatus);
2902
+ const [confirm, setConfirm] = useState(null);
2903
+ const showMenu = (canModerate || canManageStage) && !tile.isSelf;
2904
+ return /* @__PURE__ */ jsxs(
2905
+ "article",
2906
+ {
2907
+ "aria-label": tile.name,
2908
+ className: cn(
2909
+ "bg-muted relative overflow-hidden rounded-xl",
2910
+ compact ? "aspect-video w-40 shrink-0 md:w-full" : tile.kind === "screen" ? "min-h-0 flex-1 md:min-h-48" : fill ? "size-full min-h-0 md:aspect-video md:h-auto" : "aspect-video"
2911
+ ),
2912
+ children: [
2913
+ tile.kind === "idle" ? /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
2914
+ tile.picture ? /* @__PURE__ */ jsx(
2915
+ "img",
2916
+ {
2917
+ src: tile.picture,
2918
+ alt: "",
2919
+ className: "size-16 rounded-full object-cover"
2920
+ }
2921
+ ) : /* @__PURE__ */ jsx("div", { className: "bg-background flex size-16 items-center justify-center rounded-full text-xl", children: tile.name.slice(0, 1).toUpperCase() }),
2922
+ /* @__PURE__ */ jsx(
2923
+ AudioWaveform,
2924
+ {
2925
+ track: tile.isSelf ? meeting.localAudioTrack() : null,
2926
+ reducedMotion
2927
+ }
2928
+ )
2929
+ ] }) : /* @__PURE__ */ jsx(
2930
+ StudioTileVideo,
2931
+ {
2932
+ meeting,
2933
+ participantId: tile.id,
2934
+ kind: tile.kind,
2935
+ mirror: tile.kind === "camera" && tile.isSelf,
2936
+ contain: tile.kind === "screen"
2937
+ }
2938
+ ),
2939
+ /* @__PURE__ */ jsxs("div", { className: "absolute inset-x-0 bottom-0 z-10 flex items-end justify-between gap-2 bg-linear-to-t from-black/70 to-transparent p-2", children: [
2940
+ /* @__PURE__ */ jsxs("p", { className: "min-w-0 truncate text-xs font-medium text-white", children: [
2941
+ tile.name,
2942
+ !onStage ? /* @__PURE__ */ jsx("span", { className: "ml-1 rounded-full border border-white/40 px-1.5 py-0.5 text-[0.65rem]", children: labels.offAir }) : null
2943
+ ] }),
2944
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
2945
+ /* @__PURE__ */ jsx(TileStatusIcons, { tile, labels }),
2946
+ showMenu ? /* @__PURE__ */ jsx(
2947
+ StudioTileMenu,
2948
+ {
2949
+ tile,
2950
+ canModerate,
2951
+ canManageStage,
2952
+ takeOffBlocked,
2953
+ labels,
2954
+ onKick: () => setConfirm("kick"),
2955
+ onMute,
2956
+ onStopCamera,
2957
+ onStopScreen,
2958
+ onToggleStage: () => {
2959
+ if (onStage && confirmTakeOff && !takeOffBlocked) {
2960
+ setConfirm("takeOff");
2961
+ return;
2962
+ }
2963
+ onToggleStage();
2964
+ },
2965
+ onTogglePin,
2966
+ onToggleChat
2967
+ }
2968
+ ) : null
2969
+ ] })
2970
+ ] }),
2584
2971
  /* @__PURE__ */ jsx(
2585
- Input,
2972
+ ConfirmAction,
2586
2973
  {
2587
- id: "studio-display-name",
2588
- value: displayName,
2589
- maxLength: STUDIO_DISPLAY_NAME_MAX,
2590
- onChange: (event) => setDisplayName(event.target.value)
2974
+ open: confirm === "kick",
2975
+ onOpenChange: (open) => {
2976
+ if (!open) setConfirm(null);
2977
+ },
2978
+ triggerLabel: labels.kick,
2979
+ title: labels.kickTitle,
2980
+ description: labels.kickDescription,
2981
+ confirmLabel: labels.kickConfirm,
2982
+ onConfirm: () => {
2983
+ setConfirm(null);
2984
+ onKick();
2985
+ }
2591
2986
  }
2592
2987
  ),
2593
2988
  /* @__PURE__ */ jsx(
2594
- Button,
2989
+ ConfirmAction,
2595
2990
  {
2596
- type: "button",
2597
- variant: "outline",
2598
- disabled: displayNameSaveDisabled,
2599
- onClick: () => void onSaveDisplayName(),
2600
- children: labels.displayNameSave
2991
+ open: confirm === "takeOff",
2992
+ onOpenChange: (open) => {
2993
+ if (!open) setConfirm(null);
2994
+ },
2995
+ triggerLabel: labels.takeOffAir,
2996
+ title: labels.takeOffAirTitle,
2997
+ description: labels.takeOffAirDescription,
2998
+ confirmLabel: labels.takeOffAirConfirm,
2999
+ onConfirm: () => {
3000
+ setConfirm(null);
3001
+ onToggleStage();
3002
+ }
2601
3003
  }
2602
3004
  )
2603
- ] })
2604
- ] }),
2605
- /* @__PURE__ */ jsx(
2606
- SettingsCameraPreview,
2607
- {
2608
- track: videoTrack,
2609
- open,
2610
- cameraOn,
2611
- cameraOffLabel: labels.cameraOff
2612
- }
2613
- ),
2614
- /* @__PURE__ */ jsxs("div", { children: [
2615
- /* @__PURE__ */ jsx("p", { className: "sr-only", children: labels.micLevel }),
2616
- /* @__PURE__ */ jsx(
2617
- AudioWaveform,
2618
- {
2619
- track: audioTrack,
2620
- reducedMotion,
2621
- className: "w-full"
2622
- }
2623
- )
2624
- ] }),
2625
- /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-[8.5rem_minmax(0,1fr)] items-center gap-x-3 gap-y-3", children: [
2626
- /* @__PURE__ */ jsx(
2627
- DeviceRow,
2628
- {
2629
- icon: /* @__PURE__ */ jsx(Video, {}),
2630
- label: labels.cameraDevice,
2631
- options: devices.video,
2632
- value: selected.video,
2633
- onChange: (id) => void onChange("video", id)
2634
- }
2635
- ),
2636
- /* @__PURE__ */ jsx(
2637
- DeviceRow,
2638
- {
2639
- icon: /* @__PURE__ */ jsx(Mic, {}),
2640
- label: labels.micDevice,
2641
- options: devices.audio,
2642
- value: selected.audio,
2643
- onChange: (id) => void onChange("audio", id)
2644
- }
2645
- ),
2646
- /* @__PURE__ */ jsx(
2647
- DeviceRow,
2648
- {
2649
- icon: /* @__PURE__ */ jsx(Volume2, {}),
2650
- label: labels.speakerDevice,
2651
- options: devices.speaker,
2652
- value: selected.speaker,
2653
- onChange: (id) => void onChange("speaker", id),
2654
- action: /* @__PURE__ */ jsx(
2655
- Button,
2656
- {
2657
- type: "button",
2658
- size: "sm",
2659
- variant: "outline",
2660
- disabled: !selected.speaker || testingSpeaker,
2661
- onClick: () => void onTestSpeaker(),
2662
- children: labels.testSpeaker
2663
- }
2664
- )
2665
- }
2666
- )
2667
- ] })
2668
- ] }) });
3005
+ ]
3006
+ }
3007
+ );
2669
3008
  }
2670
- function SettingsCameraPreview({
2671
- track,
2672
- open,
2673
- cameraOn,
2674
- cameraOffLabel
3009
+ function TileStatusIcons({
3010
+ tile,
3011
+ labels
3012
+ }) {
3013
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
3014
+ tile.audioEnabled ? null : /* @__PURE__ */ jsx(StatusIcon, { label: labels.mute, children: /* @__PURE__ */ jsx(MicOff, {}) }),
3015
+ tile.kind !== "screen" && !tile.videoEnabled ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.cameraOff, children: /* @__PURE__ */ jsx(VideoOff, {}) }) : null,
3016
+ tile.kind !== "screen" && tile.screenShareEnabled ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.stopScreen, children: /* @__PURE__ */ jsx(MonitorUp, {}) }) : null,
3017
+ tile.pinned ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.pin, children: /* @__PURE__ */ jsx(Pin, {}) }) : null,
3018
+ tile.chatCanSend === false ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.muteChat, children: /* @__PURE__ */ jsx(MessageSquareOff, {}) }) : null
3019
+ ] });
3020
+ }
3021
+ function StatusIcon({
3022
+ label,
3023
+ children
3024
+ }) {
3025
+ return /* @__PURE__ */ jsxs("span", { className: "inline-flex text-white", children: [
3026
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "[&_svg]:size-3.5", children }),
3027
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: label })
3028
+ ] });
3029
+ }
3030
+ function StudioTileVideo({
3031
+ meeting,
3032
+ participantId,
3033
+ kind,
3034
+ mirror,
3035
+ contain
2675
3036
  }) {
2676
3037
  const ref = useRef(null);
2677
3038
  useEffect(() => {
2678
- const el = ref.current;
2679
- if (!el) return;
2680
- if (!open || !cameraOn || !track) {
2681
- el.srcObject = null;
2682
- return;
2683
- }
2684
- el.srcObject = new MediaStream([track]);
2685
- void el.play().catch(() => void 0);
2686
- return () => {
2687
- el.srcObject = null;
2688
- };
2689
- }, [cameraOn, open, track]);
2690
- if (!cameraOn) {
2691
- return /* @__PURE__ */ jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) });
2692
- }
2693
- if (!track) {
2694
- return /* @__PURE__ */ jsx("div", { className: "bg-muted aspect-video w-full rounded-xl", "aria-hidden": true });
2695
- }
2696
- return /* @__PURE__ */ jsx("div", { className: "bg-muted relative aspect-video w-full overflow-hidden rounded-xl", children: /* @__PURE__ */ jsx(
3039
+ meeting.attachVideo(participantId, ref.current, kind);
3040
+ return () => meeting.attachVideo(participantId, null, kind);
3041
+ }, [kind, meeting, participantId]);
3042
+ return /* @__PURE__ */ jsx(
2697
3043
  "video",
2698
3044
  {
2699
3045
  ref,
2700
3046
  autoPlay: true,
2701
- muted: true,
2702
3047
  playsInline: true,
2703
- className: "absolute inset-0 size-full -scale-x-100 object-cover"
3048
+ muted: true,
3049
+ disablePictureInPicture: true,
3050
+ controlsList: "nodownload noplaybackrate noremoteplayback",
3051
+ className: cn(
3052
+ "absolute inset-0 size-full",
3053
+ "pointer-events-none",
3054
+ contain ? "object-contain" : "object-cover",
3055
+ mirror && "-scale-x-100"
3056
+ )
2704
3057
  }
2705
- ) });
2706
- }
2707
- function DeviceRow({
2708
- icon,
2709
- label,
2710
- options,
2711
- value,
2712
- onChange,
2713
- action
2714
- }) {
2715
- return /* @__PURE__ */ jsxs(Fragment, { children: [
2716
- /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground flex min-w-0 items-center gap-2 text-sm", children: [
2717
- /* @__PURE__ */ jsx("span", { className: "[&_svg]:size-4", "aria-hidden": true, children: icon }),
2718
- /* @__PURE__ */ jsx("span", { className: "truncate", children: label })
2719
- ] }),
2720
- /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [
2721
- /* @__PURE__ */ jsx(
2722
- "select",
2723
- {
2724
- "aria-label": label,
2725
- className: cn(
2726
- "border-input bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
2727
- ),
2728
- disabled: options.length === 0,
2729
- value,
2730
- onChange: (event) => onChange(event.target.value),
2731
- children: options.map((option) => /* @__PURE__ */ jsx("option", { value: option.deviceId, children: option.label }, option.deviceId))
2732
- }
2733
- ),
2734
- action
2735
- ] })
2736
- ] });
3058
+ );
2737
3059
  }
2738
- function ControlIcon({
2739
- label,
2740
- pressed = false,
2741
- disabled = false,
2742
- pending = false,
2743
- tone = "default",
2744
- badge,
2745
- onClick,
2746
- children
3060
+ function StudioStage({
3061
+ participants,
3062
+ meeting,
3063
+ reducedMotion,
3064
+ canModerate,
3065
+ canManageStage,
3066
+ onAir,
3067
+ labels,
3068
+ onKick,
3069
+ onMute,
3070
+ onStopCamera,
3071
+ onStopScreen,
3072
+ onToggleStage,
3073
+ onTogglePin,
3074
+ onToggleChat,
3075
+ canTakeOff,
3076
+ onOpenPeople
2747
3077
  }) {
2748
- return /* @__PURE__ */ jsxs(Tooltip, { children: [
2749
- /* @__PURE__ */ jsx(
2750
- TooltipTrigger,
3078
+ const displayTiles = expandStudioTiles(participants);
3079
+ const stage = studioStageLayout(displayTiles, {
3080
+ activeSpeakerId: meeting.activeSpeakerId()
3081
+ });
3082
+ const sharing = stage.spotlight.length > 0;
3083
+ const overflowLabel = labels.stageOverflow.replace(
3084
+ "{count}",
3085
+ String(stage.overflow)
3086
+ );
3087
+ function renderTile(tile, compact, fill = false) {
3088
+ const takeOffBlocked = !canTakeOff(tile.id);
3089
+ return /* @__PURE__ */ jsx(
3090
+ StudioTile,
2751
3091
  {
2752
- render: /* @__PURE__ */ jsx(
2753
- Button,
2754
- {
2755
- type: "button",
2756
- size: "icon-lg",
2757
- variant: tone === "danger" ? "destructive" : pressed ? "default" : "secondary",
2758
- className: "overflow-visible",
2759
- "aria-label": label,
2760
- "aria-pressed": pressed,
2761
- disabled,
2762
- onClick
2763
- }
3092
+ tile,
3093
+ meeting,
3094
+ reducedMotion,
3095
+ compact,
3096
+ fill,
3097
+ canModerate: canModerate && !tile.isSelf,
3098
+ canManageStage: canManageStage && !tile.isSelf,
3099
+ takeOffBlocked,
3100
+ confirmTakeOff: onAir,
3101
+ labels,
3102
+ onKick: () => onKick(tile.id),
3103
+ onMute: () => onMute(tile.id),
3104
+ onStopCamera: () => onStopCamera(tile.id),
3105
+ onStopScreen: () => onStopScreen(tile.id),
3106
+ onToggleStage: () => onToggleStage(tile),
3107
+ onTogglePin: () => onTogglePin(tile),
3108
+ onToggleChat: () => onToggleChat(tile)
3109
+ },
3110
+ tile.tileId
3111
+ );
3112
+ }
3113
+ function overflowChip(compact) {
3114
+ if (stage.overflow <= 0) return null;
3115
+ return /* @__PURE__ */ jsx(
3116
+ Button,
3117
+ {
3118
+ type: "button",
3119
+ variant: "secondary",
3120
+ className: cn(
3121
+ "shrink-0",
3122
+ compact ? "aspect-video w-40 md:w-full" : "aspect-video min-h-24"
2764
3123
  ),
2765
- children: /* @__PURE__ */ jsxs("span", { className: "relative overflow-visible", children: [
2766
- pending ? /* @__PURE__ */ jsx(Loader2, { className: "animate-spin motion-reduce:animate-none" }) : children,
2767
- badge != null && badge > 0 ? /* @__PURE__ */ jsx("span", { className: "bg-primary text-primary-foreground absolute -top-2 -right-2 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.65rem]", children: badge }) : null
2768
- ] })
3124
+ onClick: onOpenPeople,
3125
+ children: overflowLabel
2769
3126
  }
2770
- ),
2771
- /* @__PURE__ */ jsx(TooltipContent, { children: label })
2772
- ] });
3127
+ );
3128
+ }
3129
+ if (sharing) {
3130
+ return /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row", children: [
3131
+ /* @__PURE__ */ jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col gap-2 bg-black p-3", children: stage.spotlight.map((tile) => renderTile(tile, false)) }),
3132
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 gap-2 overflow-x-auto p-2 md:w-64 md:flex-col md:overflow-auto", children: [
3133
+ stage.strip.map((tile) => renderTile(tile, true)),
3134
+ overflowChip(true)
3135
+ ] })
3136
+ ] });
3137
+ }
3138
+ return /* @__PURE__ */ jsxs(
3139
+ "div",
3140
+ {
3141
+ className: cn(
3142
+ "mx-auto grid min-h-0 w-full flex-1 auto-rows-fr gap-2 overflow-hidden p-2 md:auto-rows-auto md:content-center md:gap-3 md:overflow-auto md:p-4",
3143
+ stage.strip.length <= 1 ? "max-w-3xl grid-cols-1" : stage.strip.length <= 2 ? "max-w-5xl grid-cols-1 sm:grid-cols-2" : stage.strip.length <= 4 ? "max-w-5xl grid-cols-2 sm:grid-cols-2" : stage.strip.length <= 6 ? "max-w-6xl grid-cols-2 sm:grid-cols-2 xl:grid-cols-3" : "grid-cols-2 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
3144
+ ),
3145
+ children: [
3146
+ stage.strip.map((tile) => renderTile(tile, false, true)),
3147
+ overflowChip(false)
3148
+ ]
3149
+ }
3150
+ );
2773
3151
  }
2774
3152
  function StudioToolbar({
2775
3153
  session,
@@ -2797,7 +3175,6 @@ function StudioToolbar({
2797
3175
  stageEnabled,
2798
3176
  canLeaveStage,
2799
3177
  reducedMotion,
2800
- playerOrigin,
2801
3178
  onToggleMute,
2802
3179
  onToggleCamera,
2803
3180
  onToggleScreen,
@@ -2813,6 +3190,7 @@ function StudioToolbar({
2813
3190
  onClosePip,
2814
3191
  onLeave
2815
3192
  }) {
3193
+ const fullscreenSupported = document.fullscreenEnabled === true && typeof document.documentElement.requestFullscreen === "function";
2816
3194
  async function enterFullscreen() {
2817
3195
  try {
2818
3196
  await document.documentElement.requestFullscreen();
@@ -2921,49 +3299,14 @@ function StudioToolbar({
2921
3299
  children: /* @__PURE__ */ jsx(Users, {})
2922
3300
  }
2923
3301
  ) : null,
2924
- /* @__PURE__ */ jsx(
2925
- ControlIcon,
2926
- {
2927
- label: labels.share,
2928
- onClick: () => {
2929
- void navigator.clipboard.writeText(
2930
- playerWatchUrl(session.stream.id, playerOrigin)
2931
- );
2932
- toast.success(labels.shareCopied);
2933
- },
2934
- children: /* @__PURE__ */ jsx(Share2, {})
2935
- }
2936
- ),
2937
- session.role === "moderator" && session.guestUrl ? /* @__PURE__ */ jsx(
2938
- ControlIcon,
2939
- {
2940
- label: labels.shareLink,
2941
- onClick: () => {
2942
- const url = session.guestUrl;
2943
- if (!url) return;
2944
- void shareOrCopyLink({
2945
- url,
2946
- title: session.stream.title,
2947
- text: labels.shareGuestText.replace(
2948
- "{title}",
2949
- session.stream.title
2950
- )
2951
- }).then((result) => {
2952
- if (result === "copied") toast.success(labels.copied);
2953
- if (result === "failed") toast.error(labels.shareFailed);
2954
- });
2955
- },
2956
- children: /* @__PURE__ */ jsx(Link2, {})
2957
- }
2958
- ) : null,
2959
- /* @__PURE__ */ jsx(
3302
+ fullscreenSupported ? /* @__PURE__ */ jsx(
2960
3303
  ControlIcon,
2961
3304
  {
2962
3305
  label: labels.fullscreen,
2963
3306
  onClick: () => void enterFullscreen(),
2964
3307
  children: /* @__PURE__ */ jsx(Maximize, {})
2965
3308
  }
2966
- ),
3309
+ ) : null,
2967
3310
  pipSupported ? /* @__PURE__ */ jsx(
2968
3311
  ControlIcon,
2969
3312
  {
@@ -3048,7 +3391,8 @@ function StudioRoom({
3048
3391
  playerOrigin,
3049
3392
  windowGate,
3050
3393
  initialCameraOn = true,
3051
- initialMuted = false
3394
+ initialMuted = false,
3395
+ initialDevices
3052
3396
  }) {
3053
3397
  const [cameraOn, setCameraOn] = useState(initialCameraOn);
3054
3398
  const [screenOn, setScreenOn] = useState(false);
@@ -3111,6 +3455,21 @@ function StudioRoom({
3111
3455
  postLivoEvent("live", { streamId: session.stream.id });
3112
3456
  }
3113
3457
  }, [onAir, session.stream.id]);
3458
+ useEffect(() => {
3459
+ const saved = initialDevices ?? readStudioDeviceSelection();
3460
+ let cancelled = false;
3461
+ void (async () => {
3462
+ try {
3463
+ const listed = await meeting.listDevices();
3464
+ if (cancelled) return;
3465
+ await applyStudioDeviceSelection(meeting.setDevice, saved, listed);
3466
+ } catch {
3467
+ }
3468
+ })();
3469
+ return () => {
3470
+ cancelled = true;
3471
+ };
3472
+ }, [initialDevices, meeting]);
3114
3473
  useEffect(() => {
3115
3474
  return () => {
3116
3475
  for (const id of grantCheckTimers.current) window.clearTimeout(id);
@@ -3310,10 +3669,9 @@ function StudioRoom({
3310
3669
  const self = participants.find((row) => row.isSelf);
3311
3670
  const canShareFreely = session.role === "moderator" || self?.stageStatus === "ON_STAGE" || shareGrant === "granted";
3312
3671
  const selfId = self?.id ?? null;
3672
+ const selfUserId = self?.userId || null;
3313
3673
  const remoteParticipants = participants.filter((row) => !row.isSelf);
3314
- const incoming = chatMessages.filter(
3315
- (row) => row.userId !== selfId && row.userId !== self?.userId
3316
- );
3674
+ const incoming = chatMessages.filter((row) => row.userId !== selfUserId);
3317
3675
  const unread = sideTab === "chat" ? 0 : Math.max(0, incoming.length - seenIncoming);
3318
3676
  const peopleBadge = waitlisted.length + stageRequests.length;
3319
3677
  useEffect(() => {
@@ -3653,17 +4011,21 @@ function StudioRoom({
3653
4011
  ) });
3654
4012
  }
3655
4013
  if (inWaitingRoom && session.role === "guest") {
3656
- return /* @__PURE__ */ jsxs("div", { className: "flex h-dvh flex-col items-center justify-center gap-4 p-6", children: [
3657
- /* @__PURE__ */ jsx(
3658
- StudioDevicePreflight,
3659
- {
3660
- reducedMotion,
3661
- cameraLabel: labels.preflightCamera,
3662
- micLabel: labels.preflightMic
3663
- }
3664
- ),
3665
- /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", role: "status", children: labels.waitingHost })
3666
- ] });
4014
+ return /* @__PURE__ */ jsx(
4015
+ StudioGuestWaiting,
4016
+ {
4017
+ meeting,
4018
+ reducedMotion,
4019
+ cameraOn,
4020
+ muted,
4021
+ labels,
4022
+ settingsOpen,
4023
+ onToggleCamera,
4024
+ onToggleMute: () => void onToggleMute(),
4025
+ onOpenSettings: () => setSettingsOpen(true),
4026
+ onCloseSettings: setSettingsOpen
4027
+ }
4028
+ );
3667
4029
  }
3668
4030
  if (participants.length > 0 || waitlisted.length > 0) {
3669
4031
  sawParticipants.current = true;
@@ -3675,7 +4037,14 @@ function StudioRoom({
3675
4037
  /* @__PURE__ */ jsxs("header", { className: "flex h-12 items-center gap-3 border-b px-3", children: [
3676
4038
  /* @__PURE__ */ jsx("p", { className: "min-w-0 flex-1 truncate text-sm font-medium", children: session.stream.title }),
3677
4039
  onAir && liveStartedAtRef.current !== null ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: formatStudioElapsed(Date.now() - liveStartedAtRef.current) }) : null,
3678
- /* @__PURE__ */ jsx("span", { className: "rounded-full border px-2 py-0.5 text-xs", role: "status", children: statusChip }),
4040
+ onAir ? /* @__PURE__ */ jsx(StudioLiveBadge, { role: "status", children: statusChip }) : /* @__PURE__ */ jsx(
4041
+ "span",
4042
+ {
4043
+ className: "rounded-full border px-2 py-0.5 text-xs",
4044
+ role: "status",
4045
+ children: statusChip
4046
+ }
4047
+ ),
3679
4048
  session.role === "moderator" && (egressLost || !starting && !sending && !onAir && !active) ? /* @__PURE__ */ jsx(
3680
4049
  Button,
3681
4050
  {
@@ -3728,6 +4097,22 @@ function StudioRoom({
3728
4097
  trigger: /* @__PURE__ */ jsx(Button, { type: "button", variant: "destructive", disabled: pending, children: labels.stop })
3729
4098
  }
3730
4099
  ) : null,
4100
+ session.role === "moderator" ? /* @__PURE__ */ jsx(
4101
+ Button,
4102
+ {
4103
+ type: "button",
4104
+ variant: "outline",
4105
+ size: "icon-sm",
4106
+ "aria-label": labels.share,
4107
+ onClick: () => {
4108
+ void navigator.clipboard.writeText(
4109
+ playerWatchUrl(session.stream.id, playerOrigin)
4110
+ );
4111
+ toast.success(labels.shareCopied);
4112
+ },
4113
+ children: /* @__PURE__ */ jsx(Share2, {})
4114
+ }
4115
+ ) : null,
3731
4116
  embed ? null : chrome
3732
4117
  ] }),
3733
4118
  !stageEnabled && session.role === "guest" ? /* @__PURE__ */ jsx(
@@ -3767,7 +4152,7 @@ function StudioRoom({
3767
4152
  onTabChange: setSideTab,
3768
4153
  onClose: () => setSideTab(null),
3769
4154
  meeting,
3770
- selfId,
4155
+ selfUserId,
3771
4156
  participants,
3772
4157
  waitlisted,
3773
4158
  stageRequests,
@@ -3815,6 +4200,22 @@ function StudioRoom({
3815
4200
  onTakeOffAir: (userId) => {
3816
4201
  const tile = participants.find((row) => row.userId === userId);
3817
4202
  if (tile) void onSetStage(tile, false);
4203
+ },
4204
+ guestUrl: session.guestUrl,
4205
+ onShareGuestLink: () => {
4206
+ const url = session.guestUrl;
4207
+ if (!url) return;
4208
+ void shareOrCopyLink({
4209
+ url,
4210
+ title: session.stream.title,
4211
+ text: labels.shareGuestText.replace(
4212
+ "{title}",
4213
+ session.stream.title
4214
+ )
4215
+ }).then((result) => {
4216
+ if (result === "copied") toast.success(labels.copied);
4217
+ if (result === "failed") toast.error(labels.shareFailed);
4218
+ });
3818
4219
  }
3819
4220
  }
3820
4221
  ) : null
@@ -3833,7 +4234,6 @@ function StudioRoom({
3833
4234
  session,
3834
4235
  meeting,
3835
4236
  labels,
3836
- playerOrigin,
3837
4237
  muted,
3838
4238
  cameraOn,
3839
4239
  screenOn,
@@ -3863,9 +4263,9 @@ function StudioRoom({
3863
4263
  onCancelShareRequest: () => setShareGrant("idle"),
3864
4264
  onAskStage: () => {
3865
4265
  if (session.role === "moderator") {
3866
- const userId = self?.userId;
3867
- if (userId) void meeting.setStageAccess(userId, true);
3868
- else void meeting.requestStageAccess();
4266
+ void Promise.resolve(meeting.joinStage()).catch(
4267
+ () => toast.error(labels.stageError)
4268
+ );
3869
4269
  return;
3870
4270
  }
3871
4271
  void meeting.requestStageAccess();
@@ -3929,6 +4329,177 @@ function StudioRoom({
3929
4329
  ) : null
3930
4330
  ] });
3931
4331
  }
4332
+ function isOverconstrained(error) {
4333
+ if (!error || typeof error !== "object") return false;
4334
+ return error.name === "OverconstrainedError";
4335
+ }
4336
+ function trackConstraint(deviceId, exact) {
4337
+ if (exact && deviceId) {
4338
+ return { deviceId: { exact: deviceId } };
4339
+ }
4340
+ return true;
4341
+ }
4342
+ function stopStream(stream) {
4343
+ if (!stream) return;
4344
+ for (const track of stream.getTracks()) {
4345
+ track.onended = null;
4346
+ track.stop();
4347
+ }
4348
+ }
4349
+ async function requestPreviewStream(wantAudio, wantVideo, audioDeviceId, videoDeviceId, exact) {
4350
+ return navigator.mediaDevices.getUserMedia({
4351
+ audio: wantAudio ? trackConstraint(audioDeviceId, exact) : false,
4352
+ video: wantVideo ? trackConstraint(videoDeviceId, exact) : false
4353
+ });
4354
+ }
4355
+ function StudioDevicePreflight({
4356
+ reducedMotion,
4357
+ cameraLabel,
4358
+ micLabel,
4359
+ cameraOn = true,
4360
+ muted = false,
4361
+ audioDeviceId = "",
4362
+ videoDeviceId = "",
4363
+ cameraOffLabel = "Camera is off",
4364
+ onPermission,
4365
+ onError
4366
+ }) {
4367
+ const videoRef = useRef(null);
4368
+ const onPermissionRef = useRef(onPermission);
4369
+ const onErrorRef = useRef(onError);
4370
+ onPermissionRef.current = onPermission;
4371
+ onErrorRef.current = onError;
4372
+ const [audioTrack, setAudioTrack] = useState(null);
4373
+ const [cameraOk, setCameraOk] = useState(false);
4374
+ const [micOk, setMicOk] = useState(false);
4375
+ useEffect(() => {
4376
+ let cancelled = false;
4377
+ let generation = 0;
4378
+ let current = null;
4379
+ function bindEnded(stream, gen) {
4380
+ for (const track of stream.getTracks()) {
4381
+ track.onended = () => {
4382
+ if (cancelled || gen !== generation) return;
4383
+ void acquire();
4384
+ };
4385
+ }
4386
+ }
4387
+ async function acquire() {
4388
+ const gen = ++generation;
4389
+ stopStream(current);
4390
+ current = null;
4391
+ if (cancelled) return;
4392
+ if (!cameraOn && muted) {
4393
+ setAudioTrack(null);
4394
+ setCameraOk(false);
4395
+ setMicOk(false);
4396
+ if (videoRef.current) videoRef.current.srcObject = null;
4397
+ return;
4398
+ }
4399
+ if (!navigator.mediaDevices?.getUserMedia) {
4400
+ setAudioTrack(null);
4401
+ setCameraOk(false);
4402
+ setMicOk(false);
4403
+ onErrorRef.current?.();
4404
+ return;
4405
+ }
4406
+ const wantAudio = !muted;
4407
+ const wantVideo = cameraOn;
4408
+ const hadExact = Boolean(
4409
+ wantAudio && audioDeviceId || wantVideo && videoDeviceId
4410
+ );
4411
+ try {
4412
+ let next;
4413
+ try {
4414
+ next = await requestPreviewStream(
4415
+ wantAudio,
4416
+ wantVideo,
4417
+ audioDeviceId,
4418
+ videoDeviceId,
4419
+ true
4420
+ );
4421
+ } catch (error) {
4422
+ if (!isOverconstrained(error) || !hadExact) {
4423
+ throw error;
4424
+ }
4425
+ next = await requestPreviewStream(
4426
+ wantAudio,
4427
+ wantVideo,
4428
+ audioDeviceId,
4429
+ videoDeviceId,
4430
+ false
4431
+ );
4432
+ }
4433
+ if (cancelled || gen !== generation) {
4434
+ stopStream(next);
4435
+ return;
4436
+ }
4437
+ current = next;
4438
+ const video = next.getVideoTracks()[0] ?? null;
4439
+ const audio = next.getAudioTracks()[0] ?? null;
4440
+ setCameraOk(Boolean(video));
4441
+ setMicOk(Boolean(audio));
4442
+ setAudioTrack(audio);
4443
+ if (videoRef.current) {
4444
+ videoRef.current.srcObject = video ? next : null;
4445
+ }
4446
+ bindEnded(next, gen);
4447
+ onPermissionRef.current?.();
4448
+ } catch {
4449
+ if (cancelled || gen !== generation) return;
4450
+ setAudioTrack(null);
4451
+ setCameraOk(false);
4452
+ setMicOk(false);
4453
+ if (videoRef.current) videoRef.current.srcObject = null;
4454
+ onErrorRef.current?.();
4455
+ }
4456
+ }
4457
+ void acquire();
4458
+ return () => {
4459
+ cancelled = true;
4460
+ generation += 1;
4461
+ stopStream(current);
4462
+ current = null;
4463
+ };
4464
+ }, [audioDeviceId, cameraOn, muted, videoDeviceId]);
4465
+ return /* @__PURE__ */ jsxs("div", { className: "flex w-full flex-col items-center gap-3", children: [
4466
+ cameraOn ? /* @__PURE__ */ jsx(
4467
+ "video",
4468
+ {
4469
+ ref: videoRef,
4470
+ autoPlay: true,
4471
+ muted: true,
4472
+ playsInline: true,
4473
+ className: "bg-muted aspect-video w-full rounded-xl object-cover"
4474
+ }
4475
+ ) : /* @__PURE__ */ jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) }),
4476
+ /* @__PURE__ */ jsx(
4477
+ AudioWaveform,
4478
+ {
4479
+ track: muted ? null : audioTrack,
4480
+ reducedMotion
4481
+ }
4482
+ ),
4483
+ /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground text-sm", role: "status", children: [
4484
+ cameraLabel,
4485
+ ": ",
4486
+ cameraOk && cameraOn ? "OK" : "\u2014",
4487
+ " \xB7 ",
4488
+ micLabel,
4489
+ ":",
4490
+ " ",
4491
+ micOk && !muted ? "OK" : "\u2014"
4492
+ ] })
4493
+ ] });
4494
+ }
4495
+ var LOBBY_DEVICE_LABEL_DEFAULTS = {
4496
+ cameraDevice: "Camera",
4497
+ micDevice: "Microphone",
4498
+ speakerDevice: "Speaker",
4499
+ testSpeaker: "Test",
4500
+ testSpeakerError: "Could not play the speaker test",
4501
+ cameraOff: "Camera is off"
4502
+ };
3932
4503
  function StudioGuestLobby({
3933
4504
  reducedMotion,
3934
4505
  cameraOn,
@@ -3939,11 +4510,48 @@ function StudioGuestLobby({
3939
4510
  waitingForHost,
3940
4511
  error,
3941
4512
  labels,
4513
+ savedDevices,
3942
4514
  onCameraChange,
3943
4515
  onMutedChange,
3944
4516
  onDisplayNameChange,
4517
+ onDeviceChange,
3945
4518
  onJoin
3946
4519
  }) {
4520
+ const [refreshKey, setRefreshKey] = useState(0);
4521
+ const [internalSaved, setInternalSaved] = useState(
4522
+ () => readStudioDeviceSelection()
4523
+ );
4524
+ const [testingSpeaker, setTestingSpeaker] = useState(false);
4525
+ const devices = useMediaDevices(refreshKey);
4526
+ const saved = savedDevices ?? internalSaved;
4527
+ const { effective } = resolveDeviceSelection(saved, devices);
4528
+ const deviceLabels = {
4529
+ cameraDevice: labels.cameraDevice ?? LOBBY_DEVICE_LABEL_DEFAULTS.cameraDevice,
4530
+ micDevice: labels.micDevice ?? LOBBY_DEVICE_LABEL_DEFAULTS.micDevice,
4531
+ speakerDevice: labels.speakerDevice ?? LOBBY_DEVICE_LABEL_DEFAULTS.speakerDevice,
4532
+ testSpeaker: labels.testSpeaker ?? LOBBY_DEVICE_LABEL_DEFAULTS.testSpeaker,
4533
+ testSpeakerError: labels.testSpeakerError ?? LOBBY_DEVICE_LABEL_DEFAULTS.testSpeakerError,
4534
+ cameraOff: labels.cameraOff ?? LOBBY_DEVICE_LABEL_DEFAULTS.cameraOff
4535
+ };
4536
+ function changeDevice(kind, deviceId) {
4537
+ persistStudioDeviceSelection(kind, deviceId);
4538
+ onDeviceChange?.(kind, deviceId);
4539
+ if (!savedDevices) {
4540
+ setInternalSaved((prev) => ({ ...prev, [kind]: deviceId }));
4541
+ }
4542
+ }
4543
+ async function onTestSpeaker() {
4544
+ const speakerId = effective.speaker || devices.speaker[0]?.deviceId || "";
4545
+ if (!speakerId || testingSpeaker) return;
4546
+ setTestingSpeaker(true);
4547
+ try {
4548
+ await playSpeakerTest(speakerId);
4549
+ } catch {
4550
+ toast.error(deviceLabels.testSpeakerError);
4551
+ } finally {
4552
+ setTestingSpeaker(false);
4553
+ }
4554
+ }
3947
4555
  return /* @__PURE__ */ jsxs("div", { className: "mx-auto flex min-h-dvh w-full max-w-lg flex-col justify-center gap-6 p-6", children: [
3948
4556
  /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
3949
4557
  /* @__PURE__ */ jsx("h1", { className: "text-lg font-semibold", children: labels.title }),
@@ -3955,8 +4563,12 @@ function StudioGuestLobby({
3955
4563
  reducedMotion,
3956
4564
  cameraOn,
3957
4565
  muted,
4566
+ audioDeviceId: effective.audio,
4567
+ videoDeviceId: effective.video,
3958
4568
  cameraLabel: labels.camera,
3959
- micLabel: labels.mic
4569
+ micLabel: labels.mic,
4570
+ cameraOffLabel: deviceLabels.cameraOff,
4571
+ onPermission: () => setRefreshKey((n) => n === 0 ? 1 : n)
3960
4572
  }
3961
4573
  ),
3962
4574
  /* @__PURE__ */ jsxs("div", { className: "flex justify-center gap-2", children: [
@@ -3985,6 +4597,26 @@ function StudioGuestLobby({
3985
4597
  }
3986
4598
  )
3987
4599
  ] }),
4600
+ /* @__PURE__ */ jsx(
4601
+ StudioDeviceSelectGroup,
4602
+ {
4603
+ devices,
4604
+ selected: effective,
4605
+ labels: deviceLabels,
4606
+ onChange: changeDevice,
4607
+ speakerAction: /* @__PURE__ */ jsx(
4608
+ Button,
4609
+ {
4610
+ type: "button",
4611
+ size: "sm",
4612
+ variant: "outline",
4613
+ disabled: !(effective.speaker || devices.speaker[0]?.deviceId) || testingSpeaker,
4614
+ onClick: () => void onTestSpeaker(),
4615
+ children: deviceLabels.testSpeaker
4616
+ }
4617
+ )
4618
+ }
4619
+ ),
3988
4620
  showName ? /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
3989
4621
  /* @__PURE__ */ jsx(Label, { htmlFor: "studio-guest-name", children: labels.name }),
3990
4622
  /* @__PURE__ */ jsx(
@@ -4030,16 +4662,16 @@ function usePrefersReducedMotion() {
4030
4662
  // src/studio/lib/guest-identity.ts
4031
4663
  var NAME_KEY = "livo-studio-display-name";
4032
4664
  var ID_KEY = "livo-studio-guest-id";
4033
- function readStudioGuestIdentity(storage = defaultStorage()) {
4665
+ function readStudioGuestIdentity(storage = defaultStorage2()) {
4034
4666
  const guestId = storage?.getItem(ID_KEY)?.trim() || crypto.randomUUID();
4035
4667
  storage?.setItem(ID_KEY, guestId);
4036
4668
  const displayName = storage?.getItem(NAME_KEY)?.trim() ?? "";
4037
4669
  return { displayName, guestId };
4038
4670
  }
4039
- function persistStudioGuestName(displayName, storage = defaultStorage()) {
4671
+ function persistStudioGuestName(displayName, storage = defaultStorage2()) {
4040
4672
  storage?.setItem(NAME_KEY, displayName.trim());
4041
4673
  }
4042
- function defaultStorage() {
4674
+ function defaultStorage2() {
4043
4675
  if (typeof window === "undefined") return null;
4044
4676
  try {
4045
4677
  return window.localStorage;
@@ -4613,6 +5245,21 @@ function studioMeetingFromRtk(raw) {
4613
5245
  await meeting.stage?.requestAccess?.();
4614
5246
  notifyStage();
4615
5247
  },
5248
+ async joinStage() {
5249
+ if (currentStageStatus() === "ON_STAGE") {
5250
+ notifyStage();
5251
+ return;
5252
+ }
5253
+ if (meeting.self?.permissions?.stageAccess !== "ALLOWED") {
5254
+ await meeting.stage?.requestAccess?.();
5255
+ notifyStage();
5256
+ return;
5257
+ }
5258
+ await meeting.stage?.join?.();
5259
+ joinedStageLocally = true;
5260
+ await republishCameraIfOn();
5261
+ notifyStage();
5262
+ },
4616
5263
  async leaveStage() {
4617
5264
  await meeting.stage?.leave?.();
4618
5265
  notifyStage();
@@ -5185,6 +5832,9 @@ function LivoGuestStudio({
5185
5832
  const [waitingForHost, setWaitingForHost] = useState(false);
5186
5833
  const [cameraOn, setCameraOn] = useState(true);
5187
5834
  const [muted, setMuted] = useState(false);
5835
+ const [deviceSelection, setDeviceSelection] = useState(
5836
+ () => readStudioDeviceSelection()
5837
+ );
5188
5838
  const [retryAfterMs, setRetryAfterMs] = useState(null);
5189
5839
  const [sessionGen, setSessionGen] = useState(0);
5190
5840
  const joinInFlight = useRef(false);
@@ -5199,7 +5849,15 @@ function LivoGuestStudio({
5199
5849
  const identity = readStudioGuestIdentity();
5200
5850
  setDisplayName(identity.displayName);
5201
5851
  setGuestId(identity.guestId);
5852
+ setDeviceSelection(readStudioDeviceSelection());
5202
5853
  }, []);
5854
+ const onDeviceChange = useCallback(
5855
+ (kind, deviceId) => {
5856
+ persistStudioDeviceSelection(kind, deviceId);
5857
+ setDeviceSelection((prev) => ({ ...prev, [kind]: deviceId }));
5858
+ },
5859
+ []
5860
+ );
5203
5861
  const join = useCallback(async () => {
5204
5862
  if (joinInFlight.current) return;
5205
5863
  const name = displayName.trim();
@@ -5288,9 +5946,11 @@ function LivoGuestStudio({
5288
5946
  waitingForHost,
5289
5947
  error,
5290
5948
  labels: lobbyLabels,
5949
+ savedDevices: deviceSelection,
5291
5950
  onCameraChange: setCameraOn,
5292
5951
  onMutedChange: setMuted,
5293
5952
  onDisplayNameChange: setDisplayName,
5953
+ onDeviceChange,
5294
5954
  onJoin: () => void join()
5295
5955
  }
5296
5956
  );
@@ -5314,7 +5974,8 @@ function LivoGuestStudio({
5314
5974
  embed,
5315
5975
  playerOrigin,
5316
5976
  initialCameraOn: cameraOn,
5317
- initialMuted: muted
5977
+ initialMuted: muted,
5978
+ initialDevices: deviceSelection
5318
5979
  }
5319
5980
  );
5320
5981
  }