@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.
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { createContext, useContext, useState, useRef, useEffect, useCallback, useMemo } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
- import { Loader2, Share2, MicOff, Mic, Video, VideoOff, Settings, X, Link2, EyeOff, Eye, MonitorUp, MessageSquare, Users, Maximize, PictureInPicture2, PhoneOff, Undo2, Volume2, XIcon, Pin, MessageSquareOff, MoreVertical } from 'lucide-react';
4
+ import { Loader2, Share2, MicOff, Mic, Video, VideoOff, Settings, X, Link2, EyeOff, Eye, MonitorUp, Captions, MoreVertical, Maximize, PictureInPicture2, MessageSquare, Users, PhoneOff, Undo2, Volume2, ChevronUp, XIcon, Pin, MessageSquareOff } from 'lucide-react';
5
+ import { CaptionOverlay, EMPTY_LIVE_CAPTION_PLAYOUT, liveCaptionsUrl, loadLiveCaptions, mergeLiveCaptionCues, advanceLiveCaptionPlayout } from '@livo-tv/blocks/video';
5
6
  import { toast } from 'sonner';
6
7
  import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
7
8
  import { clsx } from 'clsx';
@@ -690,7 +691,14 @@ function parseStudioStreamSummary(value) {
690
691
  const status = readString(value, "status");
691
692
  if (!id || !title || !status) return null;
692
693
  const encoderLostAt = isRecord(value) ? typeof value.encoderLostAt === "number" ? value.encoderLostAt : value.encoderLostAt === null ? null : void 0 : void 0;
693
- return { id, title, status, encoderLostAt };
694
+ 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;
695
+ return {
696
+ id,
697
+ title,
698
+ status,
699
+ ...encoderLostAt !== void 0 ? { encoderLostAt } : {},
700
+ ...captionsEnabled !== void 0 ? { captionsEnabled } : {}
701
+ };
694
702
  }
