@livo-tv/sdk 1.3.3 → 1.5.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.
@@ -542,52 +542,38 @@ function canTakeOffAir(tiles, participantId, onAir) {
542
542
  (tile) => tile.id !== participantId && isOnStage(tile.stageStatus)
543
543
  );
544
544
  }
545
+ function pipSpotlightFrom(tile, kind) {
546
+ return {
547
+ participantId: tile.id,
548
+ name: tile.name,
549
+ kind,
550
+ picture: tile.picture
551
+ };
552
+ }
545
553
  function resolveStudioPipSpotlight(tiles, activeSpeakerId) {
546
554
  const visible = tiles.filter((tile) => isOnStage(tile.stageStatus));
547
555
  const pinned = visible.find((tile) => tile.pinned === true);
548
556
  if (pinned) {
549
- return {
550
- participantId: pinned.id,
551
- name: pinned.name,
552
- kind: pinned.screenShareEnabled ? "screen" : pinned.videoEnabled ? "camera" : "idle",
553
- picture: pinned.picture
554
- };
557
+ return pipSpotlightFrom(
558
+ pinned,
559
+ pinned.screenShareEnabled ? "screen" : pinned.videoEnabled ? "camera" : "idle"
560
+ );
561
+ }
562
+ const speaker = activeSpeakerId ? visible.find((tile) => tile.id === activeSpeakerId) : void 0;
563
+ if (speaker) {
564
+ return pipSpotlightFrom(speaker, speaker.videoEnabled ? "camera" : "idle");
555
565
  }
556
566
  const sharing = visible.find((tile) => tile.screenShareEnabled);
557
567
  if (sharing) {
558
- return {
559
- participantId: sharing.id,
560
- name: sharing.name,
561
- kind: "screen",
562
- picture: sharing.picture
563
- };
564
- }
565
- const speaker = activeSpeakerId ? visible.find((tile) => tile.id === activeSpeakerId && tile.videoEnabled) : void 0;
566
- if (speaker) {
567
- return {
568
- participantId: speaker.id,
569
- name: speaker.name,
570
- kind: "camera",
571
- picture: speaker.picture
572
- };
568
+ return pipSpotlightFrom(sharing, "screen");
573
569
  }
574
570
  const remoteCam = visible.find((tile) => !tile.isSelf && tile.videoEnabled);
575
571
  if (remoteCam) {
576
- return {
577
- participantId: remoteCam.id,
578
- name: remoteCam.name,
579
- kind: "camera",
580
- picture: remoteCam.picture
581
- };
572
+ return pipSpotlightFrom(remoteCam, "camera");
582
573
  }
583
574
  const self = visible.find((tile) => tile.isSelf);
584
575
  if (!self) return null;
585
- return {
586
- participantId: self.id,
587
- name: self.name,
588
- kind: self.videoEnabled ? "camera" : "idle",
589
- picture: self.picture
590
- };
576
+ return pipSpotlightFrom(self, self.videoEnabled ? "camera" : "idle");
591
577
  }
