@livo-tv/sdk 1.1.2 → 1.2.1

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.
@@ -11,13 +11,13 @@ var tailwindMerge = require('tailwind-merge');
11
11
  var button = require('@base-ui/react/button');
12
12
  var classVarianceAuthority = require('class-variance-authority');
13
13
  var jsxRuntime = require('react/jsx-runtime');
14
- var mergeProps = require('@base-ui/react/merge-props');
15
- var useRender = require('@base-ui/react/use-render');
16
- var menu = require('@base-ui/react/menu');
17
14
  var tooltip = require('@base-ui/react/tooltip');
18
15
  var DialogPrimitive = require('@radix-ui/react-dialog');
19
16
  var input = require('@base-ui/react/input');
20
17
  var LabelPrimitive = require('@radix-ui/react-label');
18
+ var mergeProps = require('@base-ui/react/merge-props');
19
+ var useRender = require('@base-ui/react/use-render');
20
+ var menu = require('@base-ui/react/menu');
21
21
 
22
22
  function _interopNamespace(e) {
23
23
  if (e && e.__esModule) return e;
@@ -1086,6 +1086,93 @@ function StudioWindowGate({
1086
1086
  }
1087
1087
  return /* @__PURE__ */ jsxRuntime.jsx(StudioWindowGateContext.Provider, { value: { reportDuplicate }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { children }, generation) });
1088
1088
  }
1089
+
1090
+ // src/studio/lib/devices.ts
1091
+ var STUDIO_DEVICE_CHANGE_DEBOUNCE_MS = 250;
1092
+ var STORAGE_KEYS = {
1093
+ audio: "livo-studio-device-audio",
1094
+ video: "livo-studio-device-video",
1095
+ speaker: "livo-studio-device-speaker"
1096
+ };
1097
+ var DEVICE_KINDS = ["audio", "video", "speaker"];
1098
+ function readStudioDeviceSelection(storage = defaultStorage()) {
1099
+ return {
1100
+ audio: storage?.getItem(STORAGE_KEYS.audio)?.trim() ?? "",
1101
+ video: storage?.getItem(STORAGE_KEYS.video)?.trim() ?? "",
1102
+ speaker: storage?.getItem(STORAGE_KEYS.speaker)?.trim() ?? ""
1103
+ };
1104
+ }
1105
+ function persistStudioDeviceSelection(kind, deviceId, storage = defaultStorage()) {
1106
+ storage?.setItem(STORAGE_KEYS[kind], deviceId.trim());
1107
+ }
1108
+ function mapMediaDevices(devices) {
1109
+ const next = { audio: [], video: [], speaker: [] };
1110
+ for (const device of devices) {
1111
+ const kind = mediaKindToStudio(device.kind);
1112
+ if (!kind || !device.deviceId) continue;
1113
+ next[kind].push({
1114
+ deviceId: device.deviceId,
1115
+ label: device.label.trim() || `Device ${next[kind].length + 1}`
1116
+ });
1117
+ }
1118
+ return next;
1119
+ }
1120
+ function resolveDeviceSelection(saved, devices) {
1121
+ const effective = {
1122
+ audio: "",
1123
+ video: "",
1124
+ speaker: ""
1125
+ };
1126
+ const missing = {
1127
+ audio: false,
1128
+ video: false,
1129
+ speaker: false
1130
+ };
1131
+ for (const kind of DEVICE_KINDS) {
1132
+ const id = saved[kind];
1133
+ if (!id) continue;
1134
+ if (devices[kind].some((option) => option.deviceId === id)) {
1135
+ effective[kind] = id;
1136
+ continue;
1137
+ }
1138
+ missing[kind] = true;
1139
+ }
1140
+ return { effective, missing };
1141
+ }
1142
+ function displayDeviceId(value, options) {
1143
+ if (options.some((option) => option.deviceId === value)) return value;
1144
+ return options[0]?.deviceId ?? "";
1145
+ }
1146
+ async function applyStudioDeviceSelection(setDevice, saved, listed) {
1147
+ const { effective } = resolveDeviceSelection(saved, listed);
1148
+ await Promise.allSettled(
1149
+ DEVICE_KINDS.filter((kind) => effective[kind]).map(
1150
+ (kind) => setDevice(kind, effective[kind])
1151
+ )
1152
+ );
1153
+ }
1154
+ function mediaKindToStudio(kind) {
1155
+ switch (kind) {
1156
+ case "audioinput":
1157
+ return "audio";
1158
+ case "videoinput":
1159
+ return "video";
1160
+ case "audiooutput":
1161
+ return "speaker";
1162
+ default: {
1163
+ const _exhaustive = kind;
1164
+ return _exhaustive;
1165
+ }
1166
+ }
1167
+ }
1168
+ function defaultStorage() {
1169
+ if (typeof window === "undefined") return null;
1170
+ try {
1171
+ return window.localStorage;
1172
+ } catch {
1173
+ return null;
1174
+ }
1175
+ }
1089
1176
  function AudioWaveform({
1090
1177
  track,
1091
1178
  reducedMotion,
@@ -1159,1211 +1246,868 @@ function AudioWaveform({
1159
1246
  }
1160
1247
  );
1161
1248
  }
1162
- function StudioDevicePreflight({
1163
- reducedMotion,
1164
- cameraLabel,
1165
- micLabel,
1166
- cameraOn = true,
1167
- muted = false
1249
+ function TooltipProvider({
1250
+ delay = 0,
1251
+ ...props
1168
1252
  }) {
1169
- const videoRef = react.useRef(null);
1170
- const streamRef = react.useRef(null);
1171
- const [audioTrack, setAudioTrack] = react.useState(null);
1172
- const [cameraOk, setCameraOk] = react.useState(false);
1173
- const [micOk, setMicOk] = react.useState(false);
1174
- react.useEffect(() => {
1175
- let cancelled = false;
1176
- void navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then((next) => {
1177
- if (cancelled) {
1178
- for (const track of next.getTracks()) track.stop();
1179
- return;
1180
- }
1181
- streamRef.current = next;
1182
- const video = next.getVideoTracks()[0] ?? null;
1183
- const audio = next.getAudioTracks()[0] ?? null;
1184
- setCameraOk(Boolean(video));
1185
- setMicOk(Boolean(audio));
1186
- setAudioTrack(audio);
1187
- if (videoRef.current && video) {
1188
- videoRef.current.srcObject = next;
1189
- }
1190
- }).catch(() => {
1191
- if (!cancelled) {
1192
- setCameraOk(false);
1193
- setMicOk(false);
1194
- }
1195
- });
1196
- return () => {
1197
- cancelled = true;
1198
- if (streamRef.current) {
1199
- for (const track of streamRef.current.getTracks()) track.stop();
1200
- streamRef.current = null;
1201
- }
1202
- };
1203
- }, []);
1204
- react.useEffect(() => {
1205
- const stream = streamRef.current;
1206
- if (!stream) return;
1207
- for (const track of stream.getVideoTracks()) track.enabled = cameraOn;
1208
- for (const track of stream.getAudioTracks()) track.enabled = !muted;
1209
- }, [cameraOn, muted]);
1210
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex w-full flex-col items-center gap-3", children: [
1211
- /* @__PURE__ */ jsxRuntime.jsx(
1212
- "video",
1213
- {
1214
- ref: videoRef,
1215
- autoPlay: true,
1216
- muted: true,
1217
- playsInline: true,
1218
- className: "bg-muted aspect-video w-full rounded-xl object-cover"
1219
- }
1220
- ),
1253
+ return /* @__PURE__ */ jsxRuntime.jsx(
1254
+ tooltip.Tooltip.Provider,
1255
+ {
1256
+ "data-slot": "tooltip-provider",
1257
+ delay,
1258
+ ...props
1259
+ }
1260
+ );
1261
+ }
1262
+ function Tooltip({ ...props }) {
1263
+ return /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.Root, { "data-slot": "tooltip", ...props });
1264
+ }
1265
+ function TooltipTrigger({ ...props }) {
1266
+ return /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.Trigger, { "data-slot": "tooltip-trigger", ...props });
1267
+ }
1268
+ function TooltipContent({
1269
+ className,
1270
+ side = "top",
1271
+ sideOffset = 4,
1272
+ align = "center",
1273
+ alignOffset = 0,
1274
+ children,
1275
+ ...props
1276
+ }) {
1277
+ return /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.Portal, { children: /* @__PURE__ */ jsxRuntime.jsx(
1278
+ tooltip.Tooltip.Positioner,
1279
+ {
1280
+ align,
1281
+ alignOffset,
1282
+ side,
1283
+ sideOffset,
1284
+ className: "isolate z-50",
1285
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
1286
+ tooltip.Tooltip.Popup,
1287
+ {
1288
+ "data-slot": "tooltip-content",
1289
+ className: cn(
1290
+ "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",
1291
+ className
1292
+ ),
1293
+ ...props,
1294
+ children: [
1295
+ children,
1296
+ /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.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" })
1297
+ ]
1298
+ }
1299
+ )
1300
+ }
1301
+ ) });
1302
+ }
1303
+ function ControlIcon({
1304
+ label,
1305
+ pressed = false,
1306
+ disabled = false,
1307
+ pending = false,
1308
+ tone = "default",
1309
+ badge,
1310
+ onClick,
1311
+ children
1312
+ }) {
1313
+ return /* @__PURE__ */ jsxRuntime.jsxs(Tooltip, { children: [
1221
1314
  /* @__PURE__ */ jsxRuntime.jsx(
1222
- AudioWaveform,
1315
+ TooltipTrigger,
1223
1316
  {
1224
- track: muted ? null : audioTrack,
1225
- reducedMotion
1317
+ render: /* @__PURE__ */ jsxRuntime.jsx(
1318
+ Button,
1319
+ {
1320
+ type: "button",
1321
+ size: "icon-lg",
1322
+ variant: tone === "danger" ? "destructive" : pressed ? "default" : "secondary",
1323
+ className: "overflow-visible",
1324
+ "aria-label": label,
1325
+ "aria-pressed": pressed,
1326
+ disabled,
1327
+ onClick
1328
+ }
1329
+ ),
1330
+ children: /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "relative overflow-visible", children: [
1331
+ pending ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "animate-spin motion-reduce:animate-none" }) : children,
1332
+ badge != null && badge > 0 ? /* @__PURE__ */ jsxRuntime.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
1333
+ ] })
1226
1334
  }
1227
1335
  ),
1228
- /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-muted-foreground text-sm", role: "status", children: [
1229
- cameraLabel,
1230
- ": ",
1231
- cameraOk && cameraOn ? "OK" : "\u2014",
1232
- " \xB7 ",
1233
- micLabel,
1234
- ":",
1235
- " ",
1236
- micOk && !muted ? "OK" : "\u2014"
1237
- ] })
1336
+ /* @__PURE__ */ jsxRuntime.jsx(TooltipContent, { children: label })
1238
1337
  ] });
1239
1338
  }
1240
- var badgeVariants = classVarianceAuthority.cva(
1241
- "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
1242
- {
1243
- variants: {
1244
- variant: {
1245
- default: "border-transparent bg-primary text-primary-foreground",
1246
- secondary: "border-transparent bg-secondary text-secondary-foreground",
1247
- destructive: "border-transparent bg-destructive text-destructive-foreground",
1248
- outline: "text-foreground",
1249
- success: "border-transparent bg-chart-5/15 text-chart-5 dark:bg-chart-5/25",
1250
- warning: "border-transparent bg-chart-4/15 text-chart-4 dark:bg-chart-4/25"
1251
- }
1252
- },
1253
- defaultVariants: { variant: "default" }
1254
- }
1255
- );
1256
- function Badge({ className, variant, ...props }) {
1257
- return /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(badgeVariants({ variant }), className), ...props });
1339
+ function Dialog({ ...props }) {
1340
+ return /* @__PURE__ */ jsxRuntime.jsx(DialogPrimitive__namespace.Root, { "data-slot": "dialog", ...props });
1258
1341
  }
1259
- function StudioPipPanel({
1260
- meeting,
1261
- labels,
1262
- cameraOn,
1263
- muted,
1264
- screenOn,
1265
- showMediaControls = true,
1266
- showScreenControl = false,
1267
- canShareFreely = false,
1268
- screenAvailable = true,
1269
- screenPending = false,
1270
- shareGrant = "idle",
1271
- onAir,
1272
- elapsed,
1273
- unread = 0,
1274
- onToggleMute,
1275
- onToggleCamera,
1276
- onToggleScreen,
1277
- onAskToShare,
1278
- onCancelShareRequest,
1279
- onBack
1342
+ function DialogPortal({
1343
+ ...props
1280
1344
  }) {
1281
- const tiles = meeting.participants();
1282
- const spotlight = resolveStudioPipSpotlight(tiles, meeting.activeSpeakerId());
1283
- const selfView = resolveStudioPipSelfView(tiles, spotlight);
1284
- const speaking = spotlight !== null && spotlight.participantId === meeting.activeSpeakerId();
1285
- const backLabel = unread > 0 ? `${labels.backToStudio} (${unread})` : labels.backToStudio;
1286
- return /* @__PURE__ */ jsxRuntime.jsxs(
1287
- "div",
1345
+ return /* @__PURE__ */ jsxRuntime.jsx(DialogPrimitive__namespace.Portal, { "data-slot": "dialog-portal", ...props });
1346
+ }
1347
+ function DialogOverlay({
1348
+ className,
1349
+ ...props
1350
+ }) {
1351
+ return /* @__PURE__ */ jsxRuntime.jsx(
1352
+ DialogPrimitive__namespace.Overlay,
1288
1353
  {
1289
- role: "region",
1290
- "aria-label": labels.pipTitle,
1291
- className: "bg-background flex h-full min-h-0 flex-col gap-2 p-2 text-foreground",
1292
- children: [
1293
- /* @__PURE__ */ jsxRuntime.jsxs(
1294
- "div",
1295
- {
1296
- className: cn(
1297
- "bg-muted relative min-h-0 flex-1 overflow-hidden rounded-xl",
1298
- speaking && "ring-2 ring-primary"
1299
- ),
1300
- children: [
1301
- /* @__PURE__ */ jsxRuntime.jsx(
1302
- PipSpotlight,
1303
- {
1304
- meeting,
1305
- spotlight,
1306
- cameraOffLabel: labels.cameraOff
1307
- }
1308
- ),
1309
- onAir ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute top-2 left-2 flex items-center gap-2", children: [
1310
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rounded-full border bg-background/80 px-2 py-0.5 text-xs backdrop-blur-sm", children: labels.live }),
1311
- elapsed ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: elapsed }) : null
1312
- ] }) : null,
1313
- selfView ? /* @__PURE__ */ jsxRuntime.jsx(
1314
- "div",
1315
- {
1316
- "data-testid": "pip-self-view",
1317
- className: "absolute right-2 bottom-2 aspect-video w-[30%] overflow-hidden rounded-lg ring-1 ring-white/40",
1318
- children: /* @__PURE__ */ jsxRuntime.jsx(PipTrackVideo, { track: meeting.localVideoTrack(), fit: "cover" })
1319
- }
1320
- ) : null
1321
- ]
1322
- }
1323
- ),
1324
- /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex flex-wrap items-center justify-center gap-2", children: [
1325
- showMediaControls ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1326
- /* @__PURE__ */ jsxRuntime.jsx(
1327
- PipControl,
1328
- {
1329
- label: muted ? labels.unmute : labels.mute,
1330
- off: muted,
1331
- onClick: onToggleMute,
1332
- children: muted ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {})
1333
- }
1334
- ),
1335
- /* @__PURE__ */ jsxRuntime.jsx(
1336
- PipControl,
1337
- {
1338
- label: labels.camera,
1339
- off: !cameraOn,
1340
- onClick: onToggleCamera,
1341
- children: cameraOn ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {})
1342
- }
1343
- )
1344
- ] }) : null,
1345
- showMediaControls && (showScreenControl || screenOn) ? pipScreenControls({
1346
- labels,
1347
- screenOn,
1348
- canShareFreely,
1349
- screenAvailable,
1350
- screenPending,
1351
- shareGrant,
1352
- onToggleScreen,
1353
- onAskToShare,
1354
- onCancelShareRequest
1355
- }) : null,
1356
- /* @__PURE__ */ jsxRuntime.jsx(PipControl, { label: backLabel, onClick: onBack, children: /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "relative", children: [
1357
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Undo2, {}),
1358
- unread > 0 ? /* @__PURE__ */ jsxRuntime.jsx(Badge, { className: "absolute -top-2 -right-2 h-4 min-w-4 px-1 text-[0.65rem]", children: unread }) : null
1359
- ] }) })
1360
- ] })
1361
- ]
1354
+ "data-slot": "dialog-overlay",
1355
+ className: cn(
1356
+ "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",
1357
+ className
1358
+ ),
1359
+ ...props
1362
1360
  }
1363
1361
  );
1364
1362
  }
1365
- function PipSpotlight({
1366
- meeting,
1367
- spotlight,
1368
- cameraOffLabel
1363
+ function DialogContent({
1364
+ className,
1365
+ children,
1366
+ showCloseButton = true,
1367
+ ...props
1369
1368
  }) {
1370
- if (!spotlight || spotlight.kind === "idle") {
1371
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2", children: [
1372
- spotlight?.picture ? /* @__PURE__ */ jsxRuntime.jsx(
1373
- "img",
1374
- {
1375
- src: spotlight.picture,
1376
- alt: "",
1377
- className: "size-14 rounded-full object-cover"
1378
- }
1379
- ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-background flex size-14 items-center justify-center rounded-full text-lg", children: (spotlight?.name ?? "?").slice(0, 1).toUpperCase() }),
1380
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-xs", children: spotlight?.name ?? cameraOffLabel })
1381
- ] });
1382
- }
1383
- const track = spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId);
1384
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1385
- /* @__PURE__ */ jsxRuntime.jsx(
1386
- PipTrackVideo,
1369
+ return /* @__PURE__ */ jsxRuntime.jsxs(DialogPortal, { children: [
1370
+ /* @__PURE__ */ jsxRuntime.jsx(DialogOverlay, {}),
1371
+ /* @__PURE__ */ jsxRuntime.jsxs(
1372
+ DialogPrimitive__namespace.Content,
1387
1373
  {
1388
- track,
1389
- fit: spotlight.kind === "screen" ? "contain" : "cover"
1374
+ "data-slot": "dialog-content",
1375
+ className: cn(
1376
+ "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",
1377
+ className
1378
+ ),
1379
+ ...props,
1380
+ children: [
1381
+ children,
1382
+ showCloseButton ? /* @__PURE__ */ jsxRuntime.jsxs(DialogPrimitive__namespace.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: [
1383
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.XIcon, { className: "size-4" }),
1384
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: "Close" })
1385
+ ] }) : null
1386
+ ]
1390
1387
  }
1391
- ),
1392
- /* @__PURE__ */ jsxRuntime.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 })
1388
+ )
1393
1389
  ] });
1394
1390
  }