695
703
  function parseStudioSession(value) {
696
704
  if (!isRecord(value)) return null;
@@ -702,7 +710,9 @@ function parseStudioSession(value) {
702
710
  const fromFull = isRecord(value.stream) ? parseStudioStreamSummary({
703
711
  id: value.stream.id,
704
712
  title: value.stream.title,
705
- status: value.stream.status
713
+ status: value.stream.status,
714
+ captionsEnabled: value.stream.captionsEnabled,
715
+ captionsUrl: value.stream.captionsUrl
706
716
  }) : null;
707
717
  const stream = fromSummary ?? fromFull;
708
718
  if (!stream) return null;
@@ -735,6 +745,14 @@ function parsePublicStreamStatus(value) {
735
745
  const status = readString(nested, "status");
736
746
  return status;
737
747
  }
748
+ function parsePublicStreamMeta(value) {
749
+ if (!isRecord(value)) return null;
750
+ const nested = isRecord(value.stream) ? value.stream : value;
751
+ const status = readString(nested, "status");
752
+ if (!status) return null;
753
+ const captionsEnabled = typeof nested.captionsEnabled === "boolean" ? nested.captionsEnabled : typeof nested.captionsUrl === "string" && nested.captionsUrl.length > 0;
754
+ return { status, captionsEnabled };
755
+ }
738
756
  function studioErrorCode(error) {
739
757
  if (!error || typeof error !== "object") return null;
740
758
  const body = "body" in error ? error.body : null;
@@ -1614,6 +1632,211 @@ async function playSpeakerTest(deviceId) {
1614
1632
  await ctx3.close();
1615
1633
  }
1616
1634
  }
1635
+
1636
+ // src/studio/lib/caption-prefs.ts
1637
+ var STUDIO_CAPTION_LOCALES = ["en", "es", "pt"];
1638
+ var DEFAULT_STUDIO_CAPTION_PREFS = {
1639
+ locale: "en",
1640
+ size: "m"
1641
+ };
1642
+ function isStudioCaptionLocale(value) {
1643
+ return STUDIO_CAPTION_LOCALES.includes(value);
1644
+ }
1645
+ var STORAGE_KEY = "livo-studio-captions";
1646
+ function defaultStorage2() {
1647
+ if (typeof window === "undefined") return null;
1648
+ try {
1649
+ return window.localStorage;
1650
+ } catch {
1651
+ return null;
1652
+ }
1653
+ }
1654
+ function isCaptionSize(value) {
1655
+ return value === "s" || value === "m" || value === "l";
1656
+ }
1657
+ function captionSizeClass(size) {
1658
+ switch (size) {
1659
+ case "s":
1660
+ return "text-[length:clamp(10px,2.1cqh,0.75rem)]";
1661
+ case "m":
1662
+ return "text-[length:clamp(10px,2.6cqh,0.875rem)]";
1663
+ case "l":
1664
+ return "text-[length:clamp(12px,3.2cqh,1.05rem)]";
1665
+ default: {
1666
+ const _exhaustive = size;
1667
+ return _exhaustive;
1668
+ }
1669
+ }
1670
+ }
1671
+ function readStudioCaptionPrefs(storage = defaultStorage2(), fallbackLocale = "en") {
1672
+ const raw = storage?.getItem(STORAGE_KEY);
1673
+ if (!raw) {
1674
+ return { ...DEFAULT_STUDIO_CAPTION_PREFS, locale: fallbackLocale };
1675
+ }
1676
+ try {
1677
+ const parsed = JSON.parse(raw);
1678
+ const locale = typeof parsed.locale === "string" && isStudioCaptionLocale(parsed.locale) ? parsed.locale : fallbackLocale;
1679
+ const size = typeof parsed.size === "string" && isCaptionSize(parsed.size) ? parsed.size : "m";
1680
+ return { locale, size };
1681
+ } catch {
1682
+ return { ...DEFAULT_STUDIO_CAPTION_PREFS, locale: fallbackLocale };
1683
+ }
1684
+ }
1685
+ function persistStudioCaptionPrefs(prefs, storage = defaultStorage2()) {
1686
+ storage?.setItem(STORAGE_KEY, JSON.stringify(prefs));
1687
+ }
1688
+ function studioCaptionsBaseUrl(apiUrl, streamId) {
1689
+ if (!apiUrl) return void 0;
1690
+ return `${apiUrl.replace(/\/$/, "")}/public/streams/${encodeURIComponent(streamId)}/captions`;
1691
+ }
1692
+
1693
+ // src/studio/lib/video-effects.ts
1694
+ var DEFAULT_STUDIO_VIDEO_EFFECTS = {
1695
+ background: "none",
1696
+ blurStrength: 50,
1697
+ filter: "none"
1698
+ };
1699
+ var FILTER_CSS = {
1700
+ none: "",
1701
+ grayscale: "grayscale(1)",
1702
+ sepia: "sepia(0.85)",
1703
+ warm: "sepia(0.35) saturate(1.2) hue-rotate(-10deg)",
1704
+ cool: "saturate(0.9) hue-rotate(15deg) brightness(1.05)",
1705
+ bright: "brightness(1.15) contrast(1.08)"
1706
+ };
1707
+ function defaultStudioEffectBackgrounds(cdnBase = "https://hls.livo.tv") {
1708
+ const root = cdnBase.replace(/\/$/, "");
1709
+ return [
1710
+ {
1711
+ id: "office",
1712
+ url: `${root}/env/backgrounds/office.jpg`,
1713
+ label: "Office"
1714
+ },
1715
+ {
1716
+ id: "lounge",
1717
+ url: `${root}/env/backgrounds/lounge.jpg`,
1718
+ label: "Lounge"
1719
+ },
1720
+ {
1721
+ id: "studio",
1722
+ url: `${root}/env/backgrounds/studio.jpg`,
1723
+ label: "Studio"
1724
+ },
1725
+ { id: "sky", url: `${root}/env/backgrounds/sky.jpg`, label: "Sky" },
1726
+ {
1727
+ id: "forest",
1728
+ url: `${root}/env/backgrounds/forest.jpg`,
1729
+ label: "Forest"
1730
+ },
1731
+ {
1732
+ id: "abstract",
1733
+ url: `${root}/env/backgrounds/abstract.jpg`,
1734
+ label: "Abstract"
1735
+ }
1736
+ ];
1737
+ }
1738
+ function createFilterVideoMiddleware(filter) {
1739
+ const css = FILTER_CSS[filter];
1740
+ if (!css) return null;
1741
+ return async () => {
1742
+ const scratch = document.createElement("canvas");
1743
+ return async (canvas, ctx3) => {
1744
+ if (scratch.width !== canvas.width || scratch.height !== canvas.height) {
1745
+ scratch.width = canvas.width;
1746
+ scratch.height = canvas.height;
1747
+ }
1748
+ const copy = scratch.getContext("2d");
1749
+ if (!copy) return;
1750
+ copy.clearRect(0, 0, scratch.width, scratch.height);
1751
+ copy.drawImage(canvas, 0, 0);
1752
+ ctx3.save();
1753
+ ctx3.filter = css;
1754
+ ctx3.drawImage(scratch, 0, 0);
1755
+ ctx3.restore();
1756
+ };
1757
+ };
1758
+ }
1759
+ async function loadBackgroundTransformer(meeting) {
1760
+ const mod = await import('@cloudflare/realtimekit-virtual-background');
1761
+ const Init = mod.default ?? mod.RealtimeKitVideoBackgroundTransformer ?? null;
1762
+ if (!Init) return null;
1763
+ return Init.init({ meeting });
1764
+ }
1765
+ async function applyStudioVideoEffects(options) {
1766
+ const self = options.self;
1767
+ if (!self?.removeAllVideoMiddlewares || !self.addVideoMiddleware) return;
1768
+ await self.removeAllVideoMiddlewares();
1769
+ const needsSegmentation = options.config.background !== "none";
1770
+ if (needsSegmentation) {
1771
+ await self.setVideoMiddlewareGlobalConfig?.({
1772
+ disablePerFrameCanvasRendering: true
1773
+ });
1774
+ try {
1775
+ const transformer = await loadBackgroundTransformer(options.meeting);
1776
+ if (transformer && options.config.background === "blur") {
1777
+ const strength = Math.min(
1778
+ 100,
1779
+ Math.max(0, options.config.blurStrength)
1780
+ );
1781
+ await self.addVideoMiddleware(
1782
+ await transformer.createBackgroundBlurVideoMiddleware(strength)
1783
+ );
1784
+ } else if (transformer && options.config.background === "image" && options.config.imageUrl) {
1785
+ await self.addVideoMiddleware(
1786
+ await transformer.createStaticBackgroundVideoMiddleware(
1787
+ options.config.imageUrl
1788
+ )
1789
+ );
1790
+ }
1791
+ } catch {
1792
+ }
1793
+ }
1794
+ const filter = createFilterVideoMiddleware(options.config.filter);
1795
+ if (filter) await self.addVideoMiddleware(filter);
1796
+ }
1797
+
1798
+ // src/studio/lib/effects-prefs.ts
1799
+ var STORAGE_KEY2 = "livo-studio-video-effects";
1800
+ function defaultStorage3() {
1801
+ if (typeof window === "undefined") return null;
1802
+ try {
1803
+ return window.localStorage;
1804
+ } catch {
1805
+ return null;
1806
+ }
1807
+ }
1808
+ var BACKGROUNDS = ["none", "blur", "image"];
1809
+ var FILTERS = [
1810
+ "none",
1811
+ "grayscale",
1812
+ "sepia",
1813
+ "warm",
1814
+ "cool",
1815
+ "bright"
1816
+ ];
1817
+ function isBackground(value) {
1818
+ return BACKGROUNDS.includes(value);
1819
+ }
1820
+ function isFilter(value) {
1821
+ return FILTERS.includes(value);
1822
+ }
1823
+ function readStudioVideoEffects(storage = defaultStorage3()) {
1824
+ const raw = storage?.getItem(STORAGE_KEY2);
1825
+ if (!raw) return { ...DEFAULT_STUDIO_VIDEO_EFFECTS };
1826
+ try {
1827
+ const parsed = JSON.parse(raw);
1828
+ const background = typeof parsed.background === "string" && isBackground(parsed.background) ? parsed.background : "none";
1829
+ const filter = typeof parsed.filter === "string" && isFilter(parsed.filter) ? parsed.filter : "none";
1830
+ const blurStrength = typeof parsed.blurStrength === "number" && Number.isFinite(parsed.blurStrength) ? Math.min(100, Math.max(0, parsed.blurStrength)) : 50;
1831
+ const imageUrl = typeof parsed.imageUrl === "string" && parsed.imageUrl ? parsed.imageUrl : void 0;
1832
+ return { background, blurStrength, imageUrl, filter };
1833
+ } catch {
1834
+ return { ...DEFAULT_STUDIO_VIDEO_EFFECTS };
1835
+ }
1836
+ }
1837
+ function persistStudioVideoEffects(config, storage = defaultStorage3()) {
1838
+ storage?.setItem(STORAGE_KEY2, JSON.stringify(config));
1839
+ }
1617
1840
  function StudioDeviceSelectGroup({
1618
1841
  devices,
1619
1842
  selected,
@@ -1692,7 +1915,11 @@ function StudioSettingsDialog({
1692
1915
  meeting,
1693
1916
  labels,
1694
1917
  reducedMotion,
1695
- cameraOn
1918
+ cameraOn,
1919
+ captionPrefs,
1920
+ onCaptionPrefsChange,
1921
+ captionsAvailable,
1922
+ effectBackgrounds
1696
1923
  }) {
1697
1924
  const devices = useMediaDevices(open ? 1 : 0, open);
1698
1925
  const [saved, setSaved] = useState(
@@ -1704,11 +1931,16 @@ function StudioSettingsDialog({
1704
1931
  const [displayName, setDisplayName] = useState("");
1705
1932
  const [savedDisplayName, setSavedDisplayName] = useState("");
1706
1933
  const [savingDisplayName, setSavingDisplayName] = useState(false);
1934
+ const [tab, setTab] = useState("general");
1935
+ const [effects, setEffects] = useState(
1936
+ () => meeting.videoEffects()
1937
+ );
1707
1938
  const { effective } = resolveDeviceSelection(saved, devices);
1708
1939
  useEffect(() => {
1709
1940
  if (!open) return;
1710
1941
  setSaved(readStudioDeviceSelection());
1711
- }, [open]);
1942
+ setEffects(meeting.videoEffects());
1943
+ }, [meeting, open]);
1712
1944
  useEffect(() => {
1713
1945
  if (!open) return;
1714
1946
  const selfName = meeting.participants().find((row) => row.isSelf)?.name?.trim() ?? "";
@@ -1761,76 +1993,306 @@ function StudioSettingsDialog({
1761
1993
  setTestingSpeaker(false);
1762
1994
  }
1763
1995
  }
1764
- return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-md", children: [
1996
+ async function applyEffects(next) {
1997
+ setEffects(next);
1998
+ persistStudioVideoEffects(next);
1999
+ try {
2000
+ await meeting.setVideoEffects(next);
2001
+ } catch {
2002
+ }
2003
+ }
2004
+ const tabs = [
2005
+ { id: "general", label: labels.settingsGeneral },
2006
+ { id: "audio", label: labels.settingsAudio },
2007
+ { id: "video", label: labels.settingsVideo },
2008
+ { id: "effects", label: labels.settingsEffects },
2009
+ { id: "subtitles", label: labels.settingsSubtitles }
2010
+ ];
2011
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-2xl", children: [
1765
2012
  /* @__PURE__ */ jsxs(DialogHeader, { children: [
1766
2013
  /* @__PURE__ */ jsx(DialogTitle, { children: labels.settingsTitle }),
1767
2014
  /* @__PURE__ */ jsx(DialogDescription, { children: labels.settingsDescription })
1768
2015
  ] }),
1769
- /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
1770
- /* @__PURE__ */ jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
1771
- /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
2016
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4 sm:flex-row", children: [
2017
+ /* @__PURE__ */ jsx(
2018
+ "div",
2019
+ {
2020
+ role: "tablist",
2021
+ "aria-label": labels.settings,
2022
+ className: "flex shrink-0 flex-row gap-1 overflow-x-auto sm:w-40 sm:flex-col",
2023
+ children: tabs.map((item) => /* @__PURE__ */ jsx(
2024
+ "button",
2025
+ {
2026
+ type: "button",
2027
+ role: "tab",
2028
+ "aria-selected": tab === item.id,
2029
+ className: cn(
2030
+ "rounded-md px-3 py-1.5 text-left text-sm",
2031
+ tab === item.id ? "bg-muted font-medium" : "text-muted-foreground hover:bg-muted/60"
2032
+ ),
2033
+ onClick: () => setTab(item.id),
2034
+ children: item.label
2035
+ },
2036
+ item.id
2037
+ ))
2038
+ }
2039
+ ),
2040
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1 space-y-4", children: [
2041
+ tab === "general" ? /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
2042
+ /* @__PURE__ */ jsx(Label, { htmlFor: "studio-display-name", children: labels.guestName }),
2043
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
2044
+ /* @__PURE__ */ jsx(
2045
+ Input,
2046
+ {
2047
+ id: "studio-display-name",
2048
+ value: displayName,
2049
+ maxLength: STUDIO_DISPLAY_NAME_MAX,
2050
+ onChange: (event) => setDisplayName(event.target.value)
2051
+ }
2052
+ ),
2053
+ /* @__PURE__ */ jsx(
2054
+ Button,
2055
+ {
2056
+ type: "button",
2057
+ variant: "outline",
2058
+ disabled: displayNameSaveDisabled,
2059
+ onClick: () => void onSaveDisplayName(),
2060
+ children: labels.displayNameSave
2061
+ }
2062
+ )
2063
+ ] })
2064
+ ] }) : null,
2065
+ tab === "audio" ? /* @__PURE__ */ jsxs(Fragment, { children: [
2066
+ /* @__PURE__ */ jsxs("div", { children: [
2067
+ /* @__PURE__ */ jsx("p", { className: "sr-only", children: labels.micLevel }),
2068
+ /* @__PURE__ */ jsx(
2069
+ AudioWaveform,
2070
+ {
2071
+ track: audioTrack,
2072
+ reducedMotion,
2073
+ className: "w-full"
2074
+ }
2075
+ )
2076
+ ] }),
2077
+ /* @__PURE__ */ jsx(
2078
+ StudioDeviceSelectGroup,
2079
+ {
2080
+ devices: { ...devices, video: [] },
2081
+ selected: effective,
2082
+ labels,
2083
+ onChange: (kind, id) => void onChange(kind, id),
2084
+ speakerAction: /* @__PURE__ */ jsx(
2085
+ Button,
2086
+ {
2087
+ type: "button",
2088
+ size: "sm",
2089
+ variant: "outline",
2090
+ disabled: testingSpeaker,
2091
+ onClick: () => void onTestSpeaker(),
2092
+ children: labels.testSpeaker
2093
+ }
2094
+ )
2095
+ }
2096
+ )
2097
+ ] }) : null,
2098
+ tab === "video" ? /* @__PURE__ */ jsxs(Fragment, { children: [
2099
+ /* @__PURE__ */ jsx(
2100
+ SettingsCameraPreview,
2101
+ {
2102
+ track: videoTrack,
2103
+ open,
2104
+ cameraOn,
2105
+ cameraOffLabel: labels.cameraOff
2106
+ }
2107
+ ),
2108
+ /* @__PURE__ */ jsx(
2109
+ StudioDeviceSelectGroup,
2110
+ {
2111
+ devices: { ...devices, audio: [], speaker: [] },
2112
+ selected: effective,
2113
+ labels,
2114
+ onChange: (kind, id) => void onChange(kind, id)
2115
+ }
2116
+ )
2117
+ ] }) : null,
2118
+ tab === "effects" ? /* @__PURE__ */ jsx(
2119
+ EffectsPanel,
2120
+ {
2121
+ labels,
2122
+ effects,
2123
+ backgrounds: effectBackgrounds,
2124
+ onApply: (next) => void applyEffects(next)
2125
+ }
2126
+ ) : null,
2127
+ tab === "subtitles" ? /* @__PURE__ */ jsx(
2128
+ SubtitlesPanel,
2129
+ {
2130
+ labels,
2131
+ prefs: captionPrefs,
2132
+ available: captionsAvailable,
2133
+ onChange: (next) => {
2134
+ persistStudioCaptionPrefs(next);
2135
+ onCaptionPrefsChange(next);
2136
+ }
2137
+ }
2138
+ ) : null
2139
+ ] })
2140
+ ] })
2141
+ ] }) });
2142
+ }
2143
+ function EffectsPanel({
2144
+ labels,
2145
+ effects,
2146
+ backgrounds,
2147
+ onApply
2148
+ }) {
2149
+ const filters = [
2150
+ { id: "none", label: labels.effectFilterNone },
2151
+ { id: "grayscale", label: labels.effectFilterGrayscale },
2152
+ { id: "sepia", label: labels.effectFilterSepia },
2153
+ { id: "warm", label: labels.effectFilterWarm },
2154
+ { id: "cool", label: labels.effectFilterCool },
2155
+ { id: "bright", label: labels.effectFilterBright }
2156
+ ];
2157
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
2158
+ /* @__PURE__ */ jsxs("div", { children: [
2159
+ /* @__PURE__ */ jsx("p", { className: "mb-2 text-sm font-medium", children: labels.settingsEffects }),
2160
+ /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-3 gap-2", children: [
1772
2161
  /* @__PURE__ */ jsx(
1773
- Input,
2162
+ EffectTile,
1774
2163
  {
1775
- id: "studio-display-name",
1776
- value: displayName,
1777
- maxLength: STUDIO_DISPLAY_NAME_MAX,
1778
- onChange: (event) => setDisplayName(event.target.value)
2164
+ label: labels.effectNone,
2165
+ active: effects.background === "none",
2166
+ onClick: () => onApply({ ...effects, background: "none" })
1779
2167
  }
1780
2168
  ),
1781
2169
  /* @__PURE__ */ jsx(
1782
- Button,
2170
+ EffectTile,
1783
2171
  {
1784
- type: "button",
1785
- variant: "outline",
1786
- disabled: displayNameSaveDisabled,
1787
- onClick: () => void onSaveDisplayName(),
1788
- children: labels.displayNameSave
2172
+ label: labels.effectBlur,
2173
+ active: effects.background === "blur" && effects.blurStrength <= 50,
2174
+ onClick: () => onApply({ ...effects, background: "blur", blurStrength: 40 })
2175
+ }
2176
+ ),
2177
+ /* @__PURE__ */ jsx(
2178
+ EffectTile,
2179
+ {
2180
+ label: labels.effectBlurStrong,
2181
+ active: effects.background === "blur" && effects.blurStrength > 50,
2182
+ onClick: () => onApply({ ...effects, background: "blur", blurStrength: 80 })
1789
2183
  }
1790
2184
  )
1791
2185
  ] })
1792
2186
  ] }),
