@livo-tv/sdk 1.5.0 → 1.6.1

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');
@@ -714,7 +715,14 @@ function parseStudioStreamSummary(value) {
714
715
  const status = readString(value, "status");
715
716
  if (!id || !title || !status) return null;
716
717
  const encoderLostAt = isRecord(value) ? typeof value.encoderLostAt === "number" ? value.encoderLostAt : value.encoderLostAt === null ? null : void 0 : void 0;
717
- 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
+ };
718
726
  }
719
727
  function parseStudioSession(value) {
720
728
  if (!isRecord(value)) return null;
@@ -726,7 +734,9 @@ function parseStudioSession(value) {
726
734
  const fromFull = isRecord(value.stream) ? parseStudioStreamSummary({
727
735
  id: value.stream.id,
728
736
  title: value.stream.title,
729
- status: value.stream.status
737
+ status: value.stream.status,
738
+ captionsEnabled: value.stream.captionsEnabled,
739
+ captionsUrl: value.stream.captionsUrl
730
740
  }) : null;
731
741
  const stream = fromSummary ?? fromFull;
732
742
  if (!stream) return null;
@@ -759,6 +769,14 @@ function parsePublicStreamStatus(value) {
759
769
  const status = readString(nested, "status");
760
770
  return status;
761
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
+ }
762
780
  function studioErrorCode(error) {
763
781
  if (!error || typeof error !== "object") return null;
764
782
  const body = "body" in error ? error.body : null;
@@ -1638,6 +1656,211 @@ async function playSpeakerTest(deviceId) {
1638
1656
  await ctx3.close();
1639
1657
  }
1640
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
+ }
1641
1864
  function StudioDeviceSelectGroup({
1642
1865
  devices,
1643
1866
  selected,
@@ -1716,7 +1939,11 @@ function StudioSettingsDialog({
1716
1939
  meeting,
1717
1940
  labels,
1718
1941
  reducedMotion,
1719
- cameraOn
1942
+ cameraOn,
1943
+ captionPrefs,
1944
+ onCaptionPrefsChange,
1945
+ captionsAvailable,
1946
+ effectBackgrounds
1720
1947
  }) {
1721
1948
  const devices = useMediaDevices(open ? 1 : 0, open);
1722
1949
  const [saved, setSaved] = react.useState(
@@ -1728,11 +1955,16 @@ function StudioSettingsDialog({
1728
1955
  const [displayName, setDisplayName] = react.useState("");
1729
1956
  const [savedDisplayName, setSavedDisplayName] = react.useState("");
1730
1957
  const [savingDisplayName, setSavingDisplayName] = react.useState(false);
1958
+ const [tab, setTab] = react.useState("general");
1959
+ const [effects, setEffects] = react.useState(
1960
+ () => meeting.videoEffects()
1961
+ );
1731
1962
  const { effective } = resolveDeviceSelection(saved, devices);
1732
1963
  react.useEffect(() => {
1733
1964
  if (!open) return;
1734
1965
  setSaved(readStudioDeviceSelection());
1735
- }, [open]);
1966
+ setEffects(meeting.videoEffects());
1967
+ }, [meeting, open]);
1736
1968
  react.useEffect(() => {
1737
1969
  if (!open) return;
1738
1970
  const selfName = meeting.participants().find((row) => row.isSelf)?.name?.trim() ?? "";
@@ -1785,76 +2017,306 @@ function StudioSettingsDialog({
1785
2017
  setTestingSpeaker(false);
1786
2018
  }
1787
2019
  }
1788
- 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: [
1789
2036
  /* @__PURE__ */ jsxRuntime.jsxs(DialogHeader, { children: [
1790
2037
  /* @__PURE__ */ jsxRuntime.jsx(DialogTitle, { children: labels.settingsTitle }),
1791
2038
  /* @__PURE__ */ jsxRuntime.jsx(DialogDescription, { children: labels.settingsDescription })
1792
2039
  ] }),
1793
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
1794
- /* @__PURE__ */ jsxRuntime.jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
1795
- /* @__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: [
1796
2185
  /* @__PURE__ */ jsxRuntime.jsx(
1797
- Input,
2186
+ EffectTile,
1798
2187
  {
1799
- id: "studio-display-name",
1800
- value: displayName,
1801
- maxLength: STUDIO_DISPLAY_NAME_MAX,
1802
- onChange: (event) => setDisplayName(event.target.value)
2188
+ label: labels.effectNone,
2189
+ active: effects.background === "none",
2190
+ onClick: () => onApply({ ...effects, background: "none" })
1803
2191
  }
1804
2192
  ),
1805
2193
  /* @__PURE__ */ jsxRuntime.jsx(
1806
- Button,
2194
+ EffectTile,
1807
2195
  {
1808
- type: "button",
1809
- variant: "outline",
1810
- disabled: displayNameSaveDisabled,
1811
- onClick: () => void onSaveDisplayName(),
1812
- 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 })
1813
2207
  }
1814
2208
  )
1815
2209
  ] })
1816
2210
  ] }),
1817
- /* @__PURE__ */ jsxRuntime.jsx(
1818
- SettingsCameraPreview,
1819
- {
1820
- track: videoTrack,
1821
- open,
1822
- cameraOn,
1823
- cameraOffLabel: labels.cameraOff
1824
- }
1825
- ),
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,
1826
2228
  /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1827
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "sr-only", children: labels.micLevel }),
1828
- /* @__PURE__ */ jsxRuntime.jsx(
1829
- 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,
1830
2232
  {
1831
- track: audioTrack,
1832
- reducedMotion,
1833
- className: "w-full"
1834
- }
1835
- )
1836
- ] }),
1837
- /* @__PURE__ */ jsxRuntime.jsx(
1838
- StudioDeviceSelectGroup,
1839
- {
1840
- devices,
1841
- selected: effective,
1842
- labels,
1843
- onChange: (kind, id) => void onChange(kind, id),
1844
- speakerAction: /* @__PURE__ */ jsxRuntime.jsx(
1845
- 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",
1846
2263
  {
1847
- type: "button",
1848
- size: "sm",
1849
- variant: "outline",
1850
- disabled: testingSpeaker,
1851
- onClick: () => void onTestSpeaker(),
1852
- children: labels.testSpeaker
2264
+ className: "bg-muted aspect-video w-full bg-cover bg-center",
2265
+ style: previewUrl ? { backgroundImage: `url(${previewUrl})` } : void 0
1853
2266
  }
1854
- )
1855
- }
1856
- )
1857
- ] }) });
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
+ ] });
1858
2320
  }