1395
- function PipTrackVideo({
1396
- track,
1397
- fit
1398
- }) {
1399
- const ref = react.useRef(null);
1400
- react.useEffect(() => {
1401
- const el = ref.current;
1402
- if (!el) return;
1403
- if (!track) {
1404
- el.srcObject = null;
1405
- return;
1406
- }
1407
- el.srcObject = new MediaStream([track]);
1408
- void el.play().catch(() => void 0);
1409
- return () => {
1410
- el.srcObject = null;
1411
- };
1412
- }, [track]);
1391
+ function DialogHeader({ className, ...props }) {
1413
1392
  return /* @__PURE__ */ jsxRuntime.jsx(
1414
- "video",
1393
+ "div",
1415
1394
  {
1416
- ref,
1417
- autoPlay: true,
1418
- playsInline: true,
1419
- muted: true,
1420
- className: cn(
1421
- "absolute inset-0 size-full",
1422
- fit === "contain" ? "object-contain" : "object-cover"
1423
- )
1395
+ "data-slot": "dialog-header",
1396
+ className: cn("flex flex-col gap-2 text-center sm:text-left", className),
1397
+ ...props
1424
1398
  }
1425
1399
  );
1426
1400
  }
1427
- function pipScreenControls({
1428
- labels,
1429
- screenOn,
1430
- canShareFreely,
1431
- screenAvailable,
1432
- screenPending,
1433
- shareGrant,
1434
- onToggleScreen,
1435
- onAskToShare,
1436
- onCancelShareRequest
1437
- }) {
1438
- if (canShareFreely || screenOn) {
1439
- return /* @__PURE__ */ jsxRuntime.jsx(
1440
- PipControl,
1441
- {
1442
- label: screenOn ? labels.stopScreen : labels.screen,
1443
- off: screenOn,
1444
- disabled: screenPending || !screenAvailable && !screenOn,
1445
- onClick: onToggleScreen,
1446
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
1447
- }
1448
- );
1449
- }
1450
- if (shareGrant === "requested") {
1451
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1452
- /* @__PURE__ */ jsxRuntime.jsx(
1453
- PipControl,
1454
- {
1455
- label: labels.shareRequested,
1456
- off: true,
1457
- disabled: true,
1458
- onClick: () => void 0,
1459
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
1460
- }
1461
- ),
1462
- /* @__PURE__ */ jsxRuntime.jsx(
1463
- PipControl,
1464
- {
1465
- label: labels.cancelShareRequest,
1466
- onClick: () => onCancelShareRequest?.(),
1467
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {})
1468
- }
1469
- )
1470
- ] });
1471
- }
1472
- return /* @__PURE__ */ jsxRuntime.jsx(PipControl, { label: labels.askToShare, onClick: () => onAskToShare?.(), children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {}) });
1473
- }
1474
- function PipControl({
1475
- label,
1476
- onClick,
1477
- off = false,
1478
- disabled = false,
1479
- children
1401
+ function DialogTitle({
1402
+ className,
1403
+ ...props
1480
1404
  }) {
1481
1405
  return /* @__PURE__ */ jsxRuntime.jsx(
1482
- Button,
1406
+ DialogPrimitive__namespace.Title,
1483
1407
  {
1484
- type: "button",
1485
- size: "icon-lg",
1486
- variant: off ? "destructive" : "secondary",
1487
- className: "size-10 rounded-full",
1488
- "aria-label": label,
1489
- disabled,
1490
- onClick,
1491
- children
1408
+ "data-slot": "dialog-title",
1409
+ className: cn("text-lg leading-none font-semibold", className),
1410
+ ...props
1492
1411
  }
1493
1412
  );
1494
1413
  }
1495
- function StudioRemoteAudio({
1496
- meeting,
1497
- participantId
1414
+ function DialogDescription({
1415
+ className,
1416
+ ...props
1498
1417
  }) {
1499
- const ref = react.useRef(null);
1500
- react.useEffect(() => {
1501
- meeting.attachAudio(participantId, ref.current);
1502
- return () => meeting.attachAudio(participantId, null);
1503
- }, [meeting, participantId]);
1504
- return /* @__PURE__ */ jsxRuntime.jsx("audio", { ref, autoPlay: true, className: "sr-only" });
1505
- }
1506
- function BubbleGroup({ className, ...props }) {
1507
1418
  return /* @__PURE__ */ jsxRuntime.jsx(
1508
- "div",
1419
+ DialogPrimitive__namespace.Description,
1509
1420
  {
1510
- "data-slot": "bubble-group",
1511
- className: cn("flex min-w-0 flex-col gap-2", className),
1421
+ "data-slot": "dialog-description",
1422
+ className: cn("text-muted-foreground text-sm", className),
1512
1423
  ...props
1513
1424
  }
1514
1425
  );
1515
1426
  }
1516
- var bubbleVariants = classVarianceAuthority.cva(
1517
- "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",
1518
- {
1519
- variants: {
1520
- variant: {
1521
- 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",
1522
- 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%)]",
1523
- 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%)]",
1524
- 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)]",
1525
- 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",
1526
- 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",
1527
- 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"
1528
- }
1529
- },
1530
- defaultVariants: {
1531
- variant: "default"
1427
+ function Input({ className, type, ...props }) {
1428
+ return /* @__PURE__ */ jsxRuntime.jsx(
1429
+ input.Input,
1430
+ {
1431
+ type,
1432
+ "data-slot": "input",
1433
+ className: cn(
1434
+ "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",
1435
+ className
1436
+ ),
1437
+ ...props
1532
1438
  }
1533
- }
1534
- );
1535
- function Bubble({
1536
- variant = "default",
1537
- align = "start",
1439
+ );
1440
+ }
1441
+ function Label({
1538
1442
  className,
1539
1443
  ...props
1540
1444
  }) {
1541
1445
  return /* @__PURE__ */ jsxRuntime.jsx(
1542
- "div",
1446
+ LabelPrimitive__namespace.Root,
1543
1447
  {
1544
- "data-slot": "bubble",
1545
- "data-variant": variant,
1546
- "data-align": align,
1547
- className: cn(bubbleVariants({ variant }), className),
1448
+ "data-slot": "label",
1449
+ className: cn(
1450
+ "flex items-center gap-2 text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
1451
+ className
1452
+ ),
1548
1453
  ...props
1549
1454
  }
1550
1455
  );
1551
1456
  }
1552
- function BubbleContent({
1553
- className,
1554
- render,
1555
- ...props
1556
- }) {
1557
- return useRender.useRender({
1558
- defaultTagName: "div",
1559
- props: mergeProps.mergeProps(
1560
- {
1561
- className: cn(
1562
- "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",
1563
- className
1564
- )
1565
- },
1566
- props
1567
- ),
1568
- render,
1569
- state: {
1570
- slot: "bubble-content"
1457
+ var EMPTY_DEVICES = {
1458
+ audio: [],
1459
+ video: [],
1460
+ speaker: []
1461
+ };
1462
+ function useMediaDevices(refreshKey = 0, enabled = true) {
1463
+ const [devices, setDevices] = react.useState(EMPTY_DEVICES);
1464
+ react.useEffect(() => {
1465
+ if (!enabled) return;
1466
+ if (typeof navigator === "undefined" || !navigator.mediaDevices) {
1467
+ return;
1571
1468
  }
1572
- });
1573
- }
1574
- classVarianceAuthority.cva(
1575
- "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",
1576
- {
1577
- variants: {
1578
- side: {
1579
- top: "top-0 -translate-y-3/4",
1580
- bottom: "bottom-0 translate-y-3/4"
1581
- },
1582
- align: {
1583
- start: "left-3",
1584
- end: "right-3"
1469
+ let cancelled = false;
1470
+ let timer;
1471
+ const load = async () => {
1472
+ try {
1473
+ const list = await navigator.mediaDevices.enumerateDevices();
1474
+ if (!cancelled) setDevices(mapMediaDevices(list));
1475
+ } catch {
1476
+ if (!cancelled) setDevices(EMPTY_DEVICES);
1585
1477
  }
1586
- },
1587
- defaultVariants: {
1588
- side: "bottom",
1589
- align: "end"
1590
- }
1591
- }
1592
- );
1478
+ };
1479
+ const onChange = () => {
1480
+ window.clearTimeout(timer);
1481
+ timer = window.setTimeout(() => {
1482
+ void load();
1483
+ }, STUDIO_DEVICE_CHANGE_DEBOUNCE_MS);
1484
+ };
1485
+ void load();
1486
+ navigator.mediaDevices.addEventListener("devicechange", onChange);
1487
+ return () => {
1488
+ cancelled = true;
1489
+ window.clearTimeout(timer);
1490
+ navigator.mediaDevices.removeEventListener("devicechange", onChange);
1491
+ };
1492
+ }, [enabled, refreshKey]);
1493
+ return devices;
1494
+ }
1593
1495
 
1594
- // src/studio/lib/chat-groups.ts
1595
- function groupChatMessages(messages, selfId) {
1596
- const groups = [];
1597
- for (const row of messages) {
1598
- const mine = Boolean(selfId) && row.userId === selfId;
1599
- const last = groups[groups.length - 1];
1600
- if (last && last.userId === row.userId && last.mine === mine) {
1601
- last.rows.push(row);
1602
- continue;
1496
+ // src/studio/lib/speaker-test.ts
1497
+ var TEST_MS = 400;
1498
+ var TEST_HZ = 440;
1499
+ async function playSpeakerTest(deviceId) {
1500
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
1501
+ if (!AudioCtx) {
1502
+ throw new Error("AudioContext unavailable");
1503
+ }
1504
+ const ctx = new AudioCtx();
1505
+ void ctx.resume();
1506
+ const oscillator = ctx.createOscillator();
1507
+ const gain = ctx.createGain();
1508
+ oscillator.type = "sine";
1509
+ oscillator.frequency.value = TEST_HZ;
1510
+ gain.gain.value = 0.18;
1511
+ oscillator.connect(gain);
1512
+ let audio = null;
1513
+ try {
1514
+ const probe = document.createElement("audio");
1515
+ const canRoute = Boolean(deviceId) && typeof probe.setSinkId === "function";
1516
+ if (canRoute && deviceId) {
1517
+ const destination = ctx.createMediaStreamDestination();
1518
+ gain.connect(destination);
1519
+ audio = probe;
1520
+ audio.autoplay = true;
1521
+ audio.playsInline = true;
1522
+ audio.srcObject = destination.stream;
1523
+ try {
1524
+ await audio.setSinkId?.(deviceId);
1525
+ await audio.play();
1526
+ } catch {
1527
+ gain.disconnect();
1528
+ gain.connect(ctx.destination);
1529
+ audio.srcObject = null;
1530
+ audio.remove();
1531
+ audio = null;
1532
+ }
1533
+ } else {
1534
+ gain.connect(ctx.destination);
1603
1535
  }
1604
- groups.push({
1605
- userId: row.userId,
1606
- name: row.name,
1607
- mine,
1608
- rows: [row]
1536
+ oscillator.start();
1537
+ await new Promise((resolve) => {
1538
+ window.setTimeout(resolve, TEST_MS);
1609
1539
  });
1540
+ oscillator.stop();
1541
+ } finally {
1542
+ if (audio) {
1543
+ audio.srcObject = null;
1544
+ audio.remove();
1545
+ }
1546
+ await ctx.close();
1610
1547
  }
1611
- return groups;
1612
1548
  }
1613
- function senderInitials(name) {
1614
- const parts = name.trim().split(/\s+/).filter((part) => part.length > 0);
1615
- if (parts.length === 0) return "?";
1616
- const first = parts[0];
1617
- if (parts.length === 1) return first.slice(0, 2).toUpperCase();
1618
- return `${first[0]}${parts[1][0]}`.toUpperCase();
1549
+ function StudioDeviceSelectGroup({
1550
+ devices,
1551
+ selected,
1552
+ labels,
1553
+ onChange,
1554
+ speakerAction
1555
+ }) {
1556
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-[8.5rem_minmax(0,1fr)] items-center gap-x-3 gap-y-3", children: [
1557
+ /* @__PURE__ */ jsxRuntime.jsx(
1558
+ DeviceRow,
1559
+ {
1560
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}),
1561
+ label: labels.cameraDevice,
1562
+ options: devices.video,
1563
+ value: selected.video,
1564
+ onChange: (id) => onChange("video", id)
1565
+ }
1566
+ ),
1567
+ /* @__PURE__ */ jsxRuntime.jsx(
1568
+ DeviceRow,
1569
+ {
1570
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {}),
1571
+ label: labels.micDevice,
1572
+ options: devices.audio,
1573
+ value: selected.audio,
1574
+ onChange: (id) => onChange("audio", id)
1575
+ }
1576
+ ),
1577
+ /* @__PURE__ */ jsxRuntime.jsx(
1578
+ DeviceRow,
1579
+ {
1580
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Volume2, {}),
1581
+ label: labels.speakerDevice,
1582
+ options: devices.speaker,
1583
+ value: selected.speaker,
1584
+ onChange: (id) => onChange("speaker", id),
1585
+ action: speakerAction
1586
+ }
1587
+ )
1588
+ ] });
1619
1589
  }
1620
- function StudioChatPanel({
1590
+ function DeviceRow({
1591
+ icon,
1592
+ label,
1593
+ options,
1594
+ value,
1595
+ onChange,
1596
+ action
1597
+ }) {
1598
+ const display = displayDeviceId(value, options);
1599
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1600
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-muted-foreground flex min-w-0 items-center gap-2 text-sm", children: [
1601
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "[&_svg]:size-4", "aria-hidden": true, children: icon }),
1602
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: label })
1603
+ ] }),
1604
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [
1605
+ options.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(
1606
+ "select",
1607
+ {
1608
+ "aria-label": label,
1609
+ className: cn(
1610
+ "border-input bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-base md:text-sm"
1611
+ ),
1612
+ value: display,
1613
+ onChange: (event) => onChange(event.target.value),
1614
+ children: options.map((option) => /* @__PURE__ */ jsxRuntime.jsx("option", { value: option.deviceId, children: option.label }, option.deviceId))
1615
+ }
1616
+ ) : null,
1617
+ action
1618
+ ] })
1619
+ ] });
1620
+ }
1621
+ function StudioSettingsDialog({
1622
+ open,
1623
+ onOpenChange,
1621
1624
  meeting,
1622
- selfId,
1623
- participants,
1624
- isModerator: isModerator2,
1625
- labels
1625
+ labels,
1626
+ reducedMotion,
1627
+ cameraOn
1626
1628
  }) {
1627
- const [tick, setTick] = react.useState(0);
1628
- const [draft, setDraft] = react.useState("");
1629
- const [editingId, setEditingId] = react.useState(null);
1630
- const listRef = react.useRef(null);
1631
- react.useEffect(
1632
- () => meeting.subscribeChat(() => setTick((n) => n + 1)),
1633
- [meeting]
1634
- );
1635
- const messages = react.useMemo(
1636
- () => meeting.chatMessages().filter((row) => !row.targetUserIds?.length),
1637
- [meeting, tick]
1638
- );
1639
- const pinned = react.useMemo(() => meeting.pinnedChatMessages(), [meeting, tick]);
1640
- const limit = meeting.chatMaxTextLimit();
1641
- const pictures = react.useMemo(() => {
1642
- const map = /* @__PURE__ */ new Map();
1643
- for (const row of participants) {
1644
- if (row.userId) map.set(row.userId, row.picture);
1645
- }
1646
- return map;
1647
- }, [participants]);
1648
- const groups = react.useMemo(
1649
- () => groupChatMessages(messages, selfId),
1650
- [messages, selfId]
1629
+ const devices = useMediaDevices(open ? 1 : 0, open);
1630
+ const [saved, setSaved] = react.useState(
1631
+ () => readStudioDeviceSelection()
1651
1632
  );
1633
+ const [audioTrack, setAudioTrack] = react.useState(null);
1634
+ const [videoTrack, setVideoTrack] = react.useState(null);
1635
+ const [testingSpeaker, setTestingSpeaker] = react.useState(false);
1636
+ const [displayName, setDisplayName] = react.useState("");
1637
+ const [savedDisplayName, setSavedDisplayName] = react.useState("");
1638
+ const [savingDisplayName, setSavingDisplayName] = react.useState(false);
1639
+ const { effective } = resolveDeviceSelection(saved, devices);
1652
1640
  react.useEffect(() => {
1653
- const list = listRef.current;
1654
- if (!list) return;
1655
- list.scrollTop = list.scrollHeight;
1656
- }, [messages.length]);
1657
- async function onSend() {
1658
- const text = draft.trim();
1659
- if (!text) return;
1660
- setDraft("");
1641
+ if (!open) return;
1642
+ setSaved(readStudioDeviceSelection());
1643
+ }, [open]);
1644
+ react.useEffect(() => {
1645
+ if (!open) return;
1646
+ const selfName = meeting.participants().find((row) => row.isSelf)?.name?.trim() ?? "";
1647
+ setDisplayName(selfName);
1648
+ setSavedDisplayName(selfName);
1649
+ }, [meeting, open]);
1650
+ react.useEffect(() => {
1651
+ if (!open) return;
1652
+ const sync = () => {
1653
+ setAudioTrack(meeting.localAudioTrack());
1654
+ setVideoTrack(meeting.localVideoTrack());
1655
+ };
1656
+ sync();
1657
+ return meeting.subscribeMedia(sync);
1658
+ }, [meeting, open]);
1659
+ const trimmedDisplayName = displayName.trim();
1660
+ const displayNameUnchanged = trimmedDisplayName === savedDisplayName;
1661
+ const displayNameSaveDisabled = !trimmedDisplayName || displayNameUnchanged || savingDisplayName;
1662
+ async function onChange(kind, deviceId) {
1663
+ persistStudioDeviceSelection(kind, deviceId);
1664
+ setSaved((prev) => ({ ...prev, [kind]: deviceId }));
1661
1665
  try {
1662
- if (editingId) {
1663
- await meeting.editChatMessage(editingId, text);
1664
- setEditingId(null);
1665
- return;
1666
- }
1667
- await meeting.sendChatMessage(text);
1668
- } catch (error) {
1669
- const message = error instanceof Error ? error.message.toLowerCase() : "";
1670
- sonner.toast.error(
1671
- message.includes("rate") ? labels.chatRateLimited : labels.chatError
1672
- );
1666
+ await meeting.setDevice(kind, deviceId);
1667
+ } catch {
1673
1668
  }
1674
1669
  }
1675
- function messageActions(row, mine) {
1676
- return /* @__PURE__ */ jsxRuntime.jsxs(
1677
- "p",
1678
- {
1679
- className: mine ? "text-muted-foreground flex flex-wrap justify-end gap-2 text-xs" : "text-muted-foreground flex flex-wrap gap-2 text-xs",
1680
- children: [
1681
- row.isEdited ? /* @__PURE__ */ jsxRuntime.jsx("span", { children: labels.chatEdited }) : null,
1682
- mine ? /* @__PURE__ */ jsxRuntime.jsx(
1683
- "button",
1684
- {
1685
- type: "button",
1686
- onClick: () => {
1687
- setEditingId(row.id);
1688
- setDraft(row.text);
1689
- },
1690
- children: labels.chatEdit
1691
- }
1692
- ) : null,
1693
- isModerator2 || mine ? /* @__PURE__ */ jsxRuntime.jsx(
1694
- "button",
1695
- {
1696
- type: "button",
1697
- onClick: () => void meeting.deleteChatMessage(row.id).catch(() => sonner.toast.error(labels.chatError)),
1698
- children: labels.chatDelete
1699
- }
1700
- ) : null,
1701
- isModerator2 ? /* @__PURE__ */ jsxRuntime.jsx(
1702
- "button",
1703
- {
1704
- type: "button",
1705
- onClick: () => void meeting.pinChatMessage(row.id, row.pinned !== true).catch(() => sonner.toast.error(labels.chatError)),
1706
- children: row.pinned ? labels.chatUnpin : labels.chatPin
1707
- }
1708
- ) : null
1709
- ]
1710
- }
1711
- );
1670
+ async function onSaveDisplayName() {
1671
+ if (displayNameSaveDisabled) return;
1672
+ setSavingDisplayName(true);
1673
+ try {
1674
+ await meeting.setDisplayName(trimmedDisplayName);
1675
+ setSavedDisplayName(trimmedDisplayName);
1676
+ setDisplayName(trimmedDisplayName);
1677
+ sonner.toast.success(labels.displayNameSaved);
1678
+ } catch {
1679
+ sonner.toast.error(labels.displayNameError);
1680
+ } finally {
1681
+ setSavingDisplayName(false);
1682
+ }
1712
1683
  }
1713
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [
1714
- pinned[0] ? /* @__PURE__ */ jsxRuntime.jsxs("div", { role: "status", className: "bg-muted border-b px-3 py-2 text-sm", children: [
1715
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-xs", children: labels.chatPinned }),
1716
- /* @__PURE__ */ jsxRuntime.jsx("p", { children: pinned[0].text })
1717
- ] }) : null,
1718
- /* @__PURE__ */ jsxRuntime.jsxs(
1719
- "div",
1720
- {
1721
- ref: listRef,
1722
- role: "log",
1723
- "aria-live": "polite",
1724
- className: "flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3",
1725
- children: [
1726
- /* @__PURE__ */ jsxRuntime.jsx(
1727
- Button,
1728
- {
1729
- type: "button",
1730
- size: "xs",
1731
- variant: "ghost",
1732
- className: "self-center",
1733
- onClick: () => {
1734
- const oldest = messages[0]?.time;
1735
- void meeting.fetchChatHistory({
1736
- timestamp: oldest
1737
- });
1738
- },
1739
- children: labels.chatLoadOlder
1740
- }
1741
- ),
1742
- groups.map((group) => {
1743
- const bubbles = group.rows.map((row) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [
1744
- /* @__PURE__ */ jsxRuntime.jsx(
1745
- Bubble,
1746
- {
1747
- align: group.mine ? "end" : "start",
1748
- variant: group.mine ? "default" : "secondary",
1749
- children: /* @__PURE__ */ jsxRuntime.jsx(BubbleContent, { children: row.text })
1750
- }
1751
- ),
1752
- messageActions(row, group.mine)
1753
- ] }, row.id));
1754
- if (group.mine) {
1755
- return /* @__PURE__ */ jsxRuntime.jsx(BubbleGroup, { className: "items-end", children: bubbles }, group.rows[0].id);
1756
- }
1757
- const picture = pictures.get(group.userId) ?? null;
1758
- return /* @__PURE__ */ jsxRuntime.jsxs(
1759
- "div",
1760
- {
1761
- className: "flex max-w-full gap-2 self-start",
1762
- children: [
1763
- picture ? /* @__PURE__ */ jsxRuntime.jsx(
1764
- "img",
1765
- {
1766
- src: picture,
1767
- alt: "",
1768
- className: "mt-0.5 size-7 shrink-0 rounded-full object-cover"
1769
- }
1770
- ) : /* @__PURE__ */ jsxRuntime.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) }),
1771
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [
1772
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground mb-1 text-xs", children: group.name }),
1773
- /* @__PURE__ */ jsxRuntime.jsx(BubbleGroup, { children: bubbles })
1774
- ] })
1775
- ]
1776
- },
1777
- group.rows[0].id
1778
- );
1779
- })
1780
- ]
1781
- }
1782
- ),
1783
- /* @__PURE__ */ jsxRuntime.jsxs(
1784
- "form",
1785
- {
1786
- className: "flex flex-col gap-2 border-t p-3",
1787
- onSubmit: (event) => {
1788
- event.preventDefault();
1789
- void onSend();
1790
- },
1791
- children: [
1792
- /* @__PURE__ */ jsxRuntime.jsx(
1793
- "input",
1794
- {
1795
- className: "border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm",
1796
- value: draft,
1797
- maxLength: limit,
1798
- onChange: (event) => setDraft(event.target.value),
1799
- placeholder: labels.chatPlaceholder,
1800
- "aria-label": labels.chatPlaceholder
1801
- }
1802
- ),
1803
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-2", children: [
1804
- /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-muted-foreground text-xs", children: [
1805
- draft.length,
1806
- "/",
1807
- limit
1808
- ] }),
1809
- /* @__PURE__ */ jsxRuntime.jsx(Button, { type: "submit", size: "sm", children: editingId ? labels.chatSave : labels.chatSend })
1810
- ] })
1811
- ]
1812
- }
1813
- )
1814
- ] });
1815
- }
1816
- function StudioWaitingRoom({
1817
- waitlisted,
1818
- labels,
1819
- onAdmitAs,
1820
- onAdmitAll,
1821
- onDeny
1822
- }) {
1823
- return /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.waiting, className: "space-y-3", children: [
1824
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.waiting }),
1825
- waitlisted.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.noWaiting }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1826
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid gap-2", children: [
1684
+ async function onTestSpeaker() {
1685
+ if (testingSpeaker) return;
1686
+ const speakerId = effective.speaker || devices.speaker[0]?.deviceId;
1687
+ setTestingSpeaker(true);
1688
+ try {
1689
+ await playSpeakerTest(speakerId || void 0);
1690
+ } catch {
1691
+ sonner.toast.error(labels.testSpeakerError);
1692
+ } finally {
1693
+ setTestingSpeaker(false);
1694
+ }
1695
+ }
1696
+ return /* @__PURE__ */ jsxRuntime.jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(DialogContent, { className: "sm:max-w-md", children: [
1697
+ /* @__PURE__ */ jsxRuntime.jsxs(DialogHeader, { children: [
1698
+ /* @__PURE__ */ jsxRuntime.jsx(DialogTitle, { children: labels.settingsTitle }),
1699
+ /* @__PURE__ */ jsxRuntime.jsx(DialogDescription, { children: labels.settingsDescription })
1700
+ ] }),
1701
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
1702
+ /* @__PURE__ */ jsxRuntime.jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
1703
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
1827
1704
  /* @__PURE__ */ jsxRuntime.jsx(
1828
- Button,
1705
+ Input,
1829
1706
  {
1830
- type: "button",
1831
- size: "xs",
1832
- className: "w-full",
1833
- onClick: () => onAdmitAll("panelist"),
1834
- children: labels.admitAllPanelists
1707
+ id: "studio-display-name",
1708
+ value: displayName,
1709
+ maxLength: STUDIO_DISPLAY_NAME_MAX,
1710
+ onChange: (event) => setDisplayName(event.target.value)
1835
1711
  }
1836
1712
  ),
1837
1713
  /* @__PURE__ */ jsxRuntime.jsx(
1838
1714
  Button,
1839
1715
  {
1840
1716
  type: "button",
1841
- size: "xs",
1842
1717
  variant: "outline",
1843
- className: "w-full",
1844
- onClick: () => onAdmitAll("audience"),
1845
- children: labels.admitAllAudience
1718
+ disabled: displayNameSaveDisabled,
1719
+ onClick: () => void onSaveDisplayName(),
1720
+ children: labels.displayNameSave
1846
1721
  }
1847
1722
  )
1848
- ] }),
1849
- /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: waitlisted.map((guest) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "space-y-2 rounded-md border p-2", children: [
1850
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: guest.name }),
1851
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid gap-2", children: [
1852
- /* @__PURE__ */ jsxRuntime.jsx(
1853
- Button,
1854
- {
1855
- type: "button",
1856
- size: "xs",
1857
- className: "w-full",
1858
- onClick: () => onAdmitAs(guest.id, "panelist"),
1859
- children: labels.admitAsPanelist
1860
- }
1861
- ),
1862
- /* @__PURE__ */ jsxRuntime.jsx(
1863
- Button,
1864
- {
1865
- type: "button",
1866
- size: "xs",
1867
- variant: "outline",
1868
- className: "w-full",
1869
- onClick: () => onAdmitAs(guest.id, "audience"),
1870
- children: labels.admitAsAudience
1871
- }
1872
- ),
1873
- /* @__PURE__ */ jsxRuntime.jsx(
1874
- Button,
1875
- {
1876
- type: "button",
1877
- size: "xs",
1878
- variant: "ghost",
1879
- className: "w-full",
1880
- onClick: () => onDeny(guest.id),
1881
- children: labels.deny
1882
- }
1883
- )
1884
- ] })
1885
- ] }, guest.id)) })
1886
- ] })
1887
- ] });
1888
- }
1889
- function StudioSidePanel({
1890
- tab,
1891
- onTabChange,
1892
- onClose,
1893
- meeting,
1894
- selfId,
1895
- participants,
1896
- waitlisted,
1897
- stageRequests,
1898
- isModerator: isModerator2,
1899
- chatBadge,
1900
- peopleBadge,
1901
- labels,
1902
- onAdmitAs,
1903
- onAdmitAll,
1904
- onDeny,
1905
- onGrantStage,
1906
- onDenyStage,
1907
- onTakeOffAir,
1908
- guestUrl,
1909
- onShareGuestLink
1910
- }) {
1911
- const panelists = participants.filter((row) => isOnStage(row.stageStatus));
1912
- const audience = participants.filter((row) => !isOnStage(row.stageStatus));
1913
- return /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: "bg-background flex w-full flex-col border-t md:w-80 md:border-l md:border-t-0", children: [
1914
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex border-b", children: [
1915
- /* @__PURE__ */ jsxRuntime.jsxs(
1916
- Button,
1723
+ ] })
1724
+ ] }),
1725
+ /* @__PURE__ */ jsxRuntime.jsx(
1726
+ SettingsCameraPreview,
1727
+ {
1728
+ track: videoTrack,
1729
+ open,
1730
+ cameraOn,
1731
+ cameraOffLabel: labels.cameraOff
1732
+ }
1733
+ ),
1734
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1735
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "sr-only", children: labels.micLevel }),
1736
+ /* @__PURE__ */ jsxRuntime.jsx(
1737
+ AudioWaveform,
1917
1738
  {
1918
- type: "button",
1919
- size: "sm",
1920
- variant: tab === "chat" ? "secondary" : "ghost",
1921
- className: "relative flex-1 rounded-none",
1922
- onClick: () => onTabChange("chat"),
1923
- children: [
1924
- labels.chat,
1925
- chatBadge > 0 ? /* @__PURE__ */ jsxRuntime.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
1926
- ]
1927
- }
1928
- ),
1929
- isModerator2 ? /* @__PURE__ */ jsxRuntime.jsxs(
1930
- Button,
1931
- {
1932
- type: "button",
1933
- size: "sm",
1934
- variant: tab === "people" ? "secondary" : "ghost",
1935
- className: "relative flex-1 rounded-none",
1936
- onClick: () => onTabChange("people"),
1937
- children: [
1938
- labels.people,
1939
- peopleBadge > 0 ? /* @__PURE__ */ jsxRuntime.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
1940
- ]
1941
- }
1942
- ) : null,
1943
- /* @__PURE__ */ jsxRuntime.jsx(
1944
- Button,
1945
- {
1946
- type: "button",
1947
- size: "icon-xs",
1948
- variant: "ghost",
1949
- className: "m-1 shrink-0",
1950
- "aria-label": labels.closePanel,
1951
- onClick: onClose,
1952
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {})
1739
+ track: audioTrack,
1740
+ reducedMotion,
1741
+ className: "w-full"
1953
1742
  }
