diff --git a/assets/icons/pause.svg b/assets/icons/pause.svg new file mode 100644 index 0000000000..1b8f4633ea --- /dev/null +++ b/assets/icons/pause.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/play.svg b/assets/icons/play.svg new file mode 100644 index 0000000000..d6f927ef3b --- /dev/null +++ b/assets/icons/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/playback.svg b/assets/icons/playback.svg new file mode 100644 index 0000000000..e5b0a81bee --- /dev/null +++ b/assets/icons/playback.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/build-configs/BuildConfigType.ts b/build-configs/BuildConfigType.ts index 254d793853..0120dad2ff 100644 --- a/build-configs/BuildConfigType.ts +++ b/build-configs/BuildConfigType.ts @@ -39,6 +39,7 @@ export type FeatureFlagsType = FixedCityType & { cityNotCooperating?: boolean cityNotCooperatingTemplate: string | null chat: boolean + tts: boolean } // Available on all platforms diff --git a/build-configs/aschaffenburg/index.ts b/build-configs/aschaffenburg/index.ts index 1c8ecd663c..eac5b70314 100644 --- a/build-configs/aschaffenburg/index.ts +++ b/build-configs/aschaffenburg/index.ts @@ -40,6 +40,7 @@ const commonAschaffenburgBuildConfig: CommonBuildConfigType = { fixedCity: 'hallo', cityNotCooperatingTemplate: null, chat: false, + tts: false, }, aboutUrls: { default: 'https://www.aschaffenburg.de/halloaschaffenburg', diff --git a/build-configs/common/theme/colors.ts b/build-configs/common/theme/colors.ts index 392fe6003a..ea5e989408 100644 --- a/build-configs/common/theme/colors.ts +++ b/build-configs/common/theme/colors.ts @@ -17,6 +17,8 @@ export type ColorsType = { warningColor: string linkColor: string themeContrast: string + grayBackgroundColor: string + slightlyDarkGray: string } export const commonLightColors = { backgroundAccentColor: '#fafafa', @@ -34,4 +36,6 @@ export const commonLightColors = { invalidInput: '#B3261E', warningColor: '#FFA726', linkColor: '#0b57d0', + grayBackgroundColor: '#dedede', + slightlyDarkGray: '#b9b9b9', } diff --git a/build-configs/integreat-e2e/index.ts b/build-configs/integreat-e2e/index.ts index 1d6b73c2ed..a61c414a80 100644 --- a/build-configs/integreat-e2e/index.ts +++ b/build-configs/integreat-e2e/index.ts @@ -22,6 +22,7 @@ const integreatE2e = { fixedCity: null, cityNotCooperatingTemplate, chat: false, + tts: false, }, } const commonIntegreatE2eBuildConfig: CommonBuildConfigType = { diff --git a/build-configs/integreat-test-cms/index.ts b/build-configs/integreat-test-cms/index.ts index eb54e443ff..5b877643d2 100644 --- a/build-configs/integreat-test-cms/index.ts +++ b/build-configs/integreat-test-cms/index.ts @@ -23,6 +23,7 @@ const integreatTestCms = { fixedCity: null, cityNotCooperatingTemplate, chat: true, + tts: true, }, } export const commonIntegreatTestCmsBuildConfig: CommonBuildConfigType = { diff --git a/build-configs/integreat/index.ts b/build-configs/integreat/index.ts index 338ebb8a36..332a1ce48e 100644 --- a/build-configs/integreat/index.ts +++ b/build-configs/integreat/index.ts @@ -39,6 +39,7 @@ const commonIntegreatBuildConfig: CommonBuildConfigType = { fixedCity: null, cityNotCooperatingTemplate, chat: true, + tts: false, }, aboutUrls: { default: 'https://integreat-app.de/about/', diff --git a/build-configs/malte-test-cms/index.ts b/build-configs/malte-test-cms/index.ts index e5dd09d96f..3543178113 100644 --- a/build-configs/malte-test-cms/index.ts +++ b/build-configs/malte-test-cms/index.ts @@ -23,6 +23,7 @@ const commonMalteTestCmsBuildConfig: CommonBuildConfigType = { fixedCity: null, cityNotCooperatingTemplate: null, chat: false, + tts: false, }, } diff --git a/build-configs/malte/index.ts b/build-configs/malte/index.ts index 0949a56da8..f17c3a52d8 100644 --- a/build-configs/malte/index.ts +++ b/build-configs/malte/index.ts @@ -41,6 +41,7 @@ const commonMalteBuildConfig: CommonBuildConfigType = { fixedCity: null, cityNotCooperatingTemplate: null, chat: false, + tts: false, }, aboutUrls: { default: 'https://www.malteser-werke.de/malte-app', diff --git a/build-configs/obdach/index.ts b/build-configs/obdach/index.ts index 691b890b25..f817dc1528 100644 --- a/build-configs/obdach/index.ts +++ b/build-configs/obdach/index.ts @@ -30,6 +30,7 @@ const commonObdachBuildConfig: CommonBuildConfigType = { fixedCity: null, cityNotCooperatingTemplate: null, chat: false, + tts: false, }, aboutUrls: { default: 'https://tuerantuer.de/digitalfabrik/projekte/netzwerkobdachwohnen/', diff --git a/native/jest.setup.ts b/native/jest.setup.ts index 49c1c05c18..bfb950c7c9 100644 --- a/native/jest.setup.ts +++ b/native/jest.setup.ts @@ -15,6 +15,8 @@ jest.mock('react-native-permissions', () => require('react-native-permissions/mo // https://reactnavigation.org/docs/testing#mocking-native-modules require('react-native-gesture-handler/jestSetup') +jest.mock('react-native-tts') + jest.mock('react-native-reanimated', () => { const Reanimated = require('react-native-reanimated/mock') diff --git a/native/package.json b/native/package.json index 5f13446e4d..d82f8f38eb 100644 --- a/native/package.json +++ b/native/package.json @@ -88,9 +88,11 @@ "react-native-safe-area-context": "^4.10.9", "react-native-screens": "^3.34.0", "react-native-svg": "^15.7.1", + "react-native-tts": "^4.1.1", "react-native-url-polyfill": "^2.0.0", "react-navigation-header-buttons": "^11.2.1", "rrule": "^2.8.1", + "sentencex": "^0.4.2", "shared": "0.0.1", "styled-components": "^6.1.13", "stylis": "^4.3.4", diff --git a/native/src/@types/sentencex.d.ts b/native/src/@types/sentencex.d.ts new file mode 100644 index 0000000000..a3135f774d --- /dev/null +++ b/native/src/@types/sentencex.d.ts @@ -0,0 +1 @@ +declare module 'sentencex' diff --git a/native/src/App.tsx b/native/src/App.tsx index 92146657e5..1df1cf3b6b 100644 --- a/native/src/App.tsx +++ b/native/src/App.tsx @@ -19,6 +19,7 @@ import IOSSafeAreaView from './components/IOSSafeAreaView' import SnackbarContainer from './components/SnackbarContainer' import StaticServerProvider from './components/StaticServerProvider' import StatusBar from './components/StatusBar' +import TtsContainer from './components/TtsContainer' import { RoutesParamsType } from './constants/NavigationTypes' import buildConfig from './constants/buildConfig' import { userAgent } from './constants/endpoint' @@ -87,16 +88,18 @@ const App = (): ReactElement => { - <> - - - - - - - - - + + <> + + + + + + + + + + diff --git a/native/src/assets/index.ts b/native/src/assets/index.ts index fda62b69f3..b516a51f11 100644 --- a/native/src/assets/index.ts +++ b/native/src/assets/index.ts @@ -24,7 +24,10 @@ import MenuIcon from '../../../assets/icons/menu.svg' import NewsIcon from '../../../assets/icons/news.svg' import NoInternetIcon from '../../../assets/icons/no-internet.svg' import NoteIcon from '../../../assets/icons/note.svg' +import PauseIcon from '../../../assets/icons/pause.svg' import PhoneIcon from '../../../assets/icons/phone.svg' +import PlayIcon from '../../../assets/icons/play.svg' +import PlaybackIcon from '../../../assets/icons/playback.svg' import POIsIcon from '../../../assets/icons/pois.svg' import RefreshIcon from '../../../assets/icons/refresh.svg' import SadSmileyIcon from '../../../assets/icons/sad-smiley.svg' @@ -93,4 +96,7 @@ export { TuNewsInactiveIcon, WarningIcon, WebsiteIcon, + PauseIcon, + PlaybackIcon, + PlayIcon, } diff --git a/native/src/components/Categories.tsx b/native/src/components/Categories.tsx index a39f4d6ee2..525f527c96 100644 --- a/native/src/components/Categories.tsx +++ b/native/src/components/Categories.tsx @@ -4,6 +4,7 @@ import { View } from 'react-native' import { CATEGORIES_ROUTE, getCategoryTiles, RouteInformationType } from 'shared' import { CategoriesMapModel, CategoryModel, CityModel } from 'shared/api' +import useTtsPlayer from '../hooks/useTtsPlayer' import testID from '../testing/testID' import { LanguageResourceCacheStateType } from '../utils/DataContainer' import CategoryListItem from './CategoryListItem' @@ -34,6 +35,7 @@ const Categories = ({ }: CategoriesProps): ReactElement => { const children = categories.getChildren(category) const cityCode = cityModel.code + useTtsPlayer(categories.isLeaf(category) ? category : undefined) const navigateToCategory = ({ path }: { path: string }) => navigateTo({ diff --git a/native/src/components/Header.tsx b/native/src/components/Header.tsx index 3286288b13..0259746249 100644 --- a/native/src/components/Header.tsx +++ b/native/src/components/Header.tsx @@ -27,6 +27,7 @@ import buildConfig from '../constants/buildConfig' import dimensions from '../constants/dimensions' import { AppContext } from '../contexts/AppContextProvider' import useSnackbar from '../hooks/useSnackbar' +import useTtsPlayer from '../hooks/useTtsPlayer' import createNavigateToFeedbackModal from '../navigation/createNavigateToFeedbackModal' import navigateToLanguageChange from '../navigation/navigateToLanguageChange' import sendTrackingSignal from '../utils/sendTrackingSignal' @@ -51,6 +52,7 @@ enum HeaderButtonTitle { Language = 'changeLanguage', Location = 'changeLocation', Search = 'search', + ReadAloud = 'readAloud', Share = 'share', Settings = 'settings', Feedback = 'feedback', @@ -83,6 +85,7 @@ const Header = ({ // Save route/canGoBack to state to prevent it from changing during navigating which would lead to flickering of the title and back button const [previousRoute] = useState(navigation.getState().routes[navigation.getState().routes.length - 2]) const [canGoBack] = useState(navigation.canGoBack()) + const { enabled: isTtsEnabled, setVisible: setTtsPlayerVisible, canRead } = useTtsPlayer() const onShare = async () => { if (!shareUrl) { @@ -186,6 +189,17 @@ const Header = ({ renderItem(HeaderButtonTitle.Language, 'language', showItems, goToLanguageChange), ] + const openTtsPlayer = isTtsEnabled + ? [ + renderOverflowItem(t(HeaderButtonTitle.ReadAloud), () => { + setTtsPlayerVisible(canRead) + if (!canRead) { + showSnackbar({ text: t('nothingToReadFullMessage') }) + } + }), + ] + : [] + const overflowItems = showOverflowItems ? [ ...(shareUrl ? [renderOverflowItem(HeaderButtonTitle.Share, onShare)] : []), @@ -193,6 +207,7 @@ const Header = ({ ? [renderOverflowItem(HeaderButtonTitle.Location, () => navigation.navigate(LANDING_ROUTE))] : []), renderOverflowItem(HeaderButtonTitle.Settings, () => navigation.navigate(SETTINGS_ROUTE)), + ...openTtsPlayer, ...(route.name !== NEWS_ROUTE ? [renderOverflowItem(HeaderButtonTitle.Feedback, navigateToFeedback)] : []), ...(route.name !== DISCLAIMER_ROUTE ? [renderOverflowItem(HeaderButtonTitle.Disclaimer, () => navigation.navigate(DISCLAIMER_ROUTE))] diff --git a/native/src/components/News.tsx b/native/src/components/News.tsx index a33d6edea3..e521b6fe1c 100644 --- a/native/src/components/News.tsx +++ b/native/src/components/News.tsx @@ -11,6 +11,7 @@ import { NavigationProps } from '../constants/NavigationTypes' import { contentAlignment } from '../constants/contentDirection' import useNavigate from '../hooks/useNavigate' import useSetRouteTitle from '../hooks/useSetRouteTitle' +import useTtsPlayer from '../hooks/useTtsPlayer' import Failure from './Failure' import List from './List' import LoadingSpinner from './LoadingSpinner' @@ -62,6 +63,7 @@ const News = ({ }: NewsProps): ReactElement => { const selectedNewsItem = news.find(_newsItem => _newsItem.id === newsId) const { t } = useTranslation('news') + useTtsPlayer(selectedNewsItem) const navigation = useNavigate().navigation as NavigationProps useSetRouteTitle({ navigation, title: getPageTitle(selectedNewsType, selectedNewsItem, t) }) diff --git a/native/src/components/Page.tsx b/native/src/components/Page.tsx index 02f3ff387a..4c492069c0 100644 --- a/native/src/components/Page.tsx +++ b/native/src/components/Page.tsx @@ -7,6 +7,7 @@ import dimensions from '../constants/dimensions' import useCityAppContext from '../hooks/useCityAppContext' import useNavigateToLink from '../hooks/useNavigateToLink' import useResourceCache from '../hooks/useResourceCache' +import useTtsPlayer from '../hooks/useTtsPlayer' import { LanguageResourceCacheStateType, PageResourceCacheEntryStateType } from '../utils/DataContainer' import { RESOURCE_CACHE_DIR_PATH } from '../utils/DatabaseConnector' import Caption from './Caption' @@ -17,6 +18,9 @@ import TimeStamp from './TimeStamp' const Container = styled.View<{ $padding: boolean }>` ${props => props.$padding && `padding: 0 ${dimensions.pageContainerPaddingHorizontal}px 8px;`} ` +const SpaceForTts = styled.View<{ $ttsPlayerVisible: boolean }>` + height: ${props => (props.$ttsPlayerVisible ? dimensions.ttsPlayerHeight : 0)}px; +` export type ParsedCacheDictionaryType = Record const createCacheDictionary = ( @@ -60,6 +64,7 @@ const Page = ({ const resourceCacheUrl = useContext(StaticServerContext) const [loading, setLoading] = useState(true) const navigateToLink = useNavigateToLink() + const { visible: ttsPlayerVisible } = useTtsPlayer() const cacheDictionary = useMemo( () => createCacheDictionary(resourceCache, resourceCacheUrl, path), @@ -90,6 +95,7 @@ const Page = ({ {!loading && AfterContent} {!loading && !!content && lastUpdate && } {!loading && Footer} + ) } diff --git a/native/src/components/TtsContainer.tsx b/native/src/components/TtsContainer.tsx new file mode 100644 index 0000000000..3ce2c5d95e --- /dev/null +++ b/native/src/components/TtsContainer.tsx @@ -0,0 +1,174 @@ +import React, { createContext, ReactElement, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AppState, Platform } from 'react-native' +import Tts from 'react-native-tts' + +import { truncate } from 'shared/utils/getExcerpt' + +import buildConfig from '../constants/buildConfig' +import { AppContext } from '../contexts/AppContextProvider' +import { reportError } from '../utils/sentry' +import TtsPlayer from './TtsPlayer' + +const MAX_TITLE_DISPLAY_CHARS = 20 + +export type TtsContextType = { + enabled?: boolean + canRead: boolean + visible: boolean + setVisible: (visible: boolean) => void + sentences: string[] | null + setSentences: (sentences: string[]) => void +} + +export const TtsContext = createContext({ + enabled: false, + canRead: false, + visible: false, + setVisible: () => undefined, + sentences: [], + setSentences: () => undefined, +}) + +type TtsContainerProps = { + children: ReactElement +} + +const TtsContainer = ({ children }: TtsContainerProps): ReactElement => { + const { languageCode } = React.useContext(AppContext) + const { t } = useTranslation('layout') + const [isPlaying, setIsPlaying] = useState(false) + const [sentenceIndex, setSentenceIndex] = useState(0) + const [visible, setVisible] = useState(false) + const [sentences, setSentences] = useState([]) + const title = sentences[0] || t('nothingToRead') + const longTitle = truncate(title, { maxChars: MAX_TITLE_DISPLAY_CHARS }) + const unsupportedLanguagesForTts = ['fa', 'ka', 'kmr'] + + const initializeTts = useCallback((): void => { + Tts.getInitStatus().catch(async error => { + reportError(`Tts-Error: ${error.code}`) + if (error.code === 'no_engine') { + await Tts.requestInstallEngine().catch((e: string) => reportError(`Failed to install tts engine: : ${e}`)) + } + }) + }, []) + + const enabled = + Platform.OS === 'android' && buildConfig().featureFlags.tts && !unsupportedLanguagesForTts.includes(languageCode) + const canRead = enabled && sentences.length > 0 // to check if content is available + + const play = useCallback( + (index = sentenceIndex) => { + Tts.stop() + const sentence = sentences[index] + if (sentence) { + setIsPlaying(true) + Tts.setDefaultLanguage(languageCode) + Tts.speak(sentence, { + androidParams: { + KEY_PARAM_PAN: 0, + KEY_PARAM_VOLUME: 0.6, + KEY_PARAM_STREAM: 'STREAM_MUSIC', + }, + iosVoiceId: '', + rate: 1, + }) + } + }, + [languageCode, sentenceIndex, sentences], + ) + + const stop = async () => { + setIsPlaying(false) + setSentenceIndex(0) + await Tts.stop() + const TTS_STOP_DELAY = 100 + await new Promise(resolve => { + setTimeout(resolve, TTS_STOP_DELAY) + }) + } + + const pause = () => { + Tts.stop() + setIsPlaying(false) + } + + const playNext = useCallback(() => { + const nextIndex = sentenceIndex + 1 + if (nextIndex < sentences.length) { + setSentenceIndex(nextIndex) + play(nextIndex) + } else { + stop() + } + }, [play, sentenceIndex, sentences.length]) + + const playPrevious = () => { + const previousIndex = Math.max(0, sentenceIndex - 1) + setSentenceIndex(previousIndex) + play(previousIndex) + } + + useEffect(() => { + if (!enabled) { + return () => undefined + } + + initializeTts() + Tts.addEventListener('tts-finish', playNext) + return () => Tts.removeAllListeners('tts-finish') + }, [enabled, initializeTts, playNext]) + + useEffect(() => { + const subscription = AppState.addEventListener('change', nextAppState => { + if (nextAppState === 'inactive' || nextAppState === 'background') { + stop() + } + }) + + return subscription.remove + }, []) + + const close = async () => { + setVisible(false) + await stop() + } + + const updateSentences = useCallback((newSentences: string[]) => { + setSentences(newSentences) + stop() + }, []) + + const ttsContextValue = useMemo( + () => ({ + enabled, + canRead, + visible, + setVisible, + sentences, + setSentences: updateSentences, + }), + [enabled, canRead, visible, sentences, updateSentences], + ) + + return ( + + {children} + {visible && ( + + )} + + ) +} + +export default TtsContainer diff --git a/native/src/components/TtsPlayer.tsx b/native/src/components/TtsPlayer.tsx new file mode 100644 index 0000000000..37422cb808 --- /dev/null +++ b/native/src/components/TtsPlayer.tsx @@ -0,0 +1,148 @@ +import React, { ReactElement } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components/native' + +import { CloseIcon, PauseIcon, PlaybackIcon, PlayIcon } from '../assets' +import Icon from './base/Icon' +import IconButton from './base/IconButton' +import Pressable from './base/Pressable' +import Text from './base/Text' + +const elevatedStyle = css` + shadow-color: ${props => props.theme.colors.textColor}; + shadow-offset: 0 2px; + shadow-opacity: 0.2; + shadow-radius: 3px; + elevation: 5; +` + +const StyledTtsPlayer = styled.View<{ $isPlaying: boolean }>` + ${elevatedStyle} + background-color: ${props => props.theme.colors.grayBackgroundColor}; + border-radius: 28px; + width: ${props => (props.$isPlaying ? '90%' : '80%')}; + display: flex; + flex-direction: ${props => (props.$isPlaying ? 'column' : 'row')}; + justify-content: center; + align-items: center; + align-self: center; + position: absolute; + bottom: 5px; + min-height: 93px; + gap: ${props => (props.$isPlaying ? '0px;' : '20px')}; +` + +const verticalMargin = 11 + +const StyledPanel = styled.View<{ $isPlaying?: boolean }>` + display: flex; + flex-direction: row; + align-items: center; + gap: 20px; + margin: ${props => (props.$isPlaying ? verticalMargin : 0)}px 0; +` + +const StyledPlayIcon = styled(IconButton)<{ disabled: boolean }>` + ${elevatedStyle} + background-color: ${props => (props.disabled ? props.theme.colors.textDisabledColor : props.theme.colors.textColor)}; + width: 50px; + height: 50px; + border-radius: 50px; +` + +const StyledBackForthButton = styled(Pressable)` + display: flex; + flex-direction: row; + gap: 5px; + align-items: flex-end; +` + +const PlayButtonIcon = styled(Icon)` + color: ${props => props.theme.colors.grayBackgroundColor}; +` + +const StyledText = styled(Text)` + font-weight: bold; +` + +const StyledPlayerHeaderText = styled(Text)` + font-weight: 600; + align-self: center; + font-size: 18px; +` + +const CloseButton = styled(Pressable)` + ${elevatedStyle} + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + border-radius: 7px; + background-color: ${props => props.theme.colors.themeColor}; + padding: 5px; + gap: 5px; + width: 176px; +` + +const CloseView = styled.View<{ $isPlaying?: boolean }>` + flex-direction: column; + gap: 10px; + margin-bottom: ${props => (props.$isPlaying ? verticalMargin : 0)}px; +` + +type TtsPlayerProps = { + isPlaying: boolean + sentences: string[] + playPrevious: () => void + playNext: () => void + close: () => Promise + pause: () => void + play: () => void + title: string +} + +const TtsPlayer = ({ + isPlaying, + playPrevious, + playNext, + close, + pause, + play, + title, + sentences, +}: TtsPlayerProps): ReactElement => { + const { t } = useTranslation('layout') + return ( + + + {isPlaying && ( + + {t('previous')} + + + )} + (isPlaying ? pause() : play())} + icon={} + /> + {isPlaying && ( + + + {t('next')} + + )} + + + {!isPlaying && {title}} + + + {t('common:close')} + + + + ) +} + +export default TtsPlayer diff --git a/native/src/components/__mocks__/react-native-tts.tsx b/native/src/components/__mocks__/react-native-tts.tsx new file mode 100644 index 0000000000..371b34536d --- /dev/null +++ b/native/src/components/__mocks__/react-native-tts.tsx @@ -0,0 +1,12 @@ +const MockTts = { + speak: jest.fn(), + stop: jest.fn(), + setDefaultLanguage: jest.fn(async () => undefined), + requestInstallEngine: jest.fn(async () => undefined), + addEventListener: jest.fn(async () => undefined), + removeAllListeners: jest.fn(), + getInitStatus: jest.fn(() => Promise.resolve('success')), + addListener: jest.fn(), +} + +export default MockTts diff --git a/native/src/components/__tests__/TtsContainer.spec.tsx b/native/src/components/__tests__/TtsContainer.spec.tsx new file mode 100644 index 0000000000..bbed7d6c9a --- /dev/null +++ b/native/src/components/__tests__/TtsContainer.spec.tsx @@ -0,0 +1,96 @@ +import { act, fireEvent, RenderAPI, screen } from '@testing-library/react-native' +import { mocked } from 'jest-mock' +import { DateTime } from 'luxon' +import React, { useEffect } from 'react' +import Tts from 'react-native-tts' + +import { PageModel } from 'shared/api' + +import buildConfig from '../../constants/buildConfig' +import useTtsPlayer from '../../hooks/useTtsPlayer' +import TestingAppContext from '../../testing/TestingAppContext' +import renderWithTheme from '../../testing/render' +import TtsContainer from '../TtsContainer' + +jest.mock('react-i18next') +jest.mock('react-native-tts') +jest.mock('react-native/Libraries/Utilities/Platform', () => ({ + OS: 'android', + select: jest.fn(), +})) + +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock') + Reanimated.useEvent = jest.fn() + return Reanimated +}) +const mockBuildConfig = (tts: boolean) => { + const previous = buildConfig() + mocked(buildConfig).mockImplementation(() => ({ + ...previous, + featureFlags: { ...previous.featureFlags, tts }, + })) +} +const dummyPage = new PageModel({ + path: '/test-path', + title: 'test', + content: '