1859
2321
  function SettingsCameraPreview({
1860
2322
  track,
@@ -1905,7 +2367,11 @@ function StudioGuestWaiting({
1905
2367
  onToggleCamera,
1906
2368
  onToggleMute,
1907
2369
  onOpenSettings,
1908
- onCloseSettings
2370
+ onCloseSettings,
2371
+ captionPrefs,
2372
+ onCaptionPrefsChange,
2373
+ captionsAvailable,
2374
+ effectBackgrounds
1909
2375
  }) {
1910
2376
  const [audioTrack, setAudioTrack] = react.useState(null);
1911
2377
  const [videoTrack, setVideoTrack] = react.useState(null);
@@ -1972,7 +2438,11 @@ function StudioGuestWaiting({
1972
2438
  meeting,
1973
2439
  labels,
1974
2440
  reducedMotion,
1975
- cameraOn
2441
+ cameraOn,
2442
+ captionPrefs,
2443
+ onCaptionPrefsChange,
2444
+ captionsAvailable,
2445
+ effectBackgrounds
1976
2446
  }
1977
2447
  )
1978
2448
  ] });
@@ -3597,6 +4067,12 @@ function StudioToolbar({
3597
4067
  stageEnabled,
3598
4068
  canLeaveStage,
3599
4069
  reducedMotion,
4070
+ captionsOn,
4071
+ captionsAvailable,
4072
+ captionsReady,
4073
+ captionPrefs,
4074
+ onCaptionPrefsChange,
4075
+ effectBackgrounds,
3600
4076
  onToggleMute,
3601
4077
  onToggleCamera,
3602
4078
  onToggleScreen,
@@ -3610,38 +4086,60 @@ function StudioToolbar({
3610
4086
  onCloseSettings,
3611
4087
  onOpenPip,
3612
4088
  onClosePip,
4089
+ onToggleCaptions,
3613
4090
  onLeave
3614
4091
  }) {
3615
- 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";
3616
4096
  async function enterFullscreen() {
3617
4097
  try {
3618
4098
  await document.documentElement.requestFullscreen();
3619
4099
  } catch {
3620
4100
  }
3621
4101
  }
3622
- 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: [
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: "border-t px-3 py-3", children: /* @__PURE__ */ jsxRuntime.jsx(TooltipProvider, { children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3623
4113
  showMediaControls ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3624
4114
  /* @__PURE__ */ jsxRuntime.jsx(
3625
- ControlIcon,
4115
+ MediaSplit,
3626
4116
  {
3627
4117
  label: muted ? labels.unmute : labels.mute,
3628
4118
  pressed: muted,
3629
4119
  tone: muted ? "danger" : "default",
3630
4120
  onClick: onToggleMute,
4121
+ pickerLabel: labels.micDevice,
4122
+ options: devices.audio,
4123
+ selected: effective.audio,
4124
+ onSelect: (id) => void onDeviceChange("audio", id),
3631
4125
  children: muted ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MicOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, {})
3632
4126
  }
3633
4127
  ),
3634
4128
  /* @__PURE__ */ jsxRuntime.jsx(
3635
- ControlIcon,
4129
+ MediaSplit,
3636
4130
  {
3637
4131
  label: labels.camera,
3638
4132
  pressed: cameraOn,
3639
4133
  onClick: onToggleCamera,
4134
+ pickerLabel: labels.cameraDevice,
4135
+ options: devices.video,
4136
+ selected: effective.video,
4137
+ onSelect: (id) => void onDeviceChange("video", id),
3640
4138
  children: cameraOn ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Video, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VideoOff, {})
3641
4139
  }
3642
4140
  )
3643
4141
  ] }) : null,
3644
- session.role === "moderator" && canManageStage ? /* @__PURE__ */ jsxRuntime.jsx(
4142
+ showStageModerator ? /* @__PURE__ */ jsxRuntime.jsx(
3645
4143
  ControlIcon,
3646
4144
  {
3647
4145
  label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : labels.goOnAir,
@@ -3661,7 +4159,7 @@ function StudioToolbar({
3661
4159
  children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.EyeOff, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eye, {})
3662
4160
  }
3663
4161
  ) : null,
3664
- session.role === "guest" && stageEnabled ? /* @__PURE__ */ jsxRuntime.jsx(
4162
+ showStageGuest ? /* @__PURE__ */ jsxRuntime.jsx(
3665
4163
  ControlIcon,
3666
4164
  {
3667
4165
  label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : selfStageStatus === "REQUESTED_TO_JOIN_STAGE" ? labels.stageRequested : labels.askToGoOnAir,
@@ -3711,6 +4209,56 @@ function StudioToolbar({
3711
4209
  }
3712
4210
  )
3713
4211
  ] }) : /* @__PURE__ */ jsxRuntime.jsx(ControlIcon, { label: labels.askToShare, onClick: onAskToShare, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MonitorUp, {}) }) : null,
4212
+ /* @__PURE__ */ jsxRuntime.jsx(
4213
+ ControlIcon,
4214
+ {
4215
+ label: captionsLabel,
4216
+ pressed: captionsOn,
4217
+ disabled: !captionsAvailable,
4218
+ onClick: onToggleCaptions,
4219
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Captions, {})
4220
+ }
4221
+ ),
4222
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
4223
+ /* @__PURE__ */ jsxRuntime.jsx(
4224
+ DropdownMenuTrigger,
4225
+ {
4226
+ render: /* @__PURE__ */ jsxRuntime.jsx(
4227
+ Button,
4228
+ {
4229
+ type: "button",
4230
+ size: "icon-lg",
4231
+ variant: "secondary",
4232
+ "aria-label": labels.more
4233
+ }
4234
+ ),
4235
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MoreVertical, {})
4236
+ }
4237
+ ),
4238
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuContent, { align: "center", className: "min-w-44", children: [
4239
+ fullscreenSupported ? /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuItem, { onClick: () => void enterFullscreen(), children: [
4240
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Maximize, {}),
4241
+ labels.fullscreen
4242
+ ] }) : null,
4243
+ pipSupported ? /* @__PURE__ */ jsxRuntime.jsxs(
4244
+ DropdownMenuItem,
4245
+ {
4246
+ onClick: () => {
4247
+ if (pipOpen) onClosePip();
4248
+ else onOpenPip();
4249
+ },
4250
+ children: [
4251
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PictureInPicture2, {}),
4252
+ pipOpen ? labels.closePip : labels.openPip
4253
+ ]
4254
+ }
4255
+ ) : null,
4256
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuItem, { onClick: onOpenSettings, children: [
4257
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, {}),
4258
+ labels.settings
4259
+ ] })
4260
+ ] })
4261
+ ] }),
3714
4262
  /* @__PURE__ */ jsxRuntime.jsx(
3715
4263
  ControlIcon,
3716
4264
  {
@@ -3731,26 +4279,6 @@ function StudioToolbar({
3731
4279
  children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Users, {})
3732
4280
  }
3733
4281
  ) : null,
3734
- fullscreenSupported ? /* @__PURE__ */ jsxRuntime.jsx(
3735
- ControlIcon,
3736
- {
3737
- label: labels.fullscreen,
3738
- onClick: () => void enterFullscreen(),
3739
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Maximize, {})
3740
- }
3741
- ) : null,
3742
- pipSupported ? /* @__PURE__ */ jsxRuntime.jsx(
3743
- ControlIcon,
3744
- {
3745
- label: pipOpen ? labels.closePip : labels.openPip,
3746
- pressed: pipOpen,
3747
- onClick: () => {
3748
- if (pipOpen) onClosePip();
3749
- else onOpenPip();
3750
- },
3751
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PictureInPicture2, {})
3752
- }
3753
- ) : null,
3754
4282
  /* @__PURE__ */ jsxRuntime.jsx(
3755
4283
  ControlIcon,
3756
4284
  {
@@ -3768,21 +4296,11 @@ function StudioToolbar({
3768
4296
  meeting,
3769
4297
  reducedMotion,
3770
4298
  cameraOn,
3771
- labels: {
3772
- settingsTitle: labels.settingsTitle,
3773
- settingsDescription: labels.settingsDescription,
3774
- guestName: labels.guestName,
3775
- displayNameSave: labels.displayNameSave,
3776
- displayNameSaved: labels.displayNameSaved,
3777
- displayNameError: labels.displayNameError,
3778
- cameraDevice: labels.cameraDevice,
3779
- micDevice: labels.micDevice,
3780
- speakerDevice: labels.speakerDevice,
3781
- testSpeaker: labels.testSpeaker,
3782
- testSpeakerError: labels.testSpeakerError,
3783
- cameraOff: labels.cameraOff,
3784
- micLevel: labels.micLevel
3785
- }
4299
+ labels,
4300
+ captionPrefs,
4301
+ onCaptionPrefsChange,
4302
+ captionsAvailable,
4303
+ effectBackgrounds
3786
4304
  }
3787
4305
  ),
3788
4306
  session.role === "guest" ? /* @__PURE__ */ jsxRuntime.jsx(
@@ -3807,7 +4325,141 @@ function StudioToolbar({
3807
4325
  )
3808
4326
  }
3809
4327
  ) : null
3810
- ] }) });
4328
+ ] }) }) });
4329
+ }
4330
+ function MediaSplit({
4331
+ label,
4332
+ pressed,
4333
+ tone = "default",
4334
+ onClick,
4335
+ pickerLabel,
4336
+ options,
4337
+ selected,
4338
+ onSelect,
4339
+ children
4340
+ }) {
4341
+ if (options.length === 0) {
4342
+ return /* @__PURE__ */ jsxRuntime.jsx(
4343
+ ControlIcon,
4344
+ {
4345
+ label,
4346
+ pressed,
4347
+ tone,
4348
+ onClick,
4349
+ children
4350
+ }
4351
+ );
4352
+ }
4353
+ const iconVariant = tone === "danger" ? "destructive" : pressed ? "default" : "secondary";
4354
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bg-secondary inline-flex h-10 items-center rounded-full p-0.5", children: [
4355
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
4356
+ /* @__PURE__ */ jsxRuntime.jsx(
4357
+ DropdownMenuTrigger,
4358
+ {
4359
+ render: /* @__PURE__ */ jsxRuntime.jsx(
4360
+ Button,
4361
+ {
4362
+ type: "button",
4363
+ size: "icon-xs",
4364
+ variant: "ghost",
4365
+ className: "text-muted-foreground hover:text-foreground h-9 w-8 rounded-full hover:bg-transparent",
4366
+ "aria-label": pickerLabel
4367
+ }
4368
+ ),
4369
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronUp, { className: "size-3.5" })
4370
+ }
4371
+ ),
4372
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuContent, { align: "start", side: "top", className: "min-w-52", children: options.map((option) => /* @__PURE__ */ jsxRuntime.jsxs(
4373
+ DropdownMenuItem,
4374
+ {
4375
+ onClick: () => onSelect(option.deviceId),
4376
+ children: [
4377
+ option.deviceId === selected ? "\u2713 " : "",
4378
+ option.label
4379
+ ]
4380
+ },
4381
+ option.deviceId
4382
+ )) })
4383
+ ] }),
4384
+ /* @__PURE__ */ jsxRuntime.jsxs(Tooltip, { children: [
4385
+ /* @__PURE__ */ jsxRuntime.jsx(
4386
+ TooltipTrigger,
4387
+ {
4388
+ render: /* @__PURE__ */ jsxRuntime.jsx(
4389
+ Button,
4390
+ {
4391
+ type: "button",
4392
+ size: "icon-lg",
4393
+ variant: iconVariant,
4394
+ className: iconVariant === "secondary" ? "rounded-full bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_10%)] hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_16%)]" : "rounded-full",
4395
+ "aria-label": label,
4396
+ "aria-pressed": pressed,
4397
+ onClick
4398
+ }
4399
+ ),
4400
+ children
4401
+ }
4402
+ ),
4403
+ /* @__PURE__ */ jsxRuntime.jsx(TooltipContent, { children: label })
4404
+ ] })
4405
+ ] });
4406
+ }
4407
+ var POLL_MS = 2e3;
4408
+ var PLAYOUT_MS = 250;
4409
+ var STUDIO_EXTRA_LAG_MS = 200;
4410
+ function useStudioCaptions({
4411
+ enabled,
4412
+ baseUrl,
4413
+ locale,
4414
+ streamStatus
4415
+ }) {
4416
+ const [playout, setPlayout] = react.useState(
4417
+ video.EMPTY_LIVE_CAPTION_PLAYOUT
4418
+ );
4419
+ const cuesRef = react.useRef([]);
4420
+ react.useEffect(() => {
4421
+ if (!enabled) {
4422
+ cuesRef.current = [];
4423
+ setPlayout(video.EMPTY_LIVE_CAPTION_PLAYOUT);
4424
+ return;
4425
+ }
4426
+ if (!baseUrl || !isStudioActiveStatus(streamStatus)) return;
4427
+ let cancelled = false;
4428
+ const url = video.liveCaptionsUrl(baseUrl, locale);
4429
+ const poll = async () => {
4430
+ const loaded = await video.loadLiveCaptions(url);
4431
+ if (cancelled || !loaded.ok) return;
4432
+ cuesRef.current = video.mergeLiveCaptionCues(cuesRef.current, loaded.cues);
4433
+ };
4434
+ void poll();
4435
+ const timer = window.setInterval(() => void poll(), POLL_MS);
4436
+ return () => {
4437
+ cancelled = true;
4438
+ window.clearInterval(timer);
4439
+ };
4440
+ }, [baseUrl, enabled, locale, streamStatus]);
4441
+ react.useEffect(() => {
4442
+ if (!enabled || !baseUrl || !isStudioActiveStatus(streamStatus)) {
4443
+ setPlayout(video.EMPTY_LIVE_CAPTION_PLAYOUT);
4444
+ return;
4445
+ }
4446
+ const tick2 = () => {
4447
+ setPlayout(
4448
+ (prev) => video.advanceLiveCaptionPlayout(
4449
+ prev,
4450
+ cuesRef.current,
4451
+ Date.now(),
4452
+ 0,
4453
+ void 0,
4454
+ STUDIO_EXTRA_LAG_MS
4455
+ )
4456
+ );
4457
+ };
4458
+ tick2();
4459
+ const timer = window.setInterval(tick2, PLAYOUT_MS);
4460
+ return () => window.clearInterval(timer);
4461
+ }, [baseUrl, enabled, streamStatus]);
4462
+ return playout;
3811
4463
  }
