@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.
@@ -518,52 +518,38 @@ function canTakeOffAir(tiles, participantId, onAir) {
518
518
  (tile) => tile.id !== participantId && isOnStage(tile.stageStatus)
519
519
  );
520
520
  }
521
+ function pipSpotlightFrom(tile, kind) {
522
+ return {
523
+ participantId: tile.id,
524
+ name: tile.name,
525
+ kind,
526
+ picture: tile.picture
527
+ };
528
+ }
521
529
  function resolveStudioPipSpotlight(tiles, activeSpeakerId) {
522
530
  const visible = tiles.filter((tile) => isOnStage(tile.stageStatus));
523
531
  const pinned = visible.find((tile) => tile.pinned === true);
524
532
  if (pinned) {
525
- return {
526
- participantId: pinned.id,
527
- name: pinned.name,
528
- kind: pinned.screenShareEnabled ? "screen" : pinned.videoEnabled ? "camera" : "idle",
529
- picture: pinned.picture
530
- };
533
+ return pipSpotlightFrom(
534
+ pinned,
535
+ pinned.screenShareEnabled ? "screen" : pinned.videoEnabled ? "camera" : "idle"
536
+ );
537
+ }
538
+ const speaker = activeSpeakerId ? visible.find((tile) => tile.id === activeSpeakerId) : void 0;
539
+ if (speaker) {
540
+ return pipSpotlightFrom(speaker, speaker.videoEnabled ? "camera" : "idle");
531
541
  }
532
542
  const sharing = visible.find((tile) => tile.screenShareEnabled);
533
543
  if (sharing) {
534
- return {
535
- participantId: sharing.id,
536
- name: sharing.name,
537
- kind: "screen",
538
- picture: sharing.picture
539
- };
540
- }
541
- const speaker = activeSpeakerId ? visible.find((tile) => tile.id === activeSpeakerId && tile.videoEnabled) : void 0;
542
- if (speaker) {
543
- return {
544
- participantId: speaker.id,
545
- name: speaker.name,
546
- kind: "camera",
547
- picture: speaker.picture
548
- };
544
+ return pipSpotlightFrom(sharing, "screen");
549
545
  }
550
546
  const remoteCam = visible.find((tile) => !tile.isSelf && tile.videoEnabled);
551
547
  if (remoteCam) {
552
- return {
553
- participantId: remoteCam.id,
554
- name: remoteCam.name,
555
- kind: "camera",
556
- picture: remoteCam.picture
557
- };
548
+ return pipSpotlightFrom(remoteCam, "camera");
558
549
  }
559
550
  const self = visible.find((tile) => tile.isSelf);
560
551
  if (!self) return null;
561
- return {
562
- participantId: self.id,
563
- name: self.name,
564
- kind: self.videoEnabled ? "camera" : "idle",
565
- picture: self.picture
566
- };
552
+ return pipSpotlightFrom(self, self.videoEnabled ? "camera" : "idle");
567
553
  }