1793
- /* @__PURE__ */ jsx(
1794
- SettingsCameraPreview,
1795
- {
1796
- track: videoTrack,
1797
- open,
1798
- cameraOn,
1799
- cameraOffLabel: labels.cameraOff
1800
- }
1801
- ),
2187
+ backgrounds.length > 0 ? /* @__PURE__ */ jsxs("div", { children: [
2188
+ /* @__PURE__ */ jsx("p", { className: "mb-2 text-sm font-medium", children: labels.effectBackgrounds }),
2189
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-2", children: backgrounds.map((bg) => /* @__PURE__ */ jsx(
2190
+ EffectTile,
2191
+ {
2192
+ label: bg.label,
2193
+ active: effects.background === "image" && effects.imageUrl === bg.url,
2194
+ previewUrl: bg.url,
2195
+ onClick: () => onApply({
2196
+ ...effects,
2197
+ background: "image",
2198
+ imageUrl: bg.url
2199
+ })
2200
+ },
2201
+ bg.id
2202
+ )) })
2203
+ ] }) : null,
1802
2204
  /* @__PURE__ */ jsxs("div", { children: [
1803
- /* @__PURE__ */ jsx("p", { className: "sr-only", children: labels.micLevel }),
1804
- /* @__PURE__ */ jsx(
1805
- AudioWaveform,
2205
+ /* @__PURE__ */ jsx("p", { className: "mb-2 text-sm font-medium", children: labels.effectFilters }),
2206
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: filters.map((filter) => /* @__PURE__ */ jsx(
2207
+ Button,
1806
2208
  {
1807
- track: audioTrack,
1808
- reducedMotion,
1809
- className: "w-full"
1810
- }
1811
- )
1812
- ] }),
1813
- /* @__PURE__ */ jsx(
1814
- StudioDeviceSelectGroup,
1815
- {
1816
- devices,
1817
- selected: effective,
1818
- labels,
1819
- onChange: (kind, id) => void onChange(kind, id),
1820
- speakerAction: /* @__PURE__ */ jsx(
1821
- Button,
2209
+ type: "button",
2210
+ size: "sm",
2211
+ variant: effects.filter === filter.id ? "default" : "outline",
2212
+ onClick: () => onApply({ ...effects, filter: filter.id }),
2213
+ children: filter.label
2214
+ },
2215
+ filter.id
2216
+ )) })
2217
+ ] })
2218
+ ] });
2219
+ }
2220
+ function EffectTile({
2221
+ label,
2222
+ active,
2223
+ previewUrl,
2224
+ onClick
2225
+ }) {
2226
+ return /* @__PURE__ */ jsxs(
2227
+ "button",
2228
+ {
2229
+ type: "button",
2230
+ "aria-pressed": active,
2231
+ onClick,
2232
+ className: cn(
2233
+ "overflow-hidden rounded-lg border text-left text-xs",
2234
+ active ? "ring-primary ring-2" : "border-border"
2235
+ ),
2236
+ children: [
2237
+ /* @__PURE__ */ jsx(
2238
+ "div",
1822
2239
  {
1823
- type: "button",
1824
- size: "sm",
1825
- variant: "outline",
1826
- disabled: testingSpeaker,
1827
- onClick: () => void onTestSpeaker(),
1828
- children: labels.testSpeaker
2240
+ className: "bg-muted aspect-video w-full bg-cover bg-center",
2241
+ style: previewUrl ? { backgroundImage: `url(${previewUrl})` } : void 0
1829
2242
  }
1830
- )
1831
- }
1832
- )
1833
- ] }) });
2243
+ ),
2244
+ /* @__PURE__ */ jsx("p", { className: "truncate px-2 py-1", children: label })
2245
+ ]
2246
+ }
2247
+ );
2248
+ }
2249
+ function SubtitlesPanel({
2250
+ labels,
2251
+ prefs,
2252
+ available,
2253
+ onChange
2254
+ }) {
2255
+ const localeLabel = {
2256
+ en: labels.localeEn,
2257
+ es: labels.localeEs,
2258
+ pt: labels.localePt
2259
+ };
2260
+ const sizes = [
2261
+ { id: "s", label: labels.captionsSizeS },
2262
+ { id: "m", label: labels.captionsSizeM },
2263
+ { id: "l", label: labels.captionsSizeL }
2264
+ ];
2265
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
2266
+ available ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.captionsDelayed }) : /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: labels.captionsNotReady }),
2267
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
2268
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.captionsLanguage }),
2269
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: STUDIO_CAPTION_LOCALES.map((locale) => /* @__PURE__ */ jsx(
2270
+ Button,
2271
+ {
2272
+ type: "button",
2273
+ size: "sm",
2274
+ variant: prefs.locale === locale ? "default" : "outline",
2275
+ onClick: () => onChange({ ...prefs, locale }),
2276
+ children: localeLabel[locale]
2277
+ },
2278
+ locale
2279
+ )) })
2280
+ ] }),
2281
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
2282
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: labels.captionsSize }),
2283
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: sizes.map((size) => /* @__PURE__ */ jsx(
2284
+ Button,
2285
+ {
2286
+ type: "button",
2287
+ size: "sm",
2288
+ variant: prefs.size === size.id ? "default" : "outline",
2289
+ onClick: () => onChange({ ...prefs, size: size.id }),
2290
+ children: size.label
2291
+ },
2292
+ size.id
2293
+ )) })
2294
+ ] })
2295
+ ] });
1834
2296
  }
