feat(mobile): add streaming tts playback
This commit is contained in:
parent
096422e034
commit
ad1cf02cd7
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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]))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
`
|
||||
|
|
@ -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")} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "未読ゼロ",
|
||||
|
|
|
|||
|
|
@ -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": "全部已读",
|
||||
|
|
|
|||
|
|
@ -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": "全部已讀",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "カスタムアクション",
|
||||
|
|
|
|||
|
|
@ -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": "自定义集成",
|
||||
|
|
|
|||
|
|
@ -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": "自訂整合",
|
||||
|
|
|
|||
Loading…
Reference in New Issue