1954
1743
  )
1955
1744
  ] }),
1956
- tab === "chat" ? /* @__PURE__ */ jsxRuntime.jsx(
1957
- StudioChatPanel,
1745
+ /* @__PURE__ */ jsxRuntime.jsx(
1746
+ StudioDeviceSelectGroup,
1958
1747
  {
1959
- meeting,
1960
- selfId,
1961
- participants,
1962
- isModerator: isModerator2,
1963
- labels
1964
- }
1965
- ) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3", children: [
1966
- guestUrl ? /* @__PURE__ */ jsxRuntime.jsxs(
1967
- Button,
1968
- {
1969
- type: "button",
1970
- variant: "outline",
1971
- className: "w-full",
1972
- onClick: onShareGuestLink,
1973
- children: [
1974
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Link2, {}),
1975
- labels.shareLink
1976
- ]
1977
- }
1978
- ) : null,
1979
- /* @__PURE__ */ jsxRuntime.jsx(
1980
- StudioWaitingRoom,
1981
- {
1982
- waitlisted,
1983
- labels,
1984
- onAdmitAs,
1985
- onAdmitAll,
1986
- onDeny
1987
- }
1988
- ),
1989
- stageRequests.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.stageRequests, className: "space-y-2", children: [
1990
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.stageRequests }),
1991
- /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: stageRequests.map((request) => /* @__PURE__ */ jsxRuntime.jsxs(
1992
- "li",
1748
+ devices,
1749
+ selected: effective,
1750
+ labels,
1751
+ onChange: (kind, id) => void onChange(kind, id),
1752
+ speakerAction: /* @__PURE__ */ jsxRuntime.jsx(
1753
+ Button,
1993
1754
  {
1994
- className: "space-y-2 rounded-md border p-2",
1995
- children: [
1996
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: request.name }),
1997
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid gap-2", children: [
1998
- /* @__PURE__ */ jsxRuntime.jsx(
1999
- Button,
2000
- {
2001
- type: "button",
2002
- size: "xs",
2003
- className: "w-full",
2004
- onClick: () => onGrantStage(request.userId),
2005
- children: labels.bringOnAir
2006
- }
2007
- ),
2008
- /* @__PURE__ */ jsxRuntime.jsx(
2009
- Button,
2010
- {
2011
- type: "button",
2012
- size: "xs",
2013
- variant: "outline",
2014
- className: "w-full",
2015
- onClick: () => onDenyStage(request.userId),
2016
- children: labels.deny
2017
- }
2018
- )
2019
- ] })
2020
- ]
2021
- },
2022
- request.userId
2023
- )) })
2024
- ] }) : null,
2025
- /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.panelists, className: "space-y-2", children: [
2026
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.panelists }),
2027
- panelists.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.noPanelists }) : /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: panelists.map((row) => {
2028
- const userId = row.userId;
2029
- return /* @__PURE__ */ jsxRuntime.jsxs(
2030
- "li",
2031
- {
2032
- className: "space-y-2 rounded-md border p-2",
2033
- children: [
2034
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: row.name }),
2035
- !row.isSelf && userId ? /* @__PURE__ */ jsxRuntime.jsx(
2036
- Button,
2037
- {
2038
- type: "button",
2039
- size: "xs",
2040
- variant: "outline",
2041
- className: "w-full",
2042
- onClick: () => onTakeOffAir(userId),
2043
- children: labels.offAir
2044
- }
2045
- ) : null
2046
- ]
2047
- },
2048
- row.id
2049
- );
2050
- }) })
2051
- ] }),
2052
- /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.audience, className: "space-y-2", children: [
2053
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.audience }),
2054
- audience.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.noAudience }) : /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: audience.map((row) => {
2055
- const userId = row.userId;
2056
- return /* @__PURE__ */ jsxRuntime.jsxs(
2057
- "li",
2058
- {
2059
- className: "space-y-2 rounded-md border p-2",
2060
- children: [
2061
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: row.name }),
2062
- !row.isSelf && userId ? /* @__PURE__ */ jsxRuntime.jsx(
2063
- Button,
2064
- {
2065
- type: "button",
2066
- size: "xs",
2067
- variant: "outline",
2068
- className: "w-full",
2069
- onClick: () => onGrantStage(userId),
2070
- children: labels.bringOnAir
2071
- }
2072
- ) : null
2073
- ]
2074
- },
2075
- row.id
2076
- );
2077
- }) })
2078
- ] })
2079
- ] })
2080
- ] });
2081
- }
2082
- function DropdownMenu({ ...props }) {
2083
- return /* @__PURE__ */ jsxRuntime.jsx(menu.Menu.Root, { "data-slot": "dropdown-menu", ...props });
2084
- }
2085
- function DropdownMenuTrigger({ ...props }) {
2086
- return /* @__PURE__ */ jsxRuntime.jsx(menu.Menu.Trigger, { "data-slot": "dropdown-menu-trigger", ...props });
1755
+ type: "button",
1756
+ size: "sm",
1757
+ variant: "outline",
1758
+ disabled: testingSpeaker,
1759
+ onClick: () => void onTestSpeaker(),
1760
+ children: labels.testSpeaker
1761
+ }
1762
+ )
1763
+ }
1764
+ )
1765
+ ] }) });
2087
1766
  }
2088
- function DropdownMenuContent({
2089
- align = "start",
2090
- alignOffset = 0,
2091
- side = "bottom",
2092
- sideOffset = 4,
2093
- className,
2094
- ...props
1767
+ function SettingsCameraPreview({
1768
+ track,
1769
+ open,
1770
+ cameraOn,
1771
+ cameraOffLabel
2095
1772
  }) {
2096
- return /* @__PURE__ */ jsxRuntime.jsx(menu.Menu.Portal, { children: /* @__PURE__ */ jsxRuntime.jsx(
2097
- menu.Menu.Positioner,
1773
+ const ref = react.useRef(null);
1774
+ react.useEffect(() => {
1775
+ const el = ref.current;
1776
+ if (!el) return;
1777
+ if (!open || !cameraOn || !track) {
1778
+ el.srcObject = null;
1779
+ return;
1780
+ }
1781
+ el.srcObject = new MediaStream([track]);
1782
+ void el.play().catch(() => void 0);
1783
+ return () => {
1784
+ el.srcObject = null;
1785
+ };
1786
+ }, [cameraOn, open, track]);
1787
+ if (!cameraOn) {
1788
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) });
1789
+ }
1790
+ if (!track) {
1791
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted aspect-video w-full rounded-xl", "aria-hidden": true });
1792
+ }
1793
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted relative aspect-video w-full overflow-hidden rounded-xl", children: /* @__PURE__ */ jsxRuntime.jsx(
1794
+ "video",
2098
1795
  {
2099
- className: "isolate z-50 outline-none",
2100
- align,
2101
- alignOffset,
2102
- side,
2103
- sideOffset,
2104
- children: /* @__PURE__ */ jsxRuntime.jsx(
2105
- menu.Menu.Popup,
2106
- {
2107
- "data-slot": "dropdown-menu-content",
2108
- className: cn(
2109
- "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",
2110
- className
2111
- ),
2112
- ...props
2113
- }
2114
- )
1796
+ ref,
1797
+ autoPlay: true,
1798
+ muted: true,
1799
+ playsInline: true,
1800
+ className: "absolute inset-0 size-full -scale-x-100 object-cover"
2115
1801
  }
2116
1802
  ) });
2117
1803
  }
