@livo-tv/sdk 1.4.0 → 1.6.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.
@@ -4,6 +4,7 @@
4
4
  var react = require('react');
5
5
  var reactDom = require('react-dom');
6
6
  var lucideReact = require('lucide-react');
7
+ var video = require('@livo-tv/blocks/video');
7
8
  var sonner = require('sonner');
8
9
  var AlertDialogPrimitive = require('@radix-ui/react-alert-dialog');
9
10
  var clsx = require('clsx');
@@ -542,52 +543,38 @@ function canTakeOffAir(tiles, participantId, onAir) {
542
543
  (tile) => tile.id !== participantId && isOnStage(tile.stageStatus)
543
544
  );
544
545
  }
546
+ function pipSpotlightFrom(tile, kind) {
547
+ return {
548
+ participantId: tile.id,
549
+ name: tile.name,
550
+ kind,
551
+ picture: tile.picture
552
+ };
553
+ }
545
554
  function resolveStudioPipSpotlight(tiles, activeSpeakerId) {
546
555
  const visible = tiles.filter((tile) => isOnStage(tile.stageStatus));
547
556
  const pinned = visible.find((tile) => tile.pinned === true);
548
557
  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
- };
558
+ return pipSpotlightFrom(
559
+ pinned,
560
+ pinned.screenShareEnabled ? "screen" : pinned.videoEnabled ? "camera" : "idle"
561
+ );
562
+ }
563
+ const speaker = activeSpeakerId ? visible.find((tile) => tile.id === activeSpeakerId) : void 0;
564
+ if (speaker) {
565
+ return pipSpotlightFrom(speaker, speaker.videoEnabled ? "camera" : "idle");
555
566
  }
556
567
  const sharing = visible.find((tile) => tile.screenShareEnabled);
557
568
  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
- };
569
+ return pipSpotlightFrom(sharing, "screen");
573
570
  }
574
571
  const remoteCam = visible.find((tile) => !tile.isSelf && tile.videoEnabled);
575
572
  if (remoteCam) {
576
- return {
577
- participantId: remoteCam.id,
578
- name: remoteCam.name,
579
- kind: "camera",
580
- picture: remoteCam.picture
581
- };
573
+ return pipSpotlightFrom(remoteCam, "camera");
582
574
  }
583
575
  const self = visible.find((tile) => tile.isSelf);
584
576
  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
- };
577
+ return pipSpotlightFrom(self, self.videoEnabled ? "camera" : "idle");
591
578
  }
