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"