592
578
  function resolveStudioPipSelfView(tiles, spotlight) {
593
579
  const self = tiles.find((tile) => tile.isSelf);
@@ -706,6 +692,9 @@ function screenShareBlock(captureSupported, permission) {
706
692
  if (permission === "NOT_ALLOWED") return "permission";
707
693
  return null;
708
694
  }
695
+ function showScreenShareControl(block) {
696
+ return block !== "permission";
697
+ }
709
698
 
710
699
  // src/studio/lib/parse.ts
711
700
  function isRecord(value) {
@@ -1274,15 +1263,15 @@ function AudioWaveform({
1274
1263
  if (!canvas || !track || reducedMotion) return;
1275
1264
  const AudioCtx = window.AudioContext || window.webkitAudioContext;
1276
1265
  if (!AudioCtx) return;
1277
- const ctx2 = new AudioCtx();
1278
- const source = ctx2.createMediaStreamSource(new MediaStream([track]));
1279
- const analyser = ctx2.createAnalyser();
1266
+ const ctx3 = new AudioCtx();
1267
+ const source = ctx3.createMediaStreamSource(new MediaStream([track]));
1268
+ const analyser = ctx3.createAnalyser();
1280
1269
  analyser.fftSize = 64;
1281
1270
  source.connect(analyser);
1282
1271
  const data = new Uint8Array(analyser.frequencyBinCount);
1283
1272
  const graphic = canvas.getContext("2d");
1284
1273
  const surface = canvas;
1285
- let frame = 0;
1274
+ let frame2 = 0;
1286
1275
  function draw() {
1287
1276
  if (!graphic) return;
1288
1277
  analyser.getByteFrequencyData(data);
@@ -1300,12 +1289,12 @@ function AudioWaveform({
1300
1289
  barHeight
1301
1290
  );
1302
1291
  }
1303
- frame = requestAnimationFrame(draw);
1292
+ frame2 = requestAnimationFrame(draw);
1304
1293
  }
1305
- frame = requestAnimationFrame(draw);
1294
+ frame2 = requestAnimationFrame(draw);
1306
1295
  return () => {
1307
- cancelAnimationFrame(frame);
1308
- void ctx2.close();
1296
+ cancelAnimationFrame(frame2);
1297
+ void ctx3.close();
1309
1298
  };
1310
1299
  }, [track, reducedMotion]);
1311
1300
  if (reducedMotion) {
@@ -1488,6 +1477,19 @@ function DialogHeader({ className, ...props }) {
1488
1477
  }
1489
1478
  );
1490
1479
  }
1480
+ function DialogFooter({ className, ...props }) {
1481
+ return /* @__PURE__ */ jsxRuntime.jsx(
1482
+ "div",
1483
+ {
1484
+ "data-slot": "dialog-footer",
1485
+ className: cn(
1486
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
1487
+ className
1488
+ ),
1489
+ ...props
1490
+ }
1491
+ );
1492
+ }
1491
1493
  function DialogTitle({
1492
1494
  className,
1493
1495
  ...props
@@ -1591,10 +1593,10 @@ async function playSpeakerTest(deviceId) {
1591
1593
  if (!AudioCtx) {
1592
1594
  throw new Error("AudioContext unavailable");
1593
1595
  }
1594
- const ctx2 = new AudioCtx();
1595
- void ctx2.resume();
1596
- const oscillator = ctx2.createOscillator();
1597
- const gain = ctx2.createGain();
1596
+ const ctx3 = new AudioCtx();
1597
+ void ctx3.resume();
1598
+ const oscillator = ctx3.createOscillator();
1599
+ const gain = ctx3.createGain();
1598
1600
  oscillator.type = "sine";
1599
1601
  oscillator.frequency.value = TEST_HZ;
1600
1602
  gain.gain.value = 0.18;
@@ -1604,7 +1606,7 @@ async function playSpeakerTest(deviceId) {
1604
1606
  const probe = document.createElement("audio");
1605
1607
  const canRoute = Boolean(deviceId) && typeof probe.setSinkId === "function";
1606
1608
  if (canRoute && deviceId) {
1607
- const destination = ctx2.createMediaStreamDestination();
1609
+ const destination = ctx3.createMediaStreamDestination();
1608
1610
  gain.connect(destination);
1609
1611
  audio = probe;
1610
1612
  audio.autoplay = true;
@@ -1615,13 +1617,13 @@ async function playSpeakerTest(deviceId) {
1615
1617
  await audio.play();
1616
1618
  } catch {
1617
1619
  gain.disconnect();
1618
- gain.connect(ctx2.destination);
1620
+ gain.connect(ctx3.destination);
1619
1621
  audio.srcObject = null;
1620
1622
  audio.remove();
1621
1623
  audio = null;
1622
1624
  }
1623
1625
  } else {
1624
- gain.connect(ctx2.destination);
1626
+ gain.connect(ctx3.destination);
1625
1627
  }
1626
1628
  oscillator.start();
1627
1629
  await new Promise((resolve) => {
@@ -1633,7 +1635,7 @@ async function playSpeakerTest(deviceId) {
1633
1635
  audio.srcObject = null;
1634
1636
  audio.remove();
1635
1637
  }
1636
- await ctx2.close();
1638
+ await ctx3.close();
1637
1639
  }
1638
1640
  }
1639
1641
  function StudioDeviceSelectGroup({
@@ -2047,6 +2049,19 @@ var badgeVariants = classVarianceAuthority.cva(
2047
2049
  function Badge({ className, variant, ...props }) {
2048
2050
  return /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(badgeVariants({ variant }), className), ...props });
2049
2051
  }
2052
+
2053
+ // src/studio/lib/studio-video-style.ts
2054
+ var STUDIO_VIDEO_MEDIA_STYLE = `
2055
+ .livo-studio-tile-video::-webkit-media-controls-start-playback-button,
2056
+ .livo-studio-inline-video::-webkit-media-controls-start-playback-button {
2057
+ display: none !important;
2058
+ -webkit-appearance: none;
2059
+ }
2060
+ .livo-studio-tile-video::-webkit-media-controls,
2061
+ .livo-studio-inline-video::-webkit-media-controls {
2062
+ display: none !important;
2063
+ }
2064
+ `;
2050
2065
  function StudioPipPanel({
2051
2066
  meeting,
2052
2067
  labels,
@@ -2101,7 +2116,7 @@ function StudioPipPanel({
2101
2116
  /* @__PURE__ */ jsxRuntime.jsx(StudioLiveBadge, { children: labels.live }),
2102
2117
  elapsed ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: elapsed }) : null
2103
2118
  ] }) : null,
2104
- selfView ? /* @__PURE__ */ jsxRuntime.jsx(
2119
+ selfView && meeting.localVideoTrack() ? /* @__PURE__ */ jsxRuntime.jsx(
2105
2120
  "div",
2106
2121
  {
2107
2122
  "data-testid": "pip-self-view",
@@ -2158,7 +2173,8 @@ function PipSpotlight({
2158
2173
  spotlight,
2159
2174
  cameraOffLabel
2160
2175
  }) {
2161
- if (!spotlight || spotlight.kind === "idle") {
2176
+ const track = spotlight && spotlight.kind !== "idle" ? spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId) : null;
2177
+ if (!spotlight || spotlight.kind === "idle" || !track) {
2162
2178
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2", children: [
2163
2179
  spotlight?.picture ? /* @__PURE__ */ jsxRuntime.jsx(
2164
2180
  "img",
@@ -2171,7 +2187,6 @@ function PipSpotlight({
2171
2187
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-xs", children: spotlight?.name ?? cameraOffLabel })
2172
2188
  ] });
2173
2189
  }
2174
- const track = spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId);
2175
2190
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2176
2191
  /* @__PURE__ */ jsxRuntime.jsx(
2177
2192
  PipTrackVideo,
@@ -2190,11 +2205,7 @@ function PipTrackVideo({
2190
2205
  const ref = react.useRef(null);
2191
2206
  react.useEffect(() => {
2192
2207
  const el = ref.current;
2193
- if (!el) return;
2194
- if (!track) {
2195
- el.srcObject = null;
2196
- return;
2197
- }
2208
+ if (!el || !track) return;
2198
2209
  el.setAttribute("webkit-playsinline", "");
2199
2210
  el.srcObject = new MediaStream([track]);
2200
2211
  void el.play().catch(() => void 0);
@@ -2202,19 +2213,24 @@ function PipTrackVideo({
2202
2213
  el.srcObject = null;
2203
2214
  };
2204
2215
  }, [track]);
2205
- return /* @__PURE__ */ jsxRuntime.jsx(
2206
- "video",
2207
- {
2208
- ref,
2209
- autoPlay: true,
2210
- playsInline: true,
2211
- muted: true,
2212
- className: cn(
2213
- "livo-studio-inline-video absolute inset-0 size-full",
2214
- fit === "contain" ? "object-contain" : "object-cover"
2215
- )
2216
- }
2217
- );
2216
+ if (!track) return null;
2217
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2218
+ /* @__PURE__ */ jsxRuntime.jsx("style", { children: STUDIO_VIDEO_MEDIA_STYLE }),
2219
+ /* @__PURE__ */ jsxRuntime.jsx(
2220
+ "video",
2221
+ {
2222
+ ref,
2223
+ autoPlay: true,
2224
+ playsInline: true,
2225
+ muted: true,
2226
+ disableRemotePlayback: true,
2227
+ className: cn(
2228
+ "livo-studio-inline-video absolute inset-0 size-full",
2229
+ fit === "contain" ? "object-contain" : "object-cover"
2230
+ )
2231
+ }
2232
+ )
2233
+ ] });
2218
2234
  }
2219
2235
  function pipScreenControls({
2220
2236
  labels,
@@ -2416,7 +2432,7 @@ function StudioChatPanel({
2416
2432
  isModerator: isModerator2,
2417
2433
  labels
2418
2434
  }) {
2419
- const [tick, setTick] = react.useState(0);
2435
+ const [tick2, setTick] = react.useState(0);
2420
2436
  const [draft, setDraft] = react.useState("");
2421
2437
  const [editingId, setEditingId] = react.useState(null);
2422
2438
  const listRef = react.useRef(null);
@@ -2426,9 +2442,9 @@ function StudioChatPanel({
2426
2442
  );
2427
2443
  const messages = react.useMemo(
2428
2444
  () => meeting.chatMessages().filter((row) => !row.targetUserIds?.length),
2429
- [meeting, tick]
2445
+ [meeting, tick2]
2430
2446
  );
2431
- const pinned = react.useMemo(() => meeting.pinnedChatMessages(), [meeting, tick]);
2447
+ const pinned = react.useMemo(() => meeting.pinnedChatMessages(), [meeting, tick2]);
2432
2448
  const limit = meeting.chatMaxTextLimit();
2433
2449
  const pictures = react.useMemo(() => {
2434
2450
  const map = /* @__PURE__ */ new Map();
@@ -2928,6 +2944,171 @@ function StudioSidePanel({
2928
2944
  ] })
2929
2945
  ] });
2930
2946
  }
2947
+
2948
+ // src/studio/lib/audio-levels.ts
2949
+ var LEVEL_SENSITIVITY = 4;
2950
+ var LEVEL_ATTACK = 0.55;
2951
+ var LEVEL_DECAY = 0.12;
2952
+ var SPEAKING_ON = 0.12;
2953
+ var SPEAKING_OFF = 0.06;
2954
+ var SPEAKING_HOLD_MS = 180;
2955
+ var tracks = /* @__PURE__ */ new Map();
2956
+ var ctx2 = null;
2957
+ var frame = 0;
2958
+ var tickRunning = false;
2959
+ function audioContextCtor2() {
2960
+ if (typeof window === "undefined") return null;
2961
+ return window.AudioContext || window.webkitAudioContext || null;
2962
+ }
2963
+ function ensureContext2() {
2964
+ const AudioCtx = audioContextCtor2();
2965
+ if (!AudioCtx) return null;
2966
+ if (!ctx2 || ctx2.state === "closed") {
2967
+ ctx2 = new AudioCtx();
2968
+ }
2969
+ return ctx2;
2970
+ }
2971
+ function rmsFromTimeDomain(data) {
2972
+ if (data.length === 0) return 0;
2973
+ let sum = 0;
2974
+ for (let i = 0; i < data.length; i += 1) {
2975
+ const centered = ((data[i] ?? 128) - 128) / 128;
2976
+ sum += centered * centered;
2977
+ }
2978
+ return Math.sqrt(sum / data.length);
2979
+ }
2980
+ function normalizeAudioLevel(rms) {
2981
+ return Math.min(1, Math.max(0, rms * LEVEL_SENSITIVITY));
2982
+ }
2983
+ function smoothAudioLevel(previous, raw) {
2984
+ const factor = raw > previous ? LEVEL_ATTACK : LEVEL_DECAY;
2985
+ return previous + (raw - previous) * factor;
2986
+ }
2987
+ function nextSpeakingGate(gate, level, now) {
2988
+ if (!gate.speaking) {
2989
+ if (level >= SPEAKING_ON) {
2990
+ return { speaking: true, quietSince: null };
2991
+ }
2992
+ return { speaking: false, quietSince: null };
2993
+ }
2994
+ if (level >= SPEAKING_OFF) {
2995
+ return { speaking: true, quietSince: null };
2996
+ }
2997
+ const quietSince = gate.quietSince ?? now;
2998
+ if (now - quietSince >= SPEAKING_HOLD_MS) {
2999
+ return { speaking: false, quietSince: null };
3000
+ }
3001
+ return { speaking: true, quietSince };
3002
+ }
3003
+ function tick() {
3004
+ for (const entry of tracks.values()) {
3005
+ entry.analyser.getByteTimeDomainData(entry.data);
3006
+ const raw = normalizeAudioLevel(rmsFromTimeDomain(entry.data));
3007
+ entry.smoothed = smoothAudioLevel(entry.smoothed, raw);
3008
+ for (const cb of entry.listeners) {
3009
+ cb(entry.smoothed);
3010
+ }
3011
+ }
3012
+ if (tracks.size > 0) {
3013
+ frame = requestAnimationFrame(tick);
3014
+ return;
3015
+ }
3016
+ tickRunning = false;
3017
+ frame = 0;
3018
+ }
3019
+ function startTick() {
3020
+ if (tickRunning) return;
3021
+ tickRunning = true;
3022
+ frame = requestAnimationFrame(tick);
3023
+ }
3024
+ function stopTickIfIdle() {
3025
+ if (tracks.size > 0 || !frame) return;
3026
+ cancelAnimationFrame(frame);
3027
+ tickRunning = false;
3028
+ frame = 0;
3029
+ }
3030
+ function subscribeStudioAudioLevel(track, cb) {
3031
+ const context = ensureContext2();
3032
+ if (!context) return () => void 0;
3033
+ void context.resume();
3034
+ const id = track.id;
3035
+ let entry = tracks.get(id);
3036
+ if (!entry) {
3037
+ const source = context.createMediaStreamSource(new MediaStream([track]));
3038
+ const analyser = context.createAnalyser();
3039
+ analyser.fftSize = 512;
3040
+ analyser.smoothingTimeConstant = 0;
3041
+ source.connect(analyser);
3042
+ entry = {
3043
+ source,
3044
+ analyser,
3045
+ data: new Uint8Array(analyser.fftSize),
3046
+ refs: 0,
3047
+ smoothed: 0,
3048
+ listeners: /* @__PURE__ */ new Set()
3049
+ };
3050
+ tracks.set(id, entry);
3051
+ }
3052
+ entry.refs += 1;
3053
+ entry.listeners.add(cb);
3054
+ startTick();
3055
+ return () => {
3056
+ const current = tracks.get(id);
3057
+ if (!current) return;
3058
+ current.listeners.delete(cb);
3059
+ current.refs -= 1;
3060
+ if (current.refs <= 0) {
3061
+ current.source.disconnect();
3062
+ current.analyser.disconnect();
3063
+ tracks.delete(id);
3064
+ }
3065
+ stopTickIfIdle();
3066
+ };
3067
+ }
3068
+
3069
+ // src/studio/hooks/use-speaking-glow.ts
3070
+ function clearSpeakingGlow(el) {
3071
+ el.style.removeProperty("--livo-speaking-level");
3072
+ el.removeAttribute("data-speaking");
3073
+ }
3074
+ function useSpeakingGlow({
3075
+ track,
3076
+ enabled,
3077
+ reducedMotion
3078
+ }) {
3079
+ const [el, setEl] = react.useState(null);
3080
+ const gateRef = react.useRef({ speaking: false, quietSince: null });
3081
+ const setRef = react.useCallback((node) => {
3082
+ setEl(node);
3083
+ }, []);
3084
+ react.useEffect(() => {
3085
+ if (!el || !enabled || !track) {
3086
+ if (el) clearSpeakingGlow(el);
3087
+ gateRef.current = { speaking: false, quietSince: null };
3088
+ return;
3089
+ }
3090
+ const unsubscribe = subscribeStudioAudioLevel(track, (level) => {
3091
+ const node = el;
3092
+ gateRef.current = nextSpeakingGate(gateRef.current, level, Date.now());
3093
+ if (gateRef.current.speaking) {
3094
+ node.dataset.speaking = "true";
3095
+ if (!reducedMotion) {
3096
+ node.style.setProperty("--livo-speaking-level", String(level));
3097
+ } else {
3098
+ node.style.removeProperty("--livo-speaking-level");
3099
+ }
3100
+ return;
3101
+ }
3102
+ clearSpeakingGlow(node);
3103
+ });
3104
+ return () => {
3105
+ unsubscribe();
3106
+ clearSpeakingGlow(el);
3107
+ gateRef.current = { speaking: false, quietSince: null };
3108
+ };
3109
+ }, [el, enabled, reducedMotion, track]);
3110
+ return setRef;
3111
+ }
2931
3112
  function DropdownMenu({ ...props }) {
2932
3113
  return /* @__PURE__ */ jsxRuntime.jsx(menu.Menu.Root, { "data-slot": "dropdown-menu", ...props });
2933
3114
  }
@@ -3076,32 +3257,30 @@ function StudioTile({
3076
3257
  const onStage = isOnStage(tile.stageStatus);
3077
3258
  const [confirm, setConfirm] = react.useState(null);
3078
3259
  const showMenu = (canModerate || canManageStage) && !tile.isSelf;
3260
+ const hasVideoTrack = tileHasVideoTrack(meeting, tile);
3261
+ const glowRef = useSpeakingGlow({
3262
+ track: tile.kind === "screen" || !tile.audioEnabled ? null : meeting.participantAudioTrack(tile.id),
3263
+ enabled: onStage,
3264
+ reducedMotion
3265
+ });
3079
3266
  return /* @__PURE__ */ jsxRuntime.jsxs(
3080
3267
  "article",
3081
3268
  {
3269
+ ref: glowRef,
3082
3270
  "aria-label": tile.name,
3083
3271
  className: cn(
3084
- "bg-muted relative overflow-hidden rounded-xl",
3272
+ "livo-studio-tile bg-muted relative overflow-hidden rounded-xl",
3085
3273
  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"
3086
3274
  ),
3087
3275
  children: [
3088
- tile.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
3089
- tile.picture ? /* @__PURE__ */ jsxRuntime.jsx(
3090
- "img",
3091
- {
3092
- src: tile.picture,
3093
- alt: "",
3094
- className: "size-16 rounded-full object-cover"
3095
- }
3096
- ) : /* @__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() }),
3097
- /* @__PURE__ */ jsxRuntime.jsx(
3098
- AudioWaveform,
3099
- {
3100
- track: tile.isSelf ? meeting.localAudioTrack() : null,
3101
- reducedMotion
3102
- }
3103
- )
3104
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(
3276
+ tile.kind === "idle" || !hasVideoTrack ? /* @__PURE__ */ jsxRuntime.jsx(
3277
+ IdlePersonTile,
3278
+ {
3279
+ tile,
3280
+ meeting,
3281
+ reducedMotion
3282
+ }
3283
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
3105
3284
  StudioTileVideo,
3106
3285
  {
3107
3286
  meeting,
@@ -3111,6 +3290,13 @@ function StudioTile({
3111
3290
  contain: tile.kind === "screen"
3112
3291
  }
3113
3292
  ),
3293
+ /* @__PURE__ */ jsxRuntime.jsx(
3294
+ "div",
3295
+ {
3296
+ "aria-hidden": true,
3297
+ className: "livo-studio-tile-glow pointer-events-none absolute inset-0 z-20 rounded-xl"
3298
+ }
3299
+ ),
3114
3300
  /* @__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: [
3115
3301
  /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "min-w-0 truncate text-xs font-medium text-white", children: [
3116
3302
  tile.name,
@@ -3193,6 +3379,37 @@ function TileStatusIcons({
3193
3379
  tile.chatCanSend === false ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.muteChat, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageSquareOff, {}) }) : null
3194
3380
  ] });
3195
3381
  }
3382
+ function tileHasVideoTrack(meeting, tile) {
3383
+ if (tile.kind === "screen") {
3384
+ return meeting.participantScreenTrack(tile.id) != null;
3385
+ }
3386
+ if (tile.kind === "idle") return false;
3387
+ if (tile.isSelf) return meeting.localVideoTrack() != null;
3388
+ return meeting.participantVideoTrack(tile.id) != null;
3389
+ }
3390
+ function IdlePersonTile({
3391
+ tile,
3392
+ meeting,
3393
+ reducedMotion
3394
+ }) {
3395
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
3396
+ tile.picture ? /* @__PURE__ */ jsxRuntime.jsx(
3397
+ "img",
3398
+ {
3399
+ src: tile.picture,
3400
+ alt: "",
3401
+ className: "size-16 rounded-full object-cover"
3402
+ }
3403
+ ) : /* @__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() }),
3404
+ /* @__PURE__ */ jsxRuntime.jsx(
3405
+ AudioWaveform,
3406
+ {
3407
+ track: tile.isSelf ? meeting.localAudioTrack() : null,
3408
+ reducedMotion
3409
+ }
3410
+ )
3411
+ ] });
3412
+ }
3196
3413
  function StatusIcon({
3197
3414
  label,
3198
3415
  children
@@ -3216,23 +3433,27 @@ function StudioTileVideo({
3216
3433
  meeting.attachVideo(participantId, el, kind);
3217
3434
  return () => meeting.attachVideo(participantId, null, kind);
3218
3435
  }, [kind, meeting, participantId]);
3219
- return /* @__PURE__ */ jsxRuntime.jsx(
3220
- "video",
3221
- {
3222
- ref,
3223
- autoPlay: true,
3224
- playsInline: true,
3225
- muted: true,
3226
- disablePictureInPicture: true,
3227
- controlsList: "nodownload noplaybackrate noremoteplayback",
3228
- className: cn(
3229
- "livo-studio-tile-video absolute inset-0 size-full",
3230
- "pointer-events-none",
3231
- contain ? "object-contain" : "object-cover",
3232
- mirror && "-scale-x-100"
3233
- )
3234
- }
3235
- );
3436
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3437
+ /* @__PURE__ */ jsxRuntime.jsx("style", { children: STUDIO_VIDEO_MEDIA_STYLE }),
3438
+ /* @__PURE__ */ jsxRuntime.jsx(
3439
+ "video",
3440
+ {
3441
+ ref,
3442
+ autoPlay: true,
3443
+ playsInline: true,
3444
+ muted: true,
3445
+ disablePictureInPicture: true,
3446
+ disableRemotePlayback: true,
3447
+ controlsList: "nodownload noplaybackrate noremoteplayback",
3448
+ className: cn(
3449
+ "livo-studio-tile-video absolute inset-0 size-full",
3450
+ "pointer-events-none",
3451
+ contain ? "object-contain" : "object-cover",
3452
+ mirror && "-scale-x-100"
3453
+ )
3454
+ }
3455
+ )
3456
+ ] });
3236
3457
  }
3237
3458
  function StudioStage({
3238
3459
  participants,
@@ -3253,9 +3474,7 @@ function StudioStage({
3253
3474
  onOpenPeople
3254
3475
  }) {
3255
3476
  const displayTiles = expandStudioTiles(participants);
3256
- const stage = studioStageLayout(displayTiles, {
3257
- activeSpeakerId: meeting.activeSpeakerId()
3258
- });
3477
+ const stage = studioStageLayout(displayTiles);
3259
3478
  const sharing = stage.spotlight.length > 0;
3260
3479
  const overflowLabel = labels.stageOverflow.replace(
3261
3480
  "{count}",
@@ -3326,6 +3545,32 @@ function StudioStage({
3326
3545
  }
3327
3546
  );
3328
3547
  }
3548
+ function StudioDownloadAppDialog({
3549
+ open,
3550
+ onOpenChange,
3551
+ downloadUrl,
3552
+ labels
3553
+ }) {
3554
+ const href = downloadUrl?.trim() ?? "";
3555
+ return /* @__PURE__ */ jsxRuntime.jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(DialogContent, { children: [
3556
+ /* @__PURE__ */ jsxRuntime.jsxs(DialogHeader, { children: [
3557
+ /* @__PURE__ */ jsxRuntime.jsx(DialogTitle, { children: labels.screenDownloadAppTitle }),
3558
+ /* @__PURE__ */ jsxRuntime.jsx(DialogDescription, { children: labels.screenUnsupportedDevice })
3559
+ ] }),
3560
+ /* @__PURE__ */ jsxRuntime.jsxs(DialogFooter, { children: [
3561
+ /* @__PURE__ */ jsxRuntime.jsx(
3562
+ Button,
3563
+ {
3564
+ type: "button",
3565
+ variant: "outline",
3566
+ onClick: () => onOpenChange(false),
3567
+ children: labels.screenDownloadAppDismiss
3568
+ }
3569
+ ),
3570
+ href ? /* @__PURE__ */ jsxRuntime.jsx(Button, { render: /* @__PURE__ */ jsxRuntime.jsx("a", { href, target: "_blank", rel: "noreferrer" }), children: labels.screenDownloadAppAction }) : /* @__PURE__ */ jsxRuntime.jsx(Button, { type: "button", disabled: true, children: labels.screenDownloadAppAction })
3571
+ ] })
3572
+ ] }) });
3573
+ }
3329
3574
  function StudioToolbar({
3330
3575
  session,
3331
3576
  meeting,
@@ -3580,7 +3825,8 @@ function StudioRoom({
3580
3825
  windowGate,
3581
3826
  initialCameraOn = true,
3582
3827
  initialMuted = false,
3583
- initialDevices
3828
+ initialDevices,
3829
+ appDownloadUrl
3584
3830
  }) {
3585
3831
  const [cameraOn, setCameraOn] = react.useState(initialCameraOn);
3586
3832
  const [screenOn, setScreenOn] = react.useState(false);
@@ -3588,7 +3834,7 @@ function StudioRoom({
3588
3834
  const [screenRequestPending, setScreenRequestPending] = react.useState(false);
3589
3835
  const [pending, setPending] = react.useState(false);
3590
3836
  const [publishing, setPublishing] = react.useState(false);
3591
- const [tick, setTick] = react.useState(0);
3837
+ const [tick2, setTick] = react.useState(0);
3592
3838
  const [leaveState, setLeaveState] = react.useState(null);
3593
3839
  const [sideTab, setSideTab] = react.useState(null);
3594
3840
  const [settingsOpen, setSettingsOpen] = react.useState(false);
@@ -3601,6 +3847,7 @@ function StudioRoom({
3601
3847
  );
3602
3848
  const [egressError, setEgressError] = react.useState(null);
3603
3849
  const [shareGrant, setShareGrant] = react.useState("idle");
3850
+ const [downloadAppOpen, setDownloadAppOpen] = react.useState(false);
3604
3851
  const [connectTimedOut, setConnectTimedOut] = react.useState(false);
3605
3852
  const [screenPermission, setScreenPermission] = react.useState(
3606
3853
  () => meeting.screenSharePermission()
@@ -3642,6 +3889,11 @@ function StudioRoom({
3642
3889
  const roomState = leaveState ?? meeting.roomState();
3643
3890
  const sessionOver = isStudioSessionOver(session.stream.status, roomState);
3644
3891
  const rejected = isStudioRejected(roomState);
3892
+ const connectionState = react.useMemo(
3893
+ () => meeting.connectionState(),
3894
+ [meeting, tick2]
3895
+ );
3896
+ const guestConnectFailed = session.role === "guest" && (connectionState === "failed" || connectionState === "connecting" && connectTimedOut);
3645
3897
  useBeforeUnload(active && !sessionOver);
3646
3898
  useWakeLock(active && !sessionOver);
3647
3899
  react.useEffect(() => {
@@ -3805,12 +4057,13 @@ function StudioRoom({
3805
4057
  void meeting.setScreenShareEnabled(false);
3806
4058
  }, [guestOffStage, meeting]);
3807
4059
  react.useEffect(() => {
3808
- if (!sessionOver && !rejected) return;
4060
+ if (!sessionOver && !rejected && !guestConnectFailed) return;
4061
+ pip.close();
3809
4062
  setShareGrant("idle");
3810
4063
  void meeting.setAudioEnabled(false);
3811
4064
  void meeting.setVideoEnabled(false);
3812
4065
  void meeting.setScreenShareEnabled(false);
3813
- }, [meeting, rejected, sessionOver]);
4066
+ }, [guestConnectFailed, meeting, pip.close, rejected, sessionOver]);
3814
4067
  react.useEffect(() => {
3815
4068
  return meeting.subscribeMedia(() => {
3816
4069
  const self2 = meeting.participants().find((row) => row.isSelf);
@@ -3832,7 +4085,7 @@ function StudioRoom({
3832
4085
  });
3833
4086
  }, [meeting]);
3834
4087
  const sawParticipants = react.useRef(false);
3835
- const participants = react.useMemo(() => meeting.participants(), [meeting, tick]);
4088
+ const participants = react.useMemo(() => meeting.participants(), [meeting, tick2]);
3836
4089
  react.useEffect(() => {
3837
4090
  const other = duplicateSelfParticipant(participants);
3838
4091
  if (!other) return;
@@ -3847,29 +4100,25 @@ function StudioRoom({
3847
4100
  () => participants.filter((row) => isOnStage(row.stageStatus)),
3848
4101
  [participants]
3849
4102
  );
3850
- const waitlisted = react.useMemo(() => meeting.waitlisted(), [meeting, tick]);
3851
- const connectionState = react.useMemo(
3852
- () => meeting.connectionState(),
3853
- [meeting, tick]
3854
- );
4103
+ const waitlisted = react.useMemo(() => meeting.waitlisted(), [meeting, tick2]);
3855
4104
  const lastMeetingError = react.useMemo(
3856
4105
  () => meeting.lastMeetingError(),
3857
- [meeting, tick]
4106
+ [meeting, tick2]
3858
4107
  );
3859
4108
  const canManageStage = meeting.canManageStage();
3860
4109
  const stageEnabled = meeting.stageEnabled();
3861
4110
  const stageRequests = react.useMemo(
3862
4111
  () => canManageStage ? meeting.stageRequests() : [],
3863
- [canManageStage, meeting, tick]
4112
+ [canManageStage, meeting, tick2]
3864
4113
  );
3865
- const chatMessages = react.useMemo(() => meeting.chatMessages(), [meeting, tick]);
4114
+ const chatMessages = react.useMemo(() => meeting.chatMessages(), [meeting, tick2]);
3866
4115
  const inWaitingRoom = meeting.inWaitingRoom();
3867
4116
  const screenAvailable = canStartScreenShare(participants);
3868
4117
  const durableScreenBlock = screenShareBlock(
3869
4118
  captureSupported,
3870
4119
  screenPermission
3871
4120
  );
3872
- const showScreenControl = durableScreenBlock === null;
4121
+ const showScreenControl = showScreenShareControl(durableScreenBlock);
3873
4122
  const canGoLive = session.role === "moderator" && canPublish(session.stream.status);
3874
4123
  const showPublishButton = session.role === "moderator" && !onAir && session.stream.status !== "ended" && session.stream.status !== "error";
3875
4124
  const self = participants.find((row) => row.isSelf);
@@ -3965,17 +4214,10 @@ function StudioRoom({
3965
4214
  }, [connectionState, session.role]);
3966
4215
  react.useEffect(() => {
3967
4216
  if (inWaitingRoom || screenNoticePrimed.current) return;
3968
- if (!durableScreenBlock) return;
4217
+ if (durableScreenBlock !== "permission") return;
3969
4218
  screenNoticePrimed.current = true;
3970
- sonner.toast.message(
3971
- durableScreenBlock === "device" ? labels.screenUnsupportedDevice : labels.screenNotAllowed
3972
- );
3973
- }, [
3974
- durableScreenBlock,
3975
- inWaitingRoom,
3976
- labels.screenNotAllowed,
3977
- labels.screenUnsupportedDevice
3978
- ]);
4219
+ sonner.toast.message(labels.screenNotAllowed);
4220
+ }, [durableScreenBlock, inWaitingRoom, labels.screenNotAllowed]);
3979
4221
  react.useEffect(() => {
3980
4222
  return meeting.subscribeBroadcast((message) => {
3981
4223
  if (message.type === "host-media") {
@@ -4084,6 +4326,10 @@ function StudioRoom({
4084
4326
  setCameraOn((current) => !current);
4085
4327
  }, []);
4086
4328
  const onToggleScreen = react.useCallback(() => {
4329
+ if (durableScreenBlock === "device") {
4330
+ setDownloadAppOpen(true);
4331
+ return;
4332
+ }
4087
4333
  if (!canShareFreely || screenRequestPending) return;
4088
4334
  if (!screenOn && !canStartScreenShare(meeting.participants())) {
4089
4335
  sonner.toast.message(labels.screenBusy);
@@ -4093,12 +4339,17 @@ function StudioRoom({
4093
4339
  setScreenOn((current) => !current);
4094
4340
  }, [
4095
4341
  canShareFreely,
4342
+ durableScreenBlock,
4096
4343
  labels.screenBusy,
4097
4344
  meeting,
4098
4345
  screenOn,
4099
4346
  screenRequestPending
4100
4347
  ]);
4101
4348
  const onAskToShare = react.useCallback(async () => {
4349
+ if (durableScreenBlock === "device") {
4350
+ setDownloadAppOpen(true);
4351
+ return;
4352
+ }
4102
4353
  const local = meeting.participants().find((row) => row.isSelf);
4103
4354
  if (!local) return;
4104
4355
  try {
@@ -4115,7 +4366,12 @@ function StudioRoom({
4115
4366
  } catch {
4116
4367
  sonner.toast.error(labels.shareRequestError);
4117
4368
  }
4118
- }, [labels.shareRequestError, labels.shareRequestSent, meeting]);
4369
+ }, [
4370
+ durableScreenBlock,
4371
+ labels.shareRequestError,
4372
+ labels.shareRequestSent,
4373
+ meeting
4374
+ ]);
4119
4375
  const verifyGrantTookEffect = react.useCallback(
4120
4376
  (userId, retried = false) => {
4121
4377
  const id = window.setTimeout(() => {
@@ -4278,7 +4534,6 @@ function StudioRoom({
4278
4534
  }
4279
4535
  ) });
4280
4536
  }
4281
- const guestConnectFailed = session.role === "guest" && (connectionState === "failed" || connectionState === "connecting" && connectTimedOut);
4282
4537
  if (guestConnectFailed) {
4283
4538
  const detail = [meeting.roomState(), lastMeetingError].filter((value) => Boolean(value)).join(" \u2014 ");
4284
4539
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex h-dvh flex-col items-center justify-center p-6", children: /* @__PURE__ */ jsxRuntime.jsx(
@@ -4612,7 +4867,21 @@ function StudioRoom({
4612
4867
  }
4613
4868
  ),
4614
4869
  pip.pipWindow.document.body
4615
- ) : null
4870
+ ) : null,
4871
+ /* @__PURE__ */ jsxRuntime.jsx(
4872
+ StudioDownloadAppDialog,
4873
+ {
4874
+ open: downloadAppOpen,
4875
+ onOpenChange: setDownloadAppOpen,
4876
+ downloadUrl: appDownloadUrl,
4877
+ labels: {
4878
+ screenDownloadAppTitle: labels.screenDownloadAppTitle,
4879
+ screenUnsupportedDevice: labels.screenUnsupportedDevice,
4880
+ screenDownloadAppAction: labels.screenDownloadAppAction,
4881
+ screenDownloadAppDismiss: labels.screenDownloadAppDismiss
4882
+ }
4883
+ }
4884
+ )
4616
4885
  ] });
4617
4886
  }
4618
4887
  function isOverconstrained(error) {
@@ -5781,6 +6050,9 @@ function studioMeetingFromRtk(raw) {
5781
6050
  localVideoTrack() {
5782
6051
  return meeting.self?.videoTrack ?? null;
5783
6052
  },
6053
+ participantAudioTrack(participantId) {
6054
+ return participantFor(participantId)?.audioTrack ?? null;
6055
+ },
5784
6056
  participantVideoTrack(participantId) {
5785
6057
  return participantFor(participantId)?.videoTrack ?? null;
5786
6058
  },
@@ -6152,7 +6424,8 @@ function LivoHostStudio({
6152
6424
  embed = false,
6153
6425
  chrome,
6154
6426
  playerOrigin,
6155
- theme
6427
+ theme,
6428
+ appDownloadUrl
6156
6429
  }) {
6157
6430
  const reducedMotion = usePrefersReducedMotion();
6158
6431
  const [session, setSession] = react.useState(null);
@@ -6232,7 +6505,8 @@ function LivoHostStudio({
6232
6505
  labels,
6233
6506
  embed,
6234
6507
  chrome,
6235
- playerOrigin
6508
+ playerOrigin,
6509
+ appDownloadUrl
6236
6510
  }
6237
6511
  );
6238
6512
  }
@@ -6245,7 +6519,8 @@ function LivoGuestStudio({
6245
6519
  lobbyLabels,
6246
6520
  embed = false,
6247
6521
  playerOrigin,
6248
- theme
6522
+ theme,
6523
+ appDownloadUrl
6249
6524
  }) {
6250
6525
  const reducedMotion = usePrefersReducedMotion();
6251
6526
  const [session, setSession] = react.useState(null);
@@ -6431,7 +6706,8 @@ function LivoGuestStudio({
6431
6706
  playerOrigin,
6432
6707
  initialCameraOn: cameraOn,
6433
6708
  initialMuted: muted,
6434
- initialDevices: deviceSelection
6709
+ initialDevices: deviceSelection,
6710
+ appDownloadUrl
6435
6711
  }
6436
6712
  );
6437
6713
  }
@@ -6536,6 +6812,9 @@ function studioRoomLabels(t) {
6536
6812
  pipTitle: t("pipTitle"),
6537
6813
  pipError: t("pipError"),
6538
6814
  screenUnsupportedDevice: t("screenUnsupportedDevice"),
6815
+ screenDownloadAppTitle: t("screenDownloadAppTitle"),
6816
+ screenDownloadAppAction: t("screenDownloadAppAction"),
6817
+ screenDownloadAppDismiss: t("screenDownloadAppDismiss"),
6539
6818
  screenNotAllowed: t("screenNotAllowed"),
6540
6819
  askToShare: t("askToShare"),
6541
6820
  shareRequested: t("shareRequested"),