2118
- function DropdownMenuItem({
2119
- className,
2120
- inset,
2121
- variant = "default",
2122
- ...props
1804
+ function StudioGuestWaiting({
1805
+ meeting,
1806
+ reducedMotion,
1807
+ cameraOn,
1808
+ muted,
1809
+ labels,
1810
+ settingsOpen,
1811
+ onToggleCamera,
1812
+ onToggleMute,
1813
+ onOpenSettings,
1814
+ onCloseSettings
2123
1815
  }) {
2124
- return /* @__PURE__ */ jsxRuntime.jsx(
2125
- menu.Menu.Item,
2126
- {
2127
- "data-slot": "dropdown-menu-item",
2128
- "data-inset": inset,
2129
- "data-variant": variant,
2130
- className: cn(
2131
- "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",
2132
- className
2133
- ),
2134
- ...props
2135
- }
2136
- );
2137
- }
2138
- function DropdownMenuSeparator({
2139
- className,
2140
- ...props
2141
- }) {
2142
- return /* @__PURE__ */ jsxRuntime.jsx(
2143
- menu.Menu.Separator,
2144
- {
2145
- "data-slot": "dropdown-menu-separator",
2146
- className: cn("-mx-1 my-1 h-px bg-border", className),
2147
- ...props
2148
- }
2149
- );
2150
- }
2151
- function StudioTileMenu({
2152
- tile,
2153
- canModerate,
2154
- canManageStage,
2155
- takeOffBlocked,
2156
- labels,
2157
- onKick,
2158
- onMute,
2159
- onStopCamera,
2160
- onStopScreen,
2161
- onToggleStage,
2162
- onTogglePin,
2163
- onToggleChat
2164
- }) {
2165
- const onStage = isOnStage(tile.stageStatus);
2166
- const showPersonItems = canModerate && tile.kind !== "screen";
2167
- const showScreenItems = canModerate && tile.kind === "screen";
2168
- const showStageItems = canManageStage && tile.kind !== "screen";
2169
- if (!showPersonItems && !showScreenItems && !showStageItems) return null;
2170
- return /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
1816
+ const [audioTrack, setAudioTrack] = react.useState(null);
1817
+ const [videoTrack, setVideoTrack] = react.useState(null);
1818
+ react.useEffect(() => {
1819
+ const sync = () => {
1820
+ setAudioTrack(meeting.localAudioTrack());
1821
+ setVideoTrack(meeting.localVideoTrack());
1822
+ };
1823
+ sync();
1824
+ return meeting.subscribeMedia(sync);
1825
+ }, [meeting]);
1826
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mx-auto flex min-h-dvh w-full max-w-lg flex-col justify-center gap-6 p-6", children: [
2171
1827
  /* @__PURE__ */ jsxRuntime.jsx(
2172
- DropdownMenuTrigger,
1828
+ WaitingCameraPreview,
2173
1829
  {
2174
- render: /* @__PURE__ */ jsxRuntime.jsx(
2175
- Button,
2176
- {
2177
- type: "button",
2178
- size: "icon-xs",
2179
- variant: "ghost",
2180
- className: "text-white hover:text-white",
2181
- "aria-label": labels.tileMenu
2182
- }
2183
- ),
2184
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MoreVertical, { className: "size-3.5" })
1830
+ track: videoTrack,
1831
+ cameraOn,
1832
+ cameraOffLabel: labels.cameraOff
2185
1833
  }
2186
1834
  ),
2187
- /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuContent, { align: "end", className: "min-w-44", children: [
2188
- showScreenItems ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onStopScreen, children: labels.stopScreen }) : null,
2189
- showPersonItems && tile.audioEnabled ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onMute, children: labels.mute }) : null,
2190
- showPersonItems && tile.kind === "camera" && tile.videoEnabled ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onStopCamera, children: labels.stopCamera }) : null,
2191
- showPersonItems || showScreenItems ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onTogglePin, children: tile.pinned ? labels.unpin : labels.pin }) : null,
2192
- showPersonItems ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onToggleChat, children: tile.chatCanSend === false ? labels.allowChat : labels.muteChat }) : null,
2193
- showStageItems ? /* @__PURE__ */ jsxRuntime.jsx(
2194
- DropdownMenuItem,
1835
+ /* @__PURE__ */ jsxRuntime.jsx(
1836
+ AudioWaveform,
1837
+ {
1838
+ track: muted ? null : audioTrack,
1839
+ reducedMotion
1840
+ }
1841
+ ),
1842
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-center text-sm", role: "status", children: labels.waitingHost }),
1843
+ /* @__PURE__ */ jsxRuntime.jsx(TooltipProvider, { children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-center gap-2", children: [
1844
+ /* @__PURE__ */ jsxRuntime.jsx(
1845
+ ControlIcon,
2195
1846
  {
2196
- disabled: onStage && takeOffBlocked,
2197
- onClick: onToggleStage,
2198
- children: onStage ? takeOffBlocked ? labels.offAirLastVisible : labels.takeOffAir : labels.bringOnAir
1847
+ label: muted ? labels.unmute : labels.mute,
1848
+ pressed: muted,
1849
+ tone: muted ? "danger" : "default",
1850
+ onClick: onToggleMute,
1851
+ children: muted ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {})
2199
1852
  }
2200
- ) : null,
2201
- showPersonItems ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2202
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuSeparator, {}),
2203
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { variant: "destructive", onClick: onKick, children: labels.kick })
2204
- ] }) : null
2205
- ] })
1853
+ ),
1854
+ /* @__PURE__ */ jsxRuntime.jsx(
1855
+ ControlIcon,
1856
+ {
1857
+ label: labels.camera,
1858
+ pressed: cameraOn,
1859
+ onClick: onToggleCamera,
1860
+ children: cameraOn ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {})
1861
+ }
1862
+ ),
1863
+ /* @__PURE__ */ jsxRuntime.jsx(
1864
+ ControlIcon,
1865
+ {
1866
+ label: labels.settings,
1867
+ pressed: settingsOpen,
1868
+ onClick: onOpenSettings,
1869
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, {})
1870
+ }
1871
+ )
1872
+ ] }) }),
1873
+ /* @__PURE__ */ jsxRuntime.jsx(
1874
+ StudioSettingsDialog,
1875
+ {
1876
+ open: settingsOpen,
1877
+ onOpenChange: onCloseSettings,
1878
+ meeting,
1879
+ labels,
1880
+ reducedMotion,
1881
+ cameraOn
1882
+ }
1883
+ )
2206
1884
  ] });
2207
1885
  }
2208
- function StudioTile({
2209
- tile,
1886
+ function WaitingCameraPreview({
1887
+ track,
1888
+ cameraOn,
1889
+ cameraOffLabel
1890
+ }) {
1891
+ const ref = react.useRef(null);
1892
+ react.useEffect(() => {
1893
+ const el = ref.current;
1894
+ if (!el) return;
1895
+ if (!cameraOn || !track) {
1896
+ el.srcObject = null;
1897
+ return;
1898
+ }
1899
+ el.srcObject = new MediaStream([track]);
1900
+ void el.play().catch(() => void 0);
1901
+ return () => {
1902
+ el.srcObject = null;
1903
+ };
1904
+ }, [cameraOn, track]);
1905
+ if (!cameraOn) {
1906
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) });
1907
+ }
1908
+ if (!track) {
1909
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted aspect-video w-full rounded-xl", "aria-hidden": true });
1910
+ }
1911
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted relative aspect-video w-full overflow-hidden rounded-xl", children: /* @__PURE__ */ jsxRuntime.jsx(
1912
+ "video",
1913
+ {
1914
+ ref,
1915
+ autoPlay: true,
1916
+ muted: true,
1917
+ playsInline: true,
1918
+ className: "absolute inset-0 size-full -scale-x-100 object-cover"
1919
+ }
1920
+ ) });
1921
+ }
1922
+ function StudioLiveBadge({
1923
+ children,
1924
+ role
1925
+ }) {
1926
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1927
+ "span",
1928
+ {
1929
+ role,
1930
+ className: "flex items-center gap-1.5 rounded bg-red-600 px-2 py-0.5 text-xs font-semibold tracking-wide text-white",
1931
+ children: [
1932
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "size-1.5 animate-pulse rounded-full bg-white motion-reduce:animate-none" }),
1933
+ children
1934
+ ]
1935
+ }
1936
+ );
1937
+ }
1938
+ var badgeVariants = classVarianceAuthority.cva(
1939
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
1940
+ {
1941
+ variants: {
1942
+ variant: {
1943
+ default: "border-transparent bg-primary text-primary-foreground",
1944
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
1945
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
1946
+ outline: "text-foreground",
1947
+ success: "border-transparent bg-chart-5/15 text-chart-5 dark:bg-chart-5/25",
1948
+ warning: "border-transparent bg-chart-4/15 text-chart-4 dark:bg-chart-4/25"
1949
+ }
1950
+ },
1951
+ defaultVariants: { variant: "default" }
1952
+ }
1953
+ );
1954
+ function Badge({ className, variant, ...props }) {
1955
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(badgeVariants({ variant }), className), ...props });
1956
+ }
1957
+ function StudioPipPanel({
2210
1958
  meeting,
2211
- reducedMotion,
2212
- compact,
2213
- canModerate,
2214
- canManageStage,
2215
- takeOffBlocked,
2216
- confirmTakeOff,
2217
1959
  labels,
2218
- onKick,
2219
- onMute,
2220
- onStopCamera,
2221
- onStopScreen,
2222
- onToggleStage,
2223
- onTogglePin,
2224
- onToggleChat
1960
+ cameraOn,
1961
+ muted,
1962
+ screenOn,
1963
+ showMediaControls = true,
1964
+ showScreenControl = false,
1965
+ canShareFreely = false,
1966
+ screenAvailable = true,
1967
+ screenPending = false,
1968
+ shareGrant = "idle",
1969
+ onAir,
1970
+ elapsed,
1971
+ unread = 0,
1972
+ onToggleMute,
1973
+ onToggleCamera,
1974
+ onToggleScreen,
1975
+ onAskToShare,
1976
+ onCancelShareRequest,
1977
+ onBack
2225
1978
  }) {
2226
- const onStage = isOnStage(tile.stageStatus);
2227
- const [confirm, setConfirm] = react.useState(null);
2228
- const showMenu = (canModerate || canManageStage) && !tile.isSelf;
1979
+ const tiles = meeting.participants();
1980
+ const spotlight = resolveStudioPipSpotlight(tiles, meeting.activeSpeakerId());
1981
+ const selfView = resolveStudioPipSelfView(tiles, spotlight);
1982
+ const speaking = spotlight !== null && spotlight.participantId === meeting.activeSpeakerId();
1983
+ const backLabel = unread > 0 ? `${labels.backToStudio} (${unread})` : labels.backToStudio;
2229
1984
  return /* @__PURE__ */ jsxRuntime.jsxs(
2230
- "article",
1985
+ "div",
2231
1986
  {
2232
- "aria-label": tile.name,
2233
- className: cn(
2234
- "bg-muted relative overflow-hidden rounded-xl",
2235
- compact ? "aspect-video w-40 shrink-0 md:w-full" : tile.kind === "screen" ? "min-h-48 flex-1" : "aspect-video"
2236
- ),
1987
+ role: "region",
1988
+ "aria-label": labels.pipTitle,
1989
+ className: "bg-background flex h-full min-h-0 flex-col gap-2 p-2 text-foreground",
2237
1990
  children: [
2238
- tile.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
2239
- tile.picture ? /* @__PURE__ */ jsxRuntime.jsx(
2240
- "img",
2241
- {
2242
- src: tile.picture,
2243
- alt: "",
2244
- className: "size-16 rounded-full object-cover"
2245
- }
2246
- ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-background flex size-16 items-center justify-center rounded-full text-xl", children: tile.name.slice(0, 1).toUpperCase() }),
2247
- /* @__PURE__ */ jsxRuntime.jsx(
2248
- AudioWaveform,
2249
- {
2250
- track: tile.isSelf ? meeting.localAudioTrack() : null,
2251
- reducedMotion
2252
- }
2253
- )
2254
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(
2255
- StudioTileVideo,
1991
+ /* @__PURE__ */ jsxRuntime.jsxs(
1992
+ "div",
2256
1993
  {
2257
- meeting,
2258
- participantId: tile.id,
2259
- kind: tile.kind,
2260
- mirror: tile.kind === "camera" && tile.isSelf,
2261
- contain: tile.kind === "screen"
2262
- }
2263
- ),
2264
- /* @__PURE__ */ jsxRuntime.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: [
2265
- /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "min-w-0 truncate text-xs font-medium text-white", children: [
2266
- tile.name,
2267
- !onStage ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ml-1 rounded-full border border-white/40 px-1.5 py-0.5 text-[0.65rem]", children: labels.offAir }) : null
2268
- ] }),
2269
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [
2270
- /* @__PURE__ */ jsxRuntime.jsx(TileStatusIcons, { tile, labels }),
2271
- showMenu ? /* @__PURE__ */ jsxRuntime.jsx(
2272
- StudioTileMenu,
2273
- {
2274
- tile,
2275
- canModerate,
2276
- canManageStage,
2277
- takeOffBlocked,
2278
- labels,
2279
- onKick: () => setConfirm("kick"),
2280
- onMute,
2281
- onStopCamera,
2282
- onStopScreen,
2283
- onToggleStage: () => {
2284
- if (onStage && confirmTakeOff && !takeOffBlocked) {
2285
- setConfirm("takeOff");
2286
- return;
2287
- }
2288
- onToggleStage();
2289
- },
2290
- onTogglePin,
2291
- onToggleChat
2292
- }
2293
- ) : null
2294
- ] })
2295
- ] }),
2296
- /* @__PURE__ */ jsxRuntime.jsx(
2297
- ConfirmAction,
2298
- {
2299
- open: confirm === "kick",
2300
- onOpenChange: (open) => {
2301
- if (!open) setConfirm(null);
2302
- },
2303
- triggerLabel: labels.kick,
2304
- title: labels.kickTitle,
2305
- description: labels.kickDescription,
2306
- confirmLabel: labels.kickConfirm,
2307
- onConfirm: () => {
2308
- setConfirm(null);
2309
- onKick();
2310
- }
1994
+ className: cn(
1995
+ "bg-muted relative min-h-0 flex-1 overflow-hidden rounded-xl",
1996
+ speaking && "ring-2 ring-primary"
1997
+ ),
1998
+ children: [
1999
+ /* @__PURE__ */ jsxRuntime.jsx(
2000
+ PipSpotlight,
2001
+ {
2002
+ meeting,
2003
+ spotlight,
2004
+ cameraOffLabel: labels.cameraOff
2005
+ }
2006
+ ),
2007
+ onAir ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute top-2 left-2 flex items-center gap-2", children: [
2008
+ /* @__PURE__ */ jsxRuntime.jsx(StudioLiveBadge, { children: labels.live }),
2009
+ elapsed ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: elapsed }) : null
2010
+ ] }) : null,
2011
+ selfView ? /* @__PURE__ */ jsxRuntime.jsx(
2012
+ "div",
2013
+ {
2014
+ "data-testid": "pip-self-view",
2015
+ className: "absolute right-2 bottom-2 aspect-video w-[30%] overflow-hidden rounded-lg ring-1 ring-white/40",
2016
+ children: /* @__PURE__ */ jsxRuntime.jsx(PipTrackVideo, { track: meeting.localVideoTrack(), fit: "cover" })
2017
+ }
2018
+ ) : null
2019
+ ]
2311
2020
  }
2312
2021
  ),
2313
- /* @__PURE__ */ jsxRuntime.jsx(
2314
- ConfirmAction,
2315
- {
2316
- open: confirm === "takeOff",
2317
- onOpenChange: (open) => {
2318
- if (!open) setConfirm(null);
2319
- },
2320
- triggerLabel: labels.takeOffAir,
2321
- title: labels.takeOffAirTitle,
2322
- description: labels.takeOffAirDescription,
2323
- confirmLabel: labels.takeOffAirConfirm,
2324
- onConfirm: () => {
2325
- setConfirm(null);
2326
- onToggleStage();
2327
- }
2328
- }
2329
- )
2022
+ /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex flex-wrap items-center justify-center gap-2", children: [
2023
+ showMediaControls ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2024
+ /* @__PURE__ */ jsxRuntime.jsx(
2025
+ PipControl,
2026
+ {
2027
+ label: muted ? labels.unmute : labels.mute,
2028
+ off: muted,
2029
+ onClick: onToggleMute,
2030
+ children: muted ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {})
2031
+ }
2032
+ ),
2033
+ /* @__PURE__ */ jsxRuntime.jsx(
2034
+ PipControl,
2035
+ {
2036
+ label: labels.camera,
2037
+ off: !cameraOn,
2038
+ onClick: onToggleCamera,
2039
+ children: cameraOn ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {})
2040
+ }
2041
+ )
2042
+ ] }) : null,
2043
+ showMediaControls && (showScreenControl || screenOn) ? pipScreenControls({
2044
+ labels,
2045
+ screenOn,
2046
+ canShareFreely,
2047
+ screenAvailable,
2048
+ screenPending,
2049
+ shareGrant,
2050
+ onToggleScreen,
2051
+ onAskToShare,
2052
+ onCancelShareRequest
2053
+ }) : null,
2054
+ /* @__PURE__ */ jsxRuntime.jsx(PipControl, { label: backLabel, onClick: onBack, children: /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "relative", children: [
2055
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Undo2, {}),
2056
+ unread > 0 ? /* @__PURE__ */ jsxRuntime.jsx(Badge, { className: "absolute -top-2 -right-2 h-4 min-w-4 px-1 text-[0.65rem]", children: unread }) : null
2057
+ ] }) })
2058
+ ] })
2330
2059
  ]
2331
2060
  }
2332
2061
  );
2333
2062
  }
2334
- function TileStatusIcons({
2335
- tile,
2336
- labels
2063
+ function PipSpotlight({
2064
+ meeting,
2065
+ spotlight,
2066
+ cameraOffLabel
2337
2067
  }) {
2068
+ if (!spotlight || spotlight.kind === "idle") {
2069
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2", children: [
2070
+ spotlight?.picture ? /* @__PURE__ */ jsxRuntime.jsx(
2071
+ "img",
2072
+ {
2073
+ src: spotlight.picture,
2074
+ alt: "",
2075
+ className: "size-14 rounded-full object-cover"
2076
+ }
2077
+ ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-background flex size-14 items-center justify-center rounded-full text-lg", children: (spotlight?.name ?? "?").slice(0, 1).toUpperCase() }),
2078
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-xs", children: spotlight?.name ?? cameraOffLabel })
2079
+ ] });
2080
+ }
2081
+ const track = spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId);
2338
2082
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2339
- tile.audioEnabled ? null : /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.mute, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) }),
2340
- tile.kind !== "screen" && !tile.videoEnabled ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.cameraOff, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {}) }) : null,
2341
- tile.kind !== "screen" && tile.screenShareEnabled ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.stopScreen, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {}) }) : null,
2342
- tile.pinned ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.pin, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Pin, {}) }) : null,
2343
- tile.chatCanSend === false ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.muteChat, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageSquareOff, {}) }) : null
2344
- ] });
2345
- }
2346
- function StatusIcon({
2347
- label,
2348
- children
2349
- }) {
2350
- return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex text-white", children: [
2351
- /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "[&_svg]:size-3.5", children }),
2352
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: label })
2083
+ /* @__PURE__ */ jsxRuntime.jsx(
2084
+ PipTrackVideo,
2085
+ {
2086
+ track,
2087
+ fit: spotlight.kind === "screen" ? "contain" : "cover"
2088
+ }
2089
+ ),
2090
+ /* @__PURE__ */ jsxRuntime.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 })
2353
2091
  ] });
2354
2092
  }
2355
- function StudioTileVideo({
2356
- meeting,
2357
- participantId,
2358
- kind,
2359
- mirror,
2360
- contain
2093
+ function PipTrackVideo({
2094
+ track,
2095
+ fit
2361
2096
  }) {
2362
2097
  const ref = react.useRef(null);
2363
2098
  react.useEffect(() => {
2364
- meeting.attachVideo(participantId, ref.current, kind);
2365
- return () => meeting.attachVideo(participantId, null, kind);
2366
- }, [kind, meeting, participantId]);
2099
+ const el = ref.current;
2100
+ if (!el) return;
2101
+ if (!track) {
2102
+ el.srcObject = null;
2103
+ return;
2104
+ }
2105
+ el.srcObject = new MediaStream([track]);
2106
+ void el.play().catch(() => void 0);
2107
+ return () => {
2108
+ el.srcObject = null;
2109
+ };
2110
+ }, [track]);
2367
2111
  return /* @__PURE__ */ jsxRuntime.jsx(
2368
2112
  "video",
2369
2113
  {
@@ -2371,604 +2115,1063 @@ function StudioTileVideo({
2371
2115
  autoPlay: true,
2372
2116
  playsInline: true,
2373
2117
  muted: true,
2374
- disablePictureInPicture: true,
2375
- controlsList: "nodownload noplaybackrate noremoteplayback",
2376
2118
  className: cn(
2377
2119
  "absolute inset-0 size-full",
2378
- "pointer-events-none",
2379
- contain ? "object-contain" : "object-cover",
2380
- mirror && "-scale-x-100"
2120
+ fit === "contain" ? "object-contain" : "object-cover"
2381
2121
  )
2382
2122
  }
2383
2123
  );
2384
2124
  }
2385
- function StudioStage({
2386
- participants,
2387
- meeting,
2388
- reducedMotion,
2389
- canModerate,
2390
- canManageStage,
2391
- onAir,
2125
+ function pipScreenControls({
2392
2126
  labels,
2393
- onKick,
2394
- onMute,
2395
- onStopCamera,
2396
- onStopScreen,
2397
- onToggleStage,
2398
- onTogglePin,
2399
- onToggleChat,
2400
- canTakeOff,
2401
- onOpenPeople
2127
+ screenOn,
2128
+ canShareFreely,
2129
+ screenAvailable,
2130
+ screenPending,
2131
+ shareGrant,
2132
+ onToggleScreen,
2133
+ onAskToShare,
2134
+ onCancelShareRequest
2402
2135
  }) {
2403
- const displayTiles = expandStudioTiles(participants);
2404
- const stage = studioStageLayout(displayTiles, {
2405
- activeSpeakerId: meeting.activeSpeakerId()
2406
- });
2407
- const sharing = stage.spotlight.length > 0;
2408
- const overflowLabel = labels.stageOverflow.replace(
2409
- "{count}",
2410
- String(stage.overflow)
2411
- );
2412
- function renderTile(tile, compact) {
2413
- const takeOffBlocked = !canTakeOff(tile.id);
2136
+ if (canShareFreely || screenOn) {
2414
2137
  return /* @__PURE__ */ jsxRuntime.jsx(
2415
- StudioTile,
2138
+ PipControl,
2416
2139
  {
2417
- tile,
2418
- meeting,
2419
- reducedMotion,
2420
- compact,
2421
- canModerate: canModerate && !tile.isSelf,
2422
- canManageStage: canManageStage && !tile.isSelf,
2423
- takeOffBlocked,
2424
- confirmTakeOff: onAir,
2425
- labels,
2426
- onKick: () => onKick(tile.id),
2427
- onMute: () => onMute(tile.id),
2428
- onStopCamera: () => onStopCamera(tile.id),
2429
- onStopScreen: () => onStopScreen(tile.id),
2430
- onToggleStage: () => onToggleStage(tile),
2431
- onTogglePin: () => onTogglePin(tile),
2432
- onToggleChat: () => onToggleChat(tile)
2433
- },
2434
- tile.tileId
2435
- );
2436
- }
2437
- function overflowChip(compact) {
2438
- if (stage.overflow <= 0) return null;
2439
- return /* @__PURE__ */ jsxRuntime.jsx(
2440
- Button,
2441
- {
2442
- type: "button",
2443
- variant: "secondary",
2444
- className: cn(
2445
- "shrink-0",
2446
- compact ? "aspect-video w-40 md:w-full" : "aspect-video min-h-24"
2447
- ),
2448
- onClick: onOpenPeople,
2449
- children: overflowLabel
2140
+ label: screenOn ? labels.stopScreen : labels.screen,
2141
+ off: screenOn,
2142
+ disabled: screenPending || !screenAvailable && !screenOn,
2143
+ onClick: onToggleScreen,
2144
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
2450
2145
  }
2451
2146
  );
2452
2147
  }
2453
- if (sharing) {
2454
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col md:flex-row", children: [
2455
- /* @__PURE__ */ jsxRuntime.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)) }),
2456
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2 overflow-auto p-2 md:w-64 md:flex-col", children: [
2457
- stage.strip.map((tile) => renderTile(tile, true)),
2458
- overflowChip(true)
2459
- ] })
2148
+ if (shareGrant === "requested") {
2149
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2150
+ /* @__PURE__ */ jsxRuntime.jsx(
2151
+ PipControl,
2152
+ {
2153
+ label: labels.shareRequested,
2154
+ off: true,
2155
+ disabled: true,
2156
+ onClick: () => void 0,
2157
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
2158
+ }
2159
+ ),
2160
+ /* @__PURE__ */ jsxRuntime.jsx(
2161
+ PipControl,
2162
+ {
2163
+ label: labels.cancelShareRequest,
2164
+ onClick: () => onCancelShareRequest?.(),
2165
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {})
2166
+ }
2167
+ )
2460
2168
  ] });
