feat(mobile): add streaming tts playback

This commit is contained in:
DIYgod 2026-04-10 20:44:09 +08:00
parent 096422e034
commit ad1cf02cd7
28 changed files with 1317 additions and 29 deletions

View File

@ -23,6 +23,7 @@ interface SelectProps<T> {
wrapperStyle?: StyleProp<ViewStyle>
label?: string
disabled?: boolean
triggerTestID?: string
}
export function Select<T>({
options,
@ -33,6 +34,7 @@ export function Select<T>({
wrapperStyle,
label,
disabled,
triggerTestID,
}: SelectProps<T>) {
const [currentValue, setCurrentValue] = useState(() => value)
useEffect(() => {
@ -49,6 +51,7 @@ export function Select<T>({
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild disabled={disabled}>
<View
testID={triggerTestID}
className={cn(
"min-w-24 flex-1 shrink flex-row items-center rounded-lg pl-3",
disabled && "opacity-50",

View File

@ -231,10 +231,22 @@ export const GroupedInsetListCell: FC<
children?: React.ReactNode
icon?: SFSymbol
onPress?: () => void
testID?: string
} & BaseCellClassNames
> = ({ label, description, children, className, leftClassName, rightClassName, icon, onPress }) => {
> = ({
label,
description,
children,
className,
leftClassName,
rightClassName,
icon,
onPress,
testID,
}) => {
return (
<GroupedInsetListBaseCell
testID={testID}
className={cn("flex flex-1 bg-secondary-system-grouped-background", className)}
as={onPress ? Pressable : undefined}
{...(onPress

View File

@ -1,8 +1,9 @@
import { atom, useAtom } from "jotai"
import { useCallback, useEffect } from "react"
import { useCallback, useEffect, useSyncExternalStore } from "react"
import TrackPlayer, { useActiveTrack, useIsPlaying } from "react-native-track-player"
import { PlayerRegistered } from "../initialize/player"
import { ttsStreamController } from "../modules/player/tts-stream-controller"
import { toast } from "./toast"
export type SimpleMediaState = "playing" | "paused" | "loading"
@ -75,6 +76,45 @@ export const player = new Player()
export { useActiveTrack, useIsPlaying, useProgress } from "react-native-track-player"
export interface ActivePlayable {
artwork?: string | null
artist?: string | null
entryId?: string | null
kind: "track-player" | "tts-stream"
title: string
}
export function useTtsStreamPlayback() {
return useSyncExternalStore(ttsStreamController.subscribe, ttsStreamController.getState)
}
export function useActivePlayable(): ActivePlayable | null {
const activeTrack = useActiveTrack()
const ttsStream = useTtsStreamPlayback()
if (ttsStream.entryId) {
return {
artwork: ttsStream.artwork,
artist: ttsStream.artist,
entryId: ttsStream.entryId,
kind: "tts-stream",
title: ttsStream.title ?? "TTS",
}
}
if (!activeTrack) {
return null
}
return {
artwork: activeTrack.artwork,
artist: activeTrack.artist,
entryId: null,
kind: "track-player",
title: activeTrack.title ?? "Unknown Title",
}
}
export const allowedRate = [0.75, 1, 1.25, 1.5, 1.75, 2]
export type Rate = (typeof allowedRate)[number]

View File

@ -18,6 +18,7 @@ import { ContextMenu } from "@/src/components/ui/context-menu"
import { Text } from "@/src/components/ui/typography/Text"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { toast } from "@/src/lib/toast"
import { playEntryTts } from "@/src/modules/player/entry-tts"
import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen"
import { getFetchEntryPayload, useSelectedFeed, useSelectedView } from "../screen/atoms"
@ -182,6 +183,22 @@ export const EntryItemContextMenu = ({
</ContextMenu.Item>
)}
<ContextMenu.Item
key="PlayTts"
onSelect={() => {
void playEntryTts(id, {
toastTitle: t("entry_content.header.play_tts"),
})
}}
>
<ContextMenu.ItemIcon
ios={{
name: "speaker.wave.2",
}}
/>
<ContextMenu.ItemTitle>{t("entry_content.header.play_tts")}</ContextMenu.ItemTitle>
</ContextMenu.Item>
{entry.url && (
<ContextMenu.Item
key="Share"

View File

@ -25,8 +25,10 @@ import { ShareForwardCuteReIcon } from "@/src/icons/share_forward_cute_re"
import { StarCuteFiIcon } from "@/src/icons/star_cute_fi"
import { StarCuteReIcon } from "@/src/icons/star_cute_re"
import { Translate2CuteReIcon } from "@/src/icons/translate_2_cute_re"
import { VoiceCuteReIcon } from "@/src/icons/voice_cute_re"
import { hideIntelligenceGlowEffect, openLink } from "@/src/lib/native"
import { toast } from "@/src/lib/toast"
import { playEntryTts } from "@/src/modules/player/entry-tts"
import { useEntryContentContext } from "./ctx"
@ -172,6 +174,19 @@ const HeaderRightActionsImpl = ({
isCheckbox: true,
inMenu: true,
},
{
key: "PlayTts",
title: t("entry_content.header.play_tts"),
icon: <VoiceCuteReIcon />,
iconIOS: { name: "speaker.wave.2" },
onPress: () => {
void playEntryTts(entryId, {
preferReadability: showReadability,
toastTitle: t("entry_content.header.play_tts"),
})
},
inMenu: true,
},
{
key: "Share",
title: t("operation.share"),
@ -228,7 +243,7 @@ const HeaderRightActionsImpl = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Pressable hitSlop={10} accessibilityLabel="More Actions">
<Pressable testID="entry-more-actions" hitSlop={10} accessibilityLabel="More Actions">
<More1CuteReIcon color={labelColor} />
</Pressable>
</DropdownMenu.Trigger>

View File

@ -8,7 +8,7 @@ import { Image } from "@/src/components/ui/image/Image"
import { Text } from "@/src/components/ui/typography/Text"
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { useActiveTrack } from "@/src/lib/player"
import { useActivePlayable } from "@/src/lib/player"
import { PlayerScreen } from "@/src/screens/PlayerScreen"
import { usePrefetchImageColors } from "@/src/store/image/hooks"
@ -16,15 +16,15 @@ import { PlayPauseButton, SeekButton } from "./control"
const allowedTabIdentifiers = new Set(["IndexTabScreen", "SubscriptionsTabScreen"])
export function GlassPlayerTabBar({ className }: { className?: string }) {
const activeTrack = useActiveTrack()
const activePlayable = useActivePlayable()
const tabRootCtx = use(BottomTabContext)
const tabScreens = useAtomValue(tabRootCtx.tabScreensAtom)
const currentIndex = useAtomValue(tabRootCtx.currentIndexAtom)
const currentTabProps = tabScreens.find((tabScreen) => tabScreen.tabScreenIndex === currentIndex)
const identifier = currentTabProps?.identifier
const isVisible = !!activeTrack && identifier && allowedTabIdentifiers.has(identifier)
const isVisible = !!activePlayable && identifier && allowedTabIdentifiers.has(identifier)
usePrefetchImageColors(activeTrack?.artwork)
usePrefetchImageColors(activePlayable?.artwork ?? undefined)
const navigation = useNavigation()
if (!isVisible) return null
@ -34,6 +34,7 @@ export function GlassPlayerTabBar({ className }: { className?: string }) {
<View className="my-6 h-[56px] flex-1">
<GlassView style={styles.glass} glassEffectStyle="regular" />
<Pressable
testID="player-tab-bar"
onPress={() => {
navigation.presentControllerView(PlayerScreen, void 0, "transparentModal")
}}
@ -41,13 +42,13 @@ export function GlassPlayerTabBar({ className }: { className?: string }) {
<View className="flex flex-row items-center gap-4 overflow-hidden rounded-2xl p-2 px-3">
<Image
source={{
uri: activeTrack?.artwork ?? "",
uri: activePlayable?.artwork ?? "",
}}
className="size-12 rounded-full"
/>
<View className="flex-1 overflow-hidden">
<Text className="text-lg font-semibold text-label" numberOfLines={1}>
{activeTrack?.title ?? ""}
{activePlayable?.title ?? ""}
</Text>
</View>
<View className="mr-2 flex flex-row gap-4">

View File

@ -13,7 +13,7 @@ import { Image } from "@/src/components/ui/image/Image"
import { Text } from "@/src/components/ui/typography/Text"
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { useActiveTrack } from "@/src/lib/player"
import { useActivePlayable } from "@/src/lib/player"
import { PlayerScreen } from "@/src/screens/PlayerScreen"
import { usePrefetchImageColors } from "@/src/store/image/hooks"
@ -21,13 +21,13 @@ import { PlayPauseButton, SeekButton } from "./control"
const allowedTabIdentifiers = new Set(["IndexTabScreen", "SubscriptionsTabScreen"])
export function PlayerTabBar({ className }: { className?: string }) {
const activeTrack = useActiveTrack()
const activePlayable = useActivePlayable()
const tabRootCtx = use(BottomTabContext)
const tabScreens = useAtomValue(tabRootCtx.tabScreensAtom)
const currentIndex = useAtomValue(tabRootCtx.currentIndexAtom)
const currentTabProps = tabScreens.find((tabScreen) => tabScreen.tabScreenIndex === currentIndex)
const identifier = currentTabProps?.identifier
const isVisible = !!activeTrack && identifier && allowedTabIdentifiers.has(identifier)
const isVisible = !!activePlayable && identifier && allowedTabIdentifiers.has(identifier)
const isVisibleSV = useSharedValue(isVisible ? 1 : 0)
useEffect(() => {
isVisibleSV.value = withTiming(isVisible ? 1 : 0)
@ -39,7 +39,7 @@ export function PlayerTabBar({ className }: { className?: string }) {
overflow: "hidden",
}
})
usePrefetchImageColors(activeTrack?.artwork)
usePrefetchImageColors(activePlayable?.artwork ?? undefined)
const navigation = useNavigation()
return (
<Animated.View
@ -47,6 +47,7 @@ export function PlayerTabBar({ className }: { className?: string }) {
className={cn("border-b-hairline border-opaque-separator/50 px-2", className)}
>
<Pressable
testID="player-tab-bar"
onPress={() => {
navigation.presentControllerView(PlayerScreen, void 0, "transparentModal")
}}
@ -54,13 +55,13 @@ export function PlayerTabBar({ className }: { className?: string }) {
<View className="flex flex-row items-center gap-4 overflow-hidden rounded-2xl p-2">
<Image
source={{
uri: activeTrack?.artwork ?? "",
uri: activePlayable?.artwork ?? "",
}}
className="size-12 rounded-lg"
/>
<View className="flex-1 overflow-hidden">
<Text className="text-lg font-semibold text-label" numberOfLines={1}>
{activeTrack?.title ?? ""}
{activePlayable?.title ?? ""}
</Text>
</View>
<View className="mr-2 flex flex-row gap-4">

View File

@ -0,0 +1,53 @@
import { useEffect, useRef } from "react"
import { StyleSheet, View } from "react-native"
import { WebView } from "react-native-webview"
import { ttsStreamController } from "./tts-stream-controller"
import { TTS_STREAM_WEBVIEW_HTML } from "./tts-stream-webview-html"
export const TtsStreamProvider = () => {
const webViewRef = useRef<WebView>(null)
useEffect(() => {
ttsStreamController.attachWebView(webViewRef.current)
return () => {
ttsStreamController.attachWebView(null)
}
}, [])
return (
<View pointerEvents="none" style={styles.container}>
<WebView
ref={webViewRef}
allowsInlineMediaPlayback
androidLayerType="software"
cacheEnabled={false}
javaScriptEnabled
mediaPlaybackRequiresUserAction={false}
onMessage={ttsStreamController.handleMessage}
originWhitelist={["*"]}
scrollEnabled={false}
source={{ html: TTS_STREAM_WEBVIEW_HTML }}
style={styles.webView}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
height: 1,
left: -10_000,
opacity: 0,
pointerEvents: "none",
position: "absolute",
top: 0,
width: 1,
},
webView: {
backgroundColor: "transparent",
height: 1,
width: 1,
},
})

View File

@ -16,11 +16,19 @@ import { StopCircleCuteFiIcon } from "@/src/icons/stop_circle_cute_fi"
import { VolumeCuteReIcon } from "@/src/icons/volume_cute_re"
import { VolumeOffCuteReIcon } from "@/src/icons/volume_off_cute_re"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { allowedRate, player, useIsPlaying, useProgress, useRate } from "@/src/lib/player"
import {
allowedRate,
player,
useIsPlaying,
useProgress,
useRate,
useTtsStreamPlayback,
} from "@/src/lib/player"
import { useVolume } from "@/src/lib/volume"
import { useColor } from "@/src/theme/colors"
import { usePlayerScreenContext } from "./context"
import { ttsStreamController } from "./tts-stream-controller"
type ControlButtonProps = {
size?: number
@ -28,19 +36,33 @@ type ControlButtonProps = {
color?: string
}
export function PlayPauseButton({ size = 24, className, color }: ControlButtonProps) {
const ttsStream = useTtsStreamPlayback()
const { playing } = useIsPlaying()
const isStreamPlaying = ttsStream.status === "playing"
const isStream = !!ttsStream.entryId
const label = useColor("label")
return (
<View className={className}>
<ReAnimatedPressable
entering={ZoomIn.springify()}
exiting={FadeOut}
key={playing ? "pause" : "play"}
key={isStream ? `tts-${ttsStream.status}` : playing ? "pause" : "play"}
onPress={() => {
if (isStream && ttsStream.entryId) {
void ttsStreamController.toggle(ttsStream.entryId)
return
}
playing ? player.pause() : player.play()
}}
>
{playing ? (
{isStream ? (
isStreamPlaying ? (
<PauseCuteFiIcon color={color ?? label} width={size} height={size} />
) : (
<PlayCuteFiIcon color={color ?? label} width={size} height={size} />
)
) : playing ? (
<PauseCuteFiIcon color={color ?? label} width={size} height={size} />
) : (
<PlayCuteFiIcon color={color ?? label} width={size} height={size} />
@ -58,6 +80,10 @@ export function SeekButton({
offset?: number
}) {
const label = useColor("label")
const ttsStream = useTtsStreamPlayback()
if (ttsStream.entryId) {
return null
}
return (
<View className={className}>
<Pressable
@ -81,6 +107,10 @@ export function SeekButton({
export function RateSelector() {
const { isBackgroundLight } = usePlayerScreenContext()
const [currentRate, setCurrentRate] = useRate()
const ttsStream = useTtsStreamPlayback()
if (ttsStream.entryId) {
return null
}
return (
<View className="flex-row items-center justify-center">
<DropdownMenu.Root>
@ -110,13 +140,18 @@ export function RateSelector() {
)
}
export function StopButton({ size = 24, className, color }: ControlButtonProps) {
const ttsStream = useTtsStreamPlayback()
const label = useColor("label")
const navigation = useNavigation()
return (
<Pressable
className={className}
onPress={() => {
player.reset()
if (ttsStream.entryId) {
void ttsStreamController.stop()
} else {
player.reset()
}
navigation.back()
}}
>
@ -125,8 +160,19 @@ export function StopButton({ size = 24, className, color }: ControlButtonProps)
)
}
export function ControlGroup() {
const ttsStream = useTtsStreamPlayback()
const { isBackgroundLight } = usePlayerScreenContext()
const buttonColor = isBackgroundLight ? "black" : "white"
if (ttsStream.entryId) {
return (
<View className="flex-row items-center justify-center gap-6">
<PlayPauseButton size={50} color={buttonColor} />
<StopButton color={buttonColor} />
</View>
)
}
return (
<View className="flex-row items-center justify-between">
<RateSelector />
@ -155,6 +201,10 @@ export function ProgressBar() {
})
const min = useSharedValue(0)
const max = useSharedValue(1)
const ttsStream = useTtsStreamPlayback()
if (ttsStream.entryId) {
return null
}
const trackElapsedTime = formatSecondsToMinutes(position)
const trackRemainingTime = formatSecondsToMinutes(duration - position)
return (
@ -213,6 +263,10 @@ export function VolumeBar() {
const progress = useSharedValue(0)
const min = useSharedValue(0)
const max = useSharedValue(1)
const ttsStream = useTtsStreamPlayback()
if (ttsStream.entryId) {
return null
}
progress.value = volume ?? 0
return (
<View className="mb-10">

View File

@ -0,0 +1,107 @@
import { getEntry } from "@follow/store/entry/getter"
import { getFeedById } from "@follow/store/feed/getter"
import TrackPlayer, { State } from "react-native-track-player"
import { getGeneralSettings } from "@/src/atoms/settings/general"
import { toastFetchError } from "@/src/lib/error-parser"
import { player } from "@/src/lib/player"
import { getEntryTtsText, requestTtsFile } from "./tts-service"
import { ttsStreamController } from "./tts-stream-controller"
let activeTtsEntryId: string | null = null
let activeTtsTrackUrl: string | null = null
const isSameEntryTtsTrack = async (entryId: string) => {
if (ttsStreamController.canToggleEntry(entryId)) {
return true
}
if (!activeTtsEntryId || !activeTtsTrackUrl || activeTtsEntryId !== entryId) {
return false
}
const activeTrack = await TrackPlayer.getActiveTrack()
return activeTrack?.url === activeTtsTrackUrl
}
const toggleCurrentTtsPlayback = async () => {
if (activeTtsEntryId && ttsStreamController.canToggleEntry(activeTtsEntryId)) {
await ttsStreamController.toggle(activeTtsEntryId)
return
}
const { state } = await TrackPlayer.getPlaybackState()
if ([State.Playing, State.Buffering, State.Loading].includes(state)) {
await TrackPlayer.pause()
return
}
await TrackPlayer.play()
}
export const playEntryTts = async (
entryId: string,
{
preferReadability = false,
toastTitle,
}: {
preferReadability?: boolean
toastTitle: string
},
) => {
try {
if (await isSameEntryTtsTrack(entryId)) {
await toggleCurrentTtsPlayback()
return
}
const entry = getEntry(entryId)
if (!entry) {
throw new Error("Entry not found")
}
const text = getEntryTtsText(entry, { preferReadability })
if (!text) {
throw new Error("No content available for TTS")
}
const { voice } = getGeneralSettings()
const feed = getFeedById(entry.feedId)
try {
await ttsStreamController.play({
artwork: entry.media?.find((media) => media.type === "photo")?.url ?? null,
artist: feed?.title ?? "Folo",
entryId,
text,
title: entry.title || toastTitle,
voice,
})
activeTtsEntryId = entryId
activeTtsTrackUrl = null
return
} catch {
// Fall back to the buffered native player when the streaming WebView is not ready.
}
const trackUrl = await requestTtsFile({
cacheKey: entryId,
text,
voice,
})
await player.play({
artwork: entry.media?.find((media) => media.type === "photo")?.url ?? undefined,
artist: feed?.title ?? "Folo",
title: entry.title || toastTitle,
url: trackUrl,
})
activeTtsEntryId = entryId
activeTtsTrackUrl = trackUrl
} catch (error) {
toastFetchError(error as Error, { title: toastTitle })
}
}

View File

@ -0,0 +1,115 @@
import type { EntryModel } from "@follow/store/entry/types"
import { parseHtml } from "@follow/utils/html"
export const TTS_SERVICE_URL = "https://tts.folo.is"
export const DEFAULT_TTS_VOICE = "en-US-AvaMultilingualNeural"
export interface TtsVoice {
FriendlyName: string
Gender: string
Locale: string
ShortName: string
}
interface TtsVoiceResponse {
voices: TtsVoice[]
}
interface TtsErrorResponse {
error?: {
message?: string
}
}
const normalizeTtsText = (value: string) =>
value
.replaceAll("\r\n", "\n")
.replaceAll(/[^\S\n]+/g, " ")
.replaceAll(/\n{3,}/g, "\n\n")
.trim()
const toPlainText = (value: string) => normalizeTtsText(parseHtml(value).toText())
const readTtsErrorMessage = async (response: Response) => {
try {
const data = (await response.clone().json()) as TtsErrorResponse
return data?.error?.message || "TTS request failed"
} catch {
return "TTS request failed"
}
}
export const getEntryTtsText = (
entry: Pick<EntryModel, "content" | "description" | "readabilityContent" | "title">,
options?: {
preferReadability?: boolean
},
) => {
const { preferReadability = false } = options ?? {}
const title = normalizeTtsText(entry.title || "")
const bodySource = preferReadability
? entry.readabilityContent || entry.content || entry.description || ""
: entry.content || entry.description || entry.readabilityContent || ""
const body = bodySource ? toPlainText(bodySource) : ""
return [title, body].filter(Boolean).join("\n\n")
}
export const fetchTtsVoices = async ({
fetch,
signal,
}: {
fetch: typeof globalThis.fetch
signal?: AbortSignal
}) => {
const response = await fetch(`${TTS_SERVICE_URL}/voices`, { signal })
if (!response.ok) {
throw new Error(await readTtsErrorMessage(response))
}
const data = (await response.json()) as TtsVoiceResponse
return data.voices ?? []
}
export const requestTtsBytes = async ({
fetch,
signal,
text,
voice,
}: {
fetch: typeof globalThis.fetch
signal?: AbortSignal
text: string
voice?: string
}) => {
const normalizedText = normalizeTtsText(text)
if (!normalizedText) {
throw new Error("Text is required")
}
const normalizedVoice = voice?.trim()
const response = await fetch(`${TTS_SERVICE_URL}/tts`, {
body: JSON.stringify({
text: normalizedText,
...(normalizedVoice ? { voice: normalizedVoice } : {}),
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
signal,
})
if (!response.ok) {
throw new Error(await readTtsErrorMessage(response))
}
if ("bytes" in response && typeof response.bytes === "function") {
return response.bytes()
}
return new Uint8Array(await response.arrayBuffer())
}

View File

@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
describe("mobile tts service", () => {
it("extracts normalized plain text from entry content", async () => {
const { getEntryTtsText } = await import("./tts-core")
assert.equal(
getEntryTtsText({
title: " Hello world ",
content: "<p>Line 1</p><p>Line 2</p>",
description: "",
readabilityContent: "",
}),
"Hello world\n\nLine 1\n\nLine 2",
)
})
it("posts the normalized text to the TTS service and writes the returned bytes to cache", async () => {
const calls: {
create?: unknown
request?: {
body?: string
headers?: Record<string, string>
method?: string
}
written?: Uint8Array
} = {}
const { requestTtsBytes } = await import("./tts-core")
const bytes = await requestTtsBytes({
fetch: async (_input, init) => {
calls.request = {
body: typeof init?.body === "string" ? init.body : undefined,
headers: init?.headers as Record<string, string>,
method: init?.method,
}
return {
ok: true,
bytes: async () => new Uint8Array([1, 2, 3]),
} as Response & { bytes: () => Promise<Uint8Array> }
},
text: " Hello world ",
voice: "en-US-AvaMultilingualNeural",
})
assert.deepEqual(calls.request, {
body: JSON.stringify({
text: "Hello world",
voice: "en-US-AvaMultilingualNeural",
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
})
assert.deepEqual(bytes, new Uint8Array([1, 2, 3]))
})
})

View File

@ -0,0 +1,78 @@
import { fetch as expoFetch } from "expo/fetch"
import { Directory, File, Paths } from "expo-file-system"
import { fetchTtsVoices as fetchTtsVoicesCore, requestTtsBytes } from "./tts-core"
export { DEFAULT_TTS_VOICE, getEntryTtsText, TTS_SERVICE_URL, type TtsVoice } from "./tts-core"
interface TtsCacheFile {
uri: string
write: (content: Uint8Array) => void
}
interface TtsDependencies {
createCacheFile: (cacheKey: string) => TtsCacheFile
fetch: typeof expoFetch
}
const sanitizeCacheKey = (value: string) =>
value
.trim()
.replaceAll(/[^\w-]+/g, "_")
.replaceAll(/^_+|_+$/g, "") || "tts"
const createCacheFile = (cacheKey: string): TtsCacheFile => {
const directory = new Directory(Paths.cache, "tts")
directory.create({
idempotent: true,
intermediates: true,
})
const file = new File(directory, `${sanitizeCacheKey(cacheKey)}-${Date.now()}.mp3`)
file.create({
intermediates: true,
overwrite: true,
})
return {
uri: file.uri,
write(content) {
file.write(content)
},
}
}
const defaultDependencies: TtsDependencies = {
createCacheFile,
fetch: expoFetch,
}
export const fetchTtsVoices = async (
signal?: AbortSignal,
dependencies: Pick<TtsDependencies, "fetch"> = defaultDependencies,
) => fetchTtsVoicesCore({ fetch: dependencies.fetch as typeof globalThis.fetch, signal })
export const requestTtsFile = async ({
cacheKey,
dependencies = defaultDependencies,
signal,
text,
voice,
}: {
cacheKey: string
dependencies?: TtsDependencies
signal?: AbortSignal
text: string
voice?: string
}) => {
const file = dependencies.createCacheFile(cacheKey)
file.write(
await requestTtsBytes({
fetch: dependencies.fetch as typeof globalThis.fetch,
signal,
text,
voice,
}),
)
return file.uri
}

View File

@ -0,0 +1,288 @@
import type { WebView as WebViewType, WebViewMessageEvent } from "react-native-webview"
export type TtsPlaybackStatus = "idle" | "loading" | "paused" | "playing"
export interface TtsStreamPlaybackState {
artwork?: string | null
artist?: string | null
entryId: string | null
status: TtsPlaybackStatus
title?: string | null
}
type TtsBridgeCommand =
| {
entryId: string
requestId: string
text: string
type: "play"
voice?: string
}
| {
entryId: string
type: "toggle"
}
| {
type: "stop"
}
type TtsBridgeEvent =
| { type: "ready" }
| { entryId: string; requestId: string; type: "started" }
| { entryId: string; type: "playing" | "paused" | "ended" }
| { entryId?: string; message: string; requestId?: string; type: "error" }
const START_TIMEOUT_MS = 10_000
class TtsStreamController {
private listeners = new Set<() => void>()
private playbackState: TtsStreamPlaybackState = {
artwork: null,
artist: null,
entryId: null,
status: "idle",
title: null,
}
private pendingStart: {
reject: (reason?: unknown) => void
requestId: string
resolve: () => void
timeoutId: ReturnType<typeof setTimeout>
} | null = null
private queuedCommands: string[] = []
private ready = false
private readyWaiters = new Set<() => void>()
private webView: WebViewType | null = null
attachWebView = (webView: WebViewType | null) => {
this.webView = webView
if (!webView) {
this.ready = false
}
}
getState = () => this.playbackState
canToggleEntry = (entryId: string) =>
this.playbackState.entryId === entryId && this.playbackState.status !== "loading"
subscribe = (listener: () => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
handleMessage = (event: WebViewMessageEvent) => {
let payload: TtsBridgeEvent
try {
payload = JSON.parse(event.nativeEvent.data) as TtsBridgeEvent
} catch {
return
}
switch (payload.type) {
case "ready": {
this.ready = true
for (const resolve of this.readyWaiters) {
resolve()
}
this.readyWaiters.clear()
this.flushQueuedCommands()
return
}
case "started": {
this.setPlaybackState({
status: "playing",
})
this.resolvePendingStart(payload.requestId)
return
}
case "playing":
case "paused": {
this.setPlaybackState({
status: payload.type,
})
return
}
case "ended": {
if (this.playbackState.entryId === payload.entryId) {
this.resetPlaybackState()
}
return
}
case "error": {
this.rejectPendingStart(payload.requestId, new Error(payload.message))
if (this.playbackState.entryId === payload.entryId) {
this.resetPlaybackState()
}
}
}
}
play = async ({
artwork,
artist,
entryId,
text,
title,
voice,
}: {
artwork?: string | null
artist?: string | null
entryId: string
text: string
title?: string | null
voice?: string
}) => {
await this.waitUntilReady()
const requestId = `${entryId}-${Date.now()}`
this.rejectPendingStart(undefined, new Error("TTS interrupted"))
this.setPlaybackState({
artwork,
artist,
entryId,
status: "loading",
title,
})
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (this.pendingStart?.requestId === requestId) {
this.pendingStart = null
this.resetPlaybackState()
reject(new Error("TTS streaming did not start in time"))
}
}, START_TIMEOUT_MS)
this.pendingStart = {
reject,
requestId,
resolve,
timeoutId,
}
this.sendCommand({
entryId,
requestId,
text,
type: "play",
voice,
})
})
}
toggle = async (entryId: string) => {
await this.waitUntilReady()
this.sendCommand({
entryId,
type: "toggle",
})
}
stop = async () => {
this.rejectPendingStart(undefined, new Error("TTS interrupted"))
this.sendCommand({
type: "stop",
})
this.resetPlaybackState()
}
private flushQueuedCommands = () => {
if (!this.webView || !this.ready) {
return
}
for (const command of this.queuedCommands) {
this.webView.postMessage(command)
}
this.queuedCommands = []
}
private resolvePendingStart = (requestId?: string) => {
if (!this.pendingStart || this.pendingStart.requestId !== requestId) {
return
}
clearTimeout(this.pendingStart.timeoutId)
this.pendingStart.resolve()
this.pendingStart = null
}
private rejectPendingStart = (requestId?: string, error?: Error) => {
if (!this.pendingStart) {
return
}
if (requestId && this.pendingStart.requestId !== requestId) {
return
}
clearTimeout(this.pendingStart.timeoutId)
this.pendingStart.reject(error)
this.pendingStart = null
}
private notify = () => {
for (const listener of this.listeners) {
listener()
}
}
private resetPlaybackState = () => {
this.playbackState = {
artwork: null,
artist: null,
entryId: null,
status: "idle",
title: null,
}
this.notify()
}
private setPlaybackState = (patch: Partial<TtsStreamPlaybackState>) => {
this.playbackState = {
...this.playbackState,
...patch,
}
this.notify()
}
private sendCommand = (command: TtsBridgeCommand) => {
const serialized = JSON.stringify(command)
if (!this.webView || !this.ready) {
this.queuedCommands.push(serialized)
return
}
this.webView.postMessage(serialized)
}
private waitUntilReady = () => {
if (this.ready && this.webView) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.readyWaiters.delete(handleReady)
reject(new Error("TTS streaming player is not ready"))
}, 5_000)
const handleReady = () => {
clearTimeout(timeoutId)
resolve()
}
this.readyWaiters.add(handleReady)
})
}
}
export const ttsStreamController = new TtsStreamController()

View File

@ -0,0 +1,377 @@
export const TTS_STREAM_WEBVIEW_HTML = String.raw`
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<title>TTS Stream</title>
</head>
<body>
<script>
(() => {
const TTS_SERVICE_URL = "https://tts.folo.is";
const TTS_MIME_FALLBACK = "audio/mpeg";
const MIN_INITIAL_DECODE_BYTES = 24 * 1024;
const MIN_INCREMENTAL_DECODE_BYTES = 16 * 1024;
const state = {
abortController: null,
audioContext: null,
chunkBytesSinceLastDecode: 0,
chunks: [],
closed: false,
decodePromise: null,
decodedDuration: 0,
entryId: null,
pendingDecode: false,
reader: null,
requestId: null,
scheduledTime: 0,
status: "idle",
totalLength: 0,
};
const postMessage = (payload) => {
window.ReactNativeWebView?.postMessage(JSON.stringify(payload));
};
const concatChunks = (chunks, totalLength) => {
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return merged.buffer;
};
const getAudioContext = () => {
const AudioContextConstructor =
window.AudioContext || window.webkitAudioContext || null;
if (!AudioContextConstructor) {
throw new Error("Streaming TTS is not supported on this device");
}
return new AudioContextConstructor();
};
const readErrorMessage = async (response) => {
try {
const data = await response.clone().json();
return data?.error?.message || "TTS request failed";
} catch {
return "TTS request failed";
}
};
const stopPlayback = async () => {
if (state.closed) {
return;
}
state.closed = true;
try {
state.abortController?.abort();
} catch {}
try {
await state.reader?.cancel();
} catch {}
try {
await state.audioContext?.close();
} catch {}
state.abortController = null;
state.audioContext = null;
state.chunkBytesSinceLastDecode = 0;
state.chunks = [];
state.closed = false;
state.decodePromise = null;
state.decodedDuration = 0;
state.entryId = null;
state.pendingDecode = false;
state.reader = null;
state.requestId = null;
state.scheduledTime = 0;
state.status = "idle";
state.totalLength = 0;
};
const scheduleDecodedBuffer = (buffer) => {
if (!state.audioContext) {
return;
}
const totalDuration = buffer.duration;
const newDuration = totalDuration - state.decodedDuration;
if (newDuration <= 0) {
state.decodedDuration = Math.max(state.decodedDuration, totalDuration);
return;
}
const sampleRate = buffer.sampleRate;
const startSample = Math.floor(state.decodedDuration * sampleRate);
const endSample = Math.floor(totalDuration * sampleRate);
const frameCount = endSample - startSample;
if (frameCount <= 0) {
return;
}
const segmentBuffer = state.audioContext.createBuffer(
buffer.numberOfChannels,
frameCount,
sampleRate,
);
for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) {
const channelData = new Float32Array(frameCount);
buffer.copyFromChannel(channelData, channel, startSample);
segmentBuffer.copyToChannel(channelData, channel, 0);
}
const source = state.audioContext.createBufferSource();
source.buffer = segmentBuffer;
source.connect(state.audioContext.destination);
source.start(state.scheduledTime);
state.scheduledTime += frameCount / sampleRate;
state.decodedDuration = totalDuration;
if (state.status !== "playing") {
state.status = "playing";
postMessage({
entryId: state.entryId,
requestId: state.requestId,
type: "started",
});
} else {
postMessage({
entryId: state.entryId,
type: "playing",
});
}
};
const decodeChunks = async () => {
if (!state.audioContext || state.closed) {
return;
}
const merged = concatChunks(state.chunks, state.totalLength);
let decoded;
try {
decoded = await state.audioContext.decodeAudioData(merged.slice(0));
} catch {
return;
}
scheduleDecodedBuffer(decoded);
};
const requestDecode = () => {
if (state.decodePromise) {
state.pendingDecode = true;
return;
}
state.decodePromise = decodeChunks()
.catch(() => {})
.finally(() => {
state.decodePromise = null;
if (state.pendingDecode) {
state.pendingDecode = false;
requestDecode();
}
});
};
const processStream = async (response) => {
if (!response.body || !response.body.getReader) {
const buffer = await response.arrayBuffer();
const decoded = await state.audioContext.decodeAudioData(buffer.slice(0));
scheduleDecodedBuffer(decoded);
return;
}
state.reader = response.body.getReader();
while (!state.closed) {
const { done, value } = await state.reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
state.chunks.push(value);
state.totalLength += value.length;
state.chunkBytesSinceLastDecode += value.length;
const threshold =
state.decodedDuration === 0
? MIN_INITIAL_DECODE_BYTES
: MIN_INCREMENTAL_DECODE_BYTES;
if (state.chunkBytesSinceLastDecode >= threshold) {
state.chunkBytesSinceLastDecode = 0;
requestDecode();
}
}
requestDecode();
if (state.decodePromise) {
await state.decodePromise;
}
};
const waitForPlaybackToFinish = async () => {
while (
!state.closed &&
state.audioContext &&
state.audioContext.currentTime < state.scheduledTime
) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
};
const handlePlay = async (payload) => {
const sameEntry = state.entryId === payload.entryId;
if (sameEntry && (state.status === "playing" || state.status === "paused")) {
await handleToggle(payload);
return;
}
await stopPlayback();
state.abortController = new AbortController();
state.audioContext = getAudioContext();
state.chunkBytesSinceLastDecode = 0;
state.chunks = [];
state.decodedDuration = 0;
state.entryId = payload.entryId;
state.requestId = payload.requestId;
state.scheduledTime = state.audioContext.currentTime;
state.status = "loading";
state.totalLength = 0;
try {
await state.audioContext.resume();
const response = await fetch(TTS_SERVICE_URL + "/tts", {
body: JSON.stringify({
text: payload.text,
...(payload.voice ? { voice: payload.voice } : {}),
}),
headers: {
"Content-Type": "application/json",
Accept: TTS_MIME_FALLBACK,
},
method: "POST",
signal: state.abortController.signal,
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
await processStream(response);
await waitForPlaybackToFinish();
postMessage({
entryId: payload.entryId,
type: "ended",
});
} catch (error) {
if (state.abortController?.signal.aborted) {
return;
}
postMessage({
entryId: payload.entryId,
message: error instanceof Error ? error.message : "TTS streaming failed",
requestId: payload.requestId,
type: "error",
});
} finally {
await stopPlayback();
}
};
const handleToggle = async (payload) => {
if (!state.audioContext || state.entryId !== payload.entryId) {
return;
}
if (state.status === "playing") {
await state.audioContext.suspend();
state.status = "paused";
postMessage({
entryId: payload.entryId,
type: "paused",
});
return;
}
if (state.status === "paused") {
await state.audioContext.resume();
state.status = "playing";
postMessage({
entryId: payload.entryId,
type: "playing",
});
}
};
const handleMessage = async (raw) => {
let payload;
try {
payload = JSON.parse(raw);
} catch {
return;
}
if (payload.type === "play") {
await handlePlay(payload);
return;
}
if (payload.type === "toggle") {
await handleToggle(payload);
return;
}
if (payload.type === "stop") {
await stopPlayback();
postMessage({
entryId: state.entryId,
type: "ended",
});
}
};
window.addEventListener("message", (event) => {
void handleMessage(event.data);
});
document.addEventListener("message", (event) => {
void handleMessage(event.data);
});
postMessage({ type: "ready" });
})();
</script>
</body>
</html>
`

View File

@ -1,4 +1,5 @@
import { ACTION_LANGUAGE_KEYS } from "@follow/shared"
import { useQuery } from "@tanstack/react-query"
import i18next from "i18next"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
@ -20,6 +21,7 @@ import {
import { Switch } from "@/src/components/ui/switch/Switch"
import { updateDayjsLocale } from "@/src/lib/i18n"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { fetchTtsVoices } from "@/src/modules/player/tts-service"
const settingSelectWrapperClassName = "w-[200px]"
@ -112,6 +114,46 @@ function TranslationModeSetting() {
)
}
function VoiceSetting() {
const { t } = useTranslation("settings")
const voice = useGeneralSettingKey("voice")
const { data } = useQuery({
queryFn: ({ signal }) => fetchTtsVoices(signal),
queryKey: ["tts-voices"],
staleTime: Number.POSITIVE_INFINITY,
})
const options = useMemo(
() =>
data?.map((item) => ({
label: item.ShortName,
subLabel: item.FriendlyName,
value: item.ShortName,
})) ?? [{ label: voice, value: voice }],
[data, voice],
)
const selectedVoice = data?.find((item) => item.ShortName === voice)
return (
<GroupedInsetListCell
label={t("general.voices")}
testID="general-voice-cell"
rightClassName={settingSelectWrapperClassName}
>
<Select
triggerTestID="general-voice-select"
value={voice}
onValueChange={(value) => {
setGeneralSetting("voice", value)
}}
displayValue={selectedVoice?.ShortName ?? voice}
options={options}
/>
</GroupedInsetListCell>
)
}
export const GeneralScreen: NavigationControllerView = () => {
const { t } = useTranslation("settings")
@ -173,6 +215,11 @@ export const GeneralScreen: NavigationControllerView = () => {
<LanguageSetting settingKey="actionLanguage" />
</GroupedInsetListCard>
<GroupedInsetListSectionHeader label={t("general.tts")} />
<GroupedInsetListCard>
<VoiceSetting />
</GroupedInsetListCard>
{/* Subscriptions */}
<GroupedInsetListSectionHeader label={t("general.subscriptions")} />

View File

@ -18,6 +18,7 @@ import { ErrorBoundary } from "../components/common/ErrorBoundary"
import { GlobalErrorScreen } from "../components/errors/GlobalErrorScreen"
import { LightboxStateProvider } from "../components/ui/lightbox/lightboxState"
import { queryClient } from "../lib/query-client"
import { TtsStreamProvider } from "../modules/player/TtsStreamProvider"
import { TimelineSelectorDragProgressProvider } from "../modules/screen/atoms"
import { AppleIAPProvider } from "./AppleIAPProvider"
import { FontScalingProvider } from "./FontScalingProvider"
@ -53,6 +54,7 @@ export const RootProviders = ({ children }: { children: ReactNode }) => {
<ComposeContextProvider contexts={contexts}>
{children}
<ServerConfigsLoader />
<TtsStreamProvider />
</ComposeContextProvider>
</View>
)

View File

@ -16,7 +16,7 @@ import { useNavigation } from "@/src/lib/navigation/hooks"
import { gentleSpringPreset } from "../constants/spring"
import type { NavigationControllerView } from "../lib/navigation/types"
import { useActiveTrack, useIsPlaying } from "../lib/player"
import { useActivePlayable, useIsPlaying, useTtsStreamPlayback } from "../lib/player"
import { PlayerScreenContext, usePlayerScreenContext } from "../modules/player/context"
import { ControlGroup, ProgressBar, VolumeBar } from "../modules/player/control"
import { useCoverGradient } from "../modules/player/hooks"
@ -24,11 +24,13 @@ import { usePrefetchImageColors } from "../store/image/hooks"
function CoverArt({ cover }: { cover?: string }) {
const scale = useSharedValue(1)
const ttsStream = useTtsStreamPlayback()
const { playing } = useIsPlaying()
const isPlaying = ttsStream.entryId ? ttsStream.status === "playing" : playing
useEffect(() => {
cancelAnimation(scale)
scale.value = withSpring(playing ? 1 : 0.7, gentleSpringPreset)
}, [playing, scale])
scale.value = withSpring(isPlaying ? 1 : 0.7, gentleSpringPreset)
}, [isPlaying, scale])
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
@ -50,9 +52,9 @@ function CoverArt({ cover }: { cover?: string }) {
)
}
export const PlayerScreen: NavigationControllerView = () => {
const activeTrack = useActiveTrack()
usePrefetchImageColors(activeTrack?.artwork)
const { gradientColors, isGradientLight } = useCoverGradient(activeTrack?.artwork)
const activePlayable = useActivePlayable()
usePrefetchImageColors(activePlayable?.artwork ?? undefined)
const { gradientColors, isGradientLight } = useCoverGradient(activePlayable?.artwork ?? undefined)
const playerScreenContextValue = useMemo(
() => ({
isBackgroundLight: isGradientLight,
@ -60,7 +62,7 @@ export const PlayerScreen: NavigationControllerView = () => {
[isGradientLight],
)
const navigation = useNavigation()
if (!activeTrack) {
if (!activePlayable) {
return null
}
return (
@ -81,7 +83,7 @@ export const PlayerScreen: NavigationControllerView = () => {
<SafeAreaView className="flex-1">
<View className="flex-1">
<DismissIndicator />
<CoverArt cover={activeTrack.artwork} />
<CoverArt cover={activePlayable.artwork ?? undefined} />
<View className="mx-10 flex-1">
<Text
className={cn(
@ -90,7 +92,7 @@ export const PlayerScreen: NavigationControllerView = () => {
)}
numberOfLines={1}
>
{activeTrack.title}
{activePlayable.title}
</Text>
<Text
className={cn(
@ -99,7 +101,7 @@ export const PlayerScreen: NavigationControllerView = () => {
)}
numberOfLines={1}
>
{activeTrack.artist}
{activePlayable.artist}
</Text>
<ProgressBar />
<ControlGroup />

View File

@ -8,6 +8,8 @@
"entry.release_to_next_entry": "Release to go to the next entry",
"entry_actions.toggle_ai_summary": "AI Summary",
"entry_content.ai_summary": "AI Summary",
"entry_content.header.play_tts": "Play TTS",
"entry_content.header.play_tts_description": "Select a TTS voice in settings and start TTS to convert to audio content",
"entry_content.no_content": "No media available",
"entry_content.no_video_url": "No video URL found",
"entry_list.zero_unread": "Zero Unread",

View File

@ -8,6 +8,8 @@
"entry.release_to_next_entry": "Relâchez pour passer à l'entrée suivante",
"entry_actions.toggle_ai_summary": "Résumé IA",
"entry_content.ai_summary": "Résumé IA",
"entry_content.header.play_tts": "Lire TTS",
"entry_content.header.play_tts_description": "Sélectionnez une voix TTS dans les paramètres et démarrez TTS pour convertir en contenu audio",
"entry_content.no_content": "Aucun média disponible",
"entry_content.no_video_url": "Aucune URL vidéo trouvée",
"entry_list.zero_unread": "Zéro non lu",

View File

@ -8,6 +8,8 @@
"entry.release_to_next_entry": "離して次のエントリーに移動",
"entry_actions.toggle_ai_summary": "AI 要約に切り替え",
"entry_content.ai_summary": "AI 要約",
"entry_content.header.play_tts": "TTS を再生",
"entry_content.header.play_tts_description": "設定でTTS音声を選択し、TTSを開始して音声コンテンツに変換します",
"entry_content.no_content": "コンテンツがありません",
"entry_content.no_video_url": "動画 URL が見つかりません",
"entry_list.zero_unread": "未読ゼロ",

View File

@ -8,6 +8,8 @@
"entry.release_to_next_entry": "松开查看下一条内容",
"entry_actions.toggle_ai_summary": "切换 AI 总结",
"entry_content.ai_summary": "AI 摘要",
"entry_content.header.play_tts": "播放文本转语音",
"entry_content.header.play_tts_description": "在设置中选择一个 TTS 语音,启动 TTS 转换为有声内容",
"entry_content.no_content": "没有内容",
"entry_content.no_video_url": "未找到视频链接",
"entry_list.zero_unread": "全部已读",

View File

@ -8,6 +8,8 @@
"entry.release_to_next_entry": "鬆開查看下一條內容",
"entry_actions.toggle_ai_summary": "切換 AI 總結",
"entry_content.ai_summary": "AI 摘要",
"entry_content.header.play_tts": "播放文字轉語音",
"entry_content.header.play_tts_description": "在設定中選擇一個 TTS 語音,啟動 TTS 轉換為有聲內容",
"entry_content.no_content": "無內容",
"entry_content.no_video_url": "未找到影片連結",
"entry_list.zero_unread": "全部已讀",

View File

@ -370,6 +370,7 @@
"general.translation_mode.description": "Choose how the translated text is displayed in the entry list.",
"general.translation_mode.label": "AI Translation Mode",
"general.translation_mode.translation-only": "Only the translation",
"general.tts": "TTS",
"general.voices": "Voices",
"integration.builtin.title": "Built-in Integration",
"integration.categories.custom_integrations": "Custom Actions",

View File

@ -370,6 +370,7 @@
"general.translation_mode.description": "Choisissez comment le texte traduit est affiché dans la liste des entrées.",
"general.translation_mode.label": "Mode de traduction IA",
"general.translation_mode.translation-only": "Seulement la traduction",
"general.tts": "TTS",
"general.voices": "Voix",
"integration.builtin.title": "Intégration intégrée",
"integration.categories.custom_integrations": "Actions personnalisées",

View File

@ -370,6 +370,7 @@
"general.translation_mode.description": "エントリリストで翻訳されたテキストの表示方法を選択します。",
"general.translation_mode.label": "AI翻訳モード",
"general.translation_mode.translation-only": "翻訳のみ",
"general.tts": "TTS",
"general.voices": "音声",
"integration.builtin.title": "組み込み統合",
"integration.categories.custom_integrations": "カスタムアクション",

View File

@ -370,6 +370,7 @@
"general.translation_mode.description": "选择译文在条目列表中的显示方式。",
"general.translation_mode.label": "翻译偏好",
"general.translation_mode.translation-only": "仅译文",
"general.tts": "TTS",
"general.voices": "声音",
"integration.builtin.title": "内置集成",
"integration.categories.custom_integrations": "自定义集成",

View File

@ -370,6 +370,7 @@
"general.translation_mode.description": "選擇譯文在條目列表中的顯示方式。",
"general.translation_mode.label": "翻譯偏好",
"general.translation_mode.translation-only": "僅譯文",
"general.tts": "TTS",
"general.voices": "聲音",
"integration.builtin.title": "內建整合",
"integration.categories.custom_integrations": "自訂整合",