An ATproto social media client -- with an independent Appview.
1import {useImperativeHandle, useRef, useState} from 'react'
2import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
3import {type AppBskyEmbedVideo} from '@atproto/api'
4import {BlueskyVideoView} from '@haileyok/bluesky-video'
5import {msg} from '@lingui/macro'
6import {useLingui} from '@lingui/react'
7
8import {HITSLOP_30} from '#/lib/constants'
9import {useAutoplayDisabled} from '#/state/preferences'
10import {atoms as a, useTheme} from '#/alf'
11import {useIsWithinMessage} from '#/components/dms/MessageContext'
12import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
13import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
14import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
15import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
16import {MediaInsetBorder} from '#/components/MediaInsetBorder'
17import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
18import {TimeIndicator} from './TimeIndicator'
19
20export function VideoEmbedInnerNative({
21 ref,
22 embed,
23 setStatus,
24 setIsLoading,
25 setIsActive,
26}: {
27 ref: React.Ref<{togglePlayback: () => void}>
28 embed: AppBskyEmbedVideo.View
29 setStatus: (status: 'playing' | 'paused') => void
30 setIsLoading: (isLoading: boolean) => void
31 setIsActive: (isActive: boolean) => void
32}) {
33 const {_} = useLingui()
34 const videoRef = useRef<BlueskyVideoView>(null)
35 const autoplayDisabled = useAutoplayDisabled()
36 const isWithinMessage = useIsWithinMessage()
37 const [muted, setMuted] = useVideoMuteState()
38
39 const [isPlaying, setIsPlaying] = useState(false)
40 const [timeRemaining, setTimeRemaining] = useState(0)
41 const [error, setError] = useState<string>()
42
43 useImperativeHandle(ref, () => ({
44 togglePlayback: () => {
45 videoRef.current?.togglePlayback()
46 },
47 }))
48
49 if (error) {
50 throw new Error(error)
51 }
52
53 return (
54 <View style={[a.flex_1, a.relative]}>
55 <BlueskyVideoView
56 url={embed.playlist}
57 autoplay={!autoplayDisabled && !isWithinMessage}
58 beginMuted={autoplayDisabled ? false : muted}
59 style={[a.rounded_sm]}
60 onActiveChange={e => {
61 setIsActive(e.nativeEvent.isActive)
62 }}
63 onLoadingChange={e => {
64 setIsLoading(e.nativeEvent.isLoading)
65 }}
66 onMutedChange={e => {
67 setMuted(e.nativeEvent.isMuted)
68 }}
69 onStatusChange={e => {
70 setStatus(e.nativeEvent.status)
71 setIsPlaying(e.nativeEvent.status === 'playing')
72 }}
73 onTimeRemainingChange={e => {
74 setTimeRemaining(e.nativeEvent.timeRemaining)
75 }}
76 onError={e => {
77 setError(e.nativeEvent.error)
78 }}
79 ref={videoRef}
80 accessibilityLabel={
81 embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
82 }
83 accessibilityHint=""
84 />
85 <VideoControls
86 enterFullscreen={() => {
87 videoRef.current?.enterFullscreen(true)
88 }}
89 toggleMuted={() => {
90 videoRef.current?.toggleMuted()
91 }}
92 togglePlayback={() => {
93 videoRef.current?.togglePlayback()
94 }}
95 isPlaying={isPlaying}
96 timeRemaining={timeRemaining}
97 />
98 <MediaInsetBorder />
99 </View>
100 )
101}
102
103function VideoControls({
104 enterFullscreen,
105 toggleMuted,
106 togglePlayback,
107 timeRemaining,
108 isPlaying,
109}: {
110 enterFullscreen: () => void
111 toggleMuted: () => void
112 togglePlayback: () => void
113 timeRemaining: number
114 isPlaying: boolean
115}) {
116 const {_} = useLingui()
117 const t = useTheme()
118 const [muted] = useVideoMuteState()
119
120 // show countdown when:
121 // 1. timeRemaining is a number - was seeing NaNs
122 // 2. duration is greater than 0 - means metadata has loaded
123 // 3. we're less than 5 second into the video
124 const showTime = !isNaN(timeRemaining)
125
126 return (
127 <View style={[a.absolute, a.inset_0]}>
128 <Pressable
129 onPress={enterFullscreen}
130 style={a.flex_1}
131 accessibilityLabel={_(msg`Video`)}
132 accessibilityHint={_(msg`Enters full screen`)}
133 accessibilityRole="button"
134 />
135 <ControlButton
136 onPress={togglePlayback}
137 label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
138 accessibilityHint={_(msg`Plays or pauses the video`)}
139 style={{left: 6}}>
140 {isPlaying ? (
141 <PauseIcon width={13} fill={t.palette.white} />
142 ) : (
143 <PlayIcon width={13} fill={t.palette.white} />
144 )}
145 </ControlButton>
146 {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
147
148 <ControlButton
149 onPress={toggleMuted}
150 label={
151 muted
152 ? _(msg({message: `Unmute`, context: 'video'}))
153 : _(msg({message: `Mute`, context: 'video'}))
154 }
155 accessibilityHint={_(msg`Toggles the sound`)}
156 style={{right: 6}}>
157 {muted ? (
158 <MuteIcon width={13} fill={t.palette.white} />
159 ) : (
160 <UnmuteIcon width={13} fill={t.palette.white} />
161 )}
162 </ControlButton>
163 </View>
164 )
165}
166
167function ControlButton({
168 onPress,
169 children,
170 label,
171 accessibilityHint,
172 style,
173}: {
174 onPress: () => void
175 children: React.ReactNode
176 label: string
177 accessibilityHint: string
178 style?: StyleProp<ViewStyle>
179}) {
180 return (
181 <View
182 style={[
183 a.absolute,
184 a.rounded_full,
185 a.justify_center,
186 {
187 backgroundColor: 'rgba(0, 0, 0, 0.5)',
188 paddingHorizontal: 4,
189 paddingVertical: 4,
190 bottom: 6,
191 minHeight: 21,
192 minWidth: 21,
193 },
194 style,
195 ]}>
196 <Pressable
197 onPress={onPress}
198 style={a.flex_1}
199 accessibilityLabel={label}
200 accessibilityHint={accessibilityHint}
201 accessibilityRole="button"
202 hitSlop={HITSLOP_30}>
203 {children}
204 </Pressable>
205 </View>
206 )
207}