592
579
  function resolveStudioPipSelfView(tiles, spotlight) {
593
580
  const self = tiles.find((tile) => tile.isSelf);
@@ -706,6 +693,9 @@ function screenShareBlock(captureSupported, permission) {
706
693
  if (permission === "NOT_ALLOWED") return "permission";
707
694
  return null;
708
695
  }
696
+ function showScreenShareControl(block) {
697
+ return block !== "permission";
698
+ }
709
699
 
710
700
  // src/studio/lib/parse.ts
711
701
  function isRecord(value) {
@@ -725,7 +715,14 @@ function parseStudioStreamSummary(value) {
725
715
  const status = readString(value, "status");
726
716
  if (!id || !title || !status) return null;
727
717
  const encoderLostAt = isRecord(value) ? typeof value.encoderLostAt === "number" ? value.encoderLostAt : value.encoderLostAt === null ? null : void 0 : void 0;
728
- return { id, title, status, encoderLostAt };
718
+ const captionsEnabled = isRecord(value) ? typeof value.captionsEnabled === "boolean" ? value.captionsEnabled : typeof value.captionsUrl === "string" && value.captionsUrl.length > 0 ? true : value.captionsUrl === null ? false : void 0 : void 0;
719
+ return {
720
+ id,
721
+ title,
722
+ status,
723
+ ...encoderLostAt !== void 0 ? { encoderLostAt } : {},
724
+ ...captionsEnabled !== void 0 ? { captionsEnabled } : {}
725
+ };
729
726
  }
730
727
  function parseStudioSession(value) {
731
728
  if (!isRecord(value)) return null;
@@ -737,7 +734,9 @@ function parseStudioSession(value) {
737
734
  const fromFull = isRecord(value.stream) ? parseStudioStreamSummary({
738
735
  id: value.stream.id,
739
736
  title: value.stream.title,
740
- status: value.stream.status
737
+ status: value.stream.status,
738
+ captionsEnabled: value.stream.captionsEnabled,
739
+ captionsUrl: value.stream.captionsUrl
741
740
  }) : null;
742
741
  const stream = fromSummary ?? fromFull;
743
742
  if (!stream) return null;
@@ -770,6 +769,14 @@ function parsePublicStreamStatus(value) {
770
769
  const status = readString(nested, "status");
771
770
  return status;
772
771
  }
772
+ function parsePublicStreamMeta(value) {
773
+ if (!isRecord(value)) return null;
774
+ const nested = isRecord(value.stream) ? value.stream : value;
775
+ const status = readString(nested, "status");
776
+ if (!status) return null;
777
+ const captionsEnabled = typeof nested.captionsEnabled === "boolean" ? nested.captionsEnabled : typeof nested.captionsUrl === "string" && nested.captionsUrl.length > 0;
778
+ return { status, captionsEnabled };
779
+ }
773
780
  function studioErrorCode(error) {
774
781
  if (!error || typeof error !== "object") return null;
775
782
  const body = "body" in error ? error.body : null;
@@ -1488,6 +1495,19 @@ function DialogHeader({ className, ...props }) {
1488
1495
  }
1489
1496
  );
1490
1497
  }
1498
+ function DialogFooter({ className, ...props }) {
1499
+ return /* @__PURE__ */ jsxRuntime.jsx(
1500
+ "div",
1501
+ {
1502
+ "data-slot": "dialog-footer",
1503
+ className: cn(
1504
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
1505
+ className
1506
+ ),
1507
+ ...props
1508
+ }
1509
+ );
1510
+ }
1491
1511
  function DialogTitle({
1492
1512
  className,
1493
1513
  ...props
@@ -1636,6 +1656,211 @@ async function playSpeakerTest(deviceId) {
1636
1656
  await ctx3.close();
1637
1657
  }
1638
1658
  }
1659
+
1660
+ // src/studio/lib/caption-prefs.ts
1661
+ var STUDIO_CAPTION_LOCALES = ["en", "es", "pt"];
1662
+ var DEFAULT_STUDIO_CAPTION_PREFS = {
1663
+ locale: "en",
1664
+ size: "m"
1665
+ };
1666
+ function isStudioCaptionLocale(value) {
1667
+ return STUDIO_CAPTION_LOCALES.includes(value);
1668
+ }
1669
+ var STORAGE_KEY = "livo-studio-captions";
1670
+ function defaultStorage2() {
1671
+ if (typeof window === "undefined") return null;
1672
+ try {
1673
+ return window.localStorage;
1674
+ } catch {
1675
+ return null;
1676
+ }
1677
+ }
1678
+ function isCaptionSize(value) {
1679
+ return value === "s" || value === "m" || value === "l";
1680
+ }
1681
+ function captionSizeClass(size) {
1682
+ switch (size) {
1683
+ case "s":
1684
+ return "text-[length:clamp(10px,2.1cqh,0.75rem)]";
1685
+ case "m":
1686
+ return "text-[length:clamp(10px,2.6cqh,0.875rem)]";
1687
+ case "l":
1688
+ return "text-[length:clamp(12px,3.2cqh,1.05rem)]";
1689
+ default: {
1690
+ const _exhaustive = size;
1691
+ return _exhaustive;
1692
+ }
1693
+ }
1694
+ }
1695
+ function readStudioCaptionPrefs(storage = defaultStorage2(), fallbackLocale = "en") {
1696
+ const raw = storage?.getItem(STORAGE_KEY);
1697
+ if (!raw) {
1698
+ return { ...DEFAULT_STUDIO_CAPTION_PREFS, locale: fallbackLocale };
1699
+ }
1700
+ try {
1701
+ const parsed = JSON.parse(raw);
1702
+ const locale = typeof parsed.locale === "string" && isStudioCaptionLocale(parsed.locale) ? parsed.locale : fallbackLocale;
1703
+ const size = typeof parsed.size === "string" && isCaptionSize(parsed.size) ? parsed.size : "m";
1704
+ return { locale, size };
1705
+ } catch {
1706
+ return { ...DEFAULT_STUDIO_CAPTION_PREFS, locale: fallbackLocale };
1707
+ }
1708
+ }
1709
+ function persistStudioCaptionPrefs(prefs, storage = defaultStorage2()) {
1710
+ storage?.setItem(STORAGE_KEY, JSON.stringify(prefs));
1711
+ }
1712
+ function studioCaptionsBaseUrl(apiUrl, streamId) {
1713
+ if (!apiUrl) return void 0;
1714
+ return `${apiUrl.replace(/\/$/, "")}/public/streams/${encodeURIComponent(streamId)}/captions`;
1715
+ }
1716
+
1717
+ // src/studio/lib/video-effects.ts
1718
+ var DEFAULT_STUDIO_VIDEO_EFFECTS = {
1719
+ background: "none",
1720
+ blurStrength: 50,
1721
+ filter: "none"
1722
+ };
1723
+ var FILTER_CSS = {
1724
+ none: "",
1725
+ grayscale: "grayscale(1)",
1726
+ sepia: "sepia(0.85)",
1727
+ warm: "sepia(0.35) saturate(1.2) hue-rotate(-10deg)",
1728
+ cool: "saturate(0.9) hue-rotate(15deg) brightness(1.05)",
1729
+ bright: "brightness(1.15) contrast(1.08)"
1730
+ };
1731
+ function defaultStudioEffectBackgrounds(cdnBase = "https://hls.livo.tv") {
1732
+ const root = cdnBase.replace(/\/$/, "");
1733
+ return [
1734
+ {
1735
+ id: "office",
1736
+ url: `${root}/env/backgrounds/office.jpg`,
1737
+ label: "Office"
1738
+ },
1739
+ {
1740
+ id: "lounge",
1741
+ url: `${root}/env/backgrounds/lounge.jpg`,
1742
+ label: "Lounge"
1743
+ },
1744
+ {
1745
+ id: "studio",
1746
+ url: `${root}/env/backgrounds/studio.jpg`,
1747
+ label: "Studio"
1748
+ },
1749
+ { id: "sky", url: `${root}/env/backgrounds/sky.jpg`, label: "Sky" },
1750
+ {
1751
+ id: "forest",
1752
+ url: `${root}/env/backgrounds/forest.jpg`,
1753
+ label: "Forest"
1754
+ },
1755
+ {
1756
+ id: "abstract",
1757
+ url: `${root}/env/backgrounds/abstract.jpg`,
1758
+ label: "Abstract"
1759
+ }
1760
+ ];
1761
+ }
1762
+ function createFilterVideoMiddleware(filter) {
1763
+ const css = FILTER_CSS[filter];
1764
+ if (!css) return null;
1765
+ return async () => {
1766
+ const scratch = document.createElement("canvas");
1767
+ return async (canvas, ctx3) => {
1768
+ if (scratch.width !== canvas.width || scratch.height !== canvas.height) {
1769
+ scratch.width = canvas.width;
1770
+ scratch.height = canvas.height;
1771
+ }
1772
+ const copy = scratch.getContext("2d");
1773
+ if (!copy) return;
1774
+ copy.clearRect(0, 0, scratch.width, scratch.height);
1775
+ copy.drawImage(canvas, 0, 0);
1776
+ ctx3.save();
1777
+ ctx3.filter = css;
1778
+ ctx3.drawImage(scratch, 0, 0);
1779
+ ctx3.restore();
1780
+ };
1781
+ };
1782
+ }
1783
+ async function loadBackgroundTransformer(meeting) {
1784
+ const mod = await import('@cloudflare/realtimekit-virtual-background');
1785
+ const Init = mod.default ?? mod.RealtimeKitVideoBackgroundTransformer ?? null;
1786
+ if (!Init) return null;
1787
+ return Init.init({ meeting });
1788
+ }
1789
+ async function applyStudioVideoEffects(options) {
1790
+ const self = options.self;
1791
+ if (!self?.removeAllVideoMiddlewares || !self.addVideoMiddleware) return;
1792
+ await self.removeAllVideoMiddlewares();
1793
+ const needsSegmentation = options.config.background !== "none";
1794
+ if (needsSegmentation) {
1795
+ await self.setVideoMiddlewareGlobalConfig?.({
1796
+ disablePerFrameCanvasRendering: true
1797
+ });
1798
+ try {
1799
+ const transformer = await loadBackgroundTransformer(options.meeting);
1800
+ if (transformer && options.config.background === "blur") {
1801
+ const strength = Math.min(
1802
+ 100,
1803
+ Math.max(0, options.config.blurStrength)
1804
+ );
1805
+ await self.addVideoMiddleware(
1806
+ await transformer.createBackgroundBlurVideoMiddleware(strength)
1807
+ );
1808
+ } else if (transformer && options.config.background === "image" && options.config.imageUrl) {
1809
+ await self.addVideoMiddleware(
1810
+ await transformer.createStaticBackgroundVideoMiddleware(
1811
+ options.config.imageUrl
1812
+ )
1813
+ );
1814
+ }
1815
+ } catch {
1816
+ }
1817
+ }
1818
+ const filter = createFilterVideoMiddleware(options.config.filter);
1819
+ if (filter) await self.addVideoMiddleware(filter);
1820
+ }
1821
+
1822
+ // src/studio/lib/effects-prefs.ts
1823
+ var STORAGE_KEY2 = "livo-studio-video-effects";
1824
+ function defaultStorage3() {
1825
+ if (typeof window === "undefined") return null;
1826
+ try {
1827
+ return window.localStorage;
1828
+ } catch {
1829
+ return null;
1830
+ }
1831
+ }
1832
+ var BACKGROUNDS = ["none", "blur", "image"];
1833
+ var FILTERS = [
1834
+ "none",
1835
+ "grayscale",
1836
+ "sepia",
1837
+ "warm",
1838
+ "cool",
1839
+ "bright"
1840
+ ];
1841
+ function isBackground(value) {
1842
+ return BACKGROUNDS.includes(value);
1843
+ }
1844
+ function isFilter(value) {
1845
+ return FILTERS.includes(value);
1846
+ }
1847
+ function readStudioVideoEffects(storage = defaultStorage3()) {
1848
+ const raw = storage?.getItem(STORAGE_KEY2);
1849
+ if (!raw) return { ...DEFAULT_STUDIO_VIDEO_EFFECTS };
1850
+ try {
1851
+ const parsed = JSON.parse(raw);
1852
+ const background = typeof parsed.background === "string" && isBackground(parsed.background) ? parsed.background : "none";
1853
+ const filter = typeof parsed.filter === "string" && isFilter(parsed.filter) ? parsed.filter : "none";
1854
+ const blurStrength = typeof parsed.blurStrength === "number" && Number.isFinite(parsed.blurStrength) ? Math.min(100, Math.max(0, parsed.blurStrength)) : 50;
1855
+ const imageUrl = typeof parsed.imageUrl === "string" && parsed.imageUrl ? parsed.imageUrl : void 0;
1856
+ return { background, blurStrength, imageUrl, filter };
1857
+ } catch {
1858
+ return { ...DEFAULT_STUDIO_VIDEO_EFFECTS };
1859
+ }
1860
+ }
1861
+ function persistStudioVideoEffects(config, storage = defaultStorage3()) {
1862
+ storage?.setItem(STORAGE_KEY2, JSON.stringify(config));
1863
+ }
1639
1864
  function StudioDeviceSelectGroup({
1640
1865
  devices,
1641
1866
  selected,
@@ -1714,7 +1939,11 @@ function StudioSettingsDialog({
1714
1939
  meeting,
1715
1940
  labels,
1716
1941
  reducedMotion,
1717
- cameraOn
1942
+ cameraOn,
1943
+ captionPrefs,
1944
+ onCaptionPrefsChange,
1945
+ captionsAvailable,
1946
+ effectBackgrounds
1718
1947
  }) {
1719
1948
  const devices = useMediaDevices(open ? 1 : 0, open);
1720
1949
  const [saved, setSaved] = react.useState(
@@ -1726,11 +1955,16 @@ function StudioSettingsDialog({
1726
1955
  const [displayName, setDisplayName] = react.useState("");
1727
1956
  const [savedDisplayName, setSavedDisplayName] = react.useState("");
1728
1957
  const [savingDisplayName, setSavingDisplayName] = react.useState(false);
1958
+ const [tab, setTab] = react.useState("general");
1959
+ const [effects, setEffects] = react.useState(
1960
+ () => meeting.videoEffects()
1961
+ );
1729
1962
  const { effective } = resolveDeviceSelection(saved, devices);
1730
1963
  react.useEffect(() => {
1731
1964
  if (!open) return;
1732
1965
  setSaved(readStudioDeviceSelection());
1733
- }, [open]);
1966
+ setEffects(meeting.videoEffects());
1967
+ }, [meeting, open]);
1734
1968
  react.useEffect(() => {
1735
1969
  if (!open) return;
1736
1970
  const selfName = meeting.participants().find((row) => row.isSelf)?.name?.trim() ?? "";
@@ -1783,76 +2017,306 @@ function StudioSettingsDialog({
1783
2017
  setTestingSpeaker(false);
1784
2018
  }
1785
2019
  }
1786
- return /* @__PURE__ */ jsxRuntime.jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(DialogContent, { className: "sm:max-w-md", children: [
2020
+ async function applyEffects(next) {
2021
+ setEffects(next);
2022
+ persistStudioVideoEffects(next);
2023
+ try {
2024
+ await meeting.setVideoEffects(next);
2025
+ } catch {
2026
+ }
2027
+ }
2028
+ const tabs = [
2029
+ { id: "general", label: labels.settingsGeneral },
2030
+ { id: "audio", label: labels.settingsAudio },
2031
+ { id: "video", label: labels.settingsVideo },
2032
+ { id: "effects", label: labels.settingsEffects },
2033
+ { id: "subtitles", label: labels.settingsSubtitles }
2034
+ ];
2035
+ return /* @__PURE__ */ jsxRuntime.jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(DialogContent, { className: "sm:max-w-2xl", children: [
1787
2036
  /* @__PURE__ */ jsxRuntime.jsxs(DialogHeader, { children: [
1788
2037
  /* @__PURE__ */ jsxRuntime.jsx(DialogTitle, { children: labels.settingsTitle }),
1789
2038
  /* @__PURE__ */ jsxRuntime.jsx(DialogDescription, { children: labels.settingsDescription })
1790
2039
  ] }),
1791
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
1792
- /* @__PURE__ */ jsxRuntime.jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
1793
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
2040
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-4 sm:flex-row", children: [
2041
+ /* @__PURE__ */ jsxRuntime.jsx(
2042
+ "div",
2043
+ {
2044
+ role: "tablist",
2045
+ "aria-label": labels.settings,
2046
+ className: "flex shrink-0 flex-row gap-1 overflow-x-auto sm:w-40 sm:flex-col",
2047
+ children: tabs.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
2048
+ "button",
2049
+ {
2050
+ type: "button",
2051
+ role: "tab",
2052
+ "aria-selected": tab === item.id,
2053
+ className: cn(
2054
+ "rounded-md px-3 py-1.5 text-left text-sm",
2055
+ tab === item.id ? "bg-muted font-medium" : "text-muted-foreground hover:bg-muted/60"
2056
+ ),
2057
+ onClick: () => setTab(item.id),
2058
+ children: item.label
2059
+ },
2060
+ item.id
2061
+ ))
2062
+ }
2063
+ ),
2064
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "min-w-0 flex-1 space-y-4", children: [
2065
+ tab === "general" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
2066
+ /* @__PURE__ */ jsxRuntime.jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
2067
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
2068
+ /* @__PURE__ */ jsxRuntime.jsx(
2069
+ Input,
2070
+ {
2071
+ id: "studio-display-name",
2072
+ value: displayName,
2073
+ maxLength: STUDIO_DISPLAY_NAME_MAX,
2074
+ onChange: (event) => setDisplayName(event.target.value)
2075
+ }
2076
+ ),
2077
+ /* @__PURE__ */ jsxRuntime.jsx(
2078
+ Button,
2079
+ {
2080
+ type: "button",
2081
+ variant: "outline",
2082
+ disabled: displayNameSaveDisabled,
2083
+ onClick: () => void onSaveDisplayName(),
2084
+ children: labels.displayNameSave
2085
+ }
2086
+ )
2087
+ ] })
2088
+ ] }) : null,
2089
+ tab === "audio" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2090
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
2091
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "sr-only", children: labels.micLevel }),
2092
+ /* @__PURE__ */ jsxRuntime.jsx(
2093
+ AudioWaveform,
2094
+ {
2095
+ track: audioTrack,
2096
+ reducedMotion,
2097
+ className: "w-full"
2098
+ }
2099
+ )
2100
+ ] }),
2101
+ /* @__PURE__ */ jsxRuntime.jsx(
2102
+ StudioDeviceSelectGroup,
2103
+ {
2104
+ devices: { ...devices, video: [] },
2105
+ selected: effective,
2106
+ labels,
2107
+ onChange: (kind, id) => void onChange(kind, id),
2108
+ speakerAction: /* @__PURE__ */ jsxRuntime.jsx(
2109
+ Button,
2110
+ {
2111
+ type: "button",
2112
+ size: "sm",
2113
+ variant: "outline",
2114
+ disabled: testingSpeaker,
2115
+ onClick: () => void onTestSpeaker(),
2116
+ children: labels.testSpeaker
2117
+ }
2118
+ )
2119
+ }
2120
+ )
2121
+ ] }) : null,
2122
+ tab === "video" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2123
+ /* @__PURE__ */ jsxRuntime.jsx(
2124
+ SettingsCameraPreview,
2125
+ {
2126
+ track: videoTrack,
2127
+ open,
2128
+ cameraOn,
2129
+ cameraOffLabel: labels.cameraOff
2130
+ }
2131
+ ),
2132
+ /* @__PURE__ */ jsxRuntime.jsx(
2133
+ StudioDeviceSelectGroup,
2134
+ {
2135
+ devices: { ...devices, audio: [], speaker: [] },
2136
+ selected: effective,
2137
+ labels,
2138
+ onChange: (kind, id) => void onChange(kind, id)
2139
+ }
2140
+ )
2141
+ ] }) : null,
2142
+ tab === "effects" ? /* @__PURE__ */ jsxRuntime.jsx(
2143
+ EffectsPanel,
2144
+ {
2145
+ labels,
2146
+ effects,
2147
+ backgrounds: effectBackgrounds,
2148
+ onApply: (next) => void applyEffects(next)
2149
+ }
2150
+ ) : null,
2151
+ tab === "subtitles" ? /* @__PURE__ */ jsxRuntime.jsx(
2152
+ SubtitlesPanel,
2153
+ {
2154
+ labels,
2155
+ prefs: captionPrefs,
2156
+ available: captionsAvailable,
2157
+ onChange: (next) => {
2158
+ persistStudioCaptionPrefs(next);
2159
+ onCaptionPrefsChange(next);
2160
+ }
2161
+ }
2162
+ ) : null
2163
+ ] })
2164
+ ] })
2165
+ ] }) });
2166
+ }
2167
+ function EffectsPanel({
2168
+ labels,
2169
+ effects,
2170
+ backgrounds,
2171
+ onApply
2172
+ }) {
2173
+ const filters = [
2174
+ { id: "none", label: labels.effectFilterNone },
2175
+ { id: "grayscale", label: labels.effectFilterGrayscale },
2176
+ { id: "sepia", label: labels.effectFilterSepia },
2177
+ { id: "warm", label: labels.effectFilterWarm },
2178
+ { id: "cool", label: labels.effectFilterCool },
2179
+ { id: "bright", label: labels.effectFilterBright }
2180
+ ];
2181
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
2182
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
2183
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-2 text-sm font-medium", children: labels.settingsEffects }),
2184
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-3 gap-2", children: [
1794
2185
  /* @__PURE__ */ jsxRuntime.jsx(
1795
- Input,
2186
+ EffectTile,
1796
2187
  {
1797
- id: "studio-display-name",
1798
- value: displayName,
1799
- maxLength: STUDIO_DISPLAY_NAME_MAX,
1800
- onChange: (event) => setDisplayName(event.target.value)
2188
+ label: labels.effectNone,
2189
+ active: effects.background === "none",
2190
+ onClick: () => onApply({ ...effects, background: "none" })
1801
2191
  }
1802
2192
  ),
1803
2193
  /* @__PURE__ */ jsxRuntime.jsx(
1804
- Button,
2194
+ EffectTile,
1805
2195
  {
1806
- type: "button",
1807
- variant: "outline",
1808
- disabled: displayNameSaveDisabled,
1809
- onClick: () => void onSaveDisplayName(),
1810
- children: labels.displayNameSave
2196
+ label: labels.effectBlur,
2197
+ active: effects.background === "blur" && effects.blurStrength <= 50,
2198
+ onClick: () => onApply({ ...effects, background: "blur", blurStrength: 40 })
2199
+ }
2200
+ ),
2201
+ /* @__PURE__ */ jsxRuntime.jsx(
2202
+ EffectTile,
2203
+ {
2204
+ label: labels.effectBlurStrong,
2205
+ active: effects.background === "blur" && effects.blurStrength > 50,
2206
+ onClick: () => onApply({ ...effects, background: "blur", blurStrength: 80 })
1811
2207
  }
1812
2208
  )
1813
2209
  ] })