1835
2297
  function SettingsCameraPreview({
1836
2298
  track,
@@ -1881,7 +2343,11 @@ function StudioGuestWaiting({
1881
2343
  onToggleCamera,
1882
2344
  onToggleMute,
1883
2345
  onOpenSettings,
1884
- onCloseSettings
2346
+ onCloseSettings,
2347
+ captionPrefs,
2348
+ onCaptionPrefsChange,
2349
+ captionsAvailable,
2350
+ effectBackgrounds
1885
2351
  }) {
1886
2352
  const [audioTrack, setAudioTrack] = useState(null);
1887
2353
  const [videoTrack, setVideoTrack] = useState(null);
@@ -1948,7 +2414,11 @@ function StudioGuestWaiting({
1948
2414
  meeting,
1949
2415
  labels,
1950
2416
  reducedMotion,
1951
- cameraOn
2417
+ cameraOn,
2418
+ captionPrefs,
2419
+ onCaptionPrefsChange,
2420
+ captionsAvailable,
2421
+ effectBackgrounds
1952
2422
  }
1953
2423
  )
1954
2424
  ] });
@@ -3573,6 +4043,12 @@ function StudioToolbar({
3573
4043
  stageEnabled,
3574
4044
  canLeaveStage,
3575
4045
  reducedMotion,
4046
+ captionsOn,
4047
+ captionsAvailable,
4048
+ captionsReady,
4049
+ captionPrefs,
4050
+ onCaptionPrefsChange,
4051
+ effectBackgrounds,
3576
4052
  onToggleMute,
3577
4053
  onToggleCamera,
3578
4054
  onToggleScreen,
@@ -3586,38 +4062,60 @@ function StudioToolbar({
3586
4062
  onCloseSettings,
3587
4063
  onOpenPip,
3588
4064
  onClosePip,
4065
+ onToggleCaptions,
3589
4066
  onLeave
3590
4067
  }) {
3591
- const fullscreenSupported = document.fullscreenEnabled === true && typeof document.documentElement.requestFullscreen === "function";
4068
+ const devices = useMediaDevices(1, true);
4069
+ const saved = readStudioDeviceSelection();
4070
+ const { effective } = resolveDeviceSelection(saved, devices);
4071
+ const fullscreenSupported = typeof document !== "undefined" && document.fullscreenEnabled === true && typeof document.documentElement.requestFullscreen === "function";
3592
4072
  async function enterFullscreen() {
3593
4073
  try {
3594
4074
  await document.documentElement.requestFullscreen();
3595
4075
  } catch {
3596
4076
  }
3597
4077
  }
3598
- return /* @__PURE__ */ jsx("footer", { className: "flex flex-wrap items-center justify-center gap-2 border-t px-3 py-3", children: /* @__PURE__ */ jsxs(TooltipProvider, { children: [
4078
+ async function onDeviceChange(kind, deviceId) {
4079
+ persistStudioDeviceSelection(kind, deviceId);
4080
+ try {
4081
+ await meeting.setDevice(kind, deviceId);
4082
+ } catch {
4083
+ }
4084
+ }
4085
+ const showStageModerator = session.role === "moderator" && canManageStage;
4086
+ const showStageGuest = session.role === "guest" && stageEnabled;
4087
+ const captionsLabel = !captionsAvailable ? labels.captions : !captionsReady ? labels.captionsNotReady : captionsOn ? labels.captionsOn : labels.captionsOff;
4088
+ return /* @__PURE__ */ jsx("footer", { className: "border-t px-3 py-3", children: /* @__PURE__ */ jsx(TooltipProvider, { children: /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3599
4089
  showMediaControls ? /* @__PURE__ */ jsxs(Fragment, { children: [
3600
4090
  /* @__PURE__ */ jsx(
3601
- ControlIcon,
4091
+ MediaSplit,
3602
4092
  {
3603
4093
  label: muted ? labels.unmute : labels.mute,
3604
4094
  pressed: muted,
3605
4095
  tone: muted ? "danger" : "default",
3606
4096
  onClick: onToggleMute,
4097
+ pickerLabel: labels.micDevice,
4098
+ options: devices.audio,
4099
+ selected: effective.audio,
4100
+ onSelect: (id) => void onDeviceChange("audio", id),
3607
4101
  children: muted ? /* @__PURE__ */ jsx(MicOff, {}) : /* @__PURE__ */ jsx(Mic, {})
3608
4102
  }
3609
4103
  ),
3610
4104
  /* @__PURE__ */ jsx(
3611
- ControlIcon,
4105
+ MediaSplit,
3612
4106
  {
3613
4107
  label: labels.camera,
3614
4108
  pressed: cameraOn,
3615
4109
  onClick: onToggleCamera,
4110
+ pickerLabel: labels.cameraDevice,
4111
+ options: devices.video,
4112
+ selected: effective.video,
4113
+ onSelect: (id) => void onDeviceChange("video", id),
3616
4114
  children: cameraOn ? /* @__PURE__ */ jsx(Video, {}) : /* @__PURE__ */ jsx(VideoOff, {})
3617
4115
  }
3618
4116
  )
3619
4117
  ] }) : null,
3620
- session.role === "moderator" && canManageStage ? /* @__PURE__ */ jsx(
4118
+ showStageModerator ? /* @__PURE__ */ jsx(
3621
4119
  ControlIcon,
3622
4120
  {
3623
4121
  label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : labels.goOnAir,
@@ -3637,7 +4135,7 @@ function StudioToolbar({
3637
4135
  children: selfStageStatus === "ON_STAGE" ? /* @__PURE__ */ jsx(EyeOff, {}) : /* @__PURE__ */ jsx(Eye, {})
3638
4136
  }
3639
4137
  ) : null,
3640
- session.role === "guest" && stageEnabled ? /* @__PURE__ */ jsx(
4138
+ showStageGuest ? /* @__PURE__ */ jsx(
3641
4139
  ControlIcon,
3642
4140
  {
3643
4141
  label: selfStageStatus === "ON_STAGE" ? labels.goOffAir : selfStageStatus === "REQUESTED_TO_JOIN_STAGE" ? labels.stageRequested : labels.askToGoOnAir,
@@ -3687,6 +4185,56 @@ function StudioToolbar({
3687
4185
  }
3688
4186
  )
3689
4187
  ] }) : /* @__PURE__ */ jsx(ControlIcon, { label: labels.askToShare, onClick: onAskToShare, children: /* @__PURE__ */ jsx(MonitorUp, {}) }) : null,
4188
+ /* @__PURE__ */ jsx(
4189
+ ControlIcon,
4190
+ {
4191
+ label: captionsLabel,
4192
+ pressed: captionsOn,
4193
+ disabled: !captionsAvailable,
4194
+ onClick: onToggleCaptions,
4195
+ children: /* @__PURE__ */ jsx(Captions, {})
4196
+ }
4197
+ ),
4198
+ /* @__PURE__ */ jsxs(DropdownMenu, { children: [
4199
+ /* @__PURE__ */ jsx(
4200
+ DropdownMenuTrigger,
4201
+ {
4202
+ render: /* @__PURE__ */ jsx(
4203
+ Button,
4204
+ {
4205
+ type: "button",
4206
+ size: "icon-lg",
4207
+ variant: "secondary",
4208
+ "aria-label": labels.more
4209
+ }
4210
+ ),
4211
+ children: /* @__PURE__ */ jsx(MoreVertical, {})
4212
+ }
4213
+ ),
4214
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "center", className: "min-w-44", children: [
4215
+ fullscreenSupported ? /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => void enterFullscreen(), children: [
4216
+ /* @__PURE__ */ jsx(Maximize, {}),
4217
+ labels.fullscreen
4218
+ ] }) : null,
4219
+ pipSupported ? /* @__PURE__ */ jsxs(
4220
+ DropdownMenuItem,
4221
+ {
4222
+ onClick: () => {
4223
+ if (pipOpen) onClosePip();
4224
+ else onOpenPip();
4225
+ },
4226
+ children: [
4227
+ /* @__PURE__ */ jsx(PictureInPicture2, {}),
4228
+ pipOpen ? labels.closePip : labels.openPip
4229
+ ]
4230
+ }
4231
+ ) : null,
4232
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: onOpenSettings, children: [
4233
+ /* @__PURE__ */ jsx(Settings, {}),
4234
+ labels.settings
4235
+ ] })
4236
+ ] })
4237
+ ] }),
3690
4238
  /* @__PURE__ */ jsx(
3691
4239
  ControlIcon,
3692
4240
  {
@@ -3707,26 +4255,6 @@ function StudioToolbar({
3707
4255
  children: /* @__PURE__ */ jsx(Users, {})
3708
4256
  }
3709
4257
  ) : null,
3710
- fullscreenSupported ? /* @__PURE__ */ jsx(
3711
- ControlIcon,
3712
- {
3713
- label: labels.fullscreen,
3714
- onClick: () => void enterFullscreen(),
3715
- children: /* @__PURE__ */ jsx(Maximize, {})
3716
- }
3717
- ) : null,
3718
- pipSupported ? /* @__PURE__ */ jsx(
3719
- ControlIcon,
3720
- {
3721
- label: pipOpen ? labels.closePip : labels.openPip,
3722
- pressed: pipOpen,
3723
- onClick: () => {
3724
- if (pipOpen) onClosePip();
3725
- else onOpenPip();
3726
- },
3727
- children: /* @__PURE__ */ jsx(PictureInPicture2, {})
3728
- }
3729
- ) : null,
3730
4258
  /* @__PURE__ */ jsx(
3731
4259
  ControlIcon,
3732
4260
  {
@@ -3744,21 +4272,11 @@ function StudioToolbar({
3744
4272
  meeting,
3745
4273
  reducedMotion,
3746
4274
  cameraOn,
3747
- labels: {
3748
- settingsTitle: labels.settingsTitle,
3749
- settingsDescription: labels.settingsDescription,
3750
- guestName: labels.guestName,
3751
- displayNameSave: labels.displayNameSave,
3752
- displayNameSaved: labels.displayNameSaved,
3753
- displayNameError: labels.displayNameError,
3754
- cameraDevice: labels.cameraDevice,
3755
- micDevice: labels.micDevice,
3756
- speakerDevice: labels.speakerDevice,
3757
- testSpeaker: labels.testSpeaker,
3758
- testSpeakerError: labels.testSpeakerError,
3759
- cameraOff: labels.cameraOff,
3760
- micLevel: labels.micLevel
3761
- }
4275
+ labels,
4276
+ captionPrefs,
4277
+ onCaptionPrefsChange,
4278
+ captionsAvailable,
4279
+ effectBackgrounds
3762
4280
  }
3763
4281
  ),
3764
4282
  session.role === "guest" ? /* @__PURE__ */ jsx(
@@ -3783,7 +4301,141 @@ function StudioToolbar({
3783
4301
  )
3784
4302
  }
3785
4303
  ) : null
3786
- ] }) });
4304
+ ] }) }) });
4305
+ }
4306
+ function MediaSplit({
4307
+ label,
4308
+ pressed,
4309
+ tone = "default",
4310
+ onClick,
4311
+ pickerLabel,
4312
+ options,
4313
+ selected,
4314
+ onSelect,
4315
+ children
4316
+ }) {
4317
+ if (options.length === 0) {
4318
+ return /* @__PURE__ */ jsx(
4319
+ ControlIcon,
4320
+ {
4321
+ label,
4322
+ pressed,
4323
+ tone,
4324
+ onClick,
4325
+ children
4326
+ }
4327
+ );
4328
+ }
4329
+ const iconVariant = tone === "danger" ? "destructive" : pressed ? "default" : "secondary";
4330
+ return /* @__PURE__ */ jsxs("div", { className: "bg-secondary inline-flex h-10 items-center rounded-full p-0.5", children: [
4331
+ /* @__PURE__ */ jsxs(DropdownMenu, { children: [
4332
+ /* @__PURE__ */ jsx(
4333
+ DropdownMenuTrigger,
4334
+ {
4335
+ render: /* @__PURE__ */ jsx(
4336
+ Button,
4337
+ {
4338
+ type: "button",
4339
+ size: "icon-xs",
4340
+ variant: "ghost",
4341
+ className: "text-muted-foreground hover:text-foreground h-9 w-8 rounded-full hover:bg-transparent",
4342
+ "aria-label": pickerLabel
4343
+ }
4344
+ ),
4345
+ children: /* @__PURE__ */ jsx(ChevronUp, { className: "size-3.5" })
4346
+ }
4347
+ ),
4348
+ /* @__PURE__ */ jsx(DropdownMenuContent, { align: "start", side: "top", className: "min-w-52", children: options.map((option) => /* @__PURE__ */ jsxs(
4349
+ DropdownMenuItem,
4350
+ {
4351
+ onClick: () => onSelect(option.deviceId),
4352
+ children: [
4353
+ option.deviceId === selected ? "\u2713 " : "",
4354
+ option.label
4355
+ ]
4356
+ },
4357
+ option.deviceId
4358
+ )) })
4359
+ ] }),
4360
+ /* @__PURE__ */ jsxs(Tooltip, { children: [
4361
+ /* @__PURE__ */ jsx(
4362
+ TooltipTrigger,
4363
+ {
4364
+ render: /* @__PURE__ */ jsx(
4365
+ Button,
4366
+ {
4367
+ type: "button",
4368
+ size: "icon-lg",
4369
+ variant: iconVariant,
4370
+ 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",
4371
+ "aria-label": label,
4372
+ "aria-pressed": pressed,
4373
+ onClick
4374
+ }
4375
+ ),
4376
+ children
4377
+ }
4378
+ ),
4379
+ /* @__PURE__ */ jsx(TooltipContent, { children: label })
4380
+ ] })
4381
+ ] });
4382
+ }
4383
+ var POLL_MS = 2e3;
4384
+ var PLAYOUT_MS = 250;
4385
+ var STUDIO_EXTRA_LAG_MS = 200;
4386
+ function useStudioCaptions({
4387
+ enabled,
4388
+ baseUrl,
4389
+ locale,
4390
+ streamStatus
4391
+ }) {
4392
+ const [playout, setPlayout] = useState(
4393
+ EMPTY_LIVE_CAPTION_PLAYOUT
4394
+ );
4395
+ const cuesRef = useRef([]);
4396
+ useEffect(() => {
4397
+ if (!enabled) {
4398
+ cuesRef.current = [];
4399
+ setPlayout(EMPTY_LIVE_CAPTION_PLAYOUT);
4400
+ return;
4401
+ }
4402
+ if (!baseUrl || !isStudioActiveStatus(streamStatus)) return;
4403
+ let cancelled = false;
4404
+ const url = liveCaptionsUrl(baseUrl, locale);
4405
+ const poll = async () => {
4406
+ const loaded = await loadLiveCaptions(url);
4407
+ if (cancelled || !loaded.ok) return;
4408
+ cuesRef.current = mergeLiveCaptionCues(cuesRef.current, loaded.cues);
4409
+ };
4410
+ void poll();
4411
+ const timer = window.setInterval(() => void poll(), POLL_MS);
4412
+ return () => {
4413
+ cancelled = true;
4414
+ window.clearInterval(timer);
4415
+ };
4416
+ }, [baseUrl, enabled, locale, streamStatus]);
4417
+ useEffect(() => {
4418
+ if (!enabled || !baseUrl || !isStudioActiveStatus(streamStatus)) {
4419
+ setPlayout(EMPTY_LIVE_CAPTION_PLAYOUT);
4420
+ return;
4421
+ }
4422
+ const tick2 = () => {
4423
+ setPlayout(
4424
+ (prev) => advanceLiveCaptionPlayout(
4425
+ prev,
4426
+ cuesRef.current,
4427
+ Date.now(),
4428
+ 0,
4429
+ void 0,
4430
+ STUDIO_EXTRA_LAG_MS
4431
+ )
4432
+ );
4433
+ };
4434
+ tick2();
4435
+ const timer = window.setInterval(tick2, PLAYOUT_MS);
4436
+ return () => window.clearInterval(timer);
4437
+ }, [baseUrl, enabled, streamStatus]);
4438
+ return playout;
3787
4439
  }