2461
2169
  }
2462
- return /* @__PURE__ */ jsxRuntime.jsxs(
2463
- "div",
2170
+ return /* @__PURE__ */ jsxRuntime.jsx(PipControl, { label: labels.askToShare, onClick: () => onAskToShare?.(), children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {}) });
2171
+ }
2172
+ function PipControl({
2173
+ label,
2174
+ onClick,
2175
+ off = false,
2176
+ disabled = false,
2177
+ children
2178
+ }) {
2179
+ return /* @__PURE__ */ jsxRuntime.jsx(
2180
+ Button,
2464
2181
  {
2465
- className: cn(
2466
- "mx-auto grid min-h-0 w-full flex-1 content-center gap-3 overflow-auto p-4",
2467
- 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"
2468
- ),
2469
- children: [
2470
- stage.strip.map((tile) => renderTile(tile, false)),
2471
- overflowChip(false)
2472
- ]
2182
+ type: "button",
2183
+ size: "icon-lg",
2184
+ variant: off ? "destructive" : "secondary",
2185
+ className: "size-10 rounded-full",
2186
+ "aria-label": label,
2187
+ disabled,
2188
+ onClick,
2189
+ children
2473
2190
  }
2474
2191
  );
2475
2192
  }
2476
- function TooltipProvider({
2477
- delay = 0,
2478
- ...props
2193
+ function StudioRemoteAudio({
2194
+ meeting,
2195
+ participantId
2479
2196
  }) {
2197
+ const ref = react.useRef(null);
2198
+ react.useEffect(() => {
2199
+ meeting.attachAudio(participantId, ref.current);
2200
+ return () => meeting.attachAudio(participantId, null);
2201
+ }, [meeting, participantId]);
2202
+ return /* @__PURE__ */ jsxRuntime.jsx("audio", { ref, autoPlay: true, className: "sr-only" });
2203
+ }
2204
+ function BubbleGroup({ className, ...props }) {
2480
2205
  return /* @__PURE__ */ jsxRuntime.jsx(
2481
- tooltip.Tooltip.Provider,
2206
+ "div",
2482
2207
  {
2483
- "data-slot": "tooltip-provider",
2484
- delay,
2208
+ "data-slot": "bubble-group",
2209
+ className: cn("flex min-w-0 flex-col gap-2", className),
2485
2210
  ...props
2486
2211
  }
2487
2212
  );
2488
2213
  }
2489
- function Tooltip({ ...props }) {
2490
- return /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.Root, { "data-slot": "tooltip", ...props });
2491
- }
2492
- function TooltipTrigger({ ...props }) {
2493
- return /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.Trigger, { "data-slot": "tooltip-trigger", ...props });
2494
- }
2495
- function TooltipContent({
2496
- className,
2497
- side = "top",
2498
- sideOffset = 4,
2499
- align = "center",
2500
- alignOffset = 0,
2501
- children,
2502
- ...props
2503
- }) {
2504
- return /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.Portal, { children: /* @__PURE__ */ jsxRuntime.jsx(
2505
- tooltip.Tooltip.Positioner,
2506
- {
2507
- align,
2508
- alignOffset,
2509
- side,
2510
- sideOffset,
2511
- className: "isolate z-50",
2512
- children: /* @__PURE__ */ jsxRuntime.jsxs(
2513
- tooltip.Tooltip.Popup,
2514
- {
2515
- "data-slot": "tooltip-content",
2516
- className: cn(
2517
- "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",
2518
- className
2519
- ),
2520
- ...props,
2521
- children: [
2522
- children,
2523
- /* @__PURE__ */ jsxRuntime.jsx(tooltip.Tooltip.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" })
2524
- ]
2525
- }
2526
- )
2214
+ var bubbleVariants = classVarianceAuthority.cva(
2215
+ "group/bubble relative flex w-max max-w-[80%] flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
2216
+ {
2217
+ variants: {
2218
+ variant: {
2219
+ 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",
2220
+ 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%)]",
2221
+ 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%)]",
2222
+ 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)]",
2223
+ 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",
2224
+ 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",
2225
+ 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"
2226
+ }
2227
+ },
2228
+ defaultVariants: {
2229
+ variant: "default"
2527
2230
  }
2528
- ) });
2529
- }
2530
- function Dialog({ ...props }) {
2531
- return /* @__PURE__ */ jsxRuntime.jsx(DialogPrimitive__namespace.Root, { "data-slot": "dialog", ...props });
2532
- }
2533
- function DialogPortal({
2534
- ...props
2535
- }) {
2536
- return /* @__PURE__ */ jsxRuntime.jsx(DialogPrimitive__namespace.Portal, { "data-slot": "dialog-portal", ...props });
2537
- }
2538
- function DialogOverlay({
2231
+ }
2232
+ );
2233
+ function Bubble({
2234
+ variant = "default",
2235
+ align = "start",
2539
2236
  className,
2540
2237
  ...props
2541
2238
  }) {
2542
2239
  return /* @__PURE__ */ jsxRuntime.jsx(
2543
- DialogPrimitive__namespace.Overlay,
2240
+ "div",
2544
2241
  {
2545
- "data-slot": "dialog-overlay",
2546
- className: cn(
2547
- "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",
2548
- className
2549
- ),
2242
+ "data-slot": "bubble",
2243
+ "data-variant": variant,
2244
+ "data-align": align,
2245
+ className: cn(bubbleVariants({ variant }), className),
2550
2246
  ...props
2551
2247
  }
2552
2248
  );
2553
2249
  }
2554
- function DialogContent({
2250
+ function BubbleContent({
2555
2251
  className,
2556
- children,
2557
- showCloseButton = true,
2252
+ render,
2558
2253
  ...props
2559
2254
  }) {
2560
- return /* @__PURE__ */ jsxRuntime.jsxs(DialogPortal, { children: [
2561
- /* @__PURE__ */ jsxRuntime.jsx(DialogOverlay, {}),
2562
- /* @__PURE__ */ jsxRuntime.jsxs(
2563
- DialogPrimitive__namespace.Content,
2255
+ return useRender.useRender({
2256
+ defaultTagName: "div",
2257
+ props: mergeProps.mergeProps(
2564
2258
  {
2565
- "data-slot": "dialog-content",
2566
2259
  className: cn(
2567
- "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",
2260
+ "w-max max-w-full overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed break-words 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",
2568
2261
  className
2569
- ),
2570
- ...props,
2571
- children: [
2572
- children,
2573
- showCloseButton ? /* @__PURE__ */ jsxRuntime.jsxs(DialogPrimitive__namespace.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: [
2574
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.XIcon, { className: "size-4" }),
2575
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: "Close" })
2576
- ] }) : null
2577
- ]
2578
- }
2579
- )
2580
- ] });
2262
+ )
2263
+ },
2264
+ props
2265
+ ),
2266
+ render,
2267
+ state: {
2268
+ slot: "bubble-content"
2269
+ }
2270
+ });
2581
2271
  }
2582
- function DialogHeader({ className, ...props }) {
2583
- return /* @__PURE__ */ jsxRuntime.jsx(
2584
- "div",
2585
- {
2586
- "data-slot": "dialog-header",
2587
- className: cn("flex flex-col gap-2 text-center sm:text-left", className),
2588
- ...props
2272
+ classVarianceAuthority.cva(
2273
+ "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",
2274
+ {
2275
+ variants: {
2276
+ side: {
2277
+ top: "top-0 -translate-y-3/4",
2278
+ bottom: "bottom-0 translate-y-3/4"
2279
+ },
2280
+ align: {
2281
+ start: "left-3",
2282
+ end: "right-3"
2283
+ }
2284
+ },
2285
+ defaultVariants: {
2286
+ side: "bottom",
2287
+ align: "end"
2589
2288
  }
2590
- );
2289
+ }
2290
+ );
2291
+
2292
+ // src/studio/lib/chat-groups.ts
2293
+ function groupChatMessages(messages, selfUserId) {
2294
+ const groups = [];
2295
+ for (const row of messages) {
2296
+ const mine = Boolean(selfUserId) && row.userId === selfUserId;
2297
+ const last = groups[groups.length - 1];
2298
+ if (last && last.userId === row.userId && last.mine === mine) {
2299
+ last.rows.push(row);
2300
+ continue;
2301
+ }
2302
+ groups.push({
2303
+ userId: row.userId,
2304
+ name: row.name,
2305
+ mine,
2306
+ rows: [row]
2307
+ });
2308
+ }
2309
+ return groups;
2310
+ }
2311
+ function senderInitials(name) {
2312
+ const parts = name.trim().split(/\s+/).filter((part) => part.length > 0);
2313
+ if (parts.length === 0) return "?";
2314
+ const first = parts[0];
2315
+ if (parts.length === 1) return first.slice(0, 2).toUpperCase();
2316
+ return `${first[0]}${parts[1][0]}`.toUpperCase();
2317
+ }
2318
+ function StudioChatPanel({
2319
+ meeting,
2320
+ selfUserId,
2321
+ participants,
2322
+ isModerator: isModerator2,
2323
+ labels
2324
+ }) {
2325
+ const [tick, setTick] = react.useState(0);
2326
+ const [draft, setDraft] = react.useState("");
2327
+ const [editingId, setEditingId] = react.useState(null);
2328
+ const listRef = react.useRef(null);
2329
+ react.useEffect(
2330
+ () => meeting.subscribeChat(() => setTick((n) => n + 1)),
2331
+ [meeting]
2332
+ );
2333
+ const messages = react.useMemo(
2334
+ () => meeting.chatMessages().filter((row) => !row.targetUserIds?.length),
2335
+ [meeting, tick]
2336
+ );
2337
+ const pinned = react.useMemo(() => meeting.pinnedChatMessages(), [meeting, tick]);
2338
+ const limit = meeting.chatMaxTextLimit();
2339
+ const pictures = react.useMemo(() => {
2340
+ const map = /* @__PURE__ */ new Map();
2341
+ for (const row of participants) {
2342
+ if (row.userId) map.set(row.userId, row.picture);
2343
+ }
2344
+ return map;
2345
+ }, [participants]);
2346
+ const groups = react.useMemo(
2347
+ () => groupChatMessages(messages, selfUserId),
2348
+ [messages, selfUserId]
2349
+ );
2350
+ react.useEffect(() => {
2351
+ const list = listRef.current;
2352
+ if (!list) return;
2353
+ list.scrollTop = list.scrollHeight;
2354
+ }, [messages.length]);
2355
+ async function onSend() {
2356
+ const text = draft.trim();
2357
+ if (!text) return;
2358
+ setDraft("");
2359
+ try {
2360
+ if (editingId) {
2361
+ await meeting.editChatMessage(editingId, text);
2362
+ setEditingId(null);
2363
+ return;
2364
+ }
2365
+ await meeting.sendChatMessage(text);
2366
+ } catch (error) {
2367
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
2368
+ sonner.toast.error(
2369
+ message.includes("rate") ? labels.chatRateLimited : labels.chatError
2370
+ );
2371
+ }
2372
+ }
2373
+ function messageActions(row, mine) {
2374
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2375
+ "p",
2376
+ {
2377
+ className: mine ? "text-muted-foreground flex flex-wrap justify-end gap-2 text-xs" : "text-muted-foreground flex flex-wrap gap-2 text-xs",
2378
+ children: [
2379
+ row.isEdited ? /* @__PURE__ */ jsxRuntime.jsx("span", { children: labels.chatEdited }) : null,
2380
+ mine ? /* @__PURE__ */ jsxRuntime.jsx(
2381
+ "button",
2382
+ {
2383
+ type: "button",
2384
+ onClick: () => {
2385
+ setEditingId(row.id);
2386
+ setDraft(row.text);
2387
+ },
2388
+ children: labels.chatEdit
2389
+ }
2390
+ ) : null,
2391
+ isModerator2 || mine ? /* @__PURE__ */ jsxRuntime.jsx(
2392
+ "button",
2393
+ {
2394
+ type: "button",
2395
+ onClick: () => void meeting.deleteChatMessage(row.id).catch(() => sonner.toast.error(labels.chatError)),
2396
+ children: labels.chatDelete
2397
+ }
2398
+ ) : null,
2399
+ isModerator2 ? /* @__PURE__ */ jsxRuntime.jsx(
2400
+ "button",
2401
+ {
2402
+ type: "button",
2403
+ onClick: () => void meeting.pinChatMessage(row.id, row.pinned !== true).catch(() => sonner.toast.error(labels.chatError)),
2404
+ children: row.pinned ? labels.chatUnpin : labels.chatPin
2405
+ }
2406
+ ) : null
2407
+ ]
2408
+ }
2409
+ );
2410
+ }
2411
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [
2412
+ pinned[0] ? /* @__PURE__ */ jsxRuntime.jsxs("div", { role: "status", className: "bg-muted border-b px-3 py-2 text-sm", children: [
2413
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-xs", children: labels.chatPinned }),
2414
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: pinned[0].text })
2415
+ ] }) : null,
2416
+ /* @__PURE__ */ jsxRuntime.jsxs(
2417
+ "div",
2418
+ {
2419
+ ref: listRef,
2420
+ role: "log",
2421
+ "aria-live": "polite",
2422
+ className: "flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3",
2423
+ children: [
2424
+ /* @__PURE__ */ jsxRuntime.jsx(
2425
+ Button,
2426
+ {
2427
+ type: "button",
2428
+ size: "xs",
2429
+ variant: "ghost",
2430
+ className: "self-center",
2431
+ onClick: () => {
2432
+ const oldest = messages[0]?.time;
2433
+ void meeting.fetchChatHistory({
2434
+ timestamp: oldest
2435
+ });
2436
+ },
2437
+ children: labels.chatLoadOlder
2438
+ }
2439
+ ),
2440
+ groups.map((group) => {
2441
+ const bubbles = group.rows.map((row) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [
2442
+ /* @__PURE__ */ jsxRuntime.jsx(
2443
+ Bubble,
2444
+ {
2445
+ align: group.mine ? "end" : "start",
2446
+ variant: group.mine ? "default" : "secondary",
2447
+ children: /* @__PURE__ */ jsxRuntime.jsx(BubbleContent, { children: row.text })
2448
+ }
2449
+ ),
2450
+ messageActions(row, group.mine)
2451
+ ] }, row.id));
2452
+ if (group.mine) {
2453
+ return /* @__PURE__ */ jsxRuntime.jsx(BubbleGroup, { className: "items-end", children: bubbles }, group.rows[0].id);
2454
+ }
2455
+ const picture = pictures.get(group.userId) ?? null;
2456
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2457
+ "div",
2458
+ {
2459
+ className: "flex max-w-full gap-2 self-start",
2460
+ children: [
2461
+ picture ? /* @__PURE__ */ jsxRuntime.jsx(
2462
+ "img",
2463
+ {
2464
+ src: picture,
2465
+ alt: "",
2466
+ className: "mt-0.5 size-7 shrink-0 rounded-full object-cover"
2467
+ }
2468
+ ) : /* @__PURE__ */ jsxRuntime.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) }),
2469
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [
2470
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground mb-1 text-xs", children: group.name }),
2471
+ /* @__PURE__ */ jsxRuntime.jsx(BubbleGroup, { children: bubbles })
2472
+ ] })
2473
+ ]
2474
+ },
2475
+ group.rows[0].id
2476
+ );
2477
+ })
2478
+ ]
2479
+ }
2480
+ ),
2481
+ /* @__PURE__ */ jsxRuntime.jsxs(
2482
+ "form",
2483
+ {
2484
+ className: "flex flex-col gap-2 border-t p-3",
2485
+ onSubmit: (event) => {
2486
+ event.preventDefault();
2487
+ void onSend();
2488
+ },
2489
+ children: [
2490
+ /* @__PURE__ */ jsxRuntime.jsx(
2491
+ Input,
2492
+ {
2493
+ className: "min-w-0 flex-1",
2494
+ value: draft,
2495
+ maxLength: limit,
2496
+ onChange: (event) => setDraft(event.target.value),
2497
+ placeholder: labels.chatPlaceholder,
2498
+ "aria-label": labels.chatPlaceholder
2499
+ }
2500
+ ),
2501
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-2", children: [
2502
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-muted-foreground text-xs", children: [
2503
+ draft.length,
2504
+ "/",
2505
+ limit
2506
+ ] }),
2507
+ /* @__PURE__ */ jsxRuntime.jsx(Button, { type: "submit", size: "sm", children: editingId ? labels.chatSave : labels.chatSend })
2508
+ ] })
2509
+ ]
2510
+ }
2511
+ )
2512
+ ] });
2513
+ }
2514
+ function StudioWaitingRoom({
2515
+ waitlisted,
2516
+ labels,
2517
+ onAdmitAs,
2518
+ onAdmitAll,
2519
+ onDeny
2520
+ }) {
2521
+ return /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.waiting, className: "space-y-3", children: [
2522
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.waiting }),
2523
+ waitlisted.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.noWaiting }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2524
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid gap-2", children: [
2525
+ /* @__PURE__ */ jsxRuntime.jsx(
2526
+ Button,
2527
+ {
2528
+ type: "button",
2529
+ size: "xs",
2530
+ className: "w-full",
2531
+ onClick: () => onAdmitAll("panelist"),
2532
+ children: labels.admitAllPanelists
2533
+ }
2534
+ ),
2535
+ /* @__PURE__ */ jsxRuntime.jsx(
2536
+ Button,
2537
+ {
2538
+ type: "button",
2539
+ size: "xs",
2540
+ variant: "outline",
2541
+ className: "w-full",
2542
+ onClick: () => onAdmitAll("audience"),
2543
+ children: labels.admitAllAudience
2544
+ }
2545
+ )
2546
+ ] }),
2547
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: waitlisted.map((guest) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "space-y-2 rounded-md border p-2", children: [
2548
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: guest.name }),
2549
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid gap-2", children: [
2550
+ /* @__PURE__ */ jsxRuntime.jsx(
2551
+ Button,
2552
+ {
2553
+ type: "button",
2554
+ size: "xs",
2555
+ className: "w-full",
2556
+ onClick: () => onAdmitAs(guest.id, "panelist"),
2557
+ children: labels.admitAsPanelist
2558
+ }
2559
+ ),
2560
+ /* @__PURE__ */ jsxRuntime.jsx(
2561
+ Button,
2562
+ {
2563
+ type: "button",
2564
+ size: "xs",
2565
+ variant: "outline",
2566
+ className: "w-full",
2567
+ onClick: () => onAdmitAs(guest.id, "audience"),
2568
+ children: labels.admitAsAudience
2569
+ }
2570
+ ),
2571
+ /* @__PURE__ */ jsxRuntime.jsx(
2572
+ Button,
2573
+ {
2574
+ type: "button",
2575
+ size: "xs",
2576
+ variant: "ghost",
2577
+ className: "w-full",
2578
+ onClick: () => onDeny(guest.id),
2579
+ children: labels.deny
2580
+ }
2581
+ )
2582
+ ] })
2583
+ ] }, guest.id)) })
2584
+ ] })
2585
+ ] });
2586
+ }
2587
+ function StudioSidePanel({
2588
+ tab,
2589
+ onTabChange,
2590
+ onClose,
2591
+ meeting,
2592
+ selfUserId,
2593
+ participants,
2594
+ waitlisted,
2595
+ stageRequests,
2596
+ isModerator: isModerator2,
2597
+ chatBadge,
2598
+ peopleBadge,
2599
+ labels,
2600
+ onAdmitAs,
2601
+ onAdmitAll,
2602
+ onDeny,
2603
+ onGrantStage,
2604
+ onDenyStage,
2605
+ onTakeOffAir,
2606
+ guestUrl,
2607
+ onShareGuestLink
2608
+ }) {
2609
+ const panelists = participants.filter((row) => isOnStage(row.stageStatus));
2610
+ const audience = participants.filter((row) => !isOnStage(row.stageStatus));
2611
+ return /* @__PURE__ */ jsxRuntime.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: [
2612
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex", children: [
2613
+ /* @__PURE__ */ jsxRuntime.jsxs(
2614
+ Button,
2615
+ {
2616
+ type: "button",
2617
+ size: "sm",
2618
+ variant: tab === "chat" ? "secondary" : "ghost",
2619
+ className: "relative flex-1 rounded-none",
2620
+ onClick: () => onTabChange("chat"),
2621
+ children: [
2622
+ labels.chat,
2623
+ chatBadge > 0 ? /* @__PURE__ */ jsxRuntime.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
2624
+ ]
2625
+ }
2626
+ ),
2627
+ isModerator2 ? /* @__PURE__ */ jsxRuntime.jsxs(
2628
+ Button,
2629
+ {
2630
+ type: "button",
2631
+ size: "sm",
2632
+ variant: tab === "people" ? "secondary" : "ghost",
2633
+ className: "relative flex-1 rounded-none",
2634
+ onClick: () => onTabChange("people"),
2635
+ children: [
2636
+ labels.people,
2637
+ peopleBadge > 0 ? /* @__PURE__ */ jsxRuntime.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
2638
+ ]
2639
+ }
2640
+ ) : null,
2641
+ /* @__PURE__ */ jsxRuntime.jsx(
2642
+ Button,
2643
+ {
2644
+ type: "button",
2645
+ size: "icon-xs",
2646
+ variant: "ghost",
2647
+ className: "m-1 shrink-0",
2648
+ "aria-label": labels.closePanel,
2649
+ onClick: onClose,
2650
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {})
2651
+ }
2652
+ )
2653
+ ] }),
2654
+ tab === "chat" ? /* @__PURE__ */ jsxRuntime.jsx(
2655
+ StudioChatPanel,
2656
+ {
2657
+ meeting,
2658
+ selfUserId,
2659
+ participants,
2660
+ isModerator: isModerator2,
2661
+ labels
2662
+ }
2663
+ ) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3", children: [
2664
+ guestUrl ? /* @__PURE__ */ jsxRuntime.jsxs(
2665
+ Button,
2666
+ {
2667
+ type: "button",
2668
+ variant: "outline",
2669
+ className: "w-full",
2670
+ onClick: onShareGuestLink,
2671
+ children: [
2672
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Link2, {}),
2673
+ labels.shareLink
2674
+ ]
2675
+ }
2676
+ ) : null,
2677
+ /* @__PURE__ */ jsxRuntime.jsx(
2678
+ StudioWaitingRoom,
2679
+ {
2680
+ waitlisted,
2681
+ labels,
2682
+ onAdmitAs,
2683
+ onAdmitAll,
2684
+ onDeny
2685
+ }
2686
+ ),
2687
+ stageRequests.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.stageRequests, className: "space-y-2", children: [
2688
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.stageRequests }),
2689
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: stageRequests.map((request) => /* @__PURE__ */ jsxRuntime.jsxs(
2690
+ "li",
2691
+ {
2692
+ className: "space-y-2 rounded-md border p-2",
2693
+ children: [
2694
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: request.name }),
2695
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid gap-2", children: [
2696
+ /* @__PURE__ */ jsxRuntime.jsx(
2697
+ Button,
2698
+ {
2699
+ type: "button",
2700
+ size: "xs",
2701
+ className: "w-full",
2702
+ onClick: () => onGrantStage(request.userId),
2703
+ children: labels.bringOnAir
2704
+ }
2705
+ ),
2706
+ /* @__PURE__ */ jsxRuntime.jsx(
2707
+ Button,
2708
+ {
2709
+ type: "button",
2710
+ size: "xs",
2711
+ variant: "outline",
2712
+ className: "w-full",
2713
+ onClick: () => onDenyStage(request.userId),
2714
+ children: labels.deny
2715
+ }
2716
+ )
2717
+ ] })
2718
+ ]
2719
+ },
2720
+ request.userId
2721
+ )) })
2722
+ ] }) : null,
2723
+ /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.panelists, className: "space-y-2", children: [
2724
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.panelists }),
2725
+ panelists.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.noPanelists }) : /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: panelists.map((row) => {
2726
+ const userId = row.userId;
2727
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2728
+ "li",
2729
+ {
2730
+ className: "space-y-2 rounded-md border p-2",
2731
+ children: [
2732
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: row.name }),
2733
+ !row.isSelf && userId ? /* @__PURE__ */ jsxRuntime.jsx(
2734
+ Button,
2735
+ {
2736
+ type: "button",
2737
+ size: "xs",
2738
+ variant: "outline",
2739
+ className: "w-full",
2740
+ onClick: () => onTakeOffAir(userId),
2741
+ children: labels.offAir
2742
+ }
2743
+ ) : null
2744
+ ]
2745
+ },
2746
+ row.id
2747
+ );
2748
+ }) })
2749
+ ] }),
2750
+ /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": labels.audience, className: "space-y-2", children: [
2751
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.audience }),
2752
+ audience.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.noAudience }) : /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-2", children: audience.map((row) => {
2753
+ const userId = row.userId;
2754
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2755
+ "li",
2756
+ {
2757
+ className: "space-y-2 rounded-md border p-2",
2758
+ children: [
2759
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm", children: row.name }),
2760
+ !row.isSelf && userId ? /* @__PURE__ */ jsxRuntime.jsx(
2761
+ Button,
2762
+ {
2763
+ type: "button",
2764
+ size: "xs",
2765
+ variant: "outline",
2766
+ className: "w-full",
2767
+ onClick: () => onGrantStage(userId),
2768
+ children: labels.bringOnAir
2769
+ }
2770
+ ) : null
2771
+ ]
2772
+ },
2773
+ row.id
2774
+ );
2775
+ }) })
2776
+ ] })
2777
+ ] })
2778
+ ] });
2779
+ }
2780
+ function DropdownMenu({ ...props }) {
2781
+ return /* @__PURE__ */ jsxRuntime.jsx(menu.Menu.Root, { "data-slot": "dropdown-menu", ...props });
2782
+ }
2783
+ function DropdownMenuTrigger({ ...props }) {
2784
+ return /* @__PURE__ */ jsxRuntime.jsx(menu.Menu.Trigger, { "data-slot": "dropdown-menu-trigger", ...props });
2591
2785
  }