This is a test

', + lastUpdate: DateTime.now(), +}) +describe('TtsContainer', () => { + const TestChild = () => { + const { setVisible } = useTtsPlayer(dummyPage) + useEffect(() => { + setVisible(true) + }, [setVisible]) + return null + } + + const renderTtsPlayer = (): RenderAPI => + renderWithTheme( + + + + + , + ) + + beforeEach(() => { + jest.clearAllMocks() + jest.clearAllTimers() + }) + + it('should initialize TTS engine on load', async () => { + mockBuildConfig(true) + renderTtsPlayer() + expect(Tts.getInitStatus).toHaveBeenCalled() + }) + + it('should start reading when the button is pressed', async () => { + renderTtsPlayer() + + // Advance any pending timers or effects + act(() => { + jest.runAllTimers() + }) + + const playButton = screen.getByRole('button', { name: 'play' }) + fireEvent.press(playButton) + + expect(Tts.speak).toHaveBeenCalledWith( + 'test', + expect.objectContaining({ + androidParams: expect.any(Object), + iosVoiceId: '', + rate: 1, + }), + ) + }) + + it('should remove TTS listeners on unmount', () => { + mockBuildConfig(true) + const { unmount } = renderTtsPlayer() + unmount() + expect(Tts.removeAllListeners).toHaveBeenCalledWith('tts-finish') + }) +}) diff --git a/native/src/constants/__mocks__/buildConfig.ts b/native/src/constants/__mocks__/buildConfig.ts index 25fd9aeb86..a3dd482f68 100644 --- a/native/src/constants/__mocks__/buildConfig.ts +++ b/native/src/constants/__mocks__/buildConfig.ts @@ -37,6 +37,7 @@ const buildConfig = jest.fn( fixedCity: null, cityNotCooperatingTemplate: 'template', chat: false, + tts: false, }, aboutUrls: { default: 'https://integreat-app.de/about/', diff --git a/native/src/constants/dimensions.ts b/native/src/constants/dimensions.ts index a6d4e2f67c..b8ef1075c0 100644 --- a/native/src/constants/dimensions.ts +++ b/native/src/constants/dimensions.ts @@ -1,6 +1,7 @@ export type DimensionsType = { headerHeight: number modalHeaderHeight: number + ttsPlayerHeight: number categoryListItem: { iconSize: number margin: number @@ -21,6 +22,7 @@ export type DimensionsType = { const dimensions: DimensionsType = { headerHeight: 60, modalHeaderHeight: 40, + ttsPlayerHeight: 100, categoryListItem: { iconSize: 20, margin: 5, diff --git a/native/src/hooks/useTtsPlayer.ts b/native/src/hooks/useTtsPlayer.ts new file mode 100644 index 0000000000..59c61f22e3 --- /dev/null +++ b/native/src/hooks/useTtsPlayer.ts @@ -0,0 +1,41 @@ +import { useContext, useEffect, useMemo } from 'react' +import segment from 'sentencex' + +import { parseHTML } from 'shared' +import { LocalNewsModel, PageModel, TunewsModel } from 'shared/api' + +import { TtsContext, TtsContextType } from '../components/TtsContainer' +import { AppContext } from '../contexts/AppContextProvider' + +const useTtsPlayer = (model?: PageModel | LocalNewsModel | TunewsModel | undefined): TtsContextType => { + const { languageCode } = useContext(AppContext) + const { setSentences, visible, setVisible, enabled, canRead } = useContext(TtsContext) + const sentences = useMemo(() => { + if (model) { + const content = parseHTML(model.content) + return [model.title, ...segment(languageCode, content)] + } + + return [] + }, [model, languageCode]) + + useEffect(() => { + if (sentences.length) { + setSentences(sentences) + } + return () => { + setSentences([]) + } + }, [sentences, setSentences]) + + return { + enabled, + canRead, + visible, + setVisible, + sentences, + setSentences, + } +} + +export default useTtsPlayer diff --git a/native/src/routes/Events.tsx b/native/src/routes/Events.tsx index 7e4f95dd3c..e0c99bd90f 100644 --- a/native/src/routes/Events.tsx +++ b/native/src/routes/Events.tsx @@ -17,6 +17,7 @@ import LayoutedScrollView from '../components/LayoutedScrollView' import List from '../components/List' import Page from '../components/Page' import PageDetail from '../components/PageDetail' +import useTtsPlayer from '../hooks/useTtsPlayer' const ListContainer = styled(Layout)` padding: 0 8px; @@ -44,6 +45,8 @@ const Events = ({ cityModel, language, navigateTo, events, slug, refresh }: Even const { t } = useTranslation('events') const { startDate, setStartDate, endDate, setEndDate, filteredEvents, startDateError } = useDateFilter(events) const [modalOpen, setModalOpen] = useState(false) + const event = events.find(it => it.slug === slug) + useTtsPlayer(event) if (!cityModel.eventsEnabled) { const error = new NotFoundError({ @@ -60,8 +63,6 @@ const Events = ({ cityModel, language, navigateTo, events, slug, refresh }: Even } if (slug) { - const event = events.find(it => it.slug === slug) - if (event) { return ( }> diff --git a/shared/utils/__tests__/getExcerpt.spec.ts b/shared/utils/__tests__/getExcerpt.spec.ts index d671e4a371..e5234eab03 100644 --- a/shared/utils/__tests__/getExcerpt.spec.ts +++ b/shared/utils/__tests__/getExcerpt.spec.ts @@ -23,6 +23,11 @@ describe('truncate', () => { expect(truncatedText).toBe('First ...') }) + it('should truncate text when the word is longer than maxChars', () => { + const truncatedText = truncate('Migrationssozialdienst (Migration welfare service)', { maxChars: TEST_CUTOFF }) + expect(truncatedText).toBe('Migrat ...') + }) + describe('reverse', () => { it('should truncate text at whitespace after cutoff', () => { const truncatedText = truncate('Before after', { maxChars: TEST_CUTOFF, reverse: true }) diff --git a/shared/utils/getExcerpt.ts b/shared/utils/getExcerpt.ts index e41ebfebbd..2892f76f62 100644 --- a/shared/utils/getExcerpt.ts +++ b/shared/utils/getExcerpt.ts @@ -25,8 +25,9 @@ export const truncate = ( const truncatedText = trimmedText.substring(trimmedText.indexOf(' ', length - actualMaxChars - 1)).trim() return `${ellipsis} ${truncatedText}` } - - const truncatedText = trimmedText.substring(0, trimmedText.lastIndexOf(' ', actualMaxChars)).trim() + const firstSpaceIndex = trimmedText.lastIndexOf(' ', actualMaxChars) + const truncatedTextLength = firstSpaceIndex < 0 || firstSpaceIndex > actualMaxChars ? actualMaxChars : firstSpaceIndex + const truncatedText = trimmedText.substring(0, truncatedTextLength).trim() return `${truncatedText} ${ellipsis}` } diff --git a/translations/translations.json b/translations/translations.json index b3dca1e8d8..7273c8eabd 100644 --- a/translations/translations.json +++ b/translations/translations.json @@ -6487,7 +6487,14 @@ "open": "Öffnen", "view": "Anzeigen", "getOnPlayStore": "Im Google Play Store anschauen", - "accessibility": "Barrierefreiheit" + "accessibility": "Barrierefreiheit", + "readAloud": "Vorlesefunktion", + "nothingToRead": "Nichts zu lesen", + "nothingToReadFullMessage": "Es gibt nichts, um es auf dieser Seite zu lesen.", + "previous": "Zurück", + "next": "Weiter", + "play": "Abspielen", + "pause": "Pause" }, "am": { "imprintAndContact": "ዕትም እና የግንኙነት መረጃ", @@ -6538,6 +6545,11 @@ "sideBarOpenAriaLabel": "فتح الشريط الجانبي", "sideBarCloseAriaLabel": "غلق الشريط الجانبي", "accessibility": "حرية الوصول", + "readAloud": "قراءة بصوت عالٍ", + "nothingToRead": "لا يوجد شيء للقراءة", + "nothingToReadFullMessage": "لا يوجد شيء لقراءته في هذه الصفحة", + "previous": "السابق", + "next": "التالي", "openInApp": "افتح في تطبيق {{appName}}", "open": "افتح", "view": "عرض", @@ -6695,7 +6707,14 @@ "open": "Open", "view": "View", "getOnPlayStore": "GET — On the Google Play Store", - "accessibility": "Accessibility" + "accessibility": "Accessibility", + "readAloud": "Read aloud", + "nothingToRead": "Nothing to read", + "nothingToReadFullMessage": "There is nothing to read on this page.", + "previous": "Prev", + "next": "Next", + "play": "Play", + "pause": "Pause" }, "es": { "imprintAndContact": "Aviso legal y contacto", diff --git a/yarn.lock b/yarn.lock index 314174cd53..fa3dacb679 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13675,6 +13675,11 @@ react-native-swipe-gestures@^1.0.5: resolved "https://registry.yarnpkg.com/react-native-swipe-gestures/-/react-native-swipe-gestures-1.0.5.tgz#a172cb0f3e7478ccd681fd36b8bfbcdd098bde7c" integrity sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw== +react-native-tts@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/react-native-tts/-/react-native-tts-4.1.1.tgz#a6caa23f3e955e913195d6342608cb4489dd8ed1" + integrity sha512-VL0TgCwkUWggbbFGIXAPKC3rM1baluAYtgOdgnaTm7UYsWf/y8n5VgmVB0J2Wa8qt1dldZ1cSsdQY9iz3evcAg== + react-native-url-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" @@ -14536,6 +14541,11 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" +sentencex@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/sentencex/-/sentencex-0.4.2.tgz#df26b8c7ecba752e259958d2e22d20893917564c" + integrity sha512-Pe/ARSCa2oJcg5ARYQsnQJPBEbAH1/h6ZWc80UuKCJO9w94setiFn4tH8iU2Eiogf5MlBMgc6NvL3ZGsguRkBA== + serialize-error@^11.0.1: version "11.0.3" resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-11.0.3.tgz#b54f439e15da5b4961340fbbd376b6b04aa52e92"