1814
2210
  ] }),
1815
- /* @__PURE__ */ jsxRuntime.jsx(
1816
- SettingsCameraPreview,
1817
- {
1818
- track: videoTrack,
1819
- open,
1820
- cameraOn,
1821
- cameraOffLabel: labels.cameraOff
1822
- }
1823
- ),
2211
+ backgrounds.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
2212
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-2 text-sm font-medium", children: labels.effectBackgrounds }),
2213
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-3 gap-2", children: backgrounds.map((bg) => /* @__PURE__ */ jsxRuntime.jsx(
2214
+ EffectTile,
2215
+ {
2216
+ label: bg.label,
2217
+ active: effects.background === "image" && effects.imageUrl === bg.url,
2218
+ previewUrl: bg.url,
2219
+ onClick: () => onApply({
2220
+ ...effects,
2221
+ background: "image",
2222
+ imageUrl: bg.url
2223
+ })
2224
+ },
2225
+ bg.id
2226
+ )) })
2227
+ ] }) : null,
1824
2228
  /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1825
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "sr-only", children: labels.micLevel }),
1826
- /* @__PURE__ */ jsxRuntime.jsx(
1827
- AudioWaveform,
2229
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-2 text-sm font-medium", children: labels.effectFilters }),
2230
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2", children: filters.map((filter) => /* @__PURE__ */ jsxRuntime.jsx(
2231
+ Button,
1828
2232
  {
1829
- track: audioTrack,
1830
- reducedMotion,
1831
- className: "w-full"
1832
- }
1833
- )
1834
- ] }),
1835
- /* @__PURE__ */ jsxRuntime.jsx(
1836
- StudioDeviceSelectGroup,
1837
- {
1838
- devices,
1839
- selected: effective,
1840
- labels,
1841
- onChange: (kind, id) => void onChange(kind, id),
1842
- speakerAction: /* @__PURE__ */ jsxRuntime.jsx(
1843
- Button,
2233
+ type: "button",
2234
+ size: "sm",
2235
+ variant: effects.filter === filter.id ? "default" : "outline",
2236
+ onClick: () => onApply({ ...effects, filter: filter.id }),
2237
+ children: filter.label
2238
+ },
2239
+ filter.id
2240
+ )) })
2241
+ ] })
2242
+ ] });
2243
+ }
2244
+ function EffectTile({
2245
+ label,
2246
+ active,
2247
+ previewUrl,
2248
+ onClick
2249
+ }) {
2250
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2251
+ "button",
2252
+ {
2253
+ type: "button",
2254
+ "aria-pressed": active,
2255
+ onClick,
2256
+ className: cn(
2257
+ "overflow-hidden rounded-lg border text-left text-xs",
2258
+ active ? "ring-primary ring-2" : "border-border"
2259
+ ),
2260
+ children: [
2261
+ /* @__PURE__ */ jsxRuntime.jsx(
2262
+ "div",
1844
2263
  {
1845
- type: "button",
1846
- size: "sm",
1847
- variant: "outline",
1848
- disabled: testingSpeaker,
1849
- onClick: () => void onTestSpeaker(),
1850
- children: labels.testSpeaker
2264
+ className: "bg-muted aspect-video w-full bg-cover bg-center",
2265
+ style: previewUrl ? { backgroundImage: `url(${previewUrl})` } : void 0
1851
2266
  }
1852
- )
1853
- }
1854
- )
1855
- ] }) });
2267
+ ),
2268
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate px-2 py-1", children: label })
2269
+ ]
2270
+ }
2271
+ );
2272
+ }
2273
+ function SubtitlesPanel({
2274
+ labels,
2275
+ prefs,
2276
+ available,
2277
+ onChange
2278
+ }) {
2279
+ const localeLabel = {
2280
+ en: labels.localeEn,
2281
+ es: labels.localeEs,
2282
+ pt: labels.localePt
2283
+ };
2284
+ const sizes = [
2285
+ { id: "s", label: labels.captionsSizeS },
2286
+ { id: "m", label: labels.captionsSizeM },
2287
+ { id: "l", label: labels.captionsSizeL }
2288
+ ];
2289
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
2290
+ available ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.captionsDelayed }) : /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-sm", children: labels.captionsNotReady }),
2291
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
2292
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.captionsLanguage }),
2293
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2", children: STUDIO_CAPTION_LOCALES.map((locale) => /* @__PURE__ */ jsxRuntime.jsx(
2294
+ Button,
2295
+ {
2296
+ type: "button",
2297
+ size: "sm",
2298
+ variant: prefs.locale === locale ? "default" : "outline",
2299
+ onClick: () => onChange({ ...prefs, locale }),
2300
+ children: localeLabel[locale]
2301
+ },
2302
+ locale
2303
+ )) })
2304
+ ] }),
2305
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
2306
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-medium", children: labels.captionsSize }),
2307
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2", children: sizes.map((size) => /* @__PURE__ */ jsxRuntime.jsx(
2308
+ Button,
2309
+ {
2310
+ type: "button",
2311
+ size: "sm",
2312
+ variant: prefs.size === size.id ? "default" : "outline",
2313
+ onClick: () => onChange({ ...prefs, size: size.id }),
2314
+ children: size.label
2315
+ },
2316
+ size.id
2317
+ )) })
2318
+ ] })
2319
+ ] });
1856
2320
  }
1857
2321
  function SettingsCameraPreview({
1858
2322
  track,
@@ -1903,7 +2367,11 @@ function StudioGuestWaiting({
1903
2367
  onToggleCamera,
1904
2368
  onToggleMute,
1905
2369
  onOpenSettings,
1906
- onCloseSettings
2370
+ onCloseSettings,
2371
+ captionPrefs,
2372
+ onCaptionPrefsChange,
2373
+ captionsAvailable,
2374
+ effectBackgrounds
1907
2375
  }) {
1908
2376
  const [audioTrack, setAudioTrack] = react.useState(null);
1909
2377
  const [videoTrack, setVideoTrack] = react.useState(null);
@@ -1970,7 +2438,11 @@ function StudioGuestWaiting({
1970
2438
  meeting,
1971
2439
  labels,
1972
2440
  reducedMotion,
1973
- cameraOn
2441
+ cameraOn,
2442
+ captionPrefs,
2443
+ onCaptionPrefsChange,
2444
+ captionsAvailable,
2445
+ effectBackgrounds
1974
2446
  }
1975
2447
  )
1976
2448
  ] });
@@ -2047,6 +2519,19 @@ var badgeVariants = classVarianceAuthority.cva(
2047
2519
  function Badge({ className, variant, ...props }) {
2048
2520
  return /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(badgeVariants({ variant }), className), ...props });
2049
2521
  }
