Folo/apps/mobile/src/modules/player/control.tsx

306 lines
9.5 KiB
TypeScript

import { cn } from "@follow/utils"
import { Pressable, StyleSheet, View } from "react-native"
import { Slider } from "react-native-awesome-slider"
import { FadeOut, useDerivedValue, useSharedValue, ZoomIn } from "react-native-reanimated"
import * as DropdownMenu from "zeego/dropdown-menu"
import { ReAnimatedPressable } from "@/src/components/common/AnimatedComponents"
import { Text } from "@/src/components/ui/typography/Text"
import { Back2CuteReIcon } from "@/src/icons/back_2_cute_re"
import { Forward2CuteReIcon } from "@/src/icons/forward_2_cute_re"
import { PauseCuteFiIcon } from "@/src/icons/pause_cute_fi"
import { PlayCuteFiIcon } from "@/src/icons/play_cute_fi"
import { RewindBackward15CuteReIcon } from "@/src/icons/rewind_backward_15_cute_re"
import { RewindForward30CuteReIcon } from "@/src/icons/rewind_forward_30_cute_re"
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,
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
className?: string
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={isStream ? `tts-${ttsStream.status}` : playing ? "pause" : "play"}
onPress={() => {
if (isStream && ttsStream.entryId) {
void ttsStreamController.toggle(ttsStream.entryId)
return
}
playing ? player.pause() : player.play()
}}
>
{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} />
)}
</ReAnimatedPressable>
</View>
)
}
export function SeekButton({
size = 24,
className,
color,
offset = 30,
}: ControlButtonProps & {
offset?: number
}) {
const label = useColor("label")
const ttsStream = useTtsStreamPlayback()
if (ttsStream.entryId) {
return null
}
return (
<View className={className}>
<Pressable
onPress={() => {
player.seekBy(offset)
}}
>
{offset === 30 ? (
<RewindForward30CuteReIcon color={color ?? label} width={size} height={size} />
) : offset === -15 ? (
<RewindBackward15CuteReIcon color={color ?? label} width={size} height={size} />
) : offset > 0 ? (
<Forward2CuteReIcon color={color ?? label} width={size} height={size} />
) : (
<Back2CuteReIcon color={color ?? label} width={size} height={size} />
)}
</Pressable>
</View>
)
}
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>
<DropdownMenu.Trigger>
<Text
className={cn(
"w-[43] text-lg font-bold",
isBackgroundLight ? "text-black/70" : "text-white/70",
)}
>
{currentRate}x
</Text>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{allowedRate.map((rate) => (
<DropdownMenu.CheckboxItem
value={rate === currentRate}
key={`${rate}`}
onSelect={() => setCurrentRate(rate)}
>
<DropdownMenu.ItemTitle>{`${rate}x`}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
)
}
export function StopButton({ size = 24, className, color }: ControlButtonProps) {
const ttsStream = useTtsStreamPlayback()
const label = useColor("label")
const navigation = useNavigation()
return (
<Pressable
className={className}
onPress={() => {
if (ttsStream.entryId) {
void ttsStreamController.stop()
} else {
player.reset()
}
navigation.back()
}}
>
<StopCircleCuteFiIcon color={color ?? label} width={size} height={size} />
</Pressable>
)
}
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 />
<SeekButton size={35} offset={-15} color={buttonColor} />
<PlayPauseButton size={50} color={buttonColor} />
<SeekButton size={35} offset={30} color={buttonColor} />
<View className="w-[43] flex-row justify-end">
<StopButton color={buttonColor} />
</View>
</View>
)
}
const formatSecondsToMinutes = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
const formattedMinutes = String(minutes).padStart(2, "0")
const formattedSeconds = String(remainingSeconds).padStart(2, "0")
return `${formattedMinutes}:${formattedSeconds}`
}
export function ProgressBar() {
const { isBackgroundLight } = usePlayerScreenContext()
const { duration, position } = useProgress(250)
const isSliding = useSharedValue(false)
const progress = useDerivedValue(() => {
return duration > 0 ? position / duration : 0
})
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 (
<View className="my-6">
<Slider
progress={progress}
minimumValue={min}
maximumValue={max}
thumbWidth={0}
containerStyle={styles.sliderTrack}
renderBubble={() => null}
theme={{
minimumTrackTintColor: "rgba(255,255,255,0.6)",
maximumTrackTintColor: "rgba(255,255,255,0.4)",
}}
onSlidingStart={() => (isSliding.value = true)}
onValueChange={async (value) => {
await player.seekTo(value * duration)
}}
onSlidingComplete={async (value) => {
if (!isSliding.value) return
isSliding.value = false
await player.seekTo(value * duration)
}}
/>
<View className="mt-3 flex-row justify-between">
<Text
style={styles.text}
className={cn(
"font-mono text-xs font-medium opacity-75",
isBackgroundLight ? "text-black" : "text-white",
)}
>
{trackElapsedTime}
</Text>
<Text
style={styles.text}
className={cn(
"font-mono text-xs font-medium opacity-75",
isBackgroundLight ? "text-black" : "text-white",
)}
>
{"-"}
{trackRemainingTime}
</Text>
</View>
</View>
)
}
export function VolumeBar() {
const { isBackgroundLight } = usePlayerScreenContext()
const buttonColor = isBackgroundLight ? "black" : "white"
const { volume, updateVolume } = useVolume()
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 className="flex-row items-center justify-between">
<VolumeOffCuteReIcon height={15} width={15} color={buttonColor} />
<View className="flex-1 flex-row px-4">
<Slider
progress={progress}
minimumValue={min}
containerStyle={styles.sliderTrack}
onValueChange={(value) => {
updateVolume(value)
}}
renderBubble={() => null}
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.4)",
minimumTrackTintColor: "rgba(255,255,255,0.6)",
}}
thumbWidth={0}
maximumValue={max}
/>
</View>
<VolumeCuteReIcon height={15} width={15} color={buttonColor} />
</View>
</View>
)
}
const styles = StyleSheet.create({
text: {
fontVariant: ["tabular-nums"],
},
sliderTrack: {
height: 7,
borderRadius: 16,
},
})