@livo-tv/sdk 1.3.3 → 1.4.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.
@@ -196,6 +196,7 @@ type StudioMeetingHandle = {
196
196
  moderatorIds: () => string[];
197
197
  localAudioTrack: () => MediaStreamTrack | null;
198
198
  localVideoTrack: () => MediaStreamTrack | null;
199
+ participantAudioTrack: (participantId: string) => MediaStreamTrack | null;
199
200
  participantVideoTrack: (participantId: string) => MediaStreamTrack | null;
200
201
  participantScreenTrack: (participantId: string) => MediaStreamTrack | null;
201
202
  activeSpeakerId: () => string | null;
@@ -196,6 +196,7 @@ type StudioMeetingHandle = {
196
196
  moderatorIds: () => string[];
197
197
  localAudioTrack: () => MediaStreamTrack | null;
198
198
  localVideoTrack: () => MediaStreamTrack | null;
199
+ participantAudioTrack: (participantId: string) => MediaStreamTrack | null;
199
200
  participantVideoTrack: (participantId: string) => MediaStreamTrack | null;
200
201
  participantScreenTrack: (participantId: string) => MediaStreamTrack | null;
201
202
  activeSpeakerId: () => string | null;
@@ -1250,15 +1250,15 @@ function AudioWaveform({
1250
1250
  if (!canvas || !track || reducedMotion) return;
1251
1251
  const AudioCtx = window.AudioContext || window.webkitAudioContext;
1252
1252
  if (!AudioCtx) return;
1253
- const ctx2 = new AudioCtx();
1254
- const source = ctx2.createMediaStreamSource(new MediaStream([track]));
1255
- const analyser = ctx2.createAnalyser();
1253
+ const ctx3 = new AudioCtx();
1254
+ const source = ctx3.createMediaStreamSource(new MediaStream([track]));
1255
+ const analyser = ctx3.createAnalyser();
1256
1256
  analyser.fftSize = 64;
1257
1257
  source.connect(analyser);
1258
1258
  const data = new Uint8Array(analyser.frequencyBinCount);
1259
1259
  const graphic = canvas.getContext("2d");
1260
1260
  const surface = canvas;
1261
- let frame = 0;
1261
+ let frame2 = 0;
1262
1262
  function draw() {
1263
1263
  if (!graphic) return;
1264
1264
  analyser.getByteFrequencyData(data);
@@ -1276,12 +1276,12 @@ function AudioWaveform({
1276
1276
  barHeight
1277
1277
  );
1278
1278
  }
1279
- frame = requestAnimationFrame(draw);
1279
+ frame2 = requestAnimationFrame(draw);
1280
1280
  }
1281
- frame = requestAnimationFrame(draw);
1281
+ frame2 = requestAnimationFrame(draw);
1282
1282
  return () => {
1283
- cancelAnimationFrame(frame);
1284
- void ctx2.close();
1283
+ cancelAnimationFrame(frame2);
1284
+ void ctx3.close();
1285
1285
  };
1286
1286
  }, [track, reducedMotion]);
1287
1287
  if (reducedMotion) {
@@ -1567,10 +1567,10 @@ async function playSpeakerTest(deviceId) {
1567
1567
  if (!AudioCtx) {
1568
1568
  throw new Error("AudioContext unavailable");
1569
1569
  }
1570
- const ctx2 = new AudioCtx();
1571
- void ctx2.resume();
1572
- const oscillator = ctx2.createOscillator();
1573
- const gain = ctx2.createGain();
1570
+ const ctx3 = new AudioCtx();
1571
+ void ctx3.resume();
1572
+ const oscillator = ctx3.createOscillator();
1573
+ const gain = ctx3.createGain();
1574
1574
  oscillator.type = "sine";
1575
1575
  oscillator.frequency.value = TEST_HZ;
1576
1576
  gain.gain.value = 0.18;
@@ -1580,7 +1580,7 @@ async function playSpeakerTest(deviceId) {
1580
1580
  const probe = document.createElement("audio");
1581
1581
  const canRoute = Boolean(deviceId) && typeof probe.setSinkId === "function";
1582
1582
  if (canRoute && deviceId) {
1583
- const destination = ctx2.createMediaStreamDestination();
1583
+ const destination = ctx3.createMediaStreamDestination();
1584
1584
  gain.connect(destination);
1585
1585
  audio = probe;
1586
1586
  audio.autoplay = true;
@@ -1591,13 +1591,13 @@ async function playSpeakerTest(deviceId) {
1591
1591
  await audio.play();
1592
1592
  } catch {
1593
1593
  gain.disconnect();
1594
- gain.connect(ctx2.destination);
1594
+ gain.connect(ctx3.destination);
1595
1595
  audio.srcObject = null;
1596
1596
  audio.remove();
1597
1597
  audio = null;
1598
1598
  }
1599
1599
  } else {
1600
- gain.connect(ctx2.destination);
1600
+ gain.connect(ctx3.destination);
1601
1601
  }
1602
1602
  oscillator.start();
1603
1603
  await new Promise((resolve) => {
@@ -1609,7 +1609,7 @@ async function playSpeakerTest(deviceId) {
1609
1609
  audio.srcObject = null;
1610
1610
  audio.remove();
1611
1611
  }
1612
- await ctx2.close();
1612
+ await ctx3.close();
1613
1613
  }
1614
1614
  }
1615
1615
  function StudioDeviceSelectGroup({
@@ -2392,7 +2392,7 @@ function StudioChatPanel({
2392
2392
  isModerator: isModerator2,
2393
2393
  labels
2394
2394
  }) {
2395
- const [tick, setTick] = useState(0);
2395
+ const [tick2, setTick] = useState(0);
2396
2396
  const [draft, setDraft] = useState("");
2397
2397
  const [editingId, setEditingId] = useState(null);
2398
2398
  const listRef = useRef(null);
@@ -2402,9 +2402,9 @@ function StudioChatPanel({
2402
2402
  );
2403
2403
  const messages = useMemo(
2404
2404
  () => meeting.chatMessages().filter((row) => !row.targetUserIds?.length),
2405
- [meeting, tick]
2405
+ [meeting, tick2]
2406
2406
  );
2407
- const pinned = useMemo(() => meeting.pinnedChatMessages(), [meeting, tick]);
2407
+ const pinned = useMemo(() => meeting.pinnedChatMessages(), [meeting, tick2]);
2408
2408
  const limit = meeting.chatMaxTextLimit();
2409
2409
  const pictures = useMemo(() => {
2410
2410
  const map = /* @__PURE__ */ new Map();
@@ -2904,6 +2904,171 @@ function StudioSidePanel({
2904
2904
  ] })
2905
2905
  ] });
2906
2906
  }
2907
+
2908
+ // src/studio/lib/audio-levels.ts
2909
+ var LEVEL_SENSITIVITY = 4;
2910
+ var LEVEL_ATTACK = 0.55;
2911
+ var LEVEL_DECAY = 0.12;
2912
+ var SPEAKING_ON = 0.12;
2913
+ var SPEAKING_OFF = 0.06;
2914
+ var SPEAKING_HOLD_MS = 180;
2915
+ var tracks = /* @__PURE__ */ new Map();
2916
+ var ctx2 = null;
2917
+ var frame = 0;
2918
+ var tickRunning = false;
2919
+ function audioContextCtor2() {
2920
+ if (typeof window === "undefined") return null;
2921
+ return window.AudioContext || window.webkitAudioContext || null;
2922
+ }
2923
+ function ensureContext2() {
2924
+ const AudioCtx = audioContextCtor2();
2925
+ if (!AudioCtx) return null;
2926
+ if (!ctx2 || ctx2.state === "closed") {
2927
+ ctx2 = new AudioCtx();
2928
+ }
2929
+ return ctx2;
2930
+ }
2931
+ function rmsFromTimeDomain(data) {
2932
+ if (data.length === 0) return 0;
2933
+ let sum = 0;
2934
+ for (let i = 0; i < data.length; i += 1) {
2935
+ const centered = ((data[i] ?? 128) - 128) / 128;
2936
+ sum += centered * centered;
2937
+ }
2938
+ return Math.sqrt(sum / data.length);
2939
+ }
2940
+ function normalizeAudioLevel(rms) {
2941
+ return Math.min(1, Math.max(0, rms * LEVEL_SENSITIVITY));
2942
+ }
2943
+ function smoothAudioLevel(previous, raw) {
2944
+ const factor = raw > previous ? LEVEL_ATTACK : LEVEL_DECAY;
2945
+ return previous + (raw - previous) * factor;
2946
+ }
2947
+ function nextSpeakingGate(gate, level, now) {
2948
+ if (!gate.speaking) {
2949
+ if (level >= SPEAKING_ON) {
2950
+ return { speaking: true, quietSince: null };
2951
+ }
2952
+ return { speaking: false, quietSince: null };
2953
+ }
2954
+ if (level >= SPEAKING_OFF) {
2955
+ return { speaking: true, quietSince: null };
2956
+ }
2957
+ const quietSince = gate.quietSince ?? now;
2958
+ if (now - quietSince >= SPEAKING_HOLD_MS) {
2959
+ return { speaking: false, quietSince: null };
2960
+ }
2961
+ return { speaking: true, quietSince };
2962
+ }
2963
+ function tick() {
2964
+ for (const entry of tracks.values()) {
2965
+ entry.analyser.getByteTimeDomainData(entry.data);
2966
+ const raw = normalizeAudioLevel(rmsFromTimeDomain(entry.data));
2967
+ entry.smoothed = smoothAudioLevel(entry.smoothed, raw);
2968
+ for (const cb of entry.listeners) {
2969
+ cb(entry.smoothed);
2970
+ }
2971
+ }
2972
+ if (tracks.size > 0) {
2973
+ frame = requestAnimationFrame(tick);
2974
+ return;
2975
+ }
2976
+ tickRunning = false;
2977
+ frame = 0;
2978
+ }
2979
+ function startTick() {
2980
+ if (tickRunning) return;
2981
+ tickRunning = true;
2982
+ frame = requestAnimationFrame(tick);
2983
+ }
2984
+ function stopTickIfIdle() {
2985
+ if (tracks.size > 0 || !frame) return;
2986
+ cancelAnimationFrame(frame);
2987
+ tickRunning = false;
2988
+ frame = 0;
2989
+ }
2990
+ function subscribeStudioAudioLevel(track, cb) {
2991
+ const context = ensureContext2();
2992
+ if (!context) return () => void 0;
2993
+ void context.resume();
2994
+ const id = track.id;
2995
+ let entry = tracks.get(id);
2996
+ if (!entry) {
2997
+ const source = context.createMediaStreamSource(new MediaStream([track]));
2998
+ const analyser = context.createAnalyser();
2999
+ analyser.fftSize = 512;
3000
+ analyser.smoothingTimeConstant = 0;
3001
+ source.connect(analyser);
3002
+ entry = {
3003
+ source,
3004
+ analyser,
3005
+ data: new Uint8Array(analyser.fftSize),
3006
+ refs: 0,
3007
+ smoothed: 0,
3008
+ listeners: /* @__PURE__ */ new Set()
3009
+ };
3010
+ tracks.set(id, entry);
3011
+ }
3012
+ entry.refs += 1;
3013
+ entry.listeners.add(cb);
3014
+ startTick();
3015
+ return () => {
3016
+ const current = tracks.get(id);
3017
+ if (!current) return;
3018
+ current.listeners.delete(cb);
3019
+ current.refs -= 1;
3020
+ if (current.refs <= 0) {
3021
+ current.source.disconnect();
3022
+ current.analyser.disconnect();
3023
+ tracks.delete(id);
3024
+ }
3025
+ stopTickIfIdle();
3026
+ };
3027
+ }
3028
+
3029
+ // src/studio/hooks/use-speaking-glow.ts
3030
+ function clearSpeakingGlow(el) {
3031
+ el.style.removeProperty("--livo-speaking-level");
3032
+ el.removeAttribute("data-speaking");
3033
+ }
3034
+ function useSpeakingGlow({
3035
+ track,
3036
+ enabled,
3037
+ reducedMotion
3038
+ }) {
3039
+ const [el, setEl] = useState(null);
3040
+ const gateRef = useRef({ speaking: false, quietSince: null });
3041
+ const setRef = useCallback((node) => {
3042
+ setEl(node);
3043
+ }, []);
3044
+ useEffect(() => {
3045
+ if (!el || !enabled || !track) {
3046
+ if (el) clearSpeakingGlow(el);
3047
+ gateRef.current = { speaking: false, quietSince: null };
3048
+ return;
3049
+ }
3050
+ const unsubscribe = subscribeStudioAudioLevel(track, (level) => {
3051
+ const node = el;
3052
+ gateRef.current = nextSpeakingGate(gateRef.current, level, Date.now());
3053
+ if (gateRef.current.speaking) {
3054
+ node.dataset.speaking = "true";
3055
+ if (!reducedMotion) {
3056
+ node.style.setProperty("--livo-speaking-level", String(level));
3057
+ } else {
3058
+ node.style.removeProperty("--livo-speaking-level");
3059
+ }
3060
+ return;
3061
+ }
3062
+ clearSpeakingGlow(node);
3063
+ });
3064
+ return () => {
3065
+ unsubscribe();
3066
+ clearSpeakingGlow(el);
3067
+ gateRef.current = { speaking: false, quietSince: null };
3068
+ };
3069
+ }, [el, enabled, reducedMotion, track]);
3070
+ return setRef;
3071
+ }
2907
3072
  function DropdownMenu({ ...props }) {
2908
3073
  return /* @__PURE__ */ jsx(Menu.Root, { "data-slot": "dropdown-menu", ...props });
2909
3074
  }
@@ -3052,12 +3217,18 @@ function StudioTile({
3052
3217
  const onStage = isOnStage(tile.stageStatus);
3053
3218
  const [confirm, setConfirm] = useState(null);
3054
3219
  const showMenu = (canModerate || canManageStage) && !tile.isSelf;
3220
+ const glowRef = useSpeakingGlow({
3221
+ track: tile.kind === "screen" || !tile.audioEnabled ? null : meeting.participantAudioTrack(tile.id),
3222
+ enabled: onStage,
3223
+ reducedMotion
3224
+ });
3055
3225
  return /* @__PURE__ */ jsxs(
3056
3226
  "article",
3057
3227
  {
3228
+ ref: glowRef,
3058
3229
  "aria-label": tile.name,
3059
3230
  className: cn(
3060
- "bg-muted relative overflow-hidden rounded-xl",
3231
+ "livo-studio-tile bg-muted relative overflow-hidden rounded-xl",
3061
3232
  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
3233
  ),
3063
3234
  children: [
@@ -3087,6 +3258,13 @@ function StudioTile({
3087
3258
  contain: tile.kind === "screen"
3088
3259
  }
3089
3260
  ),
3261
+ /* @__PURE__ */ jsx(
3262
+ "div",
3263
+ {
3264
+ "aria-hidden": true,
3265
+ className: "livo-studio-tile-glow pointer-events-none absolute inset-0 z-20 rounded-xl"
3266
+ }
3267
+ ),
3090
3268
  /* @__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
3269
  /* @__PURE__ */ jsxs("p", { className: "min-w-0 truncate text-xs font-medium text-white", children: [
3092
3270
  tile.name,
@@ -3229,9 +3407,7 @@ function StudioStage({
3229
3407
  onOpenPeople
3230
3408
  }) {
3231
3409
  const displayTiles = expandStudioTiles(participants);
3232
- const stage = studioStageLayout(displayTiles, {
3233
- activeSpeakerId: meeting.activeSpeakerId()
3234
- });
3410
+ const stage = studioStageLayout(displayTiles);
3235
3411
  const sharing = stage.spotlight.length > 0;
3236
3412
  const overflowLabel = labels.stageOverflow.replace(
3237
3413
  "{count}",
@@ -3564,7 +3740,7 @@ function StudioRoom({
3564
3740
  const [screenRequestPending, setScreenRequestPending] = useState(false);
3565
3741
  const [pending, setPending] = useState(false);
3566
3742
  const [publishing, setPublishing] = useState(false);
3567
- const [tick, setTick] = useState(0);
3743
+ const [tick2, setTick] = useState(0);
3568
3744
  const [leaveState, setLeaveState] = useState(null);
3569
3745
  const [sideTab, setSideTab] = useState(null);
3570
3746
  const [settingsOpen, setSettingsOpen] = useState(false);
@@ -3808,7 +3984,7 @@ function StudioRoom({
3808
3984
  });
3809
3985
  }, [meeting]);
3810
3986
  const sawParticipants = useRef(false);
3811
- const participants = useMemo(() => meeting.participants(), [meeting, tick]);
3987
+ const participants = useMemo(() => meeting.participants(), [meeting, tick2]);
3812
3988
  useEffect(() => {
3813
3989
  const other = duplicateSelfParticipant(participants);
3814
3990
  if (!other) return;
@@ -3823,22 +3999,22 @@ function StudioRoom({
3823
3999
  () => participants.filter((row) => isOnStage(row.stageStatus)),
3824
4000
  [participants]
3825
4001
  );
3826
- const waitlisted = useMemo(() => meeting.waitlisted(), [meeting, tick]);
4002
+ const waitlisted = useMemo(() => meeting.waitlisted(), [meeting, tick2]);
3827
4003
  const connectionState = useMemo(
3828
4004
  () => meeting.connectionState(),
3829
- [meeting, tick]
4005
+ [meeting, tick2]
3830
4006
  );
3831
4007
  const lastMeetingError = useMemo(
3832
4008
  () => meeting.lastMeetingError(),
3833
- [meeting, tick]
4009
+ [meeting, tick2]
3834
4010
  );
3835
4011
  const canManageStage = meeting.canManageStage();
3836
4012
  const stageEnabled = meeting.stageEnabled();
3837
4013
  const stageRequests = useMemo(
3838
4014
  () => canManageStage ? meeting.stageRequests() : [],
3839
- [canManageStage, meeting, tick]
4015
+ [canManageStage, meeting, tick2]
3840
4016
  );
3841
- const chatMessages = useMemo(() => meeting.chatMessages(), [meeting, tick]);
4017
+ const chatMessages = useMemo(() => meeting.chatMessages(), [meeting, tick2]);
3842
4018
  const inWaitingRoom = meeting.inWaitingRoom();
3843
4019
  const screenAvailable = canStartScreenShare(participants);
3844
4020
  const durableScreenBlock = screenShareBlock(
@@ -5757,6 +5933,9 @@ function studioMeetingFromRtk(raw) {
5757
5933
  localVideoTrack() {
5758
5934
  return meeting.self?.videoTrack ?? null;
5759
5935
  },
5936
+ participantAudioTrack(participantId) {
5937
+ return participantFor(participantId)?.audioTrack ?? null;
5938
+ },
5760
5939
  participantVideoTrack(participantId) {
5761
5940
  return participantFor(participantId)?.videoTrack ?? null;
5762
5941
  },