@livo-tv/sdk 1.1.1 → 1.2.0

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