568
554
  function resolveStudioPipSelfView(tiles, spotlight) {
569
555
  const self = tiles.find((tile) => tile.isSelf);
@@ -682,6 +668,9 @@ function screenShareBlock(captureSupported, permission) {
682
668
  if (permission === "NOT_ALLOWED") return "permission";
683
669
  return null;
684
670
  }
671
+ function showScreenShareControl(block) {
672
+ return block !== "permission";
673
+ }
685
674
 
686
675
  // src/studio/lib/parse.ts
687
676
  function isRecord(value) {
@@ -1250,15 +1239,15 @@ function AudioWaveform({
1250
1239
  if (!canvas || !track || reducedMotion) return;
1251
1240
  const AudioCtx = window.AudioContext || window.webkitAudioContext;
1252
1241
  if (!AudioCtx) return;
1253
- const ctx2 = new AudioCtx();
1254
- const source = ctx2.createMediaStreamSource(new MediaStream([track]));
1255
- const analyser = ctx2.createAnalyser();
1242
+ const ctx3 = new AudioCtx();
1243
+ const source = ctx3.createMediaStreamSource(new MediaStream([track]));
1244
+ const analyser = ctx3.createAnalyser();
1256
1245
  analyser.fftSize = 64;
1257
1246
  source.connect(analyser);
1258
1247
  const data = new Uint8Array(analyser.frequencyBinCount);
1259
1248
  const graphic = canvas.getContext("2d");
1260
1249
  const surface = canvas;
1261
- let frame = 0;
1250
+ let frame2 = 0;
1262
1251
  function draw() {
1263
1252
  if (!graphic) return;
1264
1253
  analyser.getByteFrequencyData(data);
@@ -1276,12 +1265,12 @@ function AudioWaveform({
1276
1265
  barHeight
1277
1266
  );
1278
1267
  }
1279
- frame = requestAnimationFrame(draw);
1268
+ frame2 = requestAnimationFrame(draw);
1280
1269
  }
1281
- frame = requestAnimationFrame(draw);
1270
+ frame2 = requestAnimationFrame(draw);
1282
1271
  return () => {
1283
- cancelAnimationFrame(frame);
1284
- void ctx2.close();
1272
+ cancelAnimationFrame(frame2);
1273
+ void ctx3.close();
1285
1274
  };
1286
1275
  }, [track, reducedMotion]);
1287
1276
  if (reducedMotion) {
@@ -1464,6 +1453,19 @@ function DialogHeader({ className, ...props }) {
1464
1453
  }
1465
1454
  );
1466
1455
  }
1456
+ function DialogFooter({ className, ...props }) {
1457
+ return /* @__PURE__ */ jsx(
1458
+ "div",
1459
+ {
1460
+ "data-slot": "dialog-footer",
1461
+ className: cn(
1462
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
1463
+ className
1464
+ ),
1465
+ ...props
1466
+ }
1467
+ );
1468
+ }
1467
1469
  function DialogTitle({
1468
1470
  className,
1469
1471
  ...props
@@ -1567,10 +1569,10 @@ async function playSpeakerTest(deviceId) {
1567
1569
  if (!AudioCtx) {
1568
1570
  throw new Error("AudioContext unavailable");
1569
1571
  }
1570
- const ctx2 = new AudioCtx();
1571
- void ctx2.resume();
1572
- const oscillator = ctx2.createOscillator();
1573
- const gain = ctx2.createGain();
1572
+ const ctx3 = new AudioCtx();
1573
+ void ctx3.resume();
1574
+ const oscillator = ctx3.createOscillator();
1575
+ const gain = ctx3.createGain();
1574
1576
  oscillator.type = "sine";
1575
1577
  oscillator.frequency.value = TEST_HZ;
1576
1578
  gain.gain.value = 0.18;
@@ -1580,7 +1582,7 @@ async function playSpeakerTest(deviceId) {
1580
1582
  const probe = document.createElement("audio");
1581
1583
  const canRoute = Boolean(deviceId) && typeof probe.setSinkId === "function";
1582
1584
  if (canRoute && deviceId) {
1583
- const destination = ctx2.createMediaStreamDestination();
1585
+ const destination = ctx3.createMediaStreamDestination();
1584
1586
  gain.connect(destination);
1585
1587
  audio = probe;
1586
1588
  audio.autoplay = true;
@@ -1591,13 +1593,13 @@ async function playSpeakerTest(deviceId) {
1591
1593
  await audio.play();
1592
1594
  } catch {
1593
1595
  gain.disconnect();
1594
- gain.connect(ctx2.destination);
1596
+ gain.connect(ctx3.destination);
1595
1597
  audio.srcObject = null;
1596
1598
  audio.remove();
1597
1599
  audio = null;
1598
1600
  }
1599
1601
  } else {
1600
- gain.connect(ctx2.destination);
1602
+ gain.connect(ctx3.destination);
1601
1603
  }
1602
1604
  oscillator.start();
1603
1605
  await new Promise((resolve) => {
@@ -1609,7 +1611,7 @@ async function playSpeakerTest(deviceId) {
1609
1611
  audio.srcObject = null;
1610
1612
  audio.remove();
1611
1613
  }
1612
- await ctx2.close();
1614
+ await ctx3.close();
1613
1615
  }
1614
1616
  }
1615
1617
  function StudioDeviceSelectGroup({
@@ -2023,6 +2025,19 @@ var badgeVariants = cva(
2023
2025
  function Badge({ className, variant, ...props }) {
2024
2026
  return /* @__PURE__ */ jsx("span", { className: cn(badgeVariants({ variant }), className), ...props });
2025
2027
  }
2028
+
2029
+ // src/studio/lib/studio-video-style.ts
2030
+ var STUDIO_VIDEO_MEDIA_STYLE = `
2031
+ .livo-studio-tile-video::-webkit-media-controls-start-playback-button,
2032
+ .livo-studio-inline-video::-webkit-media-controls-start-playback-button {
2033
+ display: none !important;
2034
+ -webkit-appearance: none;
2035
+ }
2036
+ .livo-studio-tile-video::-webkit-media-controls,
2037
+ .livo-studio-inline-video::-webkit-media-controls {
2038
+ display: none !important;
2039
+ }
2040
+ `;
2026
2041
  function StudioPipPanel({
2027
2042
  meeting,
2028
2043
  labels,
@@ -2077,7 +2092,7 @@ function StudioPipPanel({
2077
2092
  /* @__PURE__ */ jsx(StudioLiveBadge, { children: labels.live }),
2078
2093
  elapsed ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: elapsed }) : null
2079
2094
  ] }) : null,
2080
- selfView ? /* @__PURE__ */ jsx(
2095
+ selfView && meeting.localVideoTrack() ? /* @__PURE__ */ jsx(
2081
2096
  "div",
2082
2097
  {
2083
2098
  "data-testid": "pip-self-view",
@@ -2134,7 +2149,8 @@ function PipSpotlight({
2134
2149
  spotlight,
2135
2150
  cameraOffLabel
2136
2151
  }) {
2137
- if (!spotlight || spotlight.kind === "idle") {
2152
+ const track = spotlight && spotlight.kind !== "idle" ? spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId) : null;
2153
+ if (!spotlight || spotlight.kind === "idle" || !track) {
2138
2154
  return /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2", children: [
2139
2155
  spotlight?.picture ? /* @__PURE__ */ jsx(
2140
2156
  "img",
@@ -2147,7 +2163,6 @@ function PipSpotlight({
2147
2163
  /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: spotlight?.name ?? cameraOffLabel })
2148
2164
  ] });
2149
2165
  }
2150
- const track = spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId);
2151
2166
  return /* @__PURE__ */ jsxs(Fragment, { children: [
2152
2167
  /* @__PURE__ */ jsx(
2153
2168
  PipTrackVideo,
@@ -2166,11 +2181,7 @@ function PipTrackVideo({
2166
2181
  const ref = useRef(null);
2167
2182
  useEffect(() => {
2168
2183
  const el = ref.current;
2169
- if (!el) return;
2170
- if (!track) {
2171
- el.srcObject = null;
2172
- return;
2173
- }
2184
+ if (!el || !track) return;
2174
2185
  el.setAttribute("webkit-playsinline", "");
2175
2186
  el.srcObject = new MediaStream([track]);
2176
2187
  void el.play().catch(() => void 0);
@@ -2178,19 +2189,24 @@ function PipTrackVideo({
2178
2189
  el.srcObject = null;
2179
2190
  };
2180
2191
  }, [track]);
2181
- return /* @__PURE__ */ jsx(
2182
- "video",
2183
- {
2184
- ref,
2185
- autoPlay: true,
2186
- playsInline: true,
2187
- muted: true,
2188
- className: cn(
2189
- "livo-studio-inline-video absolute inset-0 size-full",
2190
- fit === "contain" ? "object-contain" : "object-cover"
2191
- )
2192
- }
2193
- );
2192
+ if (!track) return null;
2193
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2194
+ /* @__PURE__ */ jsx("style", { children: STUDIO_VIDEO_MEDIA_STYLE }),
2195
+ /* @__PURE__ */ jsx(
2196
+ "video",
2197
+ {
2198
+ ref,
2199
+ autoPlay: true,
2200
+ playsInline: true,
2201
+ muted: true,
2202
+ disableRemotePlayback: true,
2203
+ className: cn(
2204
+ "livo-studio-inline-video absolute inset-0 size-full",
2205
+ fit === "contain" ? "object-contain" : "object-cover"
2206
+ )
2207
+ }
2208
+ )
2209
+ ] });
2194
2210
  }
2195
2211
  function pipScreenControls({
2196
2212
  labels,
@@ -2392,7 +2408,7 @@ function StudioChatPanel({
2392
2408
  isModerator: isModerator2,
2393
2409
  labels
2394
2410
  }) {
2395
- const [tick, setTick] = useState(0);
2411
+ const [tick2, setTick] = useState(0);
2396
2412
  const [draft, setDraft] = useState("");
2397
2413
  const [editingId, setEditingId] = useState(null);
2398
2414
  const listRef = useRef(null);
@@ -2402,9 +2418,9 @@ function StudioChatPanel({
2402
2418
  );
2403
2419
  const messages = useMemo(
2404
2420
  () => meeting.chatMessages().filter((row) => !row.targetUserIds?.length),
2405
- [meeting, tick]
2421
+ [meeting, tick2]
2406
2422
  );
2407
- const pinned = useMemo(() => meeting.pinnedChatMessages(), [meeting, tick]);
2423
+ const pinned = useMemo(() => meeting.pinnedChatMessages(), [meeting, tick2]);
2408
2424
  const limit = meeting.chatMaxTextLimit();
2409
2425
  const pictures = useMemo(() => {
2410
2426
  const map = /* @__PURE__ */ new Map();
@@ -2904,6 +2920,171 @@ function StudioSidePanel({
2904
2920
  ] })
2905
2921
  ] });
2906
2922
  }
2923
+
2924
+ // src/studio/lib/audio-levels.ts
2925
+ var LEVEL_SENSITIVITY = 4;
2926
+ var LEVEL_ATTACK = 0.55;
2927
+ var LEVEL_DECAY = 0.12;
2928
+ var SPEAKING_ON = 0.12;
2929
+ var SPEAKING_OFF = 0.06;
2930
+ var SPEAKING_HOLD_MS = 180;
2931
+ var tracks = /* @__PURE__ */ new Map();
2932
+ var ctx2 = null;
2933
+ var frame = 0;
2934
+ var tickRunning = false;
2935
+ function audioContextCtor2() {
2936
+ if (typeof window === "undefined") return null;
2937
+ return window.AudioContext || window.webkitAudioContext || null;
2938
+ }
2939
+ function ensureContext2() {
2940
+ const AudioCtx = audioContextCtor2();
2941
+ if (!AudioCtx) return null;
2942
+ if (!ctx2 || ctx2.state === "closed") {
2943
+ ctx2 = new AudioCtx();
2944
+ }
2945
+ return ctx2;
2946
+ }
2947
+ function rmsFromTimeDomain(data) {
2948
+ if (data.length === 0) return 0;
2949
+ let sum = 0;
2950
+ for (let i = 0; i < data.length; i += 1) {
2951
+ const centered = ((data[i] ?? 128) - 128) / 128;
2952
+ sum += centered * centered;
2953
+ }
2954
+ return Math.sqrt(sum / data.length);
2955
+ }
2956
+ function normalizeAudioLevel(rms) {
2957
+ return Math.min(1, Math.max(0, rms * LEVEL_SENSITIVITY));
2958
+ }
2959
+ function smoothAudioLevel(previous, raw) {
2960
+ const factor = raw > previous ? LEVEL_ATTACK : LEVEL_DECAY;
2961
+ return previous + (raw - previous) * factor;
2962
+ }
2963
+ function nextSpeakingGate(gate, level, now) {
2964
+ if (!gate.speaking) {
2965
+ if (level >= SPEAKING_ON) {
2966
+ return { speaking: true, quietSince: null };
2967
+ }
2968
+ return { speaking: false, quietSince: null };
2969
+ }
2970
+ if (level >= SPEAKING_OFF) {
2971
+ return { speaking: true, quietSince: null };
2972
+ }
2973
+ const quietSince = gate.quietSince ?? now;
2974
+ if (now - quietSince >= SPEAKING_HOLD_MS) {
2975
+ return { speaking: false, quietSince: null };
2976
+ }
2977
+ return { speaking: true, quietSince };
2978
+ }
2979
+ function tick() {
2980
+ for (const entry of tracks.values()) {
2981
+ entry.analyser.getByteTimeDomainData(entry.data);
2982
+ const raw = normalizeAudioLevel(rmsFromTimeDomain(entry.data));
2983
+ entry.smoothed = smoothAudioLevel(entry.smoothed, raw);
2984
+ for (const cb of entry.listeners) {
2985
+ cb(entry.smoothed);
2986
+ }
2987
+ }
2988
+ if (tracks.size > 0) {
2989
+ frame = requestAnimationFrame(tick);
2990
+ return;
2991
+ }
2992
+ tickRunning = false;
2993
+ frame = 0;
2994
+ }
2995
+ function startTick() {
2996
+ if (tickRunning) return;
2997
+ tickRunning = true;
2998
+ frame = requestAnimationFrame(tick);
2999
+ }
3000
+ function stopTickIfIdle() {
3001
+ if (tracks.size > 0 || !frame) return;
3002
+ cancelAnimationFrame(frame);
3003
+ tickRunning = false;
3004
+ frame = 0;
3005
+ }
3006
+ function subscribeStudioAudioLevel(track, cb) {
3007
+ const context = ensureContext2();
3008
+ if (!context) return () => void 0;
3009
+ void context.resume();
3010
+ const id = track.id;
3011
+ let entry = tracks.get(id);
3012
+ if (!entry) {
3013
+ const source = context.createMediaStreamSource(new MediaStream([track]));
3014
+ const analyser = context.createAnalyser();
3015
+ analyser.fftSize = 512;
3016
+ analyser.smoothingTimeConstant = 0;
3017
+ source.connect(analyser);
3018
+ entry = {
3019
+ source,
3020
+ analyser,
3021
+ data: new Uint8Array(analyser.fftSize),
3022
+ refs: 0,
3023
+ smoothed: 0,
3024
+ listeners: /* @__PURE__ */ new Set()
3025
+ };
3026
+ tracks.set(id, entry);
3027
+ }
3028
+ entry.refs += 1;
3029
+ entry.listeners.add(cb);
3030
+ startTick();
3031
+ return () => {
3032
+ const current = tracks.get(id);
3033
+ if (!current) return;
3034
+ current.listeners.delete(cb);
3035
+ current.refs -= 1;
3036
+ if (current.refs <= 0) {
3037
+ current.source.disconnect();
3038
+ current.analyser.disconnect();
3039
+ tracks.delete(id);
3040
+ }
3041
+ stopTickIfIdle();
3042
+ };
3043
+ }
3044
+
3045
+ // src/studio/hooks/use-speaking-glow.ts
3046
+ function clearSpeakingGlow(el) {
3047
+ el.style.removeProperty("--livo-speaking-level");
3048
+ el.removeAttribute("data-speaking");
3049
+ }
3050
+ function useSpeakingGlow({
3051
+ track,
3052
+ enabled,
3053
+ reducedMotion
3054
+ }) {
3055
+ const [el, setEl] = useState(null);
3056
+ const gateRef = useRef({ speaking: false, quietSince: null });
3057
+ const setRef = useCallback((node) => {
3058
+ setEl(node);
3059
+ }, []);
3060
+ useEffect(() => {
3061
+ if (!el || !enabled || !track) {
3062
+ if (el) clearSpeakingGlow(el);
3063
+ gateRef.current = { speaking: false, quietSince: null };
3064
+ return;
3065
+ }
3066
+ const unsubscribe = subscribeStudioAudioLevel(track, (level) => {
3067
+ const node = el;
3068
+ gateRef.current = nextSpeakingGate(gateRef.current, level, Date.now());
3069
+ if (gateRef.current.speaking) {
3070
+ node.dataset.speaking = "true";
3071
+ if (!reducedMotion) {
3072
+ node.style.setProperty("--livo-speaking-level", String(level));
3073
+ } else {
3074
+ node.style.removeProperty("--livo-speaking-level");
3075
+ }
3076
+ return;
3077
+ }
3078
+ clearSpeakingGlow(node);
3079
+ });
3080
+ return () => {
3081
+ unsubscribe();
3082
+ clearSpeakingGlow(el);
3083
+ gateRef.current = { speaking: false, quietSince: null };
3084
+ };
3085
+ }, [el, enabled, reducedMotion, track]);
3086
+ return setRef;
3087
+ }
2907
3088
  function DropdownMenu({ ...props }) {
2908
3089
  return /* @__PURE__ */ jsx(Menu.Root, { "data-slot": "dropdown-menu", ...props });
2909
3090
  }
@@ -3052,32 +3233,30 @@ function StudioTile({
3052
3233
  const onStage = isOnStage(tile.stageStatus);
3053
3234
  const [confirm, setConfirm] = useState(null);
3054
3235
  const showMenu = (canModerate || canManageStage) && !tile.isSelf;
3236
+ const hasVideoTrack = tileHasVideoTrack(meeting, tile);
3237
+ const glowRef = useSpeakingGlow({
3238
+ track: tile.kind === "screen" || !tile.audioEnabled ? null : meeting.participantAudioTrack(tile.id),
3239
+ enabled: onStage,
3240
+ reducedMotion
3241
+ });
3055
3242
  return /* @__PURE__ */ jsxs(
3056
3243
  "article",
3057
3244
  {
3245
+ ref: glowRef,
3058
3246
  "aria-label": tile.name,
3059
3247
  className: cn(
3060
- "bg-muted relative overflow-hidden rounded-xl",
3248
+ "livo-studio-tile bg-muted relative overflow-hidden rounded-xl",
3061
3249
  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"
3062
3250
  ),
3063
3251
  children: [
3064
- tile.kind === "idle" ? /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
3065
- tile.picture ? /* @__PURE__ */ jsx(
3066
- "img",
3067
- {
3068
- src: tile.picture,
3069
- alt: "",
3070
- className: "size-16 rounded-full object-cover"
3071
- }
3072
- ) : /* @__PURE__ */ jsx("div", { className: "bg-background flex size-16 items-center justify-center rounded-full text-xl", children: tile.name.slice(0, 1).toUpperCase() }),
3073
- /* @__PURE__ */ jsx(
3074
- AudioWaveform,
3075
- {
3076
- track: tile.isSelf ? meeting.localAudioTrack() : null,
3077
- reducedMotion
3078
- }
3079
- )
3080
- ] }) : /* @__PURE__ */ jsx(
3252
+ tile.kind === "idle" || !hasVideoTrack ? /* @__PURE__ */ jsx(
3253
+ IdlePersonTile,
3254
+ {
3255
+ tile,
3256
+ meeting,
3257
+ reducedMotion
3258
+ }
3259
+ ) : /* @__PURE__ */ jsx(
3081
3260
  StudioTileVideo,
3082
3261
  {
3083
3262
  meeting,
@@ -3087,6 +3266,13 @@ function StudioTile({
3087
3266
  contain: tile.kind === "screen"
3088
3267
  }
3089
3268
  ),
3269
+ /* @__PURE__ */ jsx(
3270
+ "div",
3271
+ {
3272
+ "aria-hidden": true,
3273
+ className: "livo-studio-tile-glow pointer-events-none absolute inset-0 z-20 rounded-xl"
3274
+ }
3275
+ ),
3090
3276
  /* @__PURE__ */ jsxs("div", { className: "absolute inset-x-0 bottom-0 z-10 flex items-end justify-between gap-2 bg-linear-to-t from-black/70 to-transparent p-2", children: [
3091
3277
  /* @__PURE__ */ jsxs("p", { className: "min-w-0 truncate text-xs font-medium text-white", children: [
3092
3278
  tile.name,
@@ -3169,6 +3355,37 @@ function TileStatusIcons({
3169
3355
  tile.chatCanSend === false ? /* @__PURE__ */ jsx(StatusIcon, { label: labels.muteChat, children: /* @__PURE__ */ jsx(MessageSquareOff, {}) }) : null
3170
3356
  ] });
3171
3357
  }
3358
+ function tileHasVideoTrack(meeting, tile) {
3359
+ if (tile.kind === "screen") {
3360
+ return meeting.participantScreenTrack(tile.id) != null;
3361
+ }
3362
+ if (tile.kind === "idle") return false;
3363
+ if (tile.isSelf) return meeting.localVideoTrack() != null;
3364
+ return meeting.participantVideoTrack(tile.id) != null;
3365
+ }
3366
+ function IdlePersonTile({
3367
+ tile,
3368
+ meeting,
3369
+ reducedMotion
3370
+ }) {
3371
+ return /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
3372
+ tile.picture ? /* @__PURE__ */ jsx(
3373
+ "img",
3374
+ {
3375
+ src: tile.picture,
3376
+ alt: "",
3377
+ className: "size-16 rounded-full object-cover"
3378
+ }
3379
+ ) : /* @__PURE__ */ jsx("div", { className: "bg-background flex size-16 items-center justify-center rounded-full text-xl", children: tile.name.slice(0, 1).toUpperCase() }),
3380
+ /* @__PURE__ */ jsx(
3381
+ AudioWaveform,
3382
+ {
3383
+ track: tile.isSelf ? meeting.localAudioTrack() : null,
3384
+ reducedMotion
3385
+ }
3386
+ )
3387
+ ] });
3388
+ }
3172
3389
  function StatusIcon({
3173
3390
  label,
3174
3391
  children
@@ -3192,23 +3409,27 @@ function StudioTileVideo({
3192
3409
  meeting.attachVideo(participantId, el, kind);
3193
3410
  return () => meeting.attachVideo(participantId, null, kind);
3194
3411
  }, [kind, meeting, participantId]);
3195
- return /* @__PURE__ */ jsx(
3196
- "video",
3197
- {
3198
- ref,
3199
- autoPlay: true,
3200
- playsInline: true,
3201
- muted: true,
3202
- disablePictureInPicture: true,
3203
- controlsList: "nodownload noplaybackrate noremoteplayback",
3204
- className: cn(
3205
- "livo-studio-tile-video absolute inset-0 size-full",
3206
- "pointer-events-none",
3207
- contain ? "object-contain" : "object-cover",
3208
- mirror && "-scale-x-100"
3209
- )
3210
- }
3211
- );
3412
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
3413
+ /* @__PURE__ */ jsx("style", { children: STUDIO_VIDEO_MEDIA_STYLE }),
3414
+ /* @__PURE__ */ jsx(
3415
+ "video",
3416
+ {
3417
+ ref,
3418
+ autoPlay: true,
3419
+ playsInline: true,
3420
+ muted: true,
3421
+ disablePictureInPicture: true,
3422
+ disableRemotePlayback: true,
3423
+ controlsList: "nodownload noplaybackrate noremoteplayback",
3424
+ className: cn(
3425
+ "livo-studio-tile-video absolute inset-0 size-full",
3426
+ "pointer-events-none",
3427
+ contain ? "object-contain" : "object-cover",
3428
+ mirror && "-scale-x-100"
3429
+ )
3430
+ }
3431
+ )
3432
+ ] });
3212
3433
  }
3213
3434
  function StudioStage({
3214
3435
  participants,
@@ -3229,9 +3450,7 @@ function StudioStage({
3229
3450
  onOpenPeople
3230
3451
  }) {
3231
3452
  const displayTiles = expandStudioTiles(participants);
3232
- const stage = studioStageLayout(displayTiles, {
3233
- activeSpeakerId: meeting.activeSpeakerId()
3234
- });
3453
+ const stage = studioStageLayout(displayTiles);
3235
3454
  const sharing = stage.spotlight.length > 0;
3236
3455
  const overflowLabel = labels.stageOverflow.replace(
3237
3456
  "{count}",
@@ -3302,6 +3521,32 @@ function StudioStage({
3302
3521
  }
3303
3522
  );
3304
3523
  }
3524
+ function StudioDownloadAppDialog({
3525
+ open,
3526
+ onOpenChange,
3527
+ downloadUrl,
3528
+ labels
3529
+ }) {
3530
+ const href = downloadUrl?.trim() ?? "";
3531
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { children: [
3532
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
3533
+ /* @__PURE__ */ jsx(DialogTitle, { children: labels.screenDownloadAppTitle }),
3534
+ /* @__PURE__ */ jsx(DialogDescription, { children: labels.screenUnsupportedDevice })
3535
+ ] }),
3536
+ /* @__PURE__ */ jsxs(DialogFooter, { children: [
3537
+ /* @__PURE__ */ jsx(
3538
+ Button,
3539
+ {
3540
+ type: "button",
3541
+ variant: "outline",
3542
+ onClick: () => onOpenChange(false),
3543
+ children: labels.screenDownloadAppDismiss
3544
+ }
3545
+ ),
3546
+ href ? /* @__PURE__ */ jsx(Button, { render: /* @__PURE__ */ jsx("a", { href, target: "_blank", rel: "noreferrer" }), children: labels.screenDownloadAppAction }) : /* @__PURE__ */ jsx(Button, { type: "button", disabled: true, children: labels.screenDownloadAppAction })
3547
+ ] })
3548
+ ] }) });
3549
+ }
3305
3550
  function StudioToolbar({
3306
3551
  session,
3307
3552
  meeting,
@@ -3556,7 +3801,8 @@ function StudioRoom({
3556
3801
  windowGate,
3557
3802
  initialCameraOn = true,
3558
3803
  initialMuted = false,
3559
- initialDevices
3804
+ initialDevices,
3805
+ appDownloadUrl
3560
3806
  }) {
3561
3807
  const [cameraOn, setCameraOn] = useState(initialCameraOn);
3562
3808
  const [screenOn, setScreenOn] = useState(false);
@@ -3564,7 +3810,7 @@ function StudioRoom({
3564
3810
  const [screenRequestPending, setScreenRequestPending] = useState(false);
3565
3811
  const [pending, setPending] = useState(false);
3566
3812
  const [publishing, setPublishing] = useState(false);
3567
- const [tick, setTick] = useState(0);
3813
+ const [tick2, setTick] = useState(0);
3568
3814
  const [leaveState, setLeaveState] = useState(null);
3569
3815
  const [sideTab, setSideTab] = useState(null);
3570
3816
  const [settingsOpen, setSettingsOpen] = useState(false);
@@ -3577,6 +3823,7 @@ function StudioRoom({
3577
3823
  );
3578
3824
  const [egressError, setEgressError] = useState(null);
3579
3825
  const [shareGrant, setShareGrant] = useState("idle");
3826
+ const [downloadAppOpen, setDownloadAppOpen] = useState(false);
3580
3827
  const [connectTimedOut, setConnectTimedOut] = useState(false);
3581
3828
  const [screenPermission, setScreenPermission] = useState(
3582
3829
  () => meeting.screenSharePermission()
@@ -3618,6 +3865,11 @@ function StudioRoom({
3618
3865
  const roomState = leaveState ?? meeting.roomState();
3619
3866
  const sessionOver = isStudioSessionOver(session.stream.status, roomState);
3620
3867
  const rejected = isStudioRejected(roomState);
3868
+ const connectionState = useMemo(
3869
+ () => meeting.connectionState(),
3870
+ [meeting, tick2]
3871
+ );
3872
+ const guestConnectFailed = session.role === "guest" && (connectionState === "failed" || connectionState === "connecting" && connectTimedOut);
3621
3873
  useBeforeUnload(active && !sessionOver);
3622
3874
  useWakeLock(active && !sessionOver);
3623
3875
  useEffect(() => {
@@ -3781,12 +4033,13 @@ function StudioRoom({
3781
4033
  void meeting.setScreenShareEnabled(false);
3782
4034
  }, [guestOffStage, meeting]);
3783
4035
  useEffect(() => {
3784
- if (!sessionOver && !rejected) return;
4036
+ if (!sessionOver && !rejected && !guestConnectFailed) return;
4037
+ pip.close();
3785
4038
  setShareGrant("idle");
3786
4039
  void meeting.setAudioEnabled(false);
3787
4040
  void meeting.setVideoEnabled(false);
3788
4041
  void meeting.setScreenShareEnabled(false);
3789
- }, [meeting, rejected, sessionOver]);
4042
+ }, [guestConnectFailed, meeting, pip.close, rejected, sessionOver]);
3790
4043
  useEffect(() => {
3791
4044
  return meeting.subscribeMedia(() => {
3792
4045
  const self2 = meeting.participants().find((row) => row.isSelf);
@@ -3808,7 +4061,7 @@ function StudioRoom({
3808
4061
  });
3809
4062
  }, [meeting]);
3810
4063
  const sawParticipants = useRef(false);
3811
- const participants = useMemo(() => meeting.participants(), [meeting, tick]);
4064
+ const participants = useMemo(() => meeting.participants(), [meeting, tick2]);
3812
4065
  useEffect(() => {
3813
4066
  const other = duplicateSelfParticipant(participants);
3814
4067
  if (!other) return;
@@ -3823,29 +4076,25 @@ function StudioRoom({
3823
4076
  () => participants.filter((row) => isOnStage(row.stageStatus)),
3824
4077
  [participants]
3825
4078
  );
3826
- const waitlisted = useMemo(() => meeting.waitlisted(), [meeting, tick]);
3827
- const connectionState = useMemo(
3828
- () => meeting.connectionState(),
3829
- [meeting, tick]
3830
- );
4079
+ const waitlisted = useMemo(() => meeting.waitlisted(), [meeting, tick2]);
3831
4080
  const lastMeetingError = useMemo(
3832
4081
  () => meeting.lastMeetingError(),
3833
- [meeting, tick]
4082
+ [meeting, tick2]
3834
4083
  );
3835
4084
  const canManageStage = meeting.canManageStage();
3836
4085
  const stageEnabled = meeting.stageEnabled();
3837
4086
  const stageRequests = useMemo(
3838
4087
  () => canManageStage ? meeting.stageRequests() : [],
3839
- [canManageStage, meeting, tick]
4088
+ [canManageStage, meeting, tick2]
3840
4089
  );
3841
- const chatMessages = useMemo(() => meeting.chatMessages(), [meeting, tick]);
4090
+ const chatMessages = useMemo(() => meeting.chatMessages(), [meeting, tick2]);
3842
4091
  const inWaitingRoom = meeting.inWaitingRoom();
3843
4092
  const screenAvailable = canStartScreenShare(participants);
3844
4093
  const durableScreenBlock = screenShareBlock(
3845
4094
  captureSupported,
3846
4095
  screenPermission
3847
4096
  );
3848
- const showScreenControl = durableScreenBlock === null;
4097
+ const showScreenControl = showScreenShareControl(durableScreenBlock);
3849
4098
  const canGoLive = session.role === "moderator" && canPublish(session.stream.status);
3850
4099
  const showPublishButton = session.role === "moderator" && !onAir && session.stream.status !== "ended" && session.stream.status !== "error";
3851
4100
  const self = participants.find((row) => row.isSelf);
@@ -3941,17 +4190,10 @@ function StudioRoom({
3941
4190
  }, [connectionState, session.role]);
3942
4191
  useEffect(() => {
3943
4192
  if (inWaitingRoom || screenNoticePrimed.current) return;
3944
- if (!durableScreenBlock) return;
4193
+ if (durableScreenBlock !== "permission") return;
3945
4194
  screenNoticePrimed.current = true;
3946
- toast.message(
3947
- durableScreenBlock === "device" ? labels.screenUnsupportedDevice : labels.screenNotAllowed
3948
- );
3949
- }, [
3950
- durableScreenBlock,
3951
- inWaitingRoom,
3952
- labels.screenNotAllowed,
3953
- labels.screenUnsupportedDevice
3954
- ]);
4195
+ toast.message(labels.screenNotAllowed);
4196
+ }, [durableScreenBlock, inWaitingRoom, labels.screenNotAllowed]);
3955
4197
  useEffect(() => {
3956
4198
  return meeting.subscribeBroadcast((message) => {
3957
4199
  if (message.type === "host-media") {
@@ -4060,6 +4302,10 @@ function StudioRoom({
4060
4302
  setCameraOn((current) => !current);
4061
4303
  }, []);
4062
4304
  const onToggleScreen = useCallback(() => {
4305
+ if (durableScreenBlock === "device") {
4306
+ setDownloadAppOpen(true);
4307
+ return;
4308
+ }
4063
4309
  if (!canShareFreely || screenRequestPending) return;
4064
4310
  if (!screenOn && !canStartScreenShare(meeting.participants())) {
4065
4311
  toast.message(labels.screenBusy);
@@ -4069,12 +4315,17 @@ function StudioRoom({
4069
4315
  setScreenOn((current) => !current);
4070
4316
  }, [
4071
4317
  canShareFreely,
4318
+ durableScreenBlock,
4072
4319
  labels.screenBusy,
4073
4320
  meeting,
4074
4321
  screenOn,
4075
4322
  screenRequestPending
4076
4323
  ]);
4077
4324
  const onAskToShare = useCallback(async () => {
4325
+ if (durableScreenBlock === "device") {
4326
+ setDownloadAppOpen(true);
4327
+ return;
4328
+ }
4078
4329
  const local = meeting.participants().find((row) => row.isSelf);
4079
4330
  if (!local) return;
4080
4331
  try {
@@ -4091,7 +4342,12 @@ function StudioRoom({
4091
4342
  } catch {
4092
4343
  toast.error(labels.shareRequestError);
4093
4344
  }
4094
- }, [labels.shareRequestError, labels.shareRequestSent, meeting]);
4345
+ }, [
4346
+ durableScreenBlock,
4347
+ labels.shareRequestError,
4348
+ labels.shareRequestSent,
4349
+ meeting
4350
+ ]);
4095
4351
  const verifyGrantTookEffect = useCallback(
4096
4352
  (userId, retried = false) => {
4097
4353
  const id = window.setTimeout(() => {
@@ -4254,7 +4510,6 @@ function StudioRoom({
4254
4510
  }
4255
4511
  ) });
4256
4512
  }
4257
- const guestConnectFailed = session.role === "guest" && (connectionState === "failed" || connectionState === "connecting" && connectTimedOut);
4258
4513
  if (guestConnectFailed) {
4259
4514
  const detail = [meeting.roomState(), lastMeetingError].filter((value) => Boolean(value)).join(" \u2014 ");
4260
4515
  return /* @__PURE__ */ jsx("div", { className: "flex h-dvh flex-col items-center justify-center p-6", children: /* @__PURE__ */ jsx(
@@ -4588,7 +4843,21 @@ function StudioRoom({
4588
4843
  }
4589
4844
  ),
4590
4845
  pip.pipWindow.document.body
4591
- ) : null
4846
+ ) : null,
4847
+ /* @__PURE__ */ jsx(
4848
+ StudioDownloadAppDialog,
4849
+ {
4850
+ open: downloadAppOpen,
4851
+ onOpenChange: setDownloadAppOpen,
4852
+ downloadUrl: appDownloadUrl,
4853
+ labels: {
4854
+ screenDownloadAppTitle: labels.screenDownloadAppTitle,
4855
+ screenUnsupportedDevice: labels.screenUnsupportedDevice,
4856
+ screenDownloadAppAction: labels.screenDownloadAppAction,
4857
+ screenDownloadAppDismiss: labels.screenDownloadAppDismiss
4858
+ }
4859
+ }
4860
+ )
4592
4861
  ] });
4593
4862
  }
4594
4863
  function isOverconstrained(error) {
@@ -5757,6 +6026,9 @@ function studioMeetingFromRtk(raw) {
5757
6026
  localVideoTrack() {
5758
6027
  return meeting.self?.videoTrack ?? null;
5759
6028
  },
6029
+ participantAudioTrack(participantId) {
6030
+ return participantFor(participantId)?.audioTrack ?? null;
6031
+ },
5760
6032
  participantVideoTrack(participantId) {
5761
6033
  return participantFor(participantId)?.videoTrack ?? null;
5762
6034
  },
@@ -6128,7 +6400,8 @@ function LivoHostStudio({
6128
6400
  embed = false,
6129
6401
  chrome,
6130
6402
  playerOrigin,
6131
- theme
6403
+ theme,
6404
+ appDownloadUrl
6132
6405
  }) {
6133
6406
  const reducedMotion = usePrefersReducedMotion();
6134
6407
  const [session, setSession] = useState(null);
@@ -6208,7 +6481,8 @@ function LivoHostStudio({
6208
6481
  labels,
6209
6482
  embed,
6210
6483
  chrome,
6211
- playerOrigin
6484
+ playerOrigin,
6485
+ appDownloadUrl
6212
6486
  }
6213
6487
  );
6214
6488
  }
@@ -6221,7 +6495,8 @@ function LivoGuestStudio({
6221
6495
  lobbyLabels,
6222
6496
  embed = false,
6223
6497
  playerOrigin,
6224
- theme
6498
+ theme,
6499
+ appDownloadUrl
6225
6500
  }) {
6226
6501
  const reducedMotion = usePrefersReducedMotion();
6227
6502
  const [session, setSession] = useState(null);
@@ -6407,7 +6682,8 @@ function LivoGuestStudio({
6407
6682
  playerOrigin,
6408
6683
  initialCameraOn: cameraOn,
6409
6684
  initialMuted: muted,
6410
- initialDevices: deviceSelection
6685
+ initialDevices: deviceSelection,
6686
+ appDownloadUrl
6411
6687
  }
6412
6688
  );
6413
6689
  }
@@ -6512,6 +6788,9 @@ function studioRoomLabels(t) {
6512
6788
  pipTitle: t("pipTitle"),
6513
6789
  pipError: t("pipError"),
6514
6790
  screenUnsupportedDevice: t("screenUnsupportedDevice"),
6791
+ screenDownloadAppTitle: t("screenDownloadAppTitle"),
6792
+ screenDownloadAppAction: t("screenDownloadAppAction"),
6793
+ screenDownloadAppDismiss: t("screenDownloadAppDismiss"),
6515
6794
  screenNotAllowed: t("screenNotAllowed"),
6516
6795
  askToShare: t("askToShare"),
6517
6796
  shareRequested: t("shareRequested"),