3812
4464
  var STAGE_GRANT_VERIFY_MS = 4e3;
3813
4465
  var STUDIO_CONNECT_WATCHDOG_MS = 12e3;
@@ -3826,7 +4478,10 @@ function StudioRoom({
3826
4478
  initialCameraOn = true,
3827
4479
  initialMuted = false,
3828
4480
  initialDevices,
3829
- appDownloadUrl
4481
+ appDownloadUrl,
4482
+ captionsBaseUrl,
4483
+ apiUrl,
4484
+ cdnBase
3830
4485
  }) {
3831
4486
  const [cameraOn, setCameraOn] = react.useState(initialCameraOn);
3832
4487
  const [screenOn, setScreenOn] = react.useState(false);
@@ -3848,6 +4503,11 @@ function StudioRoom({
3848
4503
  const [egressError, setEgressError] = react.useState(null);
3849
4504
  const [shareGrant, setShareGrant] = react.useState("idle");
3850
4505
  const [downloadAppOpen, setDownloadAppOpen] = react.useState(false);
4506
+ const [captionsOn, setCaptionsOn] = react.useState(false);
4507
+ const [captionPrefs, setCaptionPrefs] = react.useState(
4508
+ () => readStudioCaptionPrefs()
4509
+ );
4510
+ const captionHintShown = react.useRef(false);
3851
4511
  const [connectTimedOut, setConnectTimedOut] = react.useState(false);
3852
4512
  const [screenPermission, setScreenPermission] = react.useState(
3853
4513
  () => meeting.screenSharePermission()
@@ -3873,6 +4533,19 @@ function StudioRoom({
3873
4533
  const pip = useDocumentPictureInPicture();
3874
4534
  const gateFromContext = useStudioWindowGate();
3875
4535
  const { reportDuplicate } = windowGate ?? gateFromContext;
4536
+ const resolvedCaptionsBaseUrl = captionsBaseUrl ?? studioCaptionsBaseUrl(apiUrl, session.stream.id);
4537
+ const captionsAvailable = session.stream.captionsEnabled !== false;
4538
+ const captionsReady = isStudioActiveStatus(session.stream.status);
4539
+ const captionPlayout = useStudioCaptions({
4540
+ enabled: captionsOn && captionsAvailable,
4541
+ baseUrl: resolvedCaptionsBaseUrl,
4542
+ locale: captionPrefs.locale,
4543
+ streamStatus: session.stream.status
4544
+ });
4545
+ const effectBackgrounds = react.useMemo(
4546
+ () => defaultStudioEffectBackgrounds(cdnBase),
4547
+ [cdnBase]
4548
+ );
3876
4549
  const onAir = isLiveStudioStatus(session.stream.status);
3877
4550
  const active = isStudioActiveStatus(session.stream.status);
3878
4551
  if (onAir && liveStartedAtRef.current === null) {
@@ -4557,6 +5230,10 @@ function StudioRoom({
4557
5230
  labels,
4558
5231
  settingsOpen,
4559
5232
  statusLabel: connectionState === "connecting" ? labels.connecting : labels.waitingHost,
5233
+ captionPrefs,
5234
+ onCaptionPrefsChange: setCaptionPrefs,
5235
+ captionsAvailable,
5236
+ effectBackgrounds,
4560
5237
  onToggleCamera,
4561
5238
  onToggleMute: () => void onToggleMute(),
4562
5239
  onOpenSettings: () => setSettingsOpen(true),
@@ -4664,27 +5341,37 @@ function StudioRoom({
4664
5341
  }
4665
5342
  ) : null,
4666
5343
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-1 flex-col md:flex-row", children: [
4667
- /* @__PURE__ */ jsxRuntime.jsx(
4668
- StudioStage,
4669
- {
4670
- participants: stageParticipants,
4671
- meeting,
4672
- reducedMotion,
4673
- canModerate: session.role === "moderator",
4674
- canManageStage,
4675
- onAir,
4676
- labels,
4677
- onKick: (id) => void onKick(id),
4678
- onMute: (id) => void onHostMedia(id, "audio"),
4679
- onStopCamera: (id) => void onHostMedia(id, "video"),
4680
- onStopScreen: (id) => void onHostMedia(id, "screen"),
4681
- onToggleStage: (tile) => void onSetStage(tile, !isOnStage(tile.stageStatus)),
4682
- onTogglePin: (tile) => void onTogglePin(tile),
4683
- onToggleChat: (tile) => void onToggleChat(tile),
4684
- canTakeOff: (id) => canTakeOffAir(participants, id, onAir),
4685
- onOpenPeople: () => setSideTab("people")
4686
- }
4687
- ),
5344
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex min-h-0 min-w-0 flex-1 flex-col", children: [
5345
+ /* @__PURE__ */ jsxRuntime.jsx(
5346
+ StudioStage,
5347
+ {
5348
+ participants: stageParticipants,
5349
+ meeting,
5350
+ reducedMotion,
5351
+ canModerate: session.role === "moderator",
5352
+ canManageStage,
5353
+ onAir,
5354
+ labels,
5355
+ onKick: (id) => void onKick(id),
5356
+ onMute: (id) => void onHostMedia(id, "audio"),
5357
+ onStopCamera: (id) => void onHostMedia(id, "video"),
5358
+ onStopScreen: (id) => void onHostMedia(id, "screen"),
5359
+ onToggleStage: (tile) => void onSetStage(tile, !isOnStage(tile.stageStatus)),
5360
+ onTogglePin: (tile) => void onTogglePin(tile),
5361
+ onToggleChat: (tile) => void onToggleChat(tile),
5362
+ canTakeOff: (id) => canTakeOffAir(participants, id, onAir),
5363
+ onOpenPeople: () => setSideTab("people")
5364
+ }
5365
+ ),
5366
+ captionsOn ? /* @__PURE__ */ jsxRuntime.jsx(
5367
+ video.CaptionOverlay,
5368
+ {
5369
+ screen: captionPlayout.screen,
5370
+ className: "bottom-3",
5371
+ textClassName: captionSizeClass(captionPrefs.size)
5372
+ }
5373
+ ) : null
5374
+ ] }),
4688
5375
  sideTab ? /* @__PURE__ */ jsxRuntime.jsx(
4689
5376
  StudioSidePanel,
4690
5377
  {
@@ -4797,6 +5484,12 @@ function StudioRoom({
4797
5484
  stageEnabled,
4798
5485
  canLeaveStage: selfId != null && canTakeOffAir(participants, selfId, onAir),
4799
5486
  reducedMotion,
5487
+ captionsOn,
5488
+ captionsAvailable,
5489
+ captionsReady,
5490
+ captionPrefs,
5491
+ onCaptionPrefsChange: setCaptionPrefs,
5492
+ effectBackgrounds,
4800
5493
  onToggleMute: () => void onToggleMute(),
4801
5494
  onToggleCamera,
4802
5495
  onToggleScreen,
@@ -4824,6 +5517,16 @@ function StudioRoom({
4824
5517
  onCloseSettings: setSettingsOpen,
4825
5518
  onOpenPip,
4826
5519
  onClosePip: pip.close,
5520
+ onToggleCaptions: () => {
5521
+ if (!captionsAvailable) return;
5522
+ if (!captionsOn && !captionHintShown.current) {
5523
+ captionHintShown.current = true;
5524
+ sonner.toast.message(
5525
+ captionsReady ? labels.captionsDelayed : labels.captionsNotReady
5526
+ );
5527
+ }
5528
+ setCaptionsOn((current) => !current);
5529
+ },
4827
5530
  onLeave: () => void onLeave()
4828
5531
  }
4829
5532
  ),
@@ -5218,16 +5921,16 @@ function usePrefersReducedMotion() {
5218
5921
  // src/studio/lib/guest-identity.ts
5219
5922
  var NAME_KEY = "livo-studio-display-name";
5220
5923
  var ID_KEY = "livo-studio-guest-id";
5221
- function readStudioGuestIdentity(storage = defaultStorage2()) {
5924
+ function readStudioGuestIdentity(storage = defaultStorage4()) {
5222
5925
  const guestId = storage?.getItem(ID_KEY)?.trim() || crypto.randomUUID();
5223
5926
  storage?.setItem(ID_KEY, guestId);
5224
5927
  const displayName = storage?.getItem(NAME_KEY)?.trim() ?? "";
5225
5928
  return { displayName, guestId };
5226
5929
  }
5227
- function persistStudioGuestName(displayName, storage = defaultStorage2()) {
5930
+ function persistStudioGuestName(displayName, storage = defaultStorage4()) {
5228
5931
  storage?.setItem(NAME_KEY, displayName.trim());
5229
5932
  }
5230
- function defaultStorage2() {
5933
+ function defaultStorage4() {
5231
5934
  if (typeof window === "undefined") return null;
5232
5935
  try {
5233
5936
  return window.localStorage;
@@ -5596,10 +6299,19 @@ function studioMeetingFromRtk(raw) {
5596
6299
  if (typeof window !== "undefined") {
5597
6300
  window.addEventListener("unhandledrejection", onUnhandledRejection);
5598
6301
  }
6302
+ let videoEffects = readStudioVideoEffects();
6303
+ async function applyCurrentVideoEffects() {
6304
+ await applyStudioVideoEffects({
6305
+ self: meeting.self,
6306
+ meeting,
6307
+ config: videoEffects
6308
+ });
6309
+ }
5599
6310
  async function republishCameraIfOn() {
5600
6311
  if (meeting.self?.videoEnabled !== true) return;
5601
6312
  await meeting.self.disableVideo?.();
5602
6313
  await meeting.self.enableVideo?.();
6314
+ await applyCurrentVideoEffects();
5603
6315
  }
5604
6316
  async function acceptAllowedStageIfOffAir() {
5605
6317
  if (currentStageStatus() !== "OFF_STAGE") return;
@@ -5787,6 +6499,7 @@ function studioMeetingFromRtk(raw) {
5787
6499
  async join() {
5788
6500
  await meeting.join?.();
5789
6501
  await joinStageIfAllowed();
6502
+ await applyCurrentVideoEffects();
5790
6503
  },
5791
6504
  async leave() {
5792
6505
  if (typeof window !== "undefined") {
@@ -5799,8 +6512,10 @@ function studioMeetingFromRtk(raw) {
5799
6512
  else await meeting.self?.disableAudio?.();
5800
6513
  },
5801
6514
  async setVideoEnabled(enabled) {
5802
- if (enabled) await meeting.self?.enableVideo?.();
5803
- else await meeting.self?.disableVideo?.();
6515
+ if (enabled) {
6516
+ await meeting.self?.enableVideo?.();
6517
+ await applyCurrentVideoEffects();
6518
+ } else await meeting.self?.disableVideo?.();
5804
6519
  },
5805
6520
  async setScreenShareEnabled(enabled) {
5806
6521
  if (enabled) await meeting.self?.enableScreenShare?.();
@@ -6184,6 +6899,14 @@ function studioMeetingFromRtk(raw) {
6184
6899
  }
6185
6900
  });
6186
6901
  },
6902
+ async setVideoEffects(config) {
6903
+ videoEffects = config;
6904
+ persistStudioVideoEffects(config);
6905
+ await applyCurrentVideoEffects();
6906
+ },
6907
+ videoEffects() {
6908
+ return videoEffects;
6909
+ },
6187
6910
  async setDisplayName(name) {
6188
6911
  const trimmed = name.trim().slice(0, STUDIO_DISPLAY_NAME_MAX);
6189
6912
  if (!trimmed) return;
@@ -6415,6 +7138,21 @@ async function getPublicStreamStatus(streamId, apiUrl = defaultApiUrl(), fetchFn
6415
7138
  return null;
6416
7139
  }
6417
7140
  }
7141
+ async function getPublicStreamMeta(streamId, apiUrl = defaultApiUrl(), fetchFn = fetch) {
7142
+ try {
7143
+ const body = await publicJson(
7144
+ `${apiUrl.replace(/\/$/, "")}/public/streams/${encodeURIComponent(streamId)}`,
7145
+ {},
7146
+ fetchFn
7147
+ );
7148
+ return parsePublicStreamMeta(body);
7149
+ } catch (error) {
7150
+ if (error instanceof StudioApiError && (error.status === 404 || error.status === 410)) {
7151
+ return { status: "ended", captionsEnabled: false };
7152
+ }
7153
+ return null;
7154
+ }
7155
+ }
6418
7156
  var POLL_INTERVAL_MS = 5e3;
6419
7157
  function LivoHostStudio({
6420
7158
  token,
@@ -6430,6 +7168,9 @@ function LivoHostStudio({
6430
7168
  const reducedMotion = usePrefersReducedMotion();
6431
7169
  const [session, setSession] = react.useState(null);
6432
7170
  const [status, setStatus] = react.useState(null);
7171
+ const [captionsEnabled, setCaptionsEnabled] = react.useState(
7172
+ void 0
7173
+ );
6433
7174
  const [error, setError] = react.useState(null);
6434
7175
  const { meeting, error: meetingError } = useRealtimeKitMeeting(
6435
7176
  session?.authToken ?? null
@@ -6478,6 +7219,20 @@ function LivoHostStudio({
6478
7219
  window.clearInterval(id);
6479
7220
  };
6480
7221
  }, [client, status]);
7222
+ react.useEffect(() => {
7223
+ if (!session) return;
7224
+ let cancelled = false;
7225
+ const poll = async () => {
7226
+ const next = await getPublicStreamMeta(session.stream.id, apiUrl);
7227
+ if (!cancelled && next) setCaptionsEnabled(next.captionsEnabled);
7228
+ };
7229
+ void poll();
7230
+ const id = window.setInterval(() => void poll(), POLL_INTERVAL_MS);
7231
+ return () => {
7232
+ cancelled = true;
7233
+ window.clearInterval(id);
7234
+ };
7235
+ }, [apiUrl, session]);
6481
7236
  const liveSession = react.useMemo(() => {
6482
7237
  if (!session) return null;
6483
7238
  if (!status) return session;
@@ -6485,10 +7240,11 @@ function LivoHostStudio({
6485
7240
  ...session,
6486
7241
  stream: {
6487
7242
  ...session.stream,
6488
- status
7243
+ status,
7244
+ captionsEnabled: captionsEnabled ?? session.stream.captionsEnabled
6489
7245
  }
6490
7246
  };
6491
- }, [session, status]);
7247
+ }, [captionsEnabled, session, status]);
6492
7248
  if (error || meetingError) {
6493
7249
  return /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-destructive p-6", role: "alert", children: error ?? meetingError });
6494
7250
  }
@@ -6506,7 +7262,8 @@ function LivoHostStudio({
6506
7262
  embed,
6507
7263
  chrome,
6508
7264
  playerOrigin,
6509
- appDownloadUrl
7265
+ appDownloadUrl,
7266
+ apiUrl
6510
7267
  }
6511
7268
  );
6512
7269
  }
@@ -6525,6 +7282,9 @@ function LivoGuestStudio({
6525
7282
  const reducedMotion = usePrefersReducedMotion();
6526
7283
  const [session, setSession] = react.useState(null);
6527
7284
  const [status, setStatus] = react.useState(null);
7285
+ const [captionsEnabled, setCaptionsEnabled] = react.useState(
7286
+ void 0
7287
+ );
6528
7288
  const [error, setError] = react.useState(null);
6529
7289
  const [displayName, setDisplayName] = react.useState("");
6530
7290
  const [guestId, setGuestId] = react.useState("");
@@ -6628,8 +7388,11 @@ function LivoGuestStudio({
6628
7388
  if (current === "ended" || current === "error") return;
6629
7389
  let cancelled = false;
6630
7390
  const poll = async () => {
6631
- const next = await getPublicStreamStatus(session.stream.id, apiUrl);
6632
- if (!cancelled && next) setStatus(next);
7391
+ const next = await getPublicStreamMeta(session.stream.id, apiUrl);
7392
+ if (!cancelled && next) {
7393
+ setStatus(next.status);
7394
+ setCaptionsEnabled(next.captionsEnabled);
7395
+ }
6633
7396
  };
6634
7397
  void poll();
6635
7398
  const intervalMs = current === "public" ? POLL_INTERVAL_MS2 : PREVIEW_POLL_INTERVAL_MS;
@@ -6644,9 +7407,13 @@ function LivoGuestStudio({
6644
7407
  if (!status) return session;
6645
7408
  return {
6646
7409
  ...session,
6647
- stream: { ...session.stream, status }
7410
+ stream: {
7411
+ ...session.stream,
7412
+ status,
7413
+ captionsEnabled: captionsEnabled ?? session.stream.captionsEnabled
7414
+ }
6648
7415
  };
6649
- }, [session, status]);
7416
+ }, [captionsEnabled, session, status]);
6650
7417
  react.useEffect(() => {
6651
7418
  if (retryAfterMs == null) return;
6652
7419
  const id = window.setTimeout(() => {
@@ -6707,7 +7474,8 @@ function LivoGuestStudio({
6707
7474
  initialCameraOn: cameraOn,
6708
7475
  initialMuted: muted,
6709
7476
  initialDevices: deviceSelection,
6710
- appDownloadUrl
7477
+ appDownloadUrl,
7478
+ apiUrl
6711
7479
  }
6712
7480
  );
6713
7481
  }
@@ -6883,7 +7651,37 @@ function studioRoomLabels(t) {
6883
7651
  chatPin: t("chatPin"),
6884
7652
  chatUnpin: t("chatUnpin"),
6885
7653
  chatSave: t("chatSave"),
6886
- waitingSkeleton: t("waitingSkeleton")
7654
+ waitingSkeleton: t("waitingSkeleton"),
7655
+ more: t("more"),
7656
+ captions: t("captions"),
7657
+ captionsOn: t("captionsOn"),
7658
+ captionsOff: t("captionsOff"),
7659
+ captionsNotReady: t("captionsNotReady"),
7660
+ captionsDelayed: t("captionsDelayed"),
7661
+ captionsLanguage: t("captionsLanguage"),
7662
+ captionsSize: t("captionsSize"),
7663
+ captionsSizeS: t("captionsSizeS"),
7664
+ captionsSizeM: t("captionsSizeM"),
7665
+ captionsSizeL: t("captionsSizeL"),
7666
+ settingsGeneral: t("settingsGeneral"),
7667
+ settingsAudio: t("settingsAudio"),
7668
+ settingsVideo: t("settingsVideo"),
7669
+ settingsEffects: t("settingsEffects"),
7670
+ settingsSubtitles: t("settingsSubtitles"),
7671
+ effectNone: t("effectNone"),
7672
+ effectBlur: t("effectBlur"),
7673
+ effectBlurStrong: t("effectBlurStrong"),
7674
+ effectBackgrounds: t("effectBackgrounds"),
7675
+ effectFilters: t("effectFilters"),
7676
+ effectFilterNone: t("effectFilterNone"),
7677
+ effectFilterGrayscale: t("effectFilterGrayscale"),
7678
+ effectFilterSepia: t("effectFilterSepia"),
7679
+ effectFilterWarm: t("effectFilterWarm"),
7680
+ effectFilterCool: t("effectFilterCool"),
7681
+ effectFilterBright: t("effectFilterBright"),
7682
+ localeEn: t("localeEn"),
7683
+ localeEs: t("localeEs"),
7684
+ localePt: t("localePt")
6887
7685
  };
6888
7686
  }
6889
7687
 
@@ -6901,6 +7699,7 @@ exports.applyEmbedTheme = applyEmbedTheme;
6901
7699
  exports.controlActionsFromClient = controlActionsFromClient;
6902
7700
  exports.createStudioControlClient = createStudioControlClient;
6903
7701
  exports.decodeEmbedTheme = decodeEmbedTheme;
7702
+ exports.getPublicStreamMeta = getPublicStreamMeta;
6904
7703
  exports.getPublicStreamStatus = getPublicStreamStatus;
6905
7704
  exports.getStudioJoinStatus = getStudioJoinStatus;
6906
7705
  exports.joinStudioGuest = joinStudioGuest;