2522
+
2523
+ // src/studio/lib/studio-video-style.ts
2524
+ var STUDIO_VIDEO_MEDIA_STYLE = `
2525
+ .livo-studio-tile-video::-webkit-media-controls-start-playback-button,
2526
+ .livo-studio-inline-video::-webkit-media-controls-start-playback-button {
2527
+ display: none !important;
2528
+ -webkit-appearance: none;
2529
+ }
2530
+ .livo-studio-tile-video::-webkit-media-controls,
2531
+ .livo-studio-inline-video::-webkit-media-controls {
2532
+ display: none !important;
2533
+ }
2534
+ `;
2050
2535
  function StudioPipPanel({
2051
2536
  meeting,
2052
2537
  labels,
@@ -2101,7 +2586,7 @@ function StudioPipPanel({
2101
2586
  /* @__PURE__ */ jsxRuntime.jsx(StudioLiveBadge, { children: labels.live }),
2102
2587
  elapsed ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-muted-foreground tabular-nums text-xs", children: elapsed }) : null
2103
2588
  ] }) : null,
2104
- selfView ? /* @__PURE__ */ jsxRuntime.jsx(
2589
+ selfView && meeting.localVideoTrack() ? /* @__PURE__ */ jsxRuntime.jsx(
2105
2590
  "div",
2106
2591
  {
2107
2592
  "data-testid": "pip-self-view",
@@ -2158,7 +2643,8 @@ function PipSpotlight({
2158
2643
  spotlight,
2159
2644
  cameraOffLabel
2160
2645
  }) {
2161
- if (!spotlight || spotlight.kind === "idle") {
2646
+ const track = spotlight && spotlight.kind !== "idle" ? spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId) : null;
2647
+ if (!spotlight || spotlight.kind === "idle" || !track) {
2162
2648
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2", children: [
2163
2649
  spotlight?.picture ? /* @__PURE__ */ jsxRuntime.jsx(
2164
2650
  "img",
@@ -2171,7 +2657,6 @@ function PipSpotlight({
2171
2657
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-muted-foreground text-xs", children: spotlight?.name ?? cameraOffLabel })
2172
2658
  ] });
2173
2659
  }
2174
- const track = spotlight.kind === "screen" ? meeting.participantScreenTrack(spotlight.participantId) : meeting.participantVideoTrack(spotlight.participantId);
2175
2660
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2176
2661
  /* @__PURE__ */ jsxRuntime.jsx(
2177
2662
  PipTrackVideo,
@@ -2190,11 +2675,7 @@ function PipTrackVideo({
2190
2675
  const ref = react.useRef(null);
2191
2676
  react.useEffect(() => {
2192
2677
  const el = ref.current;
2193
- if (!el) return;
2194
- if (!track) {
2195
- el.srcObject = null;
2196
- return;
2197
- }
2678
+ if (!el || !track) return;
2198
2679
  el.setAttribute("webkit-playsinline", "");
2199
2680
  el.srcObject = new MediaStream([track]);
2200
2681
  void el.play().catch(() => void 0);
@@ -2202,19 +2683,24 @@ function PipTrackVideo({
2202
2683
  el.srcObject = null;
2203
2684
  };
2204
2685
  }, [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
- );
2686
+ if (!track) return null;
2687
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2688
+ /* @__PURE__ */ jsxRuntime.jsx("style", { children: STUDIO_VIDEO_MEDIA_STYLE }),
2689
+ /* @__PURE__ */ jsxRuntime.jsx(
2690
+ "video",
2691
+ {
2692
+ ref,
2693
+ autoPlay: true,
2694
+ playsInline: true,
2695
+ muted: true,
2696
+ disableRemotePlayback: true,
2697
+ className: cn(
2698
+ "livo-studio-inline-video absolute inset-0 size-full",
2699
+ fit === "contain" ? "object-contain" : "object-cover"
2700
+ )
2701
+ }
2702
+ )
2703
+ ] });
2218
2704
  }
2219
2705
  function pipScreenControls({
2220
2706
  labels,
@@ -3241,6 +3727,7 @@ function StudioTile({
3241
3727
  const onStage = isOnStage(tile.stageStatus);
3242
3728
  const [confirm, setConfirm] = react.useState(null);
3243
3729
  const showMenu = (canModerate || canManageStage) && !tile.isSelf;
3730
+ const hasVideoTrack = tileHasVideoTrack(meeting, tile);
3244
3731
  const glowRef = useSpeakingGlow({
3245
3732
  track: tile.kind === "screen" || !tile.audioEnabled ? null : meeting.participantAudioTrack(tile.id),
3246
3733
  enabled: onStage,
@@ -3256,23 +3743,14 @@ function StudioTile({
3256
3743
  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"
3257
3744
  ),
3258
3745
  children: [
3259
- tile.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
3260
- tile.picture ? /* @__PURE__ */ jsxRuntime.jsx(
3261
- "img",
3262
- {
3263
- src: tile.picture,
3264
- alt: "",
3265
- className: "size-16 rounded-full object-cover"
3266
- }
3267
- ) : /* @__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() }),
3268
- /* @__PURE__ */ jsxRuntime.jsx(
3269
- AudioWaveform,
3270
- {
3271
- track: tile.isSelf ? meeting.localAudioTrack() : null,
3272
- reducedMotion
3273
- }
3274
- )
3275
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(
3746
+ tile.kind === "idle" || !hasVideoTrack ? /* @__PURE__ */ jsxRuntime.jsx(
3747
+ IdlePersonTile,
3748
+ {
3749
+ tile,
3750
+ meeting,
3751
+ reducedMotion
3752
+ }
3753
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
3276
3754
  StudioTileVideo,
3277
3755
  {
3278
3756
  meeting,
@@ -3371,6 +3849,37 @@ function TileStatusIcons({
3371
3849
  tile.chatCanSend === false ? /* @__PURE__ */ jsxRuntime.jsx(StatusIcon, { label: labels.muteChat, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageSquareOff, {}) }) : null
3372
3850
  ] });
3373
3851
  }
3852
+ function tileHasVideoTrack(meeting, tile) {
3853
+ if (tile.kind === "screen") {
3854
+ return meeting.participantScreenTrack(tile.id) != null;
3855
+ }
3856
+ if (tile.kind === "idle") return false;
3857
+ if (tile.isSelf) return meeting.localVideoTrack() != null;
3858
+ return meeting.participantVideoTrack(tile.id) != null;
3859
+ }
3860
+ function IdlePersonTile({
3861
+ tile,
3862
+ meeting,
3863
+ reducedMotion
3864
+ }) {
3865
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-3", children: [
3866
+ tile.picture ? /* @__PURE__ */ jsxRuntime.jsx(
3867
+ "img",
3868
+ {
3869
+ src: tile.picture,
3870
+ alt: "",
3871
+ className: "size-16 rounded-full object-cover"
3872
+ }
3873
+ ) : /* @__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() }),
3874
+ /* @__PURE__ */ jsxRuntime.jsx(
3875
+ AudioWaveform,
3876
+ {
3877
+ track: tile.isSelf ? meeting.localAudioTrack() : null,
3878
+ reducedMotion
3879
+ }
3880
+ )
3881
+ ] });
3882
+ }
3374
3883
  function StatusIcon({
3375
3884
  label,
3376
3885
  children
@@ -3394,23 +3903,27 @@ function StudioTileVideo({
3394
3903
  meeting.attachVideo(participantId, el, kind);
3395
3904
  return () => meeting.attachVideo(participantId, null, kind);
3396
3905
  }, [kind, meeting, participantId]);
3397
- return /* @__PURE__ */ jsxRuntime.jsx(
3398
- "video",
3399
- {
3400
- ref,
3401
- autoPlay: true,
3402
- playsInline: true,
3403
- muted: true,
3404
- disablePictureInPicture: true,
3405
- controlsList: "nodownload noplaybackrate noremoteplayback",
3406
- className: cn(
3407
- "livo-studio-tile-video absolute inset-0 size-full",
3408
- "pointer-events-none",
3409
- contain ? "object-contain" : "object-cover",
3410
- mirror && "-scale-x-100"
3411
- )
3412
- }
3413
- );
3906
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3907
+ /* @__PURE__ */ jsxRuntime.jsx("style", { children: STUDIO_VIDEO_MEDIA_STYLE }),
3908
+ /* @__PURE__ */ jsxRuntime.jsx(
3909
+ "video",
3910
+ {
3911
+ ref,
3912
+ autoPlay: true,
3913
+ playsInline: true,
3914
+ muted: true,
3915
+ disablePictureInPicture: true,
3916
+ disableRemotePlayback: true,
3917
+ controlsList: "nodownload noplaybackrate noremoteplayback",
3918
+ className: cn(
3919
+ "livo-studio-tile-video absolute inset-0 size-full",
3920
+ "pointer-events-none",
3921
+ contain ? "object-contain" : "object-cover",
3922
+ mirror && "-scale-x-100"
3923
+ )
3924
+ }
3925
+ )
3926
+ ] });
3414
3927
  }
3415
3928
  function StudioStage({
3416
3929
  participants,
@@ -3502,6 +4015,32 @@ function StudioStage({
3502
4015
  }
3503
4016
  );
3504
4017
  }
4018
+ function StudioDownloadAppDialog({
4019
+ open,
4020
+ onOpenChange,
4021
+ downloadUrl,
4022
+ labels
4023
+ }) {
4024
+ const href = downloadUrl?.trim() ?? "";
4025
+ return /* @__PURE__ */ jsxRuntime.jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(DialogContent, { children: [
4026
+ /* @__PURE__ */ jsxRuntime.jsxs(DialogHeader, { children: [
4027
+ /* @__PURE__ */ jsxRuntime.jsx(DialogTitle, { children: labels.screenDownloadAppTitle }),
4028
+ /* @__PURE__ */ jsxRuntime.jsx(DialogDescription, { children: labels.screenUnsupportedDevice })
4029
+ ] }),
4030
+ /* @__PURE__ */ jsxRuntime.jsxs(DialogFooter, { children: [
4031
+ /* @__PURE__ */ jsxRuntime.jsx(
4032
+ Button,
4033
+ {
4034
+ type: "button",
4035
+ variant: "outline",
4036
+ onClick: () => onOpenChange(false),
4037
+ children: labels.screenDownloadAppDismiss
4038
+ }
4039
+ ),
4040
+ 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 })
4041
+ ] })
4042
+ ] }) });
4043
+ }
3505
4044
  function StudioToolbar({
3506
4045
  session,
3507
4046
  meeting,
@@ -3528,6 +4067,12 @@ function StudioToolbar({
3528
4067
  stageEnabled,
3529
4068
  canLeaveStage,
3530
4069
  reducedMotion,
4070
+ captionsOn,
4071
+ captionsAvailable,
4072
+ captionsReady,
4073
+ captionPrefs,
4074
+ onCaptionPrefsChange,
4075
+ effectBackgrounds,
3531
4076
  onToggleMute,
3532
4077
  onToggleCamera,
3533
4078
  onToggleScreen,
@@ -3541,204 +4086,360 @@ function StudioToolbar({
3541
4086
  onCloseSettings,
3542
4087
  onOpenPip,
3543
4088
  onClosePip,
4089
+ onToggleCaptions,
3544
4090
  onLeave
3545
4091
  }) {
3546
- const fullscreenSupported = document.fullscreenEnabled === true && typeof document.documentElement.requestFullscreen === "function";
4092
+ const devices = useMediaDevices(1, true);
4093
+ const saved = readStudioDeviceSelection();
4094
+ const { effective } = resolveDeviceSelection(saved, devices);
4095
+ const fullscreenSupported = typeof document !== "undefined" && document.fullscreenEnabled === true && typeof document.documentElement.requestFullscreen === "function";
3547
4096
  async function enterFullscreen() {
3548
4097
  try {
3549
4098
  await document.documentElement.requestFullscreen();
3550
4099
  } catch {
3551
4100
  }
3552
4101
  }
3553
- return /* @__PURE__ */ jsxRuntime.jsx("footer", { className: "flex flex-wrap items-center justify-center gap-2 border-t px-3 py-3", children: /* @__PURE__ */ jsxRuntime.jsxs(TooltipProvider, { children: [
3554
- showMediaControls ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4102
+ async function onDeviceChange(kind, deviceId) {
4103
+ persistStudioDeviceSelection(kind, deviceId);
4104
+ try {
4105
+ await meeting.setDevice(kind, deviceId);
4106
+ } catch {
4107
+ }
4108
+ }
4109
+ const showStageModerator = session.role === "moderator" && canManageStage;
4110
+ const showStageGuest = session.role === "guest" && stageEnabled;
4111
+ const captionsLabel = !captionsAvailable ? labels.captions : !captionsReady ? labels.captionsNotReady : captionsOn ? labels.captionsOn : labels.captionsOff;
4112
+ return /* @__PURE__ */ jsxRuntime.jsx("footer", { className: "flex flex-wrap items-center justify-center gap-3 border-t px-3 py-3 sm:justify-between", children: /* @__PURE__ */ jsxRuntime.jsxs(TooltipProvider, { children: [
4113
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
4114
+ showMediaControls ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4115
+ /* @__PURE__ */ jsxRuntime.jsx(
4116
+ MediaSplit,
4117
+ {
4118
+ label: muted ? labels.unmute : labels.mute,
4119
+ pressed: muted,
4120
+ tone: muted ? "danger" : "default",
4121
+ onClick: onToggleMute,
4122
+ pickerLabel: labels.micDevice,
4123
+ options: devices.audio,
4124
+ selected: effective.audio,
4125
+ onSelect: (id) => void onDeviceChange("audio", id),
4126
+ children: muted ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {})
4127
+ }
4128
+ ),
4129
+ /* @__PURE__ */ jsxRuntime.jsx(
4130
+ MediaSplit,
4131
+ {
4132
+ label: labels.camera,
4133
+ pressed: cameraOn,
4134
+ onClick: onToggleCamera,
4135
+ pickerLabel: labels.cameraDevice,
4136
+ options: devices.video,
4137
+ selected: effective.video,
4138
+ onSelect: (id) => void onDeviceChange("video", id),
4139
+ children: cameraOn ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {})
4140
+ }
4141
+ )
4142
+ ] }) : null,
4143
+ showStageModerator ? /* @__PURE__ */ jsxRuntime.jsx(
4144
+ ControlIcon,
4145
+ {
4146
+ label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : labels.goOnAir,
4147
+ pressed: selfStageStatus !== "ON_STAGE",
4148
+ disabled: selfStageStatus === "ON_STAGE" && !canLeaveStage,
4149
+ onClick: () => {
4150
+ if (selfStageStatus === "ON_STAGE") {
4151
+ if (!canLeaveStage) {
4152
+ sonner.toast.message(labels.offAirLastVisible);
4153
+ return;
4154
+ }
4155
+ onLeaveStage();
4156
+ return;
4157
+ }
4158
+ onAskStage();
4159
+ },
4160
+ children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.EyeOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eye, {})
4161
+ }
4162
+ ) : null,
4163
+ showStageGuest ? /* @__PURE__ */ jsxRuntime.jsx(
4164
+ ControlIcon,
4165
+ {
4166
+ label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : selfStageStatus === "REQUESTED_TO_JOIN_STAGE" ? labels.stageRequested : labels.askToGoOnAir,
4167
+ pressed: selfStageStatus === "REQUESTED_TO_JOIN_STAGE",
4168
+ disabled: selfStageStatus === "REQUESTED_TO_JOIN_STAGE" || selfStageStatus === "ON_STAGE" && !canLeaveStage,
4169
+ onClick: () => {
4170
+ if (selfStageStatus === "ON_STAGE") {
4171
+ if (!canLeaveStage) {
4172
+ sonner.toast.message(labels.offAirLastVisible);
4173
+ return;
4174
+ }
4175
+ onLeaveStage();
4176
+ return;
4177
+ }
4178
+ onAskStage();
4179
+ },
4180
+ children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.EyeOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eye, {})
4181
+ }
4182
+ ) : null,
4183
+ showScreenControl ? canShareFreely ? /* @__PURE__ */ jsxRuntime.jsx(
4184
+ ControlIcon,
4185
+ {
4186
+ label: labels.screen,
4187
+ pressed: screenOn,
4188
+ disabled: screenPending || !screenAvailable && !screenOn,
4189
+ pending: screenPending,
4190
+ onClick: onToggleScreen,
4191
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
4192
+ }
4193
+ ) : shareGrant === "requested" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4194
+ /* @__PURE__ */ jsxRuntime.jsx(
4195
+ ControlIcon,
4196
+ {
4197
+ label: labels.shareRequested,
4198
+ pressed: true,
4199
+ disabled: true,
4200
+ onClick: () => void 0,
4201
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
4202
+ }
4203
+ ),
4204
+ /* @__PURE__ */ jsxRuntime.jsx(
4205
+ ControlIcon,
4206
+ {
4207
+ label: labels.cancelShareRequest,
4208
+ onClick: onCancelShareRequest,
4209
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {})
4210
+ }
4211
+ )
4212
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(ControlIcon, { label: labels.askToShare, onClick: onAskToShare, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {}) }) : null,
4213
+ /* @__PURE__ */ jsxRuntime.jsx(
4214
+ ControlIcon,
4215
+ {
4216
+ label: captionsLabel,
4217
+ pressed: captionsOn,
4218
+ disabled: !captionsAvailable,
4219
+ onClick: onToggleCaptions,
4220
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Captions, {})
4221
+ }
4222
+ ),
4223
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
4224
+ /* @__PURE__ */ jsxRuntime.jsx(
4225
+ DropdownMenuTrigger,
4226
+ {
4227
+ render: /* @__PURE__ */ jsxRuntime.jsx(
4228
+ Button,
4229
+ {
4230
+ type: "button",
4231
+ size: "icon-lg",
4232
+ variant: "secondary",
4233
+ "aria-label": labels.more
4234
+ }
4235
+ ),
4236
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MoreVertical, {})
4237
+ }
4238
+ ),
4239
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuContent, { align: "center", className: "min-w-44", children: [
4240
+ fullscreenSupported ? /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuItem, { onClick: () => void enterFullscreen(), children: [
4241
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Maximize, {}),
4242
+ labels.fullscreen
4243
+ ] }) : null,
4244
+ pipSupported ? /* @__PURE__ */ jsxRuntime.jsxs(
4245
+ DropdownMenuItem,
4246
+ {
4247
+ onClick: () => {
4248
+ if (pipOpen) onClosePip();
4249
+ else onOpenPip();
4250
+ },
4251
+ children: [
4252
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PictureInPicture2, {}),
4253
+ pipOpen ? labels.closePip : labels.openPip
4254
+ ]
4255
+ }
4256
+ ) : null,
4257
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuItem, { onClick: onOpenSettings, children: [
4258
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, {}),
4259
+ labels.settings
4260
+ ] })
4261
+ ] })
4262
+ ] })
4263
+ ] }),
4264
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3555
4265
  /* @__PURE__ */ jsxRuntime.jsx(
3556
4266
  ControlIcon,
3557
4267
  {
3558
- label: muted ? labels.unmute : labels.mute,
3559
- pressed: muted,
3560
- tone: muted ? "danger" : "default",
3561
- onClick: onToggleMute,
3562
- children: muted ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {})
4268
+ label: unreadChat > 0 ? `${labels.chat} (${unreadChat})` : labels.chat,
4269
+ pressed: chatOpen,
4270
+ badge: unreadChat,
4271
+ onClick: onToggleChat,
4272
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageSquare, {})
3563
4273
  }