2592
- function DialogTitle({
2786
+ function DropdownMenuContent({
2787
+ align = "start",
2788
+ alignOffset = 0,
2789
+ side = "bottom",
2790
+ sideOffset = 4,
2593
2791
  className,
2594
2792
  ...props
2595
2793
  }) {
2596
- return /* @__PURE__ */ jsxRuntime.jsx(
2597
- DialogPrimitive__namespace.Title,
2794
+ return /* @__PURE__ */ jsxRuntime.jsx(menu.Menu.Portal, { children: /* @__PURE__ */ jsxRuntime.jsx(
2795
+ menu.Menu.Positioner,
2598
2796
  {
2599
- "data-slot": "dialog-title",
2600
- className: cn("text-lg leading-none font-semibold", className),
2601
- ...props
2797
+ className: "isolate z-50 outline-none",
2798
+ align,
2799
+ alignOffset,
2800
+ side,
2801
+ sideOffset,
2802
+ children: /* @__PURE__ */ jsxRuntime.jsx(
2803
+ menu.Menu.Popup,
2804
+ {
2805
+ "data-slot": "dropdown-menu-content",
2806
+ className: cn(
2807
+ "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",
2808
+ className
2809
+ ),
2810
+ ...props
2811
+ }
2812
+ )
2602
2813
  }
2603
- );
2814
+ ) });
2604
2815
  }
2605
- function DialogDescription({
2816
+ function DropdownMenuItem({
2606
2817
  className,
2818
+ inset,
2819
+ variant = "default",
2607
2820
  ...props
2608
2821
  }) {
2609
2822
  return /* @__PURE__ */ jsxRuntime.jsx(
2610
- DialogPrimitive__namespace.Description,
2611
- {
2612
- "data-slot": "dialog-description",
2613
- className: cn("text-muted-foreground text-sm", className),
2614
- ...props
2615
- }
2616
- );
2617
- }
2618
- function Input({ className, type, ...props }) {
2619
- return /* @__PURE__ */ jsxRuntime.jsx(
2620
- input.Input,
2823
+ menu.Menu.Item,
2621
2824
  {
2622
- type,
2623
- "data-slot": "input",
2825
+ "data-slot": "dropdown-menu-item",
2826
+ "data-inset": inset,
2827
+ "data-variant": variant,
2624
2828
  className: cn(
2625
- "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",
2829
+ "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",
2626
2830
  className
2627
2831
  ),
2628
2832
  ...props
2629
2833
  }
2630
2834
  );
2631
2835
  }
2632
- function Label({
2836
+ function DropdownMenuSeparator({
2633
2837
  className,
2634
2838
  ...props
2635
2839
  }) {
2636
2840
  return /* @__PURE__ */ jsxRuntime.jsx(
2637
- LabelPrimitive__namespace.Root,
2841
+ menu.Menu.Separator,
2638
2842
  {
2639
- "data-slot": "label",
2640
- className: cn(
2641
- "flex items-center gap-2 text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
2642
- className
2643
- ),
2843
+ "data-slot": "dropdown-menu-separator",
2844
+ className: cn("-mx-1 my-1 h-px bg-border", className),
2644
2845
  ...props
2645
2846
  }
2646
2847
  );
2647
2848
  }
2648
-
2649
- // src/studio/lib/speaker-test.ts
2650
- var TEST_MS = 400;
2651
- var TEST_HZ = 440;
2652
- async function playSpeakerTest(deviceId) {
2653
- const AudioCtx = window.AudioContext || window.webkitAudioContext;
2654
- if (!AudioCtx) {
2655
- throw new Error("AudioContext unavailable");
2656
- }
2657
- const ctx = new AudioCtx();
2658
- const destination = ctx.createMediaStreamDestination();
2659
- const oscillator = ctx.createOscillator();
2660
- const gain = ctx.createGain();
2661
- oscillator.type = "sine";
2662
- oscillator.frequency.value = TEST_HZ;
2663
- gain.gain.value = 0.18;
2664
- oscillator.connect(gain);
2665
- gain.connect(destination);
2666
- const audio = document.createElement("audio");
2667
- audio.autoplay = true;
2668
- audio.srcObject = destination.stream;
2669
- try {
2670
- const sink = audio;
2671
- if (typeof sink.setSinkId !== "function") {
2672
- throw new Error("setSinkId unsupported");
2673
- }
2674
- await sink.setSinkId(deviceId);
2675
- oscillator.start();
2676
- await ctx.resume();
2677
- await new Promise((resolve) => {
2678
- window.setTimeout(resolve, TEST_MS);
2679
- });
2680
- oscillator.stop();
2681
- } finally {
2682
- audio.srcObject = null;
2683
- audio.remove();
2684
- await ctx.close();
2685
- }
2849
+ function StudioTileMenu({
2850
+ tile,
2851
+ canModerate,
2852
+ canManageStage,
2853
+ takeOffBlocked,
2854
+ labels,
2855
+ onKick,
2856
+ onMute,
2857
+ onStopCamera,
2858
+ onStopScreen,
2859
+ onToggleStage,
2860
+ onTogglePin,
2861
+ onToggleChat
2862
+ }) {
2863
+ const onStage = isOnStage(tile.stageStatus);
2864
+ const showPersonItems = canModerate && tile.kind !== "screen";
2865
+ const showScreenItems = canModerate && tile.kind === "screen";
2866
+ const showStageItems = canManageStage && tile.kind !== "screen";
2867
+ if (!showPersonItems && !showScreenItems && !showStageItems) return null;
2868
+ return /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
2869
+ /* @__PURE__ */ jsxRuntime.jsx(
2870
+ DropdownMenuTrigger,
2871
+ {
2872
+ render: /* @__PURE__ */ jsxRuntime.jsx(
2873
+ Button,
2874
+ {
2875
+ type: "button",
2876
+ size: "icon-xs",
2877
+ variant: "ghost",
2878
+ className: "text-white hover:text-white",
2879
+ "aria-label": labels.tileMenu
2880
+ }
2881
+ ),
2882
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MoreVertical, { className: "size-3.5" })
2883
+ }
2884
+ ),
2885
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuContent, { align: "end", className: "min-w-44", children: [
2886
+ showScreenItems ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onStopScreen, children: labels.stopScreen }) : null,
2887
+ showPersonItems && tile.audioEnabled ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onMute, children: labels.mute }) : null,
2888
+ showPersonItems && tile.kind === "camera" && tile.videoEnabled ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onStopCamera, children: labels.stopCamera }) : null,
2889
+ showPersonItems || showScreenItems ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onTogglePin, children: tile.pinned ? labels.unpin : labels.pin }) : null,
2890
+ showPersonItems ? /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { onClick: onToggleChat, children: tile.chatCanSend === false ? labels.allowChat : labels.muteChat }) : null,
2891
+ showStageItems ? /* @__PURE__ */ jsxRuntime.jsx(
2892
+ DropdownMenuItem,
2893
+ {
2894
+ disabled: onStage && takeOffBlocked,
2895
+ onClick: onToggleStage,
2896
+ children: onStage ? takeOffBlocked ? labels.offAirLastVisible : labels.takeOffAir : labels.bringOnAir
2897
+ }
2898
+ ) : null,
2899
+ showPersonItems ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2900
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuSeparator, {}),
2901
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuItem, { variant: "destructive", onClick: onKick, children: labels.kick })
2902
+ ] }) : null
2903
+ ] })
2904
+ ] });
2686
2905
  }
