Add live captions viewer and language selection
This commit is contained in:
10
API.js
10
API.js
@@ -463,6 +463,16 @@ const API = {
|
|||||||
},
|
},
|
||||||
pingChatPresence() {
|
pingChatPresence() {
|
||||||
return postCall("/chat/ping", {}).then((data) => Array.isArray(data?.activeUsers) ? data.activeUsers : []);
|
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 : [],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
App.js
5
App.js
@@ -30,6 +30,7 @@ import NewGroup from './Views/NewGroup.js';
|
|||||||
import Slideshow from './Views/Slideshow.js';
|
import Slideshow from './Views/Slideshow.js';
|
||||||
import SongPlayer from './Views/SongPlayer.js';
|
import SongPlayer from './Views/SongPlayer.js';
|
||||||
import GlobalChat from './Views/GlobalChat.js';
|
import GlobalChat from './Views/GlobalChat.js';
|
||||||
|
import LiveCaptions from './Views/LiveCaptions.js';
|
||||||
import BiblePicker from './Views/BiblePicker.js';
|
import BiblePicker from './Views/BiblePicker.js';
|
||||||
import BibleChapterView from './Views/BibleChapterView.js';
|
import BibleChapterView from './Views/BibleChapterView.js';
|
||||||
import Bible from './Views/Bible.js';
|
import Bible from './Views/Bible.js';
|
||||||
@@ -429,6 +430,10 @@ export default function App() {
|
|||||||
name="GlobalChat"
|
name="GlobalChat"
|
||||||
component={GlobalChat}
|
component={GlobalChat}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="LiveCaptions"
|
||||||
|
component={LiveCaptions}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="BiblePicker"
|
name="BiblePicker"
|
||||||
component={BiblePicker}
|
component={BiblePicker}
|
||||||
|
|||||||
152
Views/LiveCaptions.js
Normal file
152
Views/LiveCaptions.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, FlatList } from "react-native";
|
||||||
|
import { Text, Chip, Menu, Button } from "react-native-paper";
|
||||||
|
import API from "../API.js";
|
||||||
|
import i18n from "../i18nMessages.js";
|
||||||
|
|
||||||
|
const POLL_MS = 1800;
|
||||||
|
|
||||||
|
const toLangLabel = (lang = "") => {
|
||||||
|
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 (
|
||||||
|
<View style={{ marginBottom: 12, backgroundColor: "#fff", borderRadius: 10, padding: 12, borderWidth: 1, borderColor: "#e5e7eb" }}>
|
||||||
|
<Text style={{ color: "#6b7280", fontSize: 11, marginBottom: 4 }}>
|
||||||
|
{item?.sourceLang ? `${i18n.t("message.originalLanguage")}: ${toLangLabel(item.sourceLang)}` : i18n.t("message.originalLanguage")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: "#111827" }}>
|
||||||
|
{item?.original || ""}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "#6b7280", fontSize: 11, marginTop: 8, marginBottom: 4 }}>
|
||||||
|
{i18n.t("message.selectedTranslation")}: {toLangLabel(selectedLang)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 16, color: "#1f2937" }}>
|
||||||
|
{displayTranslation}
|
||||||
|
</Text>
|
||||||
|
{isFallback ? (
|
||||||
|
<Text style={{ fontSize: 11, color: "#6b7280", marginTop: 6 }}>
|
||||||
|
{i18n.t("message.translationFallback")}
|
||||||
|
</Text>
|
||||||
|
) : <></>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={{ flex: 1, padding: 14 }}>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 24, fontWeight: "700" }}>{i18n.t("message.liveCaptions")}</Text>
|
||||||
|
<Text style={{ color: "#6b7280" }}>{i18n.t("message.liveCaptionsSubtitle")}</Text>
|
||||||
|
</View>
|
||||||
|
<Chip icon="circle" compact style={{ backgroundColor: "#ecfdf5" }} textStyle={{ color: "#047857" }}>
|
||||||
|
{i18n.t("message.liveNow")}
|
||||||
|
</Chip>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ marginBottom: 12 }}>
|
||||||
|
<Text style={{ marginBottom: 6 }}>{i18n.t("message.translationLanguage")}</Text>
|
||||||
|
<Menu
|
||||||
|
visible={langMenuVisible}
|
||||||
|
onDismiss={() => setLangMenuVisible(false)}
|
||||||
|
anchor={
|
||||||
|
<Button mode="outlined" onPress={() => setLangMenuVisible(true)}>
|
||||||
|
{toLangLabel(selectedLang)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{languageOptions.map((lang) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={lang}
|
||||||
|
title={toLangLabel(lang)}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedLang(lang);
|
||||||
|
setLangMenuVisible(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? <Text>{i18n.t("message.loadingLiveCaptions")}</Text> : <></>}
|
||||||
|
{!isLoading && captions.length === 0 ? <Text>{i18n.t("message.awaitingLiveCaptions")}</Text> : <></>}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={captions}
|
||||||
|
keyExtractor={(item, index) => `${item?.sequence || "caption"}-${index}`}
|
||||||
|
renderItem={({ item }) => <CaptionRow item={item} selectedLang={selectedLang} />}
|
||||||
|
contentContainerStyle={{ paddingBottom: 18 }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LiveCaptions;
|
||||||
@@ -88,6 +88,7 @@ let MenuView = ({ navigation }) => {
|
|||||||
<List.Section title={i18n.t("message.userActions")}>
|
<List.Section title={i18n.t("message.userActions")}>
|
||||||
<List.Item key='ProfileEditor' title={i18n.t('message.profile')} onPress={() => { navigation.navigate("ProfileSettings") }} left={props => <List.Icon {...props} icon="person" />} />
|
<List.Item key='ProfileEditor' title={i18n.t('message.profile')} onPress={() => { navigation.navigate("ProfileSettings") }} left={props => <List.Icon {...props} icon="person" />} />
|
||||||
<List.Item key='GlobalChat' title='Global Chat' onPress={() => { navigation.navigate("GlobalChat") }} left={props => <List.Icon {...props} icon="chat" />} />
|
<List.Item key='GlobalChat' title='Global Chat' onPress={() => { navigation.navigate("GlobalChat") }} left={props => <List.Icon {...props} icon="chat" />} />
|
||||||
|
<List.Item key='LiveCaptions' title={i18n.t("message.liveCaptions")} onPress={() => { navigation.navigate("LiveCaptions") }} left={props => <List.Icon {...props} icon="closed-caption" />} />
|
||||||
<List.Item key='Settings' title={i18n.t('message.settings')} left={props => <List.Icon {...props} icon="settings" />} />
|
<List.Item key='Settings' title={i18n.t('message.settings')} left={props => <List.Icon {...props} icon="settings" />} />
|
||||||
<List.Item key="Logout" title={i18n.t('message.logout')} onPress={() => { navigation.navigate("Logout") }} left={props => <List.Icon {...props} icon="logout" />} />
|
<List.Item key="Logout" title={i18n.t('message.logout')} onPress={() => { navigation.navigate("Logout") }} left={props => <List.Icon {...props} icon="logout" />} />
|
||||||
</List.Section>
|
</List.Section>
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ const messages = {
|
|||||||
writeMessage: "Write a message...",
|
writeMessage: "Write a message...",
|
||||||
send: "Send",
|
send: "Send",
|
||||||
aiTranslated: "AI Translated",
|
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",
|
completeProfileTitle: "Complete your profile",
|
||||||
completeProfileBody: "Add a profile photo and short description so people can recognize you.",
|
completeProfileBody: "Add a profile photo and short description so people can recognize you.",
|
||||||
completeProfileMissingHint: "Looks like your profile is still missing some details.",
|
completeProfileMissingHint: "Looks like your profile is still missing some details.",
|
||||||
@@ -270,6 +279,15 @@ const messages = {
|
|||||||
writeMessage: "Escribe un mensaje...",
|
writeMessage: "Escribe un mensaje...",
|
||||||
send: "Enviar",
|
send: "Enviar",
|
||||||
aiTranslated: "Traducido por IA",
|
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",
|
completeProfileTitle: "Completa tu perfil",
|
||||||
completeProfileBody: "Agrega una foto de perfil y una breve descripción para que las personas puedan reconocerte.",
|
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.",
|
completeProfileMissingHint: "Parece que a tu perfil todavía le faltan algunos detalles.",
|
||||||
@@ -416,6 +434,15 @@ const messages = {
|
|||||||
writeMessage: "Écrire un message...",
|
writeMessage: "Écrire un message...",
|
||||||
send: "Envoyer",
|
send: "Envoyer",
|
||||||
aiTranslated: "Traduit par l'IA",
|
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",
|
completeProfileTitle: "Complétez votre profil",
|
||||||
completeProfileBody: "Ajoutez une photo de profil et une courte description pour que les autres puissent vous reconnaître.",
|
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.",
|
completeProfileMissingHint: "Il semble que votre profil manque encore de quelques informations.",
|
||||||
@@ -562,6 +589,15 @@ const messages = {
|
|||||||
writeMessage: "Skriv en besked...",
|
writeMessage: "Skriv en besked...",
|
||||||
send: "Send",
|
send: "Send",
|
||||||
aiTranslated: "AI-oversat",
|
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",
|
completeProfileTitle: "Udfyld din profil",
|
||||||
completeProfileBody: "Tilføj et profilbillede og en kort beskrivelse, så andre kan genkende dig.",
|
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.",
|
completeProfileMissingHint: "Det ser ud til, at din profil stadig mangler nogle detaljer.",
|
||||||
|
|||||||
Reference in New Issue
Block a user