3564
4274
  ),
3565
- /* @__PURE__ */ jsxRuntime.jsx(
4275
+ session.role === "moderator" ? /* @__PURE__ */ jsxRuntime.jsx(
3566
4276
  ControlIcon,
3567
4277
  {
3568
- label: labels.camera,
3569
- pressed: cameraOn,
3570
- onClick: onToggleCamera,
3571
- children: cameraOn ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {})
4278
+ label: peopleBadge > 0 ? `${labels.people} (${peopleBadge})` : labels.people,
4279
+ pressed: peopleOpen,
4280
+ badge: peopleBadge,
4281
+ onClick: onTogglePeople,
4282
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Users, {})
3572
4283
  }
3573
- )
3574
- ] }) : null,
3575
- session.role === "moderator" && canManageStage ? /* @__PURE__ */ jsxRuntime.jsx(
3576
- ControlIcon,
3577
- {
3578
- label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : labels.goOnAir,
3579
- pressed: selfStageStatus !== "ON_STAGE",
3580
- disabled: selfStageStatus === "ON_STAGE" && !canLeaveStage,
3581
- onClick: () => {
3582
- if (selfStageStatus === "ON_STAGE") {
3583
- if (!canLeaveStage) {
3584
- sonner.toast.message(labels.offAirLastVisible);
3585
- return;
3586
- }
3587
- onLeaveStage();
3588
- return;
3589
- }
3590
- onAskStage();
3591
- },
3592
- children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.EyeOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eye, {})
3593
- }
3594
- ) : null,
3595
- session.role === "guest" && stageEnabled ? /* @__PURE__ */ jsxRuntime.jsx(
3596
- ControlIcon,
3597
- {
3598
- label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : selfStageStatus === "REQUESTED_TO_JOIN_STAGE" ? labels.stageRequested : labels.askToGoOnAir,
3599
- pressed: selfStageStatus === "REQUESTED_TO_JOIN_STAGE",
3600
- disabled: selfStageStatus === "REQUESTED_TO_JOIN_STAGE" || selfStageStatus === "ON_STAGE" && !canLeaveStage,
3601
- onClick: () => {
3602
- if (selfStageStatus === "ON_STAGE") {
3603
- if (!canLeaveStage) {
3604
- sonner.toast.message(labels.offAirLastVisible);
3605
- return;
3606
- }
3607
- onLeaveStage();
3608
- return;
3609
- }
3610
- onAskStage();
3611
- },
3612
- children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.EyeOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eye, {})
3613
- }
3614
- ) : null,
3615
- showScreenControl ? canShareFreely ? /* @__PURE__ */ jsxRuntime.jsx(
3616
- ControlIcon,
3617
- {
3618
- label: labels.screen,
3619
- pressed: screenOn,
3620
- disabled: screenPending || !screenAvailable && !screenOn,
3621
- pending: screenPending,
3622
- onClick: onToggleScreen,
3623
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
3624
- }
3625
- ) : shareGrant === "requested" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4284
+ ) : null,
3626
4285
  /* @__PURE__ */ jsxRuntime.jsx(
3627
4286
  ControlIcon,
3628
4287
  {
3629
- label: labels.shareRequested,
3630
- pressed: true,
3631
- disabled: true,
3632
- onClick: () => void 0,
3633
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {})
4288
+ label: labels.settings,
4289
+ pressed: settingsOpen,
4290
+ onClick: onOpenSettings,
4291
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, {})
3634
4292
  }
3635
4293
  ),
3636
4294
  /* @__PURE__ */ jsxRuntime.jsx(
3637
- ControlIcon,
4295
+ StudioSettingsDialog,
3638
4296
  {
3639
- label: labels.cancelShareRequest,
3640
- onClick: onCancelShareRequest,
3641
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {})
4297
+ open: settingsOpen,
4298
+ onOpenChange: onCloseSettings,
4299
+ meeting,
4300
+ reducedMotion,
4301
+ cameraOn,
4302
+ labels,
4303
+ captionPrefs,
4304
+ onCaptionPrefsChange,
4305
+ captionsAvailable,
4306
+ effectBackgrounds
3642
4307
  }
3643
- )
3644
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(ControlIcon, { label: labels.askToShare, onClick: onAskToShare, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {}) }) : null,
3645
- /* @__PURE__ */ jsxRuntime.jsx(
3646
- ControlIcon,
3647
- {
3648
- label: unreadChat > 0 ? `${labels.chat} (${unreadChat})` : labels.chat,
3649
- pressed: chatOpen,
3650
- badge: unreadChat,
3651
- onClick: onToggleChat,
3652
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageSquare, {})
3653
- }
3654
- ),
3655
- session.role === "moderator" ? /* @__PURE__ */ jsxRuntime.jsx(
3656
- ControlIcon,
3657
- {
3658
- label: peopleBadge > 0 ? `${labels.people} (${peopleBadge})` : labels.people,
3659
- pressed: peopleOpen,
3660
- badge: peopleBadge,
3661
- onClick: onTogglePeople,
3662
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Users, {})
3663
- }
3664
- ) : null,
3665
- fullscreenSupported ? /* @__PURE__ */ jsxRuntime.jsx(
3666
- ControlIcon,
3667
- {
3668
- label: labels.fullscreen,
3669
- onClick: () => void enterFullscreen(),
3670
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Maximize, {})
3671
- }
3672
- ) : null,
3673
- pipSupported ? /* @__PURE__ */ jsxRuntime.jsx(
3674
- ControlIcon,
3675
- {
3676
- label: pipOpen ? labels.closePip : labels.openPip,
3677
- pressed: pipOpen,
3678
- onClick: () => {
3679
- if (pipOpen) onClosePip();
3680
- else onOpenPip();
3681
- },
3682
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PictureInPicture2, {})
3683
- }
3684
- ) : null,
4308
+ ),
4309
+ session.role === "guest" ? /* @__PURE__ */ jsxRuntime.jsx(
4310
+ ConfirmAction,
4311
+ {
4312
+ triggerLabel: labels.leave,
4313
+ title: labels.leaveTitle,
4314
+ description: labels.leaveDescription,
4315
+ confirmLabel: labels.leaveConfirm,
4316
+ pending,
4317
+ onConfirm: onLeave,
4318
+ trigger: /* @__PURE__ */ jsxRuntime.jsx(
4319
+ Button,
4320
+ {
4321
+ type: "button",
4322
+ variant: "destructive",
4323
+ size: "icon-lg",
4324
+ disabled: pending,
4325
+ "aria-label": labels.leave,
4326
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PhoneOff, {})
4327
+ }
4328
+ )
4329
+ }
4330
+ ) : null
4331
+ ] })
4332
+ ] }) });
4333
+ }
4334
+ function MediaSplit({
4335
+ label,
4336
+ pressed,
4337
+ tone = "default",
4338
+ onClick,
4339
+ pickerLabel,
4340
+ options,
4341
+ selected,
4342
+ onSelect,
4343
+ children
4344
+ }) {
4345
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center", children: [
3685
4346
  /* @__PURE__ */ jsxRuntime.jsx(
3686
4347
  ControlIcon,
3687
4348
  {
3688
- label: labels.settings,
3689
- pressed: settingsOpen,
3690
- onClick: onOpenSettings,
3691
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, {})
4349
+ label,
4350
+ pressed,
4351
+ tone,
4352
+ onClick,
4353
+ children
3692
4354
  }
3693
4355
  ),
