@livo-tv/sdk 1.1.2 → 1.2.0

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