2687
- function StudioSettingsDialog({
2688
- open,
2689
- onOpenChange,
2906
+ function StudioTile({
2907
+ tile,
2690
2908
  meeting,
2691
- labels,
2692
2909
  reducedMotion,
2693
- cameraOn
2910
+ compact,
2911
+ fill = false,
2912
+ canModerate,
2913
+ canManageStage,
2914
+ takeOffBlocked,
2915
+ confirmTakeOff,
2916
+ labels,
2917
+ onKick,
2918
+ onMute,
2919
+ onStopCamera,
2920
+ onStopScreen,
2921
+ onToggleStage,
2922
+ onTogglePin,
2923
+ onToggleChat
2694
2924
  }) {
2695
- const [devices, setDevices] = react.useState({
2696
- audio: [],
2697
- video: [],
2698
- speaker: []
2699
- });
2700
- const [selected, setSelected] = react.useState({
2701
- audio: "",
2702
- video: "",
2703
- speaker: ""
2704
- });
2705
- const [audioTrack, setAudioTrack] = react.useState(null);
2706
- const [videoTrack, setVideoTrack] = react.useState(null);
2707
- const [testingSpeaker, setTestingSpeaker] = react.useState(false);
2708
- const [displayName, setDisplayName] = react.useState("");
2709
- const [savedDisplayName, setSavedDisplayName] = react.useState("");
2710
- const [savingDisplayName, setSavingDisplayName] = react.useState(false);
2711
- react.useEffect(() => {
2712
- if (!open) return;
2713
- const selfName = meeting.participants().find((row) => row.isSelf)?.name?.trim() ?? "";
2714
- setDisplayName(selfName);
2715
- setSavedDisplayName(selfName);
2716
- }, [meeting, open]);
2717
- react.useEffect(() => {
2718
- if (!open) return;
2719
- const sync = () => {
2720
- setAudioTrack(meeting.localAudioTrack());
2721
- setVideoTrack(meeting.localVideoTrack());
2722
- };
2723
- sync();
2724
- return meeting.subscribeMedia(sync);
2725
- }, [meeting, open]);
2726
- react.useEffect(() => {
2727
- if (!open) return;
2728
- let cancelled = false;
2729
- void meeting.listDevices().then((next) => {
2730
- if (cancelled) return;
2731
- setDevices(next);
2732
- setSelected((prev) => ({
2733
- audio: prev.audio || next.audio[0]?.deviceId || "",
2734
- video: prev.video || next.video[0]?.deviceId || "",
2735
- speaker: prev.speaker || next.speaker[0]?.deviceId || ""
2736
- }));
2737
- }).catch(() => {
2738
- });
2739
- return () => {
2740
- cancelled = true;
2741
- };
2742
- }, [meeting, open]);
2743
- const trimmedDisplayName = displayName.trim();
2744
- const displayNameUnchanged = trimmedDisplayName === savedDisplayName;
2745
- const displayNameSaveDisabled = !trimmedDisplayName || displayNameUnchanged || savingDisplayName;
2746
- async function onChange(kind, deviceId) {
2747
- setSelected((prev) => ({ ...prev, [kind]: deviceId }));
2748
- await meeting.setDevice(kind, deviceId);
2749
- }
2750
- async function onSaveDisplayName() {
2751
- if (displayNameSaveDisabled) return;
2752
- setSavingDisplayName(true);
2753
- try {
2754
- await meeting.setDisplayName(trimmedDisplayName);
2755
- setSavedDisplayName(trimmedDisplayName);
2756
- setDisplayName(trimmedDisplayName);
2757
- sonner.toast.success(labels.displayNameSaved);
2758
- } catch {
2759
- sonner.toast.error(labels.displayNameError);
2760
- } finally {
2761
- setSavingDisplayName(false);
2762
- }
2763
- }
2764
- async function onTestSpeaker() {
2765
- if (!selected.speaker || testingSpeaker) return;
2766
- setTestingSpeaker(true);
2767
- try {
2768
- await playSpeakerTest(selected.speaker);
2769
- } catch {
2770
- sonner.toast.error(labels.testSpeakerError);
2771
- } finally {
2772
- setTestingSpeaker(false);
2773
- }
2774
- }
2775
- return /* @__PURE__ */ jsxRuntime.jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(DialogContent, { className: "sm:max-w-md", children: [
2776
- /* @__PURE__ */ jsxRuntime.jsxs(DialogHeader, { children: [
2777
- /* @__PURE__ */ jsxRuntime.jsx(DialogTitle, { children: labels.settingsTitle }),
2778
- /* @__PURE__ */ jsxRuntime.jsx(DialogDescription, { children: labels.settingsDescription })
2779
- ] }),
2780
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
2781
- /* @__PURE__ */ jsxRuntime.jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
2782
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
2925
+ const onStage = isOnStage(tile.stageStatus);
2926
+ const [confirm, setConfirm] = react.useState(null);
2927
+ const showMenu = (canModerate || canManageStage) && !tile.isSelf;
2928
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2929
+ "article",
2930
+ {
2931
+ "aria-label": tile.name,
2932
+ className: cn(
2933
+ "bg-muted relative overflow-hidden rounded-xl",
2934
+ 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"
2935
+ ),
2936
+ children: [
2937
+ tile.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
2938
+ tile.picture ? /* @__PURE__ */ jsxRuntime.jsx(
2939
+ "img",
2940
+ {
2941
+ src: tile.picture,
2942
+ alt: "",
2943
+ className: "size-16 rounded-full object-cover"
2944
+ }
2945
+ ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-background flex size-16 items-center justify-center rounded-full text-xl", children: tile.name.slice(0, 1).toUpperCase() }),
2946
+ /* @__PURE__ */ jsxRuntime.jsx(
2947
+ AudioWaveform,
2948
+ {
2949
+ track: tile.isSelf ? meeting.localAudioTrack() : null,
2950
+ reducedMotion
2951
+ }
2952
+ )
2953
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(
2954
+ StudioTileVideo,
2955
+ {
2956
+ meeting,
2957
+ participantId: tile.id,
2958
+ kind: tile.kind,
2959
+ mirror: tile.kind === "camera" && tile.isSelf,
2960
+ contain: tile.kind === "screen"
2961
+ }
2962
+ ),
2963
+ /* @__PURE__ */ jsxRuntime.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: [
2964
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "min-w-0 truncate text-xs font-medium text-white", children: [
2965
+ tile.name,
2966
+ !onStage ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ml-1 rounded-full border border-white/40 px-1.5 py-0.5 text-[0.65rem]", children: labels.offAir }) : null
2967
+ ] }),
2968
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [
2969
+ /* @__PURE__ */ jsxRuntime.jsx(TileStatusIcons, { tile, labels }),
2970
+ showMenu ? /* @__PURE__ */ jsxRuntime.jsx(
2971
+ StudioTileMenu,
2972
+ {
2973
+ tile,
2974
+ canModerate,
2975
+ canManageStage,
2976
+ takeOffBlocked,
2977
+ labels,
2978
+ onKick: () => setConfirm("kick"),
2979
+ onMute,
2980
+ onStopCamera,
2981
+ onStopScreen,
2982
+ onToggleStage: () => {
2983
+ if (onStage && confirmTakeOff && !takeOffBlocked) {
2984
+ setConfirm("takeOff");
2985
+ return;
2986
+ }
2987
+ onToggleStage();
2988
+ },
2989
+ onTogglePin,
2990
+ onToggleChat
2991
+ }
2992
+ ) : null
2993
+ ] })
2994
+ ] }),
2783
2995
  /* @__PURE__ */ jsxRuntime.jsx(
2784
- Input,
2996
+ ConfirmAction,
2785
2997
  {
2786
- id: "studio-display-name",
2787
- value: displayName,
2788
- maxLength: STUDIO_DISPLAY_NAME_MAX,
2789
- onChange: (event) => setDisplayName(event.target.value)
2998
+ open: confirm === "kick",
2999
+ onOpenChange: (open) => {
3000
+ if (!open) setConfirm(null);
3001
+ },
3002
+ triggerLabel: labels.kick,
3003
+ title: labels.kickTitle,
3004
+ description: labels.kickDescription,
3005
+ confirmLabel: labels.kickConfirm,
3006
+ onConfirm: () => {
3007
+ setConfirm(null);
3008
+ onKick();
3009
+ }
2790
3010
  }
2791
3011
  ),
2792
3012
  /* @__PURE__ */ jsxRuntime.jsx(
2793
- Button,
3013
+ ConfirmAction,
2794
3014
  {
2795
- type: "button",
2796
- variant: "outline",
2797
- disabled: displayNameSaveDisabled,
2798
- onClick: () => void onSaveDisplayName(),
2799
- children: labels.displayNameSave
3015
+ open: confirm === "takeOff",
3016
+ onOpenChange: (open) => {
3017
+ if (!open) setConfirm(null);
3018
+ },
3019
+ triggerLabel: labels.takeOffAir,
3020
+ title: labels.takeOffAirTitle,
3021
+ description: labels.takeOffAirDescription,
3022
+ confirmLabel: labels.takeOffAirConfirm,
3023
+ onConfirm: () => {
3024
+ setConfirm(null);
3025
+ onToggleStage();
3026
+ }
2800
3027
  }
2801
3028
  )
2802
- ] })
2803
- ] }),
2804
- /* @__PURE__ */ jsxRuntime.jsx(
2805
- SettingsCameraPreview,
2806
- {
2807
- track: videoTrack,
2808
- open,
2809
- cameraOn,
2810
- cameraOffLabel: labels.cameraOff
2811
- }
2812
- ),
2813
- /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
2814
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "sr-only", children: labels.micLevel }),
2815
- /* @__PURE__ */ jsxRuntime.jsx(
2816
- AudioWaveform,
2817
- {
2818
- track: audioTrack,
2819
- reducedMotion,
2820
- className: "w-full"
2821
- }
2822
- )
2823
- ] }),
2824
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-[8.5rem_minmax(0,1fr)] items-center gap-x-3 gap-y-3", children: [
2825
- /* @__PURE__ */ jsxRuntime.jsx(
2826
- DeviceRow,
2827
- {
2828
- icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}),
2829
- label: labels.cameraDevice,
2830
- options: devices.video,
2831
- value: selected.video,
2832
- onChange: (id) => void onChange("video", id)
2833
- }
2834
- ),
2835
- /* @__PURE__ */ jsxRuntime.jsx(
2836
- DeviceRow,
2837
- {
2838
- icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {}),
2839
- label: labels.micDevice,
2840
- options: devices.audio,
2841
- value: selected.audio,
2842
- onChange: (id) => void onChange("audio", id)
2843
- }
2844
- ),
2845
- /* @__PURE__ */ jsxRuntime.jsx(
2846
- DeviceRow,
2847
- {
2848
- icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Volume2, {}),
2849
- label: labels.speakerDevice,
2850
- options: devices.speaker,
2851
- value: selected.speaker,
2852
- onChange: (id) => void onChange("speaker", id),
2853
- action: /* @__PURE__ */ jsxRuntime.jsx(
2854
- Button,
2855
- {
2856
- type: "button",
2857
- size: "sm",
2858
- variant: "outline",
2859
- disabled: !selected.speaker || testingSpeaker,
2860
- onClick: () => void onTestSpeaker(),
2861
- children: labels.testSpeaker
2862
- }
2863
- )
2864
- }
2865
- )
2866
- ] })
2867
- ] }) });
3029
+ ]
3030
+ }
3031
+ );
2868
3032
  }
2869
- function SettingsCameraPreview({
2870
- track,
2871
- open,
2872
- cameraOn,
2873
- cameraOffLabel
3033
+ function TileStatusIcons({
3034
+ tile,
3035
+ labels
3036
+ }) {
3037
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3038
+ tile.audioEnabled ? null : /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.mute, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) }),
3039
+ tile.kind !== "screen" && !tile.videoEnabled ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.cameraOff, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {}) }) : null,
3040
+ tile.kind !== "screen" && tile.screenShareEnabled ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.stopScreen, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {}) }) : null,
3041
+ tile.pinned ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.pin, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Pin, {}) }) : null,
3042
+ tile.chatCanSend === false ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.muteChat, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageSquareOff, {}) }) : null
3043
+ ] });
3044
+ }
3045
+ function StatusIcon({
3046
+ label,
3047
+ children
3048
+ }) {
3049
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex text-white", children: [
3050
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "[&_svg]:size-3.5", children }),
3051
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: label })
3052
+ ] });
3053
+ }
3054
+ function StudioTileVideo({
3055
+ meeting,
3056
+ participantId,
3057
+ kind,
3058
+ mirror,
3059
+ contain
2874
3060
  }) {
2875
3061
  const ref = react.useRef(null);
2876
3062
  react.useEffect(() => {
2877
- const el = ref.current;
2878
- if (!el) return;
2879
- if (!open || !cameraOn || !track) {
2880
- el.srcObject = null;
2881
- return;
2882
- }
2883
- el.srcObject = new MediaStream([track]);
2884
- void el.play().catch(() => void 0);
2885
- return () => {
2886
- el.srcObject = null;
2887
- };
2888
- }, [cameraOn, open, track]);
2889
- if (!cameraOn) {
2890
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) });
2891
- }
2892
- if (!track) {
2893
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted aspect-video w-full rounded-xl", "aria-hidden": true });
2894
- }
2895
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted relative aspect-video w-full overflow-hidden rounded-xl", children: /* @__PURE__ */ jsxRuntime.jsx(
3063
+ meeting.attachVideo(participantId, ref.current, kind);
3064
+ return () => meeting.attachVideo(participantId, null, kind);
3065
+ }, [kind, meeting, participantId]);
3066
+ return /* @__PURE__ */ jsxRuntime.jsx(
2896
3067
  "video",
2897
3068
  {
2898
3069
  ref,
2899
3070
  autoPlay: true,
2900
- muted: true,
2901
3071
  playsInline: true,
2902
- className: "absolute inset-0 size-full -scale-x-100 object-cover"
3072
+ muted: true,
3073
+ disablePictureInPicture: true,
3074
+ controlsList: "nodownload noplaybackrate noremoteplayback",
3075
+ className: cn(
3076
+ "absolute inset-0 size-full",
3077
+ "pointer-events-none",
3078
+ contain ? "object-contain" : "object-cover",
3079
+ mirror && "-scale-x-100"
3080
+ )
2903
3081
  }
2904
- ) });
2905
- }
2906
- function DeviceRow({
2907
- icon,
2908
- label,
2909
- options,
2910
- value,
2911
- onChange,
2912
- action
2913
- }) {
2914
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2915
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-muted-foreground flex min-w-0 items-center gap-2 text-sm", children: [
2916
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "[&_svg]:size-4", "aria-hidden": true, children: icon }),
2917
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: label })
2918
- ] }),
2919
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [
2920
- /* @__PURE__ */ jsxRuntime.jsx(
2921
- "select",
2922
- {
2923
- "aria-label": label,
2924
- className: cn(
2925
- "border-input bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
2926
- ),
2927
- disabled: options.length === 0,
2928
- value,
2929
- onChange: (event) => onChange(event.target.value),
2930
- children: options.map((option) => /* @__PURE__ */ jsxRuntime.jsx("option", { value: option.deviceId, children: option.label }, option.deviceId))
2931
- }
2932
- ),
2933
- action
2934
- ] })
2935
- ] });
3082
+ );
2936
3083
  }
2937
- function ControlIcon({
2938
- label,
2939
- pressed = false,
2940
- disabled = false,
2941
- pending = false,
2942
- tone = "default",
2943
- badge,
2944
- onClick,
2945
- children
3084
+ function StudioStage({
3085
+ participants,
3086
+ meeting,
3087
+ reducedMotion,
3088
+ canModerate,
3089
+ canManageStage,
3090
+ onAir,
3091
+ labels,
3092
+ onKick,
3093
+ onMute,
3094
+ onStopCamera,
3095
+ onStopScreen,
3096
+ onToggleStage,
3097
+ onTogglePin,
3098
+ onToggleChat,
3099
+ canTakeOff,
3100
+ onOpenPeople
2946
3101
  }) {
2947
- return /* @__PURE__ */ jsxRuntime.jsxs(Tooltip, { children: [
2948
- /* @__PURE__ */ jsxRuntime.jsx(
2949
- TooltipTrigger,
3102
+ const displayTiles = expandStudioTiles(participants);
3103
+ const stage = studioStageLayout(displayTiles, {
3104
+ activeSpeakerId: meeting.activeSpeakerId()
3105
+ });
3106
+ const sharing = stage.spotlight.length > 0;
3107
+ const overflowLabel = labels.stageOverflow.replace(
3108
+ "{count}",
3109
+ String(stage.overflow)
3110
+ );
3111
+ function renderTile(tile, compact, fill = false) {
3112
+ const takeOffBlocked = !canTakeOff(tile.id);
3113
+ return /* @__PURE__ */ jsxRuntime.jsx(
3114
+ StudioTile,
2950
3115
  {
2951
- render: /* @__PURE__ */ jsxRuntime.jsx(
2952
- Button,
2953
- {
2954
- type: "button",
2955
- size: "icon-lg",
2956
- variant: tone === "danger" ? "destructive" : pressed ? "default" : "secondary",
2957
- className: "overflow-visible",
2958
- "aria-label": label,
2959
- "aria-pressed": pressed,
2960
- disabled,
2961
- onClick
2962
- }
3116
+ tile,
3117
+ meeting,
3118
+ reducedMotion,
3119
+ compact,
3120
+ fill,
3121
+ canModerate: canModerate && !tile.isSelf,
3122
+ canManageStage: canManageStage && !tile.isSelf,
3123
+ takeOffBlocked,
3124
+ confirmTakeOff: onAir,
3125
+ labels,
3126
+ onKick: () => onKick(tile.id),
3127
+ onMute: () => onMute(tile.id),
3128
+ onStopCamera: () => onStopCamera(tile.id),
3129
+ onStopScreen: () => onStopScreen(tile.id),
3130
+ onToggleStage: () => onToggleStage(tile),
3131
+ onTogglePin: () => onTogglePin(tile),
3132
+ onToggleChat: () => onToggleChat(tile)
3133
+ },
3134
+ tile.tileId
3135
+ );
3136
+ }
3137
+ function overflowChip(compact) {
3138
+ if (stage.overflow <= 0) return null;
3139
+ return /* @__PURE__ */ jsxRuntime.jsx(
3140
+ Button,
3141
+ {
3142
+ type: "button",
3143
+ variant: "secondary",
3144
+ className: cn(
3145
+ "shrink-0",
3146
+ compact ? "aspect-video w-40 md:w-full" : "aspect-video min-h-24"
2963
3147
  ),
2964
- children: /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "relative overflow-visible", children: [
2965
- pending ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "animate-spin motion-reduce:animate-none" }) : children,
2966
- badge != null && badge > 0 ? /* @__PURE__ */ jsxRuntime.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
2967
- ] })
3148
+ onClick: onOpenPeople,
3149
+ children: overflowLabel
2968
3150
  }
2969
- ),
2970
- /* @__PURE__ */ jsxRuntime.jsx(TooltipContent, { children: label })
2971
- ] });
3151
+ );
3152
+ }
3153
+ if (sharing) {
3154
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row", children: [
3155
+ /* @__PURE__ */ jsxRuntime.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)) }),
3156
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex shrink-0 gap-2 overflow-x-auto p-2 md:w-64 md:flex-col md:overflow-auto", children: [
3157
+ stage.strip.map((tile) => renderTile(tile, true)),
3158
+ overflowChip(true)
3159
+ ] })
3160
+ ] });
3161
+ }
3162
+ return /* @__PURE__ */ jsxRuntime.jsxs(
3163
+ "div",
3164
+ {
3165
+ className: cn(
3166
+ "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",
3167
+ 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"
3168
+ ),
3169
+ children: [
3170
+ stage.strip.map((tile) => renderTile(tile, false, true)),
3171
+ overflowChip(false)
3172
+ ]
3173
+ }
3174
+ );
2972
3175
  }