3788
4440
  var STAGE_GRANT_VERIFY_MS = 4e3;
3789
4441
  var STUDIO_CONNECT_WATCHDOG_MS = 12e3;
@@ -3802,7 +4454,10 @@ function StudioRoom({
3802
4454
  initialCameraOn = true,
3803
4455
  initialMuted = false,
3804
4456
  initialDevices,
3805
- appDownloadUrl
4457
+ appDownloadUrl,
4458
+ captionsBaseUrl,
4459
+ apiUrl,
4460
+ cdnBase
3806
4461
  }) {
3807
4462
  const [cameraOn, setCameraOn] = useState(initialCameraOn);
3808
4463
  const [screenOn, setScreenOn] = useState(false);
@@ -3824,6 +4479,11 @@ function StudioRoom({
3824
4479
  const [egressError, setEgressError] = useState(null);
3825
4480
  const [shareGrant, setShareGrant] = useState("idle");
3826
4481
  const [downloadAppOpen, setDownloadAppOpen] = useState(false);
4482
+ const [captionsOn, setCaptionsOn] = useState(false);
4483
+ const [captionPrefs, setCaptionPrefs] = useState(
4484
+ () => readStudioCaptionPrefs()
4485
+ );
4486
+ const captionHintShown = useRef(false);
3827
4487
  const [connectTimedOut, setConnectTimedOut] = useState(false);
3828
4488
  const [screenPermission, setScreenPermission] = useState(
3829
4489
  () => meeting.screenSharePermission()
@@ -3849,6 +4509,19 @@ function StudioRoom({
3849
4509
  const pip = useDocumentPictureInPicture();
3850
4510
  const gateFromContext = useStudioWindowGate();
3851
4511
  const { reportDuplicate } = windowGate ?? gateFromContext;
4512
+ const resolvedCaptionsBaseUrl = captionsBaseUrl ?? studioCaptionsBaseUrl(apiUrl, session.stream.id);
4513
+ const captionsAvailable = session.stream.captionsEnabled !== false;
4514
+ const captionsReady = isStudioActiveStatus(session.stream.status);
4515
+ const captionPlayout = useStudioCaptions({
4516
+ enabled: captionsOn && captionsAvailable,
4517
+ baseUrl: resolvedCaptionsBaseUrl,
4518
+ locale: captionPrefs.locale,
4519
+ streamStatus: session.stream.status
4520
+ });
4521
+ const effectBackgrounds = useMemo(
4522
+ () => defaultStudioEffectBackgrounds(cdnBase),
4523
+ [cdnBase]
4524
+ );
3852
4525
  const onAir = isLiveStudioStatus(session.stream.status);
3853
4526
  const active = isStudioActiveStatus(session.stream.status);
3854
4527
  if (onAir && liveStartedAtRef.current === null) {
@@ -4533,6 +5206,10 @@ function StudioRoom({
4533
5206
  labels,
4534
5207
  settingsOpen,
4535
5208
  statusLabel: connectionState === "connecting" ? labels.connecting : labels.waitingHost,
5209
+ captionPrefs,
5210
+ onCaptionPrefsChange: setCaptionPrefs,
5211
+ captionsAvailable,
5212
+ effectBackgrounds,
4536
5213
  onToggleCamera,
4537
5214
  onToggleMute: () => void onToggleMute(),
4538
5215
  onOpenSettings: () => setSettingsOpen(true),
@@ -4640,27 +5317,37 @@ function StudioRoom({
4640
5317
  }
4641
5318
  ) : null,
4642
5319
  /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col md:flex-row", children: [
4643
- /* @__PURE__ */ jsx(
4644
- StudioStage,
4645
- {
4646
- participants: stageParticipants,
4647
- meeting,
4648
- reducedMotion,
4649
- canModerate: session.role === "moderator",
4650
- canManageStage,
4651
- onAir,
4652
- labels,
4653
- onKick: (id) => void onKick(id),
4654
- onMute: (id) => void onHostMedia(id, "audio"),
4655
- onStopCamera: (id) => void onHostMedia(id, "video"),
4656
- onStopScreen: (id) => void onHostMedia(id, "screen"),
4657
- onToggleStage: (tile) => void onSetStage(tile, !isOnStage(tile.stageStatus)),
4658
- onTogglePin: (tile) => void onTogglePin(tile),
4659
- onToggleChat: (tile) => void onToggleChat(tile),
4660
- canTakeOff: (id) => canTakeOffAir(participants, id, onAir),
4661
- onOpenPeople: () => setSideTab("people")
4662
- }
4663
- ),
5320
+ /* @__PURE__ */ jsxs("div", { className: "relative flex min-h-0 min-w-0 flex-1 flex-col", children: [
5321
+ /* @__PURE__ */ jsx(
5322
+ StudioStage,
5323
+ {
5324
+ participants: stageParticipants,
5325
+ meeting,
5326
+ reducedMotion,
5327
+ canModerate: session.role === "moderator",
5328
+ canManageStage,
5329
+ onAir,
5330
+ labels,
5331
+ onKick: (id) => void onKick(id),
5332
+ onMute: (id) => void onHostMedia(id, "audio"),
5333
+ onStopCamera: (id) => void onHostMedia(id, "video"),
5334
+ onStopScreen: (id) => void onHostMedia(id, "screen"),
5335
+ onToggleStage: (tile) => void onSetStage(tile, !isOnStage(tile.stageStatus)),
5336
+ onTogglePin: (tile) => void onTogglePin(tile),
5337
+ onToggleChat: (tile) => void onToggleChat(tile),
5338
+ canTakeOff: (id) => canTakeOffAir(participants, id, onAir),
5339
+ onOpenPeople: () => setSideTab("people")
5340
+ }
5341
+ ),
5342
+ captionsOn ? /* @__PURE__ */ jsx(
5343
+ CaptionOverlay,
5344
+ {
5345
+ screen: captionPlayout.screen,
5346
+ className: "bottom-3",
5347
+ textClassName: captionSizeClass(captionPrefs.size)
5348
+ }
5349
+ ) : null
5350
+ ] }),
4664
5351
  sideTab ? /* @__PURE__ */ jsx(
4665
5352
  StudioSidePanel,
4666
5353
  {
@@ -4773,6 +5460,12 @@ function StudioRoom({
4773
5460
  stageEnabled,
4774
5461
  canLeaveStage: selfId != null && canTakeOffAir(participants, selfId, onAir),
4775
5462
  reducedMotion,
5463
+ captionsOn,
5464
+ captionsAvailable,
5465
+ captionsReady,
5466
+ captionPrefs,
5467
+ onCaptionPrefsChange: setCaptionPrefs,
5468
+ effectBackgrounds,
4776
5469
  onToggleMute: () => void onToggleMute(),
4777
5470
  onToggleCamera,
4778
5471
  onToggleScreen,
@@ -4800,6 +5493,16 @@ function StudioRoom({
4800
5493
  onCloseSettings: setSettingsOpen,
4801
5494
  onOpenPip,
4802
5495
  onClosePip: pip.close,
5496
+ onToggleCaptions: () => {
5497
+ if (!captionsAvailable) return;
5498
+ if (!captionsOn && !captionHintShown.current) {
5499
+ captionHintShown.current = true;
5500
+ toast.message(
5501
+ captionsReady ? labels.captionsDelayed : labels.captionsNotReady
5502
+ );
5503
+ }
5504
+ setCaptionsOn((current) => !current);
5505
+ },
4803
5506
  onLeave: () => void onLeave()
4804
5507
  }
4805
5508
  ),
@@ -5194,16 +5897,16 @@ function usePrefersReducedMotion() {
5194
5897
  // src/studio/lib/guest-identity.ts
5195
5898
  var NAME_KEY = "livo-studio-display-name";
5196
5899
  var ID_KEY = "livo-studio-guest-id";
5197
- function readStudioGuestIdentity(storage = defaultStorage2()) {
5900
+ function readStudioGuestIdentity(storage = defaultStorage4()) {
5198
5901
  const guestId = storage?.getItem(ID_KEY)?.trim() || crypto.randomUUID();
5199
5902
  storage?.setItem(ID_KEY, guestId);
5200
5903
  const displayName = storage?.getItem(NAME_KEY)?.trim() ?? "";
5201
5904
  return { displayName, guestId };
5202
5905
  }
5203
- function persistStudioGuestName(displayName, storage = defaultStorage2()) {
5906
+ function persistStudioGuestName(displayName, storage = defaultStorage4()) {
5204
5907
  storage?.setItem(NAME_KEY, displayName.trim());
5205
5908
  }
5206
- function defaultStorage2() {
5909
+ function defaultStorage4() {
5207
5910
  if (typeof window === "undefined") return null;
5208
5911
  try {
5209
5912
  return window.localStorage;
@@ -5572,10 +6275,19 @@ function studioMeetingFromRtk(raw) {
5572
6275
  if (typeof window !== "undefined") {
5573
6276
  window.addEventListener("unhandledrejection", onUnhandledRejection);
5574
6277
  }
6278
+ let videoEffects = readStudioVideoEffects();
6279
+ async function applyCurrentVideoEffects() {
6280
+ await applyStudioVideoEffects({
6281
+ self: meeting.self,
6282
+ meeting,
6283
+ config: videoEffects
6284
+ });
6285
+ }
5575
6286
  async function republishCameraIfOn() {
5576
6287
  if (meeting.self?.videoEnabled !== true) return;
5577
6288
  await meeting.self.disableVideo?.();
5578
6289
  await meeting.self.enableVideo?.();
6290
+ await applyCurrentVideoEffects();
5579
6291
  }
5580
6292
  async function acceptAllowedStageIfOffAir() {
5581
6293
  if (currentStageStatus() !== "OFF_STAGE") return;
@@ -5763,6 +6475,7 @@ function studioMeetingFromRtk(raw) {
5763
6475
  async join() {
5764
6476
  await meeting.join?.();
5765
6477
  await joinStageIfAllowed();
6478
+ await applyCurrentVideoEffects();
5766
6479
  },
5767
6480
  async leave() {
5768
6481
  if (typeof window !== "undefined") {
@@ -5775,8 +6488,10 @@ function studioMeetingFromRtk(raw) {
5775
6488
  else await meeting.self?.disableAudio?.();
5776
6489
  },
5777
6490
  async setVideoEnabled(enabled) {
5778
- if (enabled) await meeting.self?.enableVideo?.();
5779
- else await meeting.self?.disableVideo?.();
6491
+ if (enabled) {
6492
+ await meeting.self?.enableVideo?.();
6493
+ await applyCurrentVideoEffects();
6494
+ } else await meeting.self?.disableVideo?.();
5780
6495
  },
5781
6496
  async setScreenShareEnabled(enabled) {
5782
6497
  if (enabled) await meeting.self?.enableScreenShare?.();
@@ -6160,6 +6875,14 @@ function studioMeetingFromRtk(raw) {
6160
6875
  }
6161
6876
  });
6162
6877
  },
6878
+ async setVideoEffects(config) {
6879
+ videoEffects = config;
6880
+ persistStudioVideoEffects(config);
6881
+ await applyCurrentVideoEffects();
6882
+ },
6883
+ videoEffects() {
6884
+ return videoEffects;
6885
+ },
6163
6886
  async setDisplayName(name) {
6164
6887
  const trimmed = name.trim().slice(0, STUDIO_DISPLAY_NAME_MAX);
6165
6888
  if (!trimmed) return;
@@ -6391,6 +7114,21 @@ async function getPublicStreamStatus(streamId, apiUrl = defaultApiUrl(), fetchFn
6391
7114
  return null;
6392
7115
  }
6393
7116
  }
7117
+ async function getPublicStreamMeta(streamId, apiUrl = defaultApiUrl(), fetchFn = fetch) {
7118
+ try {
7119
+ const body = await publicJson(
7120
+ `${apiUrl.replace(/\/$/, "")}/public/streams/${encodeURIComponent(streamId)}`,
7121
+ {},
7122
+ fetchFn
7123
+ );
7124
+ return parsePublicStreamMeta(body);
7125
+ } catch (error) {
7126
+ if (error instanceof StudioApiError && (error.status === 404 || error.status === 410)) {
7127
+ return { status: "ended", captionsEnabled: false };
7128
+ }
7129
+ return null;
7130
+ }
7131
+ }
6394
7132
  var POLL_INTERVAL_MS = 5e3;
6395
7133
  function LivoHostStudio({
6396
7134
  token,
@@ -6406,6 +7144,9 @@ function LivoHostStudio({
6406
7144
  const reducedMotion = usePrefersReducedMotion();
6407
7145
  const [session, setSession] = useState(null);
6408
7146
  const [status, setStatus] = useState(null);
7147
+ const [captionsEnabled, setCaptionsEnabled] = useState(
7148
+ void 0
7149
+ );
6409
7150
  const [error, setError] = useState(null);
6410
7151
  const { meeting, error: meetingError } = useRealtimeKitMeeting(
6411
7152
  session?.authToken ?? null
@@ -6454,6 +7195,20 @@ function LivoHostStudio({
6454
7195
  window.clearInterval(id);
6455
7196
  };
6456
7197
  }, [client, status]);
7198
+ useEffect(() => {
7199
+ if (!session) return;
7200
+ let cancelled = false;
7201
+ const poll = async () => {
7202
+ const next = await getPublicStreamMeta(session.stream.id, apiUrl);
7203
+ if (!cancelled && next) setCaptionsEnabled(next.captionsEnabled);
7204
+ };
7205
+ void poll();
7206
+ const id = window.setInterval(() => void poll(), POLL_INTERVAL_MS);
7207
+ return () => {
7208
+ cancelled = true;
7209
+ window.clearInterval(id);
7210
+ };
7211
+ }, [apiUrl, session]);
6457
7212
  const liveSession = useMemo(() => {
6458
7213
  if (!session) return null;
6459
7214
  if (!status) return session;
@@ -6461,10 +7216,11 @@ function LivoHostStudio({
6461
7216
  ...session,
6462
7217
  stream: {
6463
7218
  ...session.stream,
6464
- status
7219
+ status,
7220
+ captionsEnabled: captionsEnabled ?? session.stream.captionsEnabled
6465
7221
  }
6466
7222
  };
6467
- }, [session, status]);
7223
+ }, [captionsEnabled, session, status]);
6468
7224
  if (error || meetingError) {
6469
7225
  return /* @__PURE__ */ jsx("p", { className: "text-destructive p-6", role: "alert", children: error ?? meetingError });
6470
7226
  }
@@ -6482,7 +7238,8 @@ function LivoHostStudio({
6482
7238
  embed,
6483
7239
  chrome,
6484
7240
  playerOrigin,
6485
- appDownloadUrl
7241
+ appDownloadUrl,
7242
+ apiUrl
6486
7243
  }
6487
7244
  );
6488
7245
  }
@@ -6501,6 +7258,9 @@ function LivoGuestStudio({
6501
7258
  const reducedMotion = usePrefersReducedMotion();
6502
7259
  const [session, setSession] = useState(null);
6503
7260
  const [status, setStatus] = useState(null);
7261
+ const [captionsEnabled, setCaptionsEnabled] = useState(
7262
+ void 0
7263
+ );
6504
7264
  const [error, setError] = useState(null);
6505
7265
  const [displayName, setDisplayName] = useState("");
6506
7266
  const [guestId, setGuestId] = useState("");
@@ -6604,8 +7364,11 @@ function LivoGuestStudio({
6604
7364
  if (current === "ended" || current === "error") return;
6605
7365
  let cancelled = false;
6606
7366
  const poll = async () => {
6607
- const next = await getPublicStreamStatus(session.stream.id, apiUrl);
6608
- if (!cancelled && next) setStatus(next);
7367
+ const next = await getPublicStreamMeta(session.stream.id, apiUrl);
7368
+ if (!cancelled && next) {
7369
+ setStatus(next.status);
7370
+ setCaptionsEnabled(next.captionsEnabled);
7371
+ }
6609
7372
  };
6610
7373
  void poll();
6611
7374
  const intervalMs = current === "public" ? POLL_INTERVAL_MS2 : PREVIEW_POLL_INTERVAL_MS;
@@ -6620,9 +7383,13 @@ function LivoGuestStudio({
6620
7383
  if (!status) return session;
6621
7384
  return {
6622
7385
  ...session,
6623
- stream: { ...session.stream, status }
7386
+ stream: {
7387
+ ...session.stream,
7388
+ status,
7389
+ captionsEnabled: captionsEnabled ?? session.stream.captionsEnabled
7390
+ }
6624
7391
  };
6625
- }, [session, status]);
7392
+ }, [captionsEnabled, session, status]);
6626
7393
  useEffect(() => {
6627
7394
  if (retryAfterMs == null) return;
6628
7395
  const id = window.setTimeout(() => {
@@ -6683,7 +7450,8 @@ function LivoGuestStudio({
6683
7450
  initialCameraOn: cameraOn,
6684
7451
  initialMuted: muted,
6685
7452
  initialDevices: deviceSelection,
6686
- appDownloadUrl
7453
+ appDownloadUrl,
7454
+ apiUrl
6687
7455
  }
6688
7456
  );
6689
7457
  }
@@ -6859,10 +7627,40 @@ function studioRoomLabels(t) {
6859
7627
  chatPin: t("chatPin"),
6860
7628
  chatUnpin: t("chatUnpin"),
6861
7629
  chatSave: t("chatSave"),
6862
- waitingSkeleton: t("waitingSkeleton")
7630
+ waitingSkeleton: t("waitingSkeleton"),
7631
+ more: t("more"),
7632
+ captions: t("captions"),
7633
+ captionsOn: t("captionsOn"),
7634
+ captionsOff: t("captionsOff"),
7635
+ captionsNotReady: t("captionsNotReady"),
7636
+ captionsDelayed: t("captionsDelayed"),
7637
+ captionsLanguage: t("captionsLanguage"),
7638
+ captionsSize: t("captionsSize"),
7639
+ captionsSizeS: t("captionsSizeS"),
7640
+ captionsSizeM: t("captionsSizeM"),
7641
+ captionsSizeL: t("captionsSizeL"),
7642
+ settingsGeneral: t("settingsGeneral"),
7643
+ settingsAudio: t("settingsAudio"),
7644
+ settingsVideo: t("settingsVideo"),
7645
+ settingsEffects: t("settingsEffects"),
7646
+ settingsSubtitles: t("settingsSubtitles"),
7647
+ effectNone: t("effectNone"),
7648
+ effectBlur: t("effectBlur"),
7649
+ effectBlurStrong: t("effectBlurStrong"),
7650
+ effectBackgrounds: t("effectBackgrounds"),
7651
+ effectFilters: t("effectFilters"),
7652
+ effectFilterNone: t("effectFilterNone"),
7653
+ effectFilterGrayscale: t("effectFilterGrayscale"),
7654
+ effectFilterSepia: t("effectFilterSepia"),
7655
+ effectFilterWarm: t("effectFilterWarm"),
7656
+ effectFilterCool: t("effectFilterCool"),
7657
+ effectFilterBright: t("effectFilterBright"),
7658
+ localeEn: t("localeEn"),
7659
+ localeEs: t("localeEs"),
7660
+ localePt: t("localePt")
6863
7661
  };
6864
7662
  }
6865
7663
 
6866
- export { LivoGuestStudio, LivoHostStudio, StudioApiError, StudioControlClient, StudioControlError, StudioGuestLobby, StudioRoom, StudioSkeleton, StudioWindowGate, StudioWindowGateContext, applyEmbedTheme, controlActionsFromClient, createStudioControlClient, decodeEmbedTheme, getPublicStreamStatus, getStudioJoinStatus, joinStudioGuest, redeemStudioHost, studioErrorCode, studioRoomLabels, usePrefersReducedMotion, useRealtimeKitMeeting, useStudioWindowGate };
7664
+ export { LivoGuestStudio, LivoHostStudio, StudioApiError, StudioControlClient, StudioControlError, StudioGuestLobby, StudioRoom, StudioSkeleton, StudioWindowGate, StudioWindowGateContext, applyEmbedTheme, controlActionsFromClient, createStudioControlClient, decodeEmbedTheme, getPublicStreamMeta, getPublicStreamStatus, getStudioJoinStatus, joinStudioGuest, redeemStudioHost, studioErrorCode, studioRoomLabels, usePrefersReducedMotion, useRealtimeKitMeeting, useStudioWindowGate };
6867
7665
  //# sourceMappingURL=index.js.map
6868
7666
  //# sourceMappingURL=index.js.map