3694
- /* @__PURE__ */ jsxRuntime.jsx(
3695
- StudioSettingsDialog,
3696
- {
3697
- open: settingsOpen,
3698
- onOpenChange: onCloseSettings,
3699
- meeting,
3700
- reducedMotion,
3701
- cameraOn,
3702
- labels: {
3703
- settingsTitle: labels.settingsTitle,
3704
- settingsDescription: labels.settingsDescription,
3705
- guestName: labels.guestName,
3706
- displayNameSave: labels.displayNameSave,
3707
- displayNameSaved: labels.displayNameSaved,
3708
- displayNameError: labels.displayNameError,
3709
- cameraDevice: labels.cameraDevice,
3710
- micDevice: labels.micDevice,
3711
- speakerDevice: labels.speakerDevice,
3712
- testSpeaker: labels.testSpeaker,
3713
- testSpeakerError: labels.testSpeakerError,
3714
- cameraOff: labels.cameraOff,
3715
- micLevel: labels.micLevel
4356
+ options.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
4357
+ /* @__PURE__ */ jsxRuntime.jsx(
4358
+ DropdownMenuTrigger,
4359
+ {
4360
+ render: /* @__PURE__ */ jsxRuntime.jsx(
4361
+ Button,
4362
+ {
4363
+ type: "button",
4364
+ size: "icon-xs",
4365
+ variant: "secondary",
4366
+ className: "-ml-1",
4367
+ "aria-label": pickerLabel
4368
+ }
4369
+ ),
4370
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronUp, { className: "size-3.5" })
3716
4371
  }
3717
- }
3718
- ),
3719
- session.role === "guest" ? /* @__PURE__ */ jsxRuntime.jsx(
3720
- ConfirmAction,
3721
- {
3722
- triggerLabel: labels.leave,
3723
- title: labels.leaveTitle,
3724
- description: labels.leaveDescription,
3725
- confirmLabel: labels.leaveConfirm,
3726
- pending,
3727
- onConfirm: onLeave,
3728
- trigger: /* @__PURE__ */ jsxRuntime.jsx(
3729
- Button,
3730
- {
3731
- type: "button",
3732
- variant: "destructive",
3733
- size: "icon-lg",
3734
- disabled: pending,
3735
- "aria-label": labels.leave,
3736
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PhoneOff, {})
3737
- }
4372
+ ),
4373
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuContent, { align: "start", side: "top", className: "min-w-52", children: options.map((option) => /* @__PURE__ */ jsxRuntime.jsxs(
4374
+ DropdownMenuItem,
4375
+ {
4376
+ onClick: () => onSelect(option.deviceId),
4377
+ children: [
4378
+ option.deviceId === selected ? "\u2713 " : "",
4379
+ option.label
4380
+ ]
4381
+ },
4382
+ option.deviceId
4383
+ )) })
4384
+ ] }) : null
4385
+ ] });
4386
+ }
4387
+ var POLL_MS = 2e3;
4388
+ var PLAYOUT_MS = 250;
4389
+ var STUDIO_EXTRA_LAG_MS = 200;
4390
+ function useStudioCaptions({
4391
+ enabled,
4392
+ baseUrl,
4393
+ locale,
4394
+ streamStatus
4395
+ }) {
4396
+ const [playout, setPlayout] = react.useState(
4397
+ video.EMPTY_LIVE_CAPTION_PLAYOUT
4398
+ );
4399
+ const cuesRef = react.useRef([]);
4400
+ react.useEffect(() => {
4401
+ if (!enabled) {
4402
+ cuesRef.current = [];
4403
+ setPlayout(video.EMPTY_LIVE_CAPTION_PLAYOUT);
4404
+ return;
4405
+ }
4406
+ if (!baseUrl || !isStudioActiveStatus(streamStatus)) return;
4407
+ let cancelled = false;
4408
+ const url = video.liveCaptionsUrl(baseUrl, locale);
4409
+ const poll = async () => {
4410
+ const loaded = await video.loadLiveCaptions(url);
4411
+ if (cancelled || !loaded.ok) return;
4412
+ cuesRef.current = video.mergeLiveCaptionCues(cuesRef.current, loaded.cues);
4413
+ };
4414
+ void poll();
4415
+ const timer = window.setInterval(() => void poll(), POLL_MS);
4416
+ return () => {
4417
+ cancelled = true;
4418
+ window.clearInterval(timer);
4419
+ };
4420
+ }, [baseUrl, enabled, locale, streamStatus]);
4421
+ react.useEffect(() => {
4422
+ if (!enabled || !baseUrl || !isStudioActiveStatus(streamStatus)) {
4423
+ setPlayout(video.EMPTY_LIVE_CAPTION_PLAYOUT);
4424
+ return;
4425
+ }
4426
+ const tick2 = () => {
4427
+ setPlayout(
4428
+ (prev) => video.advanceLiveCaptionPlayout(
4429
+ prev,
4430
+ cuesRef.current,
4431
+ Date.now(),
4432
+ 0,
4433
+ void 0,
4434
+ STUDIO_EXTRA_LAG_MS
3738
4435
  )
3739
- }
3740
- ) : null
3741
- ] }) });
4436
+ );
4437
+ };
4438
+ tick2();
4439
+ const timer = window.setInterval(tick2, PLAYOUT_MS);
4440
+ return () => window.clearInterval(timer);
4441
+ }, [baseUrl, enabled, streamStatus]);
4442
+ return playout;
3742
4443
  }
3743
4444
  var STAGE_GRANT_VERIFY_MS = 4e3;
3744
4445
  var STUDIO_CONNECT_WATCHDOG_MS = 12e3;
