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")}
+
+
+
+ {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.",