From 74d84470a998c32b1ba7bee08e8679ebacfd43ae Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Thu, 26 Feb 2026 22:31:04 -0500 Subject: [PATCH] Add live captions viewer and language selection --- API.js | 10 +++ App.js | 5 ++ Views/LiveCaptions.js | 152 ++++++++++++++++++++++++++++++++++++++++++ Views/Menu.js | 1 + i18nMessages.js | 36 ++++++++++ 5 files changed, 204 insertions(+) create mode 100644 Views/LiveCaptions.js diff --git a/API.js b/API.js index 0a63c59..92c6cf7 100644 --- a/API.js +++ b/API.js @@ -463,6 +463,16 @@ const API = { }, pingChatPresence() { return postCall("/chat/ping", {}).then((data) => Array.isArray(data?.activeUsers) ? data.activeUsers : []); + }, + // Live captions + getLiveCaptions(sinceSequence, limit = 40) { + const params = { limit }; + if (Number.isFinite(sinceSequence)) params.sinceSequence = sinceSequence; + return getCall("/live-captions/stream", params).then((data) => ({ + latestSequence: Number.isFinite(data?.latestSequence) ? data.latestSequence : 0, + captions: Array.isArray(data?.captions) ? data.captions : [], + availableLanguages: Array.isArray(data?.availableLanguages) ? data.availableLanguages : [], + })); } } diff --git a/App.js b/App.js index 206ad83..4f7a7de 100644 --- a/App.js +++ b/App.js @@ -30,6 +30,7 @@ import NewGroup from './Views/NewGroup.js'; import Slideshow from './Views/Slideshow.js'; import SongPlayer from './Views/SongPlayer.js'; import GlobalChat from './Views/GlobalChat.js'; +import LiveCaptions from './Views/LiveCaptions.js'; import BiblePicker from './Views/BiblePicker.js'; import BibleChapterView from './Views/BibleChapterView.js'; import Bible from './Views/Bible.js'; @@ -429,6 +430,10 @@ export default function App() { name="GlobalChat" component={GlobalChat} /> + { + const normalized = String(lang || "").toLowerCase(); + if (!normalized || normalized === "original") return i18n.t("message.originalLanguage"); + if (normalized === "en") return i18n.t("message.languageEnglish"); + if (normalized === "es") return i18n.t("message.languageSpanish"); + if (normalized === "fr") return i18n.t("message.languageFrench"); + if (normalized === "da") return i18n.t("message.languageDanish"); + return normalized.toUpperCase(); +}; + +const CaptionRow = ({ item, selectedLang }) => { + const translation = item?.translations?.[selectedLang]; + const displayTranslation = translation || item?.original || ""; + const isFallback = !translation && selectedLang && selectedLang !== "original" && selectedLang !== item?.sourceLang; + return ( + + + {item?.sourceLang ? `${i18n.t("message.originalLanguage")}: ${toLangLabel(item.sourceLang)}` : i18n.t("message.originalLanguage")} + + + {item?.original || ""} + + + {i18n.t("message.selectedTranslation")}: {toLangLabel(selectedLang)} + + + {displayTranslation} + + {isFallback ? ( + + {i18n.t("message.translationFallback")} + + ) : <>} + + ); +}; + +const LiveCaptions = () => { + const [captions, setCaptions] = React.useState([]); + const [availableLanguages, setAvailableLanguages] = React.useState([]); + const [selectedLang, setSelectedLang] = React.useState((i18n?.locale || "en").toLowerCase()); + const [isLoading, setIsLoading] = React.useState(true); + const [langMenuVisible, setLangMenuVisible] = React.useState(false); + const latestSequenceRef = React.useRef(0); + + const refresh = React.useCallback(async (initial = false) => { + const sinceSequence = initial ? undefined : latestSequenceRef.current; + const response = await API.getLiveCaptions(sinceSequence, 50); + const incoming = Array.isArray(response?.captions) ? response.captions : []; + const languages = Array.isArray(response?.availableLanguages) ? response.availableLanguages : []; + + if (initial) { + setCaptions(incoming); + } else if (incoming.length > 0) { + setCaptions((prev) => { + const bySequence = new Map(prev.map((item) => [item.sequence, item])); + incoming.forEach((item) => { + if (!item || !Number.isFinite(item.sequence)) return; + bySequence.set(item.sequence, item); + }); + return Array.from(bySequence.values()).sort((a, b) => (a.sequence || 0) - (b.sequence || 0)).slice(-150); + }); + } + + if (Number.isFinite(response?.latestSequence)) { + latestSequenceRef.current = response.latestSequence; + } else if (incoming.length) { + const maxSeq = incoming.reduce((acc, item) => Math.max(acc, Number(item?.sequence) || 0), latestSequenceRef.current); + latestSequenceRef.current = maxSeq; + } + + setAvailableLanguages(languages); + setIsLoading(false); + }, []); + + React.useEffect(() => { + refresh(true); + const interval = setInterval(() => { + refresh(false); + }, POLL_MS); + return () => clearInterval(interval); + }, [refresh]); + + const languageOptions = React.useMemo(() => { + const unique = new Set(["original", ...availableLanguages]); + return Array.from(unique).filter(Boolean); + }, [availableLanguages]); + + React.useEffect(() => { + if (languageOptions.includes(selectedLang)) return; + if (languageOptions.length) setSelectedLang(languageOptions[0]); + }, [languageOptions, selectedLang]); + + return ( + + + + {i18n.t("message.liveCaptions")} + {i18n.t("message.liveCaptionsSubtitle")} + + + {i18n.t("message.liveNow")} + + + + + {i18n.t("message.translationLanguage")} + setLangMenuVisible(false)} + anchor={ + + } + > + {languageOptions.map((lang) => ( + { + setSelectedLang(lang); + setLangMenuVisible(false); + }} + /> + ))} + + + + {isLoading ? {i18n.t("message.loadingLiveCaptions")} : <>} + {!isLoading && captions.length === 0 ? {i18n.t("message.awaitingLiveCaptions")} : <>} + + `${item?.sequence || "caption"}-${index}`} + renderItem={({ item }) => } + contentContainerStyle={{ paddingBottom: 18 }} + style={{ flex: 1 }} + /> + + ); +}; + +export default LiveCaptions; diff --git a/Views/Menu.js b/Views/Menu.js index f6bd620..f5594de 100644 --- a/Views/Menu.js +++ b/Views/Menu.js @@ -88,6 +88,7 @@ let MenuView = ({ navigation }) => { { navigation.navigate("ProfileSettings") }} left={props => } /> { navigation.navigate("GlobalChat") }} left={props => } /> + { navigation.navigate("LiveCaptions") }} left={props => } /> } /> { navigation.navigate("Logout") }} left={props => } /> diff --git a/i18nMessages.js b/i18nMessages.js index 74a3d36..ae3af9d 100644 --- a/i18nMessages.js +++ b/i18nMessages.js @@ -124,6 +124,15 @@ const messages = { writeMessage: "Write a message...", send: "Send", aiTranslated: "AI Translated", + liveCaptions: "Live Captions", + liveCaptionsSubtitle: "Realtime captions for current service", + liveNow: "Live", + translationLanguage: "Translation language", + selectedTranslation: "Selected translation", + originalLanguage: "Original", + loadingLiveCaptions: "Loading live captions...", + awaitingLiveCaptions: "Waiting for live captions...", + translationFallback: "Translation unavailable for this line. Showing original text.", completeProfileTitle: "Complete your profile", completeProfileBody: "Add a profile photo and short description so people can recognize you.", completeProfileMissingHint: "Looks like your profile is still missing some details.", @@ -270,6 +279,15 @@ const messages = { writeMessage: "Escribe un mensaje...", send: "Enviar", aiTranslated: "Traducido por IA", + liveCaptions: "Subtítulos en vivo", + liveCaptionsSubtitle: "Subtítulos en tiempo real del servicio actual", + liveNow: "En vivo", + translationLanguage: "Idioma de traducción", + selectedTranslation: "Traducción seleccionada", + originalLanguage: "Original", + loadingLiveCaptions: "Cargando subtítulos en vivo...", + awaitingLiveCaptions: "Esperando subtítulos en vivo...", + translationFallback: "No hay traducción para esta línea. Mostrando texto original.", completeProfileTitle: "Completa tu perfil", completeProfileBody: "Agrega una foto de perfil y una breve descripción para que las personas puedan reconocerte.", completeProfileMissingHint: "Parece que a tu perfil todavía le faltan algunos detalles.", @@ -416,6 +434,15 @@ const messages = { writeMessage: "Écrire un message...", send: "Envoyer", aiTranslated: "Traduit par l'IA", + liveCaptions: "Sous-titres en direct", + liveCaptionsSubtitle: "Sous-titres en temps réel du service en cours", + liveNow: "En direct", + translationLanguage: "Langue de traduction", + selectedTranslation: "Traduction sélectionnée", + originalLanguage: "Original", + loadingLiveCaptions: "Chargement des sous-titres en direct...", + awaitingLiveCaptions: "En attente des sous-titres en direct...", + translationFallback: "Traduction indisponible pour cette ligne. Texte original affiché.", completeProfileTitle: "Complétez votre profil", completeProfileBody: "Ajoutez une photo de profil et une courte description pour que les autres puissent vous reconnaître.", completeProfileMissingHint: "Il semble que votre profil manque encore de quelques informations.", @@ -562,6 +589,15 @@ const messages = { writeMessage: "Skriv en besked...", send: "Send", aiTranslated: "AI-oversat", + liveCaptions: "Live undertekster", + liveCaptionsSubtitle: "Realtidsundertekster for nuværende gudstjeneste", + liveNow: "Live", + translationLanguage: "Oversættelsessprog", + selectedTranslation: "Valgt oversættelse", + originalLanguage: "Original", + loadingLiveCaptions: "Indlæser live undertekster...", + awaitingLiveCaptions: "Venter på live undertekster...", + translationFallback: "Oversættelse er ikke tilgængelig for denne linje. Viser original tekst.", completeProfileTitle: "Udfyld din profil", completeProfileBody: "Tilføj et profilbillede og en kort beskrivelse, så andre kan genkende dig.", completeProfileMissingHint: "Det ser ud til, at din profil stadig mangler nogle detaljer.",