@@ -3756,7 +4457,11 @@ function StudioRoom({
3756
4457
  windowGate,
3757
4458
  initialCameraOn = true,
3758
4459
  initialMuted = false,
3759
- initialDevices
4460
+ initialDevices,
4461
+ appDownloadUrl,
4462
+ captionsBaseUrl,
4463
+ apiUrl,
4464
+ cdnBase
3760
4465
  }) {
3761
4466
  const [cameraOn, setCameraOn] = react.useState(initialCameraOn);
3762
4467
  const [screenOn, setScreenOn] = react.useState(false);
@@ -3777,6 +4482,12 @@ function StudioRoom({
3777
4482
  );
3778
4483
  const [egressError, setEgressError] = react.useState(null);
3779
4484
  const [shareGrant, setShareGrant] = react.useState("idle");
4485
+ const [downloadAppOpen, setDownloadAppOpen] = react.useState(false);
4486
+ const [captionsOn, setCaptionsOn] = react.useState(false);
4487
+ const [captionPrefs, setCaptionPrefs] = react.useState(
4488
+ () => readStudioCaptionPrefs()
4489
+ );
4490
+ const captionHintShown = react.useRef(false);
3780
4491
  const [connectTimedOut, setConnectTimedOut] = react.useState(false);
3781
4492
  const [screenPermission, setScreenPermission] = react.useState(
3782
4493
  () => meeting.screenSharePermission()
@@ -3802,6 +4513,19 @@ function StudioRoom({
3802
4513
  const pip = useDocumentPictureInPicture();
3803
4514
  const gateFromContext = useStudioWindowGate();
3804
4515
  const { reportDuplicate } = windowGate ?? gateFromContext;
4516
+ const resolvedCaptionsBaseUrl = captionsBaseUrl ?? studioCaptionsBaseUrl(apiUrl, session.stream.id);
4517
+ const captionsAvailable = session.stream.captionsEnabled !== false;
4518
+ const captionsReady = isStudioActiveStatus(session.stream.status);
4519
+ const captionPlayout = useStudioCaptions({
4520
+ enabled: captionsOn && captionsAvailable,
4521
+ baseUrl: resolvedCaptionsBaseUrl,
4522
+ locale: captionPrefs.locale,
4523
+ streamStatus: session.stream.status
4524
+ });
4525
+ const effectBackgrounds = react.useMemo(
4526
+ () => defaultStudioEffectBackgrounds(cdnBase),
4527
+ [cdnBase]
4528
+ );
3805
4529
  const onAir = isLiveStudioStatus(session.stream.status);
3806
4530
  const active = isStudioActiveStatus(session.stream.status);
3807
4531
  if (onAir && liveStartedAtRef.current === null) {
@@ -3818,6 +4542,11 @@ function StudioRoom({
3818
4542
  const roomState = leaveState ?? meeting.roomState();
3819
4543
  const sessionOver = isStudioSessionOver(session.stream.status, roomState);
3820
4544
  const rejected = isStudioRejected(roomState);
4545
+ const connectionState = react.useMemo(
4546
+ () => meeting.connectionState(),
4547
+ [meeting, tick2]
4548
+ );
4549
+ const guestConnectFailed = session.role === "guest" && (connectionState === "failed" || connectionState === "connecting" && connectTimedOut);
3821
4550
  useBeforeUnload(active && !sessionOver);
3822
4551
  useWakeLock(active && !sessionOver);
3823
4552
  react.useEffect(() => {
@@ -3981,12 +4710,13 @@ function StudioRoom({
3981
4710
  void meeting.setScreenShareEnabled(false);
3982
4711
  }, [guestOffStage, meeting]);
3983
4712
  react.useEffect(() => {
3984
- if (!sessionOver && !rejected) return;
4713
+ if (!sessionOver && !rejected && !guestConnectFailed) return;
4714
+ pip.close();
3985
4715
  setShareGrant("idle");
3986
4716
  void meeting.setAudioEnabled(false);
3987
4717
  void meeting.setVideoEnabled(false);
3988
4718
  void meeting.setScreenShareEnabled(false);
3989
- }, [meeting, rejected, sessionOver]);
4719
+ }, [guestConnectFailed, meeting, pip.close, rejected, sessionOver]);
3990
4720
  react.useEffect(() => {
3991
4721
  return meeting.subscribeMedia(() => {
3992
4722
  const self2 = meeting.participants().find((row) => row.isSelf);
@@ -4024,10 +4754,6 @@ function StudioRoom({
4024
4754
  [participants]
4025
4755
  );
4026
4756
  const waitlisted = react.useMemo(() => meeting.waitlisted(), [meeting, tick2]);
4027
- const connectionState = react.useMemo(
4028
- () => meeting.connectionState(),
4029
- [meeting, tick2]
4030
- );
4031
4757
  const lastMeetingError = react.useMemo(
4032
4758
  () => meeting.lastMeetingError(),
4033
4759
  [meeting, tick2]
@@ -4045,7 +4771,7 @@ function StudioRoom({
4045
4771
  captureSupported,
4046
4772
  screenPermission
4047
4773
  );
4048
- const showScreenControl = durableScreenBlock === null;
4774
+ const showScreenControl = showScreenShareControl(durableScreenBlock);
4049
4775
  const canGoLive = session.role === "moderator" && canPublish(session.stream.status);
4050
4776
  const showPublishButton = session.role === "moderator" && !onAir && session.stream.status !== "ended" && session.stream.status !== "error";
4051
4777
  const self = participants.find((row) => row.isSelf);
@@ -4141,17 +4867,10 @@ function StudioRoom({
4141
4867
  }, [connectionState, session.role]);
4142
4868
  react.useEffect(() => {
4143
4869
  if (inWaitingRoom || screenNoticePrimed.current) return;
4144
- if (!durableScreenBlock) return;
4870
+ if (durableScreenBlock !== "permission") return;
4145
4871
  screenNoticePrimed.current = true;
4146
- sonner.toast.message(
4147
- durableScreenBlock === "device" ? labels.screenUnsupportedDevice : labels.screenNotAllowed
4148
- );
4149
- }, [
4150
- durableScreenBlock,
4151
- inWaitingRoom,
4152
- labels.screenNotAllowed,
4153
- labels.screenUnsupportedDevice
4154
- ]);
4872
+ sonner.toast.message(labels.screenNotAllowed);
4873
+ }, [durableScreenBlock, inWaitingRoom, labels.screenNotAllowed]);
4155
4874
  react.useEffect(() => {
4156
4875
  return meeting.subscribeBroadcast((message) => {
4157
4876
  if (message.type === "host-media") {
@@ -4260,6 +4979,10 @@ function StudioRoom({
4260
4979
  setCameraOn((current) => !current);
4261
4980
  }, []);
4262
4981
  const onToggleScreen = react.useCallback(() => {
4982
+ if (durableScreenBlock === "device") {
4983
+ setDownloadAppOpen(true);
4984
+ return;
4985
+ }
4263
4986
  if (!canShareFreely || screenRequestPending) return;
4264
4987
  if (!screenOn && !canStartScreenShare(meeting.participants())) {
4265
4988
  sonner.toast.message(labels.screenBusy);
@@ -4269,12 +4992,17 @@ function StudioRoom({
4269
4992
  setScreenOn((current) => !current);
4270
4993
  }, [
4271
4994
  canShareFreely,
4995
+ durableScreenBlock,
4272
4996
  labels.screenBusy,
4273
4997
  meeting,
4274
4998
  screenOn,
4275
4999
  screenRequestPending
4276
5000
  ]);
4277
5001
  const onAskToShare = react.useCallback(async () => {
5002
+ if (durableScreenBlock === "device") {
5003
+ setDownloadAppOpen(true);
5004
+ return;
5005
+ }
4278
5006
  const local = meeting.participants().find((row) => row.isSelf);
4279
5007
  if (!local) return;
4280
5008
  try {
@@ -4291,7 +5019,12 @@ function StudioRoom({
4291
5019
  } catch {
4292
5020
  sonner.toast.error(labels.shareRequestError);
4293
5021
  }
4294
- }, [labels.shareRequestError, labels.shareRequestSent, meeting]);
5022
+ }, [
5023
+ durableScreenBlock,
5024
+ labels.shareRequestError,
5025
+ labels.shareRequestSent,
5026
+ meeting
5027
+ ]);
4295
5028
  const verifyGrantTookEffect = react.useCallback(
4296
5029
  (userId, retried = false) => {
4297
5030
  const id = window.setTimeout(() => {
@@ -4454,7 +5187,6 @@ function StudioRoom({
4454
5187
  }
4455
5188
  ) });
4456
5189
  }
4457
- const guestConnectFailed = session.role === "guest" && (connectionState === "failed" || connectionState === "connecting" && connectTimedOut);
4458
5190
  if (guestConnectFailed) {
4459
5191
  const detail = [meeting.roomState(), lastMeetingError].filter((value) => Boolean(value)).join(" \u2014 ");
4460
5192
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex h-dvh flex-col items-center justify-center p-6", children: /* @__PURE__ */ jsxRuntime.jsx(
@@ -4478,6 +5210,10 @@ function StudioRoom({
4478
5210
  labels,
4479
5211
  settingsOpen,
4480
5212
  statusLabel: connectionState === "connecting" ? labels.connecting : labels.waitingHost,
5213
+ captionPrefs,
5214
+ onCaptionPrefsChange: setCaptionPrefs,
5215
+ captionsAvailable,
5216
+ effectBackgrounds,
4481
5217
  onToggleCamera,
4482
5218
  onToggleMute: () => void onToggleMute(),
4483
5219
  onOpenSettings: () => setSettingsOpen(true),
@@ -4585,27 +5321,37 @@ function StudioRoom({
4585
5321
  }
4586
5322
  ) : null,
4587
5323
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col md:flex-row", children: [
4588
- /* @__PURE__ */ jsxRuntime.jsx(
4589
- StudioStage,
4590
- {
4591
- participants: stageParticipants,
4592
- meeting,
4593
- reducedMotion,
4594
- canModerate: session.role === "moderator",
4595
- canManageStage,
4596
- onAir,
4597
- labels,
4598
- onKick: (id) => void onKick(id),
4599
- onMute: (id) => void onHostMedia(id, "audio"),
4600
- onStopCamera: (id) => void onHostMedia(id, "video"),
4601
- onStopScreen: (id) => void onHostMedia(id, "screen"),
4602
- onToggleStage: (tile) => void onSetStage(tile, !isOnStage(tile.stageStatus)),
4603
- onTogglePin: (tile) => void onTogglePin(tile),
4604
- onToggleChat: (tile) => void onToggleChat(tile),
4605
- canTakeOff: (id) => canTakeOffAir(participants, id, onAir),
4606
- onOpenPeople: () => setSideTab("people")
4607
- }
4608
- ),
5324
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex min-h-0 min-w-0 flex-1 flex-col", children: [
5325
+ /* @__PURE__ */ jsxRuntime.jsx(
5326
+ StudioStage,
5327
+ {
5328
+ participants: stageParticipants,
5329
+ meeting,
5330
+ reducedMotion,
5331
+ canModerate: session.role === "moderator",
5332
+ canManageStage,
5333
+ onAir,
5334
+ labels,
5335
+ onKick: (id) => void onKick(id),
5336
+ onMute: (id) => void onHostMedia(id, "audio"),
5337
+ onStopCamera: (id) => void onHostMedia(id, "video"),
5338
+ onStopScreen: (id) => void onHostMedia(id, "screen"),
5339
+ onToggleStage: (tile) => void onSetStage(tile, !isOnStage(tile.stageStatus)),
5340
+ onTogglePin: (tile) => void onTogglePin(tile),
5341
+ onToggleChat: (tile) => void onToggleChat(tile),
5342
+ canTakeOff: (id) => canTakeOffAir(participants, id, onAir),
5343
+ onOpenPeople: () => setSideTab("people")
5344
+ }
5345
+ ),
5346
+ captionsOn ? /* @__PURE__ */ jsxRuntime.jsx(
5347
+ video.CaptionOverlay,
5348
+ {
5349
+ screen: captionPlayout.screen,
5350
+ className: "bottom-3",
5351
+ textClassName: captionSizeClass(captionPrefs.size)
5352
+ }
5353
+ ) : null
5354
+ ] }),
4609
5355
  sideTab ? /* @__PURE__ */ jsxRuntime.jsx(
4610
5356
  StudioSidePanel,
4611
5357
  {
@@ -4718,6 +5464,12 @@ function StudioRoom({
4718
5464
  stageEnabled,
4719
5465
  canLeaveStage: selfId != null && canTakeOffAir(participants, selfId, onAir),
4720
5466
  reducedMotion,
5467
+ captionsOn,
5468
+ captionsAvailable,
5469
+ captionsReady,
5470
+ captionPrefs,
5471
+ onCaptionPrefsChange: setCaptionPrefs,
5472
+ effectBackgrounds,
4721
5473
  onToggleMute: () => void onToggleMute(),
4722
5474
  onToggleCamera,
4723
5475
  onToggleScreen,
@@ -4745,6 +5497,16 @@ function StudioRoom({
4745
5497
  onCloseSettings: setSettingsOpen,
4746
5498
  onOpenPip,
4747
5499
  onClosePip: pip.close,
5500
+ onToggleCaptions: () => {
5501
+ if (!captionsAvailable) return;
5502
+ if (!captionsOn && !captionHintShown.current) {
5503
+ captionHintShown.current = true;
5504
+ sonner.toast.message(
5505
+ captionsReady ? labels.captionsDelayed : labels.captionsNotReady
5506
+ );
5507
+ }
5508
+ setCaptionsOn((current) => !current);
5509
+ },
4748
5510
  onLeave: () => void onLeave()
4749
5511
  }
4750
5512
  ),
@@ -4788,7 +5550,21 @@ function StudioRoom({
4788
5550
  }
4789
5551
  ),
4790
5552
  pip.pipWindow.document.body
4791
- ) : null
5553
+ ) : null,
5554
+ /* @__PURE__ */ jsxRuntime.jsx(
5555
+ StudioDownloadAppDialog,
5556
+ {
5557
+ open: downloadAppOpen,
5558
+ onOpenChange: setDownloadAppOpen,
5559
+ downloadUrl: appDownloadUrl,
5560
+ labels: {
5561
+ screenDownloadAppTitle: labels.screenDownloadAppTitle,
5562
+ screenUnsupportedDevice: labels.screenUnsupportedDevice,
5563
+ screenDownloadAppAction: labels.screenDownloadAppAction,
5564
+ screenDownloadAppDismiss: labels.screenDownloadAppDismiss
5565
+ }
5566
+ }
5567
+ )
4792
5568
  ] });
4793
5569
  }
4794
5570
  function isOverconstrained(error) {
@@ -5125,16 +5901,16 @@ function usePrefersReducedMotion() {
5125
5901
  // src/studio/lib/guest-identity.ts
5126
5902
  var NAME_KEY = "livo-studio-display-name";
5127
5903
  var ID_KEY = "livo-studio-guest-id";
5128
- function readStudioGuestIdentity(storage = defaultStorage2()) {
5904
+ function readStudioGuestIdentity(storage = defaultStorage4()) {
5129
5905
  const guestId = storage?.getItem(ID_KEY)?.trim() || crypto.randomUUID();
5130
5906
  storage?.setItem(ID_KEY, guestId);
5131
5907
  const displayName = storage?.getItem(NAME_KEY)?.trim() ?? "";
5132
5908
  return { displayName, guestId };
5133
5909
  }
5134
- function persistStudioGuestName(displayName, storage = defaultStorage2()) {
5910
+ function persistStudioGuestName(displayName, storage = defaultStorage4()) {
5135
5911
  storage?.setItem(NAME_KEY, displayName.trim());
5136
5912
  }
5137
- function defaultStorage2() {
5913
+ function defaultStorage4() {
5138
5914
  if (typeof window === "undefined") return null;
5139
5915
  try {
5140
5916
  return window.localStorage;
@@ -5503,10 +6279,19 @@ function studioMeetingFromRtk(raw) {
5503
6279
  if (typeof window !== "undefined") {
5504
6280
  window.addEventListener("unhandledrejection", onUnhandledRejection);
5505
6281
  }
6282
+ let videoEffects = readStudioVideoEffects();
6283
+ async function applyCurrentVideoEffects() {
6284
+ await applyStudioVideoEffects({
6285
+ self: meeting.self,
6286
+ meeting,
6287
+ config: videoEffects
6288
+ });
6289
+ }
5506
6290
  async function republishCameraIfOn() {
5507
6291
  if (meeting.self?.videoEnabled !== true) return;
5508
6292
  await meeting.self.disableVideo?.();
5509
6293
  await meeting.self.enableVideo?.();
6294
+ await applyCurrentVideoEffects();
5510
6295
  }
5511
6296
  async function acceptAllowedStageIfOffAir() {
5512
6297
  if (currentStageStatus() !== "OFF_STAGE") return;
@@ -5694,6 +6479,7 @@ function studioMeetingFromRtk(raw) {
5694
6479
  async join() {
5695
6480
  await meeting.join?.();
5696
6481
  await joinStageIfAllowed();
6482
+ await applyCurrentVideoEffects();
5697
6483
  },
5698
6484
  async leave() {
5699
6485
  if (typeof window !== "undefined") {
@@ -5706,8 +6492,10 @@ function studioMeetingFromRtk(raw) {
5706
6492
  else await meeting.self?.disableAudio?.();
5707
6493
  },
5708
6494
  async setVideoEnabled(enabled) {
5709
- if (enabled) await meeting.self?.enableVideo?.();
5710
- else await meeting.self?.disableVideo?.();
6495
+ if (enabled) {
6496
+ await meeting.self?.enableVideo?.();
6497
+ await applyCurrentVideoEffects();
6498
+ } else await meeting.self?.disableVideo?.();
5711
6499
  },
5712
6500
  async setScreenShareEnabled(enabled) {
5713
6501
  if (enabled) await meeting.self?.enableScreenShare?.();
@@ -6091,6 +6879,14 @@ function studioMeetingFromRtk(raw) {
6091
6879
  }
6092
6880
  });
6093
6881
  },
6882
+ async setVideoEffects(config) {
6883
+ videoEffects = config;
6884
+ persistStudioVideoEffects(config);
6885
+ await applyCurrentVideoEffects();
6886
+ },
6887
+ videoEffects() {
6888
+ return videoEffects;
6889
+ },
6094
6890
  async setDisplayName(name) {
6095
6891
  const trimmed = name.trim().slice(0, STUDIO_DISPLAY_NAME_MAX);
6096
6892
  if (!trimmed) return;
@@ -6322,6 +7118,21 @@ async function getPublicStreamStatus(streamId, apiUrl = defaultApiUrl(), fetchFn
6322
7118
  return null;
6323
7119
  }
6324
7120
  }
7121
+ async function getPublicStreamMeta(streamId, apiUrl = defaultApiUrl(), fetchFn = fetch) {
7122
+ try {
7123
+ const body = await publicJson(
7124
+ `${apiUrl.replace(/\/$/, "")}/public/streams/${encodeURIComponent(streamId)}`,
7125
+ {},
7126
+ fetchFn
7127
+ );
7128
+ return parsePublicStreamMeta(body);
7129
+ } catch (error) {
7130
+ if (error instanceof StudioApiError && (error.status === 404 || error.status === 410)) {
7131
+ return { status: "ended", captionsEnabled: false };
7132
+ }
7133
+ return null;
7134
+ }
7135
+ }
6325
7136
  var POLL_INTERVAL_MS = 5e3;
6326
7137
  function LivoHostStudio({
6327
7138
  token,
@@ -6331,11 +7142,15 @@ function LivoHostStudio({
6331
7142
  embed = false,
6332
7143
  chrome,
6333
7144
  playerOrigin,
6334
- theme
7145
+ theme,
7146
+ appDownloadUrl
6335
7147
  }) {
6336
7148
  const reducedMotion = usePrefersReducedMotion();
6337
7149
  const [session, setSession] = react.useState(null);
6338
7150
  const [status, setStatus] = react.useState(null);
7151
+ const [captionsEnabled, setCaptionsEnabled] = react.useState(
7152
+ void 0
7153
+ );
6339
7154
  const [error, setError] = react.useState(null);
6340
7155
  const { meeting, error: meetingError } = useRealtimeKitMeeting(
6341
7156
  session?.authToken ?? null
@@ -6384,6 +7199,20 @@ function LivoHostStudio({
6384
7199
  window.clearInterval(id);
6385
7200
  };
6386
7201
  }, [client, status]);
7202
+ react.useEffect(() => {
7203
+ if (!session) return;
7204
+ let cancelled = false;
7205
+ const poll = async () => {
7206
+ const next = await getPublicStreamMeta(session.stream.id, apiUrl);
7207
+ if (!cancelled && next) setCaptionsEnabled(next.captionsEnabled);
7208
+ };
7209
+ void poll();
7210
+ const id = window.setInterval(() => void poll(), POLL_INTERVAL_MS);
7211
+ return () => {
7212
+ cancelled = true;
7213
+ window.clearInterval(id);
7214
+ };
7215
+ }, [apiUrl, session]);
6387
7216
  const liveSession = react.useMemo(() => {
6388
7217
  if (!session) return null;
6389
7218
  if (!status) return session;
@@ -6391,10 +7220,11 @@ function LivoHostStudio({
6391
7220
  ...session,
6392
7221
  stream: {
6393
7222
  ...session.stream,
6394
- status
7223
+ status,
7224
+ captionsEnabled: captionsEnabled ?? session.stream.captionsEnabled
6395
7225
  }
6396
7226
  };
6397
- }, [session, status]);
7227
+ }, [captionsEnabled, session, status]);
6398
7228
  if (error || meetingError) {
6399
7229
  return /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-destructive p-6", role: "alert", children: error ?? meetingError });
6400
7230
  }
@@ -6411,7 +7241,9 @@ function LivoHostStudio({
6411
7241
  labels,
6412
7242
  embed,
6413
7243
  chrome,
6414
- playerOrigin
7244
+ playerOrigin,
7245
+ appDownloadUrl,
7246
+ apiUrl
6415
7247
  }
6416
7248
  );
6417
7249
  }
@@ -6424,11 +7256,15 @@ function LivoGuestStudio({
6424
7256
  lobbyLabels,
6425
7257
  embed = false,
6426
7258
  playerOrigin,
6427
- theme
7259
+ theme,
7260
+ appDownloadUrl
6428
7261
  }) {
6429
7262
  const reducedMotion = usePrefersReducedMotion();
6430
7263
  const [session, setSession] = react.useState(null);
6431
7264
  const [status, setStatus] = react.useState(null);
7265
+ const [captionsEnabled, setCaptionsEnabled] = react.useState(
7266
+ void 0
7267
+ );
6432
7268
  const [error, setError] = react.useState(null);
6433
7269
  const [displayName, setDisplayName] = react.useState("");
6434
7270
  const [guestId, setGuestId] = react.useState("");
@@ -6532,8 +7368,11 @@ function LivoGuestStudio({
6532
7368
  if (current === "ended" || current === "error") return;
6533
7369
  let cancelled = false;
6534
7370
  const poll = async () => {
6535
- const next = await getPublicStreamStatus(session.stream.id, apiUrl);
6536
- if (!cancelled && next) setStatus(next);
7371
+ const next = await getPublicStreamMeta(session.stream.id, apiUrl);
7372
+ if (!cancelled && next) {
7373
+ setStatus(next.status);
7374
+ setCaptionsEnabled(next.captionsEnabled);
7375
+ }
6537
7376
  };
6538
7377
  void poll();
6539
7378
  const intervalMs = current === "public" ? POLL_INTERVAL_MS2 : PREVIEW_POLL_INTERVAL_MS;
@@ -6548,9 +7387,13 @@ function LivoGuestStudio({
6548
7387
  if (!status) return session;
6549
7388
  return {
6550
7389
  ...session,
6551
- stream: { ...session.stream, status }
7390
+ stream: {
7391
+ ...session.stream,
7392
+ status,
7393
+ captionsEnabled: captionsEnabled ?? session.stream.captionsEnabled
7394
+ }
6552
7395
  };
6553
- }, [session, status]);
7396
+ }, [captionsEnabled, session, status]);
6554
7397
  react.useEffect(() => {
6555
7398
  if (retryAfterMs == null) return;
6556
7399
  const id = window.setTimeout(() => {
@@ -6610,7 +7453,9 @@ function LivoGuestStudio({
6610
7453
  playerOrigin,
6611
7454
  initialCameraOn: cameraOn,
6612
7455
  initialMuted: muted,
6613
- initialDevices: deviceSelection
7456
+ initialDevices: deviceSelection,
7457
+ appDownloadUrl,
7458
+ apiUrl
6614
7459
  }
6615
7460
  );
6616
7461
  }
@@ -6715,6 +7560,9 @@ function studioRoomLabels(t) {
6715
7560
  pipTitle: t("pipTitle"),
6716
7561
  pipError: t("pipError"),
6717
7562
  screenUnsupportedDevice: t("screenUnsupportedDevice"),
7563
+ screenDownloadAppTitle: t("screenDownloadAppTitle"),
7564
+ screenDownloadAppAction: t("screenDownloadAppAction"),
7565
+ screenDownloadAppDismiss: t("screenDownloadAppDismiss"),
6718
7566
  screenNotAllowed: t("screenNotAllowed"),
6719
7567
  askToShare: t("askToShare"),
6720
7568
  shareRequested: t("shareRequested"),
@@ -6783,7 +7631,37 @@ function studioRoomLabels(t) {
6783
7631
  chatPin: t("chatPin"),
6784
7632
  chatUnpin: t("chatUnpin"),
6785
7633
  chatSave: t("chatSave"),
6786
- waitingSkeleton: t("waitingSkeleton")
7634
+ waitingSkeleton: t("waitingSkeleton"),
7635
+ more: t("more"),
7636
+ captions: t("captions"),
7637
+ captionsOn: t("captionsOn"),
7638
+ captionsOff: t("captionsOff"),
7639
+ captionsNotReady: t("captionsNotReady"),
7640
+ captionsDelayed: t("captionsDelayed"),
7641
+ captionsLanguage: t("captionsLanguage"),
7642
+ captionsSize: t("captionsSize"),
7643
+ captionsSizeS: t("captionsSizeS"),
7644
+ captionsSizeM: t("captionsSizeM"),
7645
+ captionsSizeL: t("captionsSizeL"),
7646
+ settingsGeneral: t("settingsGeneral"),
7647
+ settingsAudio: t("settingsAudio"),
7648
+ settingsVideo: t("settingsVideo"),
7649
+ settingsEffects: t("settingsEffects"),
7650
+ settingsSubtitles: t("settingsSubtitles"),
7651
+ effectNone: t("effectNone"),
7652
+ effectBlur: t("effectBlur"),
7653
+ effectBlurStrong: t("effectBlurStrong"),
7654
+ effectBackgrounds: t("effectBackgrounds"),
7655
+ effectFilters: t("effectFilters"),
7656
+ effectFilterNone: t("effectFilterNone"),
7657
+ effectFilterGrayscale: t("effectFilterGrayscale"),
7658
+ effectFilterSepia: t("effectFilterSepia"),
7659
+ effectFilterWarm: t("effectFilterWarm"),
7660
+ effectFilterCool: t("effectFilterCool"),
7661
+ effectFilterBright: t("effectFilterBright"),
7662
+ localeEn: t("localeEn"),
7663
+ localeEs: t("localeEs"),
7664
+ localePt: t("localePt")
6787
7665
  };
6788
7666
  }
6789
7667
 
@@ -6801,6 +7679,7 @@ exports.applyEmbedTheme = applyEmbedTheme;
6801
7679
  exports.controlActionsFromClient = controlActionsFromClient;
6802
7680
  exports.createStudioControlClient = createStudioControlClient;
6803
7681
  exports.decodeEmbedTheme = decodeEmbedTheme;
7682
+ exports.getPublicStreamMeta = getPublicStreamMeta;
6804
7683
  exports.getPublicStreamStatus = getPublicStreamStatus;
6805
7684
  exports.getStudioJoinStatus = getStudioJoinStatus;
6806
7685
  exports.joinStudioGuest = joinStudioGuest;