2973
3176
  function StudioToolbar({
2974
3177
  session,
@@ -3011,6 +3214,7 @@ function StudioToolbar({
3011
3214
  onClosePip,
3012
3215
  onLeave
3013
3216
  }) {
3217
+ const fullscreenSupported = document.fullscreenEnabled === true && typeof document.documentElement.requestFullscreen === "function";
3014
3218
  async function enterFullscreen() {
3015
3219
  try {
3016
3220
  await document.documentElement.requestFullscreen();
@@ -3059,14 +3263,24 @@ function StudioToolbar({
3059
3263
  children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.EyeOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eye, {})
3060
3264
  }
3061
3265
  ) : null,
3062
- session.role === "guest" && stageEnabled && selfStageStatus !== "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(
3266
+ session.role === "guest" && stageEnabled ? /* @__PURE__ */ jsxRuntime.jsx(
3063
3267
  ControlIcon,
3064
3268
  {
3065
- label: selfStageStatus === "REQUESTED_TO_JOIN_STAGE" ? labels.stageRequested : labels.askToGoOnAir,
3269
+ label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : selfStageStatus === "REQUESTED_TO_JOIN_STAGE" ? labels.stageRequested : labels.askToGoOnAir,
3066
3270
  pressed: selfStageStatus === "REQUESTED_TO_JOIN_STAGE",
3067
- disabled: selfStageStatus === "REQUESTED_TO_JOIN_STAGE",
3068
- onClick: onAskStage,
3069
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Users, {})
3271
+ disabled: selfStageStatus === "REQUESTED_TO_JOIN_STAGE" || selfStageStatus === "ON_STAGE" && !canLeaveStage,
3272
+ onClick: () => {
3273
+ if (selfStageStatus === "ON_STAGE") {
3274
+ if (!canLeaveStage) {
3275
+ sonner.toast.message(labels.offAirLastVisible);
3276
+ return;
3277
+ }
3278
+ onLeaveStage();
3279
+ return;
3280
+ }
3281
+ onAskStage();
3282
+ },
3283
+ children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.EyeOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eye, {})
3070
3284
  }
3071
3285
  ) : null,
3072
3286
  showScreenControl ? canShareFreely ? /* @__PURE__ */ jsxRuntime.jsx(
@@ -3119,14 +3333,14 @@ function StudioToolbar({
3119
3333
  children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Users, {})
3120
3334
  }
3121
3335
  ) : null,
3122
- /* @__PURE__ */ jsxRuntime.jsx(
3336
+ fullscreenSupported ? /* @__PURE__ */ jsxRuntime.jsx(
3123
3337
  ControlIcon,
3124
3338
  {
3125
3339
  label: labels.fullscreen,
3126
3340
  onClick: () => void enterFullscreen(),
3127
3341
  children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Maximize, {})
3128
3342
  }
3129
- ),
3343
+ ) : null,
3130
3344
  pipSupported ? /* @__PURE__ */ jsxRuntime.jsx(
3131
3345
  ControlIcon,
3132
3346
  {
@@ -3211,7 +3425,8 @@ function StudioRoom({
3211
3425
  playerOrigin,
3212
3426
  windowGate,
3213
3427
  initialCameraOn = true,
3214
- initialMuted = false
3428
+ initialMuted = false,
3429
+ initialDevices
3215
3430
  }) {
3216
3431
  const [cameraOn, setCameraOn] = react.useState(initialCameraOn);
3217
3432
  const [screenOn, setScreenOn] = react.useState(false);
@@ -3274,6 +3489,21 @@ function StudioRoom({
3274
3489
  postLivoEvent("live", { streamId: session.stream.id });
3275
3490
  }
3276
3491
  }, [onAir, session.stream.id]);
3492
+ react.useEffect(() => {
3493
+ const saved = initialDevices ?? readStudioDeviceSelection();
3494
+ let cancelled = false;
3495
+ void (async () => {
3496
+ try {
3497
+ const listed = await meeting.listDevices();
3498
+ if (cancelled) return;
3499
+ await applyStudioDeviceSelection(meeting.setDevice, saved, listed);
3500
+ } catch {
3501
+ }
3502
+ })();
3503
+ return () => {
3504
+ cancelled = true;
3505
+ };
3506
+ }, [initialDevices, meeting]);
3277
3507
  react.useEffect(() => {
3278
3508
  return () => {
3279
3509
  for (const id of grantCheckTimers.current) window.clearTimeout(id);
@@ -3473,10 +3703,9 @@ function StudioRoom({
3473
3703
  const self = participants.find((row) => row.isSelf);
3474
3704
  const canShareFreely = session.role === "moderator" || self?.stageStatus === "ON_STAGE" || shareGrant === "granted";
3475
3705
  const selfId = self?.id ?? null;
3706
+ const selfUserId = self?.userId || null;
3476
3707
  const remoteParticipants = participants.filter((row) => !row.isSelf);
3477
- const incoming = chatMessages.filter(
3478
- (row) => row.userId !== selfId && row.userId !== self?.userId
3479
- );
3708
+ const incoming = chatMessages.filter((row) => row.userId !== selfUserId);
3480
3709
  const unread = sideTab === "chat" ? 0 : Math.max(0, incoming.length - seenIncoming);
3481
3710
  const peopleBadge = waitlisted.length + stageRequests.length;
3482
3711
  react.useEffect(() => {
@@ -3816,17 +4045,21 @@ function StudioRoom({
3816
4045
  ) });
3817
4046
  }
3818
4047
  if (inWaitingRoom && session.role === "guest") {
3819
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex h-dvh flex-col items-center justify-center gap-4 p-6", children: [
3820
- /* @__PURE__ */ jsxRuntime.jsx(
3821
- StudioDevicePreflight,
3822
- {
3823
- reducedMotion,
3824
- cameraLabel: labels.preflightCamera,
3825
- micLabel: labels.preflightMic
3826
- }
3827
- ),
3828
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground", role: "status", children: labels.waitingHost })
3829
- ] });
4048
+ return /* @__PURE__ */ jsxRuntime.jsx(
4049
+ StudioGuestWaiting,
4050
+ {
4051
+ meeting,
4052
+ reducedMotion,
4053
+ cameraOn,
4054
+ muted,
4055
+ labels,
4056
+ settingsOpen,
4057
+ onToggleCamera,
4058
+ onToggleMute: () => void onToggleMute(),
4059
+ onOpenSettings: () => setSettingsOpen(true),
4060
+ onCloseSettings: setSettingsOpen
4061
+ }
4062
+ );
3830
4063
  }
3831
4064
  if (participants.length > 0 || waitlisted.length > 0) {
3832
4065
  sawParticipants.current = true;
@@ -3838,7 +4071,14 @@ function StudioRoom({
3838
4071
  /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex h-12 items-center gap-3 border-b px-3", children: [
3839
4072
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "min-w-0 flex-1 truncate text-sm font-medium", children: session.stream.title }),
3840
4073
  onAir && liveStartedAtRef.current !== null ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: formatStudioElapsed(Date.now() - liveStartedAtRef.current) }) : null,
3841
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rounded-full border px-2 py-0.5 text-xs", role: "status", children: statusChip }),
4074
+ onAir ? /* @__PURE__ */ jsxRuntime.jsx(StudioLiveBadge, { role: "status", children: statusChip }) : /* @__PURE__ */ jsxRuntime.jsx(
4075
+ "span",
4076
+ {
4077
+ className: "rounded-full border px-2 py-0.5 text-xs",
4078
+ role: "status",
4079
+ children: statusChip
4080
+ }
4081
+ ),
3842
4082
  session.role === "moderator" && (egressLost || !starting && !sending && !onAir && !active) ? /* @__PURE__ */ jsxRuntime.jsx(
3843
4083
  Button,
3844
4084
  {
@@ -3946,7 +4186,7 @@ function StudioRoom({
3946
4186
  onTabChange: setSideTab,
3947
4187
  onClose: () => setSideTab(null),
3948
4188
  meeting,
3949
- selfId,
4189
+ selfUserId,
3950
4190
  participants,
3951
4191
  waitlisted,
3952
4192
  stageRequests,
@@ -4123,6 +4363,177 @@ function StudioRoom({
4123
4363
  ) : null
4124
4364
  ] });
4125
4365
  }
4366
+ function isOverconstrained(error) {
4367
+ if (!error || typeof error !== "object") return false;
4368
+ return error.name === "OverconstrainedError";
4369
+ }
4370
+ function trackConstraint(deviceId, exact) {
4371
+ if (exact && deviceId) {
4372
+ return { deviceId: { exact: deviceId } };
4373
+ }
4374
+ return true;
4375
+ }
4376
+ function stopStream(stream) {
4377
+ if (!stream) return;
4378
+ for (const track of stream.getTracks()) {
4379
+ track.onended = null;
4380
+ track.stop();
4381
+ }
4382
+ }
4383
+ async function requestPreviewStream(wantAudio, wantVideo, audioDeviceId, videoDeviceId, exact) {
4384
+ return navigator.mediaDevices.getUserMedia({
4385
+ audio: wantAudio ? trackConstraint(audioDeviceId, exact) : false,
4386
+ video: wantVideo ? trackConstraint(videoDeviceId, exact) : false
4387
+ });
4388
+ }
4389
+ function StudioDevicePreflight({
4390
+ reducedMotion,
4391
+ cameraLabel,
4392
+ micLabel,
4393
+ cameraOn = true,
4394
+ muted = false,
4395
+ audioDeviceId = "",
4396
+ videoDeviceId = "",
4397
+ cameraOffLabel = "Camera is off",
4398
+ onPermission,
4399
+ onError
4400
+ }) {
4401
+ const videoRef = react.useRef(null);
4402
+ const onPermissionRef = react.useRef(onPermission);
4403
+ const onErrorRef = react.useRef(onError);
4404
+ onPermissionRef.current = onPermission;
4405
+ onErrorRef.current = onError;
4406
+ const [audioTrack, setAudioTrack] = react.useState(null);
4407
+ const [cameraOk, setCameraOk] = react.useState(false);
4408
+ const [micOk, setMicOk] = react.useState(false);
4409
+ react.useEffect(() => {
4410
+ let cancelled = false;
4411
+ let generation = 0;
4412
+ let current = null;
4413
+ function bindEnded(stream, gen) {
4414
+ for (const track of stream.getTracks()) {
4415
+ track.onended = () => {
4416
+ if (cancelled || gen !== generation) return;
4417
+ void acquire();
4418
+ };
4419
+ }
4420
+ }
4421
+ async function acquire() {
4422
+ const gen = ++generation;
4423
+ stopStream(current);
4424
+ current = null;
4425
+ if (cancelled) return;
4426
+ if (!cameraOn && muted) {
4427
+ setAudioTrack(null);
4428
+ setCameraOk(false);
4429
+ setMicOk(false);
4430
+ if (videoRef.current) videoRef.current.srcObject = null;
4431
+ return;
4432
+ }
4433
+ if (!navigator.mediaDevices?.getUserMedia) {
4434
+ setAudioTrack(null);
4435
+ setCameraOk(false);
4436
+ setMicOk(false);
4437
+ onErrorRef.current?.();
4438
+ return;
4439
+ }
4440
+ const wantAudio = !muted;
4441
+ const wantVideo = cameraOn;
4442
+ const hadExact = Boolean(
4443
+ wantAudio && audioDeviceId || wantVideo && videoDeviceId
4444
+ );
4445
+ try {
4446
+ let next;
4447
+ try {
4448
+ next = await requestPreviewStream(
4449
+ wantAudio,
4450
+ wantVideo,
4451
+ audioDeviceId,
4452
+ videoDeviceId,
4453
+ true
4454
+ );
4455
+ } catch (error) {
4456
+ if (!isOverconstrained(error) || !hadExact) {
4457
+ throw error;
4458
+ }
4459
+ next = await requestPreviewStream(
4460
+ wantAudio,
4461
+ wantVideo,
4462
+ audioDeviceId,
4463
+ videoDeviceId,
4464
+ false
4465
+ );
4466
+ }
4467
+ if (cancelled || gen !== generation) {
4468
+ stopStream(next);
4469
+ return;
4470
+ }
4471
+ current = next;
4472
+ const video = next.getVideoTracks()[0] ?? null;
4473
+ const audio = next.getAudioTracks()[0] ?? null;
4474
+ setCameraOk(Boolean(video));
4475
+ setMicOk(Boolean(audio));
4476
+ setAudioTrack(audio);
4477
+ if (videoRef.current) {
4478
+ videoRef.current.srcObject = video ? next : null;
4479
+ }
4480
+ bindEnded(next, gen);
4481
+ onPermissionRef.current?.();
4482
+ } catch {
4483
+ if (cancelled || gen !== generation) return;
4484
+ setAudioTrack(null);
4485
+ setCameraOk(false);
4486
+ setMicOk(false);
4487
+ if (videoRef.current) videoRef.current.srcObject = null;
4488
+ onErrorRef.current?.();
4489
+ }
4490
+ }
4491
+ void acquire();
4492
+ return () => {
4493
+ cancelled = true;
4494
+ generation += 1;
4495
+ stopStream(current);
4496
+ current = null;
4497
+ };
4498
+ }, [audioDeviceId, cameraOn, muted, videoDeviceId]);
4499
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex w-full flex-col items-center gap-3", children: [
4500
+ cameraOn ? /* @__PURE__ */ jsxRuntime.jsx(
4501
+ "video",
4502
+ {
4503
+ ref: videoRef,
4504
+ autoPlay: true,
4505
+ muted: true,
4506
+ playsInline: true,
4507
+ className: "bg-muted aspect-video w-full rounded-xl object-cover"
4508
+ }
4509
+ ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-muted flex aspect-video w-full items-center justify-center rounded-xl", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: cameraOffLabel }) }),
4510
+ /* @__PURE__ */ jsxRuntime.jsx(
4511
+ AudioWaveform,
4512
+ {
4513
+ track: muted ? null : audioTrack,
4514
+ reducedMotion
4515
+ }
4516
+ ),
4517
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-muted-foreground text-sm", role: "status", children: [
4518
+ cameraLabel,
4519
+ ": ",
4520
+ cameraOk && cameraOn ? "OK" : "\u2014",
4521
+ " \xB7 ",
4522
+ micLabel,
4523
+ ":",
4524
+ " ",
4525
+ micOk && !muted ? "OK" : "\u2014"
4526
+ ] })
4527
+ ] });
4528
+ }
4529
+ var LOBBY_DEVICE_LABEL_DEFAULTS = {
4530
+ cameraDevice: "Camera",
4531
+ micDevice: "Microphone",
4532
+ speakerDevice: "Speaker",
4533
+ testSpeaker: "Test",
4534
+ testSpeakerError: "Could not play the speaker test",
4535
+ cameraOff: "Camera is off"
4536
+ };
4126
4537
  function StudioGuestLobby({
4127
4538
  reducedMotion,
4128
4539
  cameraOn,
@@ -4133,11 +4544,48 @@ function StudioGuestLobby({
4133
4544
  waitingForHost,
4134
4545
  error,
4135
4546
  labels,
4547
+ savedDevices,
4136
4548
  onCameraChange,
4137
4549
  onMutedChange,
4138
4550
  onDisplayNameChange,
4551
+ onDeviceChange,
4139
4552
  onJoin
4140
4553
  }) {
4554
+ const [refreshKey, setRefreshKey] = react.useState(0);
4555
+ const [internalSaved, setInternalSaved] = react.useState(
4556
+ () => readStudioDeviceSelection()
4557
+ );
4558
+ const [testingSpeaker, setTestingSpeaker] = react.useState(false);
4559
+ const devices = useMediaDevices(refreshKey);
4560
+ const saved = savedDevices ?? internalSaved;
4561
+ const { effective } = resolveDeviceSelection(saved, devices);
4562
+ const deviceLabels = {
4563
+ cameraDevice: labels.cameraDevice ?? LOBBY_DEVICE_LABEL_DEFAULTS.cameraDevice,
4564
+ micDevice: labels.micDevice ?? LOBBY_DEVICE_LABEL_DEFAULTS.micDevice,
4565
+ speakerDevice: labels.speakerDevice ?? LOBBY_DEVICE_LABEL_DEFAULTS.speakerDevice,
4566
+ testSpeaker: labels.testSpeaker ?? LOBBY_DEVICE_LABEL_DEFAULTS.testSpeaker,
4567
+ testSpeakerError: labels.testSpeakerError ?? LOBBY_DEVICE_LABEL_DEFAULTS.testSpeakerError,
4568
+ cameraOff: labels.cameraOff ?? LOBBY_DEVICE_LABEL_DEFAULTS.cameraOff
4569
+ };
4570
+ function changeDevice(kind, deviceId) {
4571
+ persistStudioDeviceSelection(kind, deviceId);
4572
+ onDeviceChange?.(kind, deviceId);
4573
+ if (!savedDevices) {
4574
+ setInternalSaved((prev) => ({ ...prev, [kind]: deviceId }));
4575
+ }
4576
+ }
4577
+ async function onTestSpeaker() {
4578
+ const speakerId = effective.speaker || devices.speaker[0]?.deviceId || "";
4579
+ if (!speakerId || testingSpeaker) return;
4580
+ setTestingSpeaker(true);
4581
+ try {
4582
+ await playSpeakerTest(speakerId);
4583
+ } catch {
4584
+ sonner.toast.error(deviceLabels.testSpeakerError);
4585
+ } finally {
4586
+ setTestingSpeaker(false);
4587
+ }
4588
+ }
4141
4589
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mx-auto flex min-h-dvh w-full max-w-lg flex-col justify-center gap-6 p-6", children: [
4142
4590
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-1", children: [
4143
4591
  /* @__PURE__ */ jsxRuntime.jsx("h1", { className: "text-lg font-semibold", children: labels.title }),
@@ -4149,8 +4597,12 @@ function StudioGuestLobby({
4149
4597
  reducedMotion,
4150
4598
  cameraOn,
4151
4599
  muted,
4600
+ audioDeviceId: effective.audio,
4601
+ videoDeviceId: effective.video,
4152
4602
  cameraLabel: labels.camera,
4153
- micLabel: labels.mic
4603
+ micLabel: labels.mic,
4604
+ cameraOffLabel: deviceLabels.cameraOff,
4605
+ onPermission: () => setRefreshKey((n) => n === 0 ? 1 : n)
4154
4606
  }
4155
4607
  ),
4156
4608
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-center gap-2", children: [
@@ -4179,6 +4631,26 @@ function StudioGuestLobby({
4179
4631
  }
4180
4632
  )
4181
4633
  ] }),
4634
+ /* @__PURE__ */ jsxRuntime.jsx(
4635
+ StudioDeviceSelectGroup,
4636
+ {
4637
+ devices,
4638
+ selected: effective,
4639
+ labels: deviceLabels,
4640
+ onChange: changeDevice,
4641
+ speakerAction: /* @__PURE__ */ jsxRuntime.jsx(
4642
+ Button,
4643
+ {
4644
+ type: "button",
4645
+ size: "sm",
4646
+ variant: "outline",
4647
+ disabled: !(effective.speaker || devices.speaker[0]?.deviceId) || testingSpeaker,
4648
+ onClick: () => void onTestSpeaker(),
4649
+ children: deviceLabels.testSpeaker
4650
+ }
4651
+ )
4652
+ }
4653
+ ),
4182
4654
  showName ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid gap-2", children: [
4183
4655
  /* @__PURE__ */ jsxRuntime.jsx(Label, { htmlFor: "studio-guest-name", children: labels.name }),
4184
4656
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -4224,16 +4696,16 @@ function usePrefersReducedMotion() {
4224
4696
  // src/studio/lib/guest-identity.ts
4225
4697
  var NAME_KEY = "livo-studio-display-name";
4226
4698
  var ID_KEY = "livo-studio-guest-id";
4227
- function readStudioGuestIdentity(storage = defaultStorage()) {
4699
+ function readStudioGuestIdentity(storage = defaultStorage2()) {
4228
4700
  const guestId = storage?.getItem(ID_KEY)?.trim() || crypto.randomUUID();
4229
4701
  storage?.setItem(ID_KEY, guestId);
4230
4702
  const displayName = storage?.getItem(NAME_KEY)?.trim() ?? "";
4231
4703
  return { displayName, guestId };
4232
4704
  }
4233
- function persistStudioGuestName(displayName, storage = defaultStorage()) {
4705
+ function persistStudioGuestName(displayName, storage = defaultStorage2()) {
4234
4706
  storage?.setItem(NAME_KEY, displayName.trim());
4235
4707
  }
4236
- function defaultStorage() {
4708
+ function defaultStorage2() {
4237
4709
  if (typeof window === "undefined") return null;
4238
4710
  try {
4239
4711
  return window.localStorage;
@@ -4817,7 +5289,15 @@ function studioMeetingFromRtk(raw) {
4817
5289
  notifyStage();
4818
5290
  return;
4819
5291
  }
4820
- await meeting.stage?.join?.();
5292
+ try {
5293
+ await meeting.stage?.join?.();
5294
+ } catch {
5295
+ const userId = meeting.self?.userId;
5296
+ if (userId) {
5297
+ await meeting.stage?.grantAccess?.([userId]);
5298
+ }
5299
+ await meeting.stage?.join?.();
5300
+ }
4821
5301
  joinedStageLocally = true;
4822
5302
  await republishCameraIfOn();
4823
5303
  notifyStage();
@@ -5107,7 +5587,11 @@ function useRealtimeKitMeeting(authToken, generation = 0) {
5107
5587
  const [meeting, setMeeting] = react.useState(null);
5108
5588
  const [error, setError] = react.useState(null);
5109
5589
  react.useEffect(() => {
5110
- if (!authToken) return;
5590
+ if (!authToken) {
5591
+ setMeeting(null);
5592
+ setError(null);
5593
+ return;
5594
+ }
5111
5595
  let cancelled = false;
5112
5596
  let handle = null;
5113
5597
  setError(null);
@@ -5394,6 +5878,9 @@ function LivoGuestStudio({
5394
5878
  const [waitingForHost, setWaitingForHost] = react.useState(false);
5395
5879
  const [cameraOn, setCameraOn] = react.useState(true);
5396
5880
  const [muted, setMuted] = react.useState(false);
5881
+ const [deviceSelection, setDeviceSelection] = react.useState(
5882
+ () => readStudioDeviceSelection()
5883
+ );
5397
5884
  const [retryAfterMs, setRetryAfterMs] = react.useState(null);
5398
5885
  const [sessionGen, setSessionGen] = react.useState(0);
5399
5886
  const joinInFlight = react.useRef(false);
@@ -5408,7 +5895,15 @@ function LivoGuestStudio({
5408
5895
  const identity = readStudioGuestIdentity();
5409
5896
  setDisplayName(identity.displayName);
5410
5897
  setGuestId(identity.guestId);
5898
+ setDeviceSelection(readStudioDeviceSelection());
5411
5899
  }, []);
5900
+ const onDeviceChange = react.useCallback(
5901
+ (kind, deviceId) => {
5902
+ persistStudioDeviceSelection(kind, deviceId);
5903
+ setDeviceSelection((prev) => ({ ...prev, [kind]: deviceId }));
5904
+ },
5905
+ []
5906
+ );
5412
5907
  const join = react.useCallback(async () => {
5413
5908
  if (joinInFlight.current) return;
5414
5909
  const name = displayName.trim();
@@ -5441,9 +5936,9 @@ function LivoGuestStudio({
5441
5936
  setError(err instanceof Error ? err.message : lobbyLabels.joinError);
5442
5937
  setWaitingForHost(false);
5443
5938
  }
5444
- setJoining(false);
5445
5939
  } finally {
5446
5940
  joinInFlight.current = false;
5941
+ setJoining(false);
5447
5942
  }
5448
5943
  }, [apiUrl, displayName, guestId, lobbyLabels, token]);
5449
5944
  react.useEffect(() => {
@@ -5497,9 +5992,11 @@ function LivoGuestStudio({
5497
5992
  waitingForHost,
5498
5993
  error,
5499
5994
  labels: lobbyLabels,
5995
+ savedDevices: deviceSelection,
5500
5996
  onCameraChange: setCameraOn,
5501
5997
  onMutedChange: setMuted,
5502
5998
  onDisplayNameChange: setDisplayName,
5999
+ onDeviceChange,
5503
6000
  onJoin: () => void join()
5504
6001
  }
5505
6002
  );
@@ -5517,13 +6014,18 @@ function LivoGuestStudio({
5517
6014
  onRejoin: () => {
5518
6015
  setSession(null);
5519
6016
  setSessionGen((n) => n + 1);
6017
+ setJoining(false);
6018
+ setWaitingForHost(false);
6019
+ setError(null);
6020
+ joinInFlight.current = false;
5520
6021
  },
5521
6022
  reducedMotion,
5522
6023
  labels,
5523
6024
  embed,
5524
6025
  playerOrigin,
5525
6026
  initialCameraOn: cameraOn,
5526
- initialMuted: muted
6027
+ initialMuted: muted,
6028
+ initialDevices: deviceSelection
5527
6029
  }
5528
6030
  );
5529
6031
  }