Bluesky app fork with some witchin' additions 💫

[Videos] Video player - PR #2 - better web support (#4732)

* attempt some sort of "usurping" system

* polling-based active video approach

* split into inner component again

* click to steal active video

* disable findAndActivateVideo on native

* new intersectionobserver approach - wip

* fix types

* disable perf optimisation to allow overflow

* make active player indicator subtler, clean up video utils

* partially fix double-playing

* start working on controls

* fullscreen API

* get buttons working somewhat

* rm source from where it shouldn't be

* use video elem as source of truth

* fix keyboard nav + mute state

* new icons, add fullscreen + time + fix play

* unmount when far offscreen + round 2dp

* listen globally to clicks rather than blur event

* move controls to new file

* reduce quality when not active

* add hover state to buttons

* stop propagation of videoplayer click

* move around autoplay effects

* increase background contrast

* add subtitles button

* add stopPropagation to root of video player

* clean up VideoWebControls

* fix chrome

* change quality based on focused state

* use autoLevelCapping instead of nextLevel

* get subtitle track from stream

* always use hlsjs

* rework hls into a ref

* render player earlier, allowing preload

* add error boundary

* clean up component structure and organisation

* rework fullscreen API

* disable fullscreen on iPhone

* don't play when ready on pause

* debounce buffering

* simplify giant list of event listeners

* update pref

* reduce prop drilling

* minimise rerenders in `ActiveViewContext`

* restore prop drilling

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>

authored by samuel.fm

Samuel Newman
Hailey
and committed by
GitHub
fff2c079 b701e8c6

+1085 -85
+1
assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
+1
assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
+1
assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" fill="#000"/></svg>
+1
assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z" fill="#000"/></svg>
+1
assets/icons/cc_filled_stroke2_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z" fill="#000"/></svg>
+1
assets/icons/cc_stroke2_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z" fill="#000"/></svg>
+1
assets/icons/pause_filled_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z" fill="#000"/></svg>
+1
assets/icons/pause_filled_corner2_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z" fill="#000"/></svg>
+1
assets/icons/pause_stroke2_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z" fill="#000"/></svg>
+1
assets/icons/pause_stroke2_corner2_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z" fill="#000"/></svg>
+1
assets/icons/play_filled_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z" fill="#000"/></svg>
+1
assets/icons/play_stroke2_corner0_rounded.svg
··· 1 + <svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z" fill="#000"/></svg>
+17
src/components/icons/ArrowsDiagonal.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z', 5 + }) 6 + 7 + export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', 9 + }) 10 + 11 + export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({ 12 + path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z', 13 + }) 14 + 15 + export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({ 16 + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', 17 + })
+9
src/components/icons/CC.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z', 5 + }) 6 + 7 + export const CC_Filled_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z', 9 + })
+17
src/components/icons/Pause.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z', 5 + }) 6 + 7 + export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z', 9 + }) 10 + 11 + export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({ 12 + path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z', 13 + }) 14 + 15 + export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({ 16 + path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z', 17 + })
+8
src/components/icons/Play.tsx
··· 1 1 import {createSinglePathSVG} from './TEMPLATE' 2 2 3 + export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z', 5 + }) 6 + 7 + export const Play_Filled_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z', 9 + }) 10 + 3 11 export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 12 path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z', 5 13 })
+1
src/platform/detection.ts
··· 14 14 isWeb && 15 15 // @ts-ignore we know window exists -prf 16 16 global.window.matchMedia(isMobileWebMediaQuery)?.matches 17 + export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent) 17 18 18 19 export const deviceLocales = dedupArray( 19 20 getLocales?.()
-3
src/screens/Messages/Conversation/MessagesList.tsx
··· 387 387 renderItem={renderItem} 388 388 keyExtractor={keyExtractor} 389 389 disableFullWindowScroll={true} 390 - // Prevents wrong position in Firefox when sending a message 391 - // as well as scroll getting stuck on Chome when scrolling upwards. 392 - disableContainStyle={true} 393 390 disableVirtualization={true} 394 391 style={animatedListStyle} 395 392 // The extra two items account for the header and the footer components
+2
src/state/persisted/schema.ts
··· 91 91 disableAutoplay: z.boolean().optional(), 92 92 kawaii: z.boolean().optional(), 93 93 hasCheckedForStarterPack: z.boolean().optional(), 94 + subtitlesEnabled: z.boolean().optional(), 94 95 /** @deprecated */ 95 96 mutedThreads: z.array(z.string()), 96 97 }) ··· 133 134 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), 134 135 kawaii: false, 135 136 hasCheckedForStarterPack: false, 137 + subtitlesEnabled: true, 136 138 } 137 139 138 140 export function tryParse(rawData: string): Schema | undefined {
+5 -1
src/state/preferences/index.tsx
··· 9 9 import {Provider as KawaiiProvider} from './kawaii' 10 10 import {Provider as LanguagesProvider} from './languages' 11 11 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 12 + import {Provider as SubtitlesProvider} from './subtitles' 12 13 import {Provider as UsedStarterPacksProvider} from './used-starter-packs' 13 14 14 15 export { ··· 24 25 export * from './hidden-posts' 25 26 export {useLabelDefinitions} from './label-defs' 26 27 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' 28 + export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles' 27 29 28 30 export function Provider({children}: React.PropsWithChildren<{}>) { 29 31 return ( ··· 36 38 <DisableHapticsProvider> 37 39 <AutoplayProvider> 38 40 <UsedStarterPacksProvider> 39 - <KawaiiProvider>{children}</KawaiiProvider> 41 + <SubtitlesProvider> 42 + <KawaiiProvider>{children}</KawaiiProvider> 43 + </SubtitlesProvider> 40 44 </UsedStarterPacksProvider> 41 45 </AutoplayProvider> 42 46 </DisableHapticsProvider>
+42
src/state/preferences/subtitles.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = boolean 6 + type SetContext = (v: boolean) => void 7 + 8 + const stateContext = React.createContext<StateContext>( 9 + Boolean(persisted.defaults.subtitlesEnabled), 10 + ) 11 + const setContext = React.createContext<SetContext>((_: boolean) => {}) 12 + 13 + export function Provider({children}: {children: React.ReactNode}) { 14 + const [state, setState] = React.useState( 15 + Boolean(persisted.get('subtitlesEnabled')), 16 + ) 17 + 18 + const setStateWrapped = React.useCallback( 19 + (subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => { 20 + setState(Boolean(subtitlesEnabled)) 21 + persisted.write('subtitlesEnabled', subtitlesEnabled) 22 + }, 23 + [setState], 24 + ) 25 + 26 + React.useEffect(() => { 27 + return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => { 28 + setState(Boolean(nextSubtitlesEnabled)) 29 + }) 30 + }, [setStateWrapped]) 31 + 32 + return ( 33 + <stateContext.Provider value={state}> 34 + <setContext.Provider value={setStateWrapped}> 35 + {children} 36 + </setContext.Provider> 37 + </stateContext.Provider> 38 + ) 39 + } 40 + 41 + export const useSubtitlesEnabled = () => React.useContext(stateContext) 42 + export const useSetSubtitlesEnabled = () => React.useContext(setContext)
-1
src/view/com/posts/FeedItem.tsx
··· 507 507 paddingRight: 15, 508 508 // @ts-ignore web only -prf 509 509 cursor: 'pointer', 510 - overflow: 'hidden', 511 510 }, 512 511 replyLine: { 513 512 width: 2,
-2
src/view/com/util/List.tsx
··· 28 28 // Web only prop to contain the scroll to the container rather than the window 29 29 disableFullWindowScroll?: boolean 30 30 sideBorders?: boolean 31 - // Web only prop to disable a perf optimization (which would otherwise be on). 32 - disableContainStyle?: boolean 33 31 } 34 32 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> 35 33
+4 -18
src/view/com/util/List.web.tsx
··· 4 4 5 5 import {batchedUpdates} from '#/lib/batchedUpdates' 6 6 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 7 + import {usePalette} from '#/lib/hooks/usePalette' 8 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 7 9 import {useScrollHandlers} from '#/lib/ScrollContext' 8 - import {isSafari} from 'lib/browser' 9 - import {usePalette} from 'lib/hooks/usePalette' 10 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 11 - import {addStyle} from 'lib/styles' 10 + import {addStyle} from '#/lib/styles' 12 11 13 12 export type ListMethods = any // TODO: Better types. 14 13 export type ListProps<ItemT> = Omit< ··· 26 25 // Web only prop to contain the scroll to the container rather than the window 27 26 disableFullWindowScroll?: boolean 28 27 sideBorders?: boolean 29 - // Web only prop to disable a perf optimization (which would otherwise be on). 30 - disableContainStyle?: boolean 31 28 } 32 29 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. 33 30 ··· 60 57 extraData, 61 58 style, 62 59 sideBorders = true, 63 - disableContainStyle, 64 60 ...props 65 61 }: ListProps<ItemT>, 66 62 ref: React.Ref<ListMethods>, ··· 364 360 renderItem={renderItem} 365 361 extraData={extraData} 366 362 onItemSeen={onItemSeen} 367 - disableContainStyle={disableContainStyle} 368 363 /> 369 364 ) 370 365 })} ··· 442 437 renderItem, 443 438 extraData: _unused, 444 439 onItemSeen, 445 - disableContainStyle, 446 440 }: { 447 441 item: ItemT 448 442 index: number ··· 452 446 | ((data: {index: number; item: any; separators: any}) => React.ReactNode) 453 447 extraData: any 454 448 onItemSeen: ((item: any) => void) | undefined 455 - disableContainStyle?: boolean 456 449 }): React.ReactNode { 457 450 const rowRef = React.useRef(null) 458 451 const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined) ··· 501 494 return null 502 495 } 503 496 504 - const shouldDisableContainStyle = disableContainStyle || isSafari 505 497 return ( 506 - <View 507 - style={shouldDisableContainStyle ? undefined : styles.contain} 508 - ref={rowRef}> 498 + <View ref={rowRef}> 509 499 {renderItem({item, index, separators: null as any})} 510 500 </View> 511 501 ) ··· 575 565 maxWidth: 600, 576 566 marginLeft: 'auto', 577 567 marginRight: 'auto', 578 - }, 579 - contain: { 580 - // @ts-ignore web only 581 - contain: 'layout paint', 582 568 }, 583 569 minHeightViewport: { 584 570 // @ts-ignore web only
+80 -9
src/view/com/util/post-embeds/ActiveVideoContext.tsx
··· 1 - import React, {useCallback, useId, useMemo, useState} from 'react' 1 + import React, { 2 + useCallback, 3 + useEffect, 4 + useId, 5 + useMemo, 6 + useRef, 7 + useState, 8 + } from 'react' 9 + import {useWindowDimensions} from 'react-native' 2 10 11 + import {isNative} from '#/platform/detection' 3 12 import {VideoPlayerProvider} from './VideoPlayerContext' 4 13 5 14 const ActiveVideoContext = React.createContext<{ 6 15 activeViewId: string | null 7 16 setActiveView: (viewId: string, src: string) => void 17 + sendViewPosition: (viewId: string, y: number) => void 8 18 } | null>(null) 9 19 10 20 export function ActiveVideoProvider({children}: {children: React.ReactNode}) { 11 21 const [activeViewId, setActiveViewId] = useState<string | null>(null) 22 + const activeViewLocationRef = useRef(Infinity) 12 23 const [source, setSource] = useState<string | null>(null) 24 + const {height: windowHeight} = useWindowDimensions() 25 + 26 + // minimising re-renders by using refs 27 + const manuallySetRef = useRef(false) 28 + const activeViewIdRef = useRef(activeViewId) 29 + useEffect(() => { 30 + activeViewIdRef.current = activeViewId 31 + }, [activeViewId]) 32 + 33 + const setActiveView = useCallback( 34 + (viewId: string, src: string) => { 35 + setActiveViewId(viewId) 36 + setSource(src) 37 + manuallySetRef.current = true 38 + // we don't know the exact position, but it's definitely on screen 39 + // so just guess that it's in the middle. Any value is fine 40 + // so long as it's not offscreen 41 + activeViewLocationRef.current = windowHeight / 2 42 + }, 43 + [windowHeight], 44 + ) 45 + 46 + const sendViewPosition = useCallback( 47 + (viewId: string, y: number) => { 48 + if (isNative) return 49 + 50 + if (viewId === activeViewIdRef.current) { 51 + activeViewLocationRef.current = y 52 + } else { 53 + if ( 54 + distanceToIdealPosition(y) < 55 + distanceToIdealPosition(activeViewLocationRef.current) 56 + ) { 57 + // if the old view was manually set, only usurp if the old view is offscreen 58 + if ( 59 + manuallySetRef.current && 60 + withinViewport(activeViewLocationRef.current) 61 + ) { 62 + return 63 + } 64 + 65 + setActiveViewId(viewId) 66 + activeViewLocationRef.current = y 67 + manuallySetRef.current = false 68 + } 69 + } 70 + 71 + function distanceToIdealPosition(yPos: number) { 72 + return Math.abs(yPos - windowHeight / 2.5) 73 + } 74 + 75 + function withinViewport(yPos: number) { 76 + return yPos > 0 && yPos < windowHeight 77 + } 78 + }, 79 + [windowHeight], 80 + ) 13 81 14 82 const value = useMemo( 15 83 () => ({ 16 84 activeViewId, 17 - setActiveView: (viewId: string, src: string) => { 18 - setActiveViewId(viewId) 19 - setSource(src) 20 - }, 85 + setActiveView, 86 + sendViewPosition, 21 87 }), 22 - [activeViewId], 88 + [activeViewId, setActiveView, sendViewPosition], 23 89 ) 24 90 25 91 return ( 26 92 <ActiveVideoContext.Provider value={value}> 27 - <VideoPlayerProvider source={source ?? ''} viewId={activeViewId}> 93 + <VideoPlayerProvider source={source ?? ''}> 28 94 {children} 29 95 </VideoPlayerProvider> 30 96 </ActiveVideoContext.Provider> 31 97 ) 32 98 } 33 99 34 - export function useActiveVideoView() { 100 + export function useActiveVideoView({source}: {source: string}) { 35 101 const context = React.useContext(ActiveVideoContext) 36 102 if (!context) { 37 103 throw new Error('useActiveVideo must be used within a ActiveVideoProvider') ··· 41 107 return { 42 108 active: context.activeViewId === id, 43 109 setActive: useCallback( 44 - (source: string) => context.setActiveView(id, source), 110 + () => context.setActiveView(id, source), 111 + [context, id, source], 112 + ), 113 + currentActiveView: context.activeViewId, 114 + sendPosition: useCallback( 115 + (y: number) => context.sendViewPosition(id, y), 45 116 [context, id], 46 117 ), 47 118 }
+9 -3
src/view/com/util/post-embeds/VideoEmbed.tsx
··· 11 11 12 12 export function VideoEmbed({source}: {source: string}) { 13 13 const t = useTheme() 14 - const {active, setActive} = useActiveVideoView() 14 + const {active, setActive} = useActiveVideoView({source}) 15 15 const {_} = useLingui() 16 16 17 - const onPress = useCallback(() => setActive(source), [setActive, source]) 17 + const onPress = useCallback(() => setActive(), [setActive]) 18 18 19 19 return ( 20 20 <View ··· 27 27 a.my_xs, 28 28 ]}> 29 29 {active ? ( 30 - <VideoEmbedInner source={source} /> 30 + <VideoEmbedInner 31 + source={source} 32 + // web only 33 + active={active} 34 + setActive={setActive} 35 + onScreen={true} 36 + /> 31 37 ) : ( 32 38 <Button 33 39 style={[a.flex_1, t.atoms.bg_contrast_25]}
+190
src/view/com/util/post-embeds/VideoEmbed.web.tsx
··· 1 + import React, {useCallback, useEffect, useRef, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {atoms as a, useTheme} from '#/alf' 7 + import {Button, ButtonText} from '#/components/Button' 8 + import {Text} from '#/components/Typography' 9 + import {ErrorBoundary} from '../ErrorBoundary' 10 + import {useActiveVideoView} from './ActiveVideoContext' 11 + import {VideoEmbedInner} from './VideoEmbedInner' 12 + import {HLSUnsupportedError} from './VideoEmbedInner.web' 13 + 14 + export function VideoEmbed({source}: {source: string}) { 15 + const t = useTheme() 16 + const ref = useRef<HTMLDivElement>(null) 17 + const {active, setActive, sendPosition, currentActiveView} = 18 + useActiveVideoView({source}) 19 + const [onScreen, setOnScreen] = useState(false) 20 + 21 + useEffect(() => { 22 + if (!ref.current) return 23 + const observer = new IntersectionObserver( 24 + entries => { 25 + const entry = entries[0] 26 + if (!entry) return 27 + setOnScreen(entry.isIntersecting) 28 + sendPosition( 29 + entry.boundingClientRect.y + entry.boundingClientRect.height / 2, 30 + ) 31 + }, 32 + {threshold: 0.5}, 33 + ) 34 + observer.observe(ref.current) 35 + return () => observer.disconnect() 36 + }, [sendPosition]) 37 + 38 + const [key, setKey] = useState(0) 39 + const renderError = useCallback( 40 + (error: unknown) => ( 41 + <VideoError error={error} retry={() => setKey(key + 1)} /> 42 + ), 43 + [key], 44 + ) 45 + 46 + return ( 47 + <View 48 + style={[ 49 + a.w_full, 50 + {aspectRatio: 16 / 9}, 51 + t.atoms.bg_contrast_25, 52 + a.rounded_sm, 53 + a.my_xs, 54 + ]}> 55 + <div 56 + ref={ref} 57 + style={{display: 'flex', flex: 1, cursor: 'default'}} 58 + onClick={evt => evt.stopPropagation()}> 59 + <ErrorBoundary renderError={renderError} key={key}> 60 + <ViewportObserver 61 + sendPosition={sendPosition} 62 + isAnyViewActive={currentActiveView !== null}> 63 + <VideoEmbedInner 64 + source={source} 65 + active={active} 66 + setActive={setActive} 67 + onScreen={onScreen} 68 + /> 69 + </ViewportObserver> 70 + </ErrorBoundary> 71 + </div> 72 + </View> 73 + ) 74 + } 75 + 76 + /** 77 + * Renders a 100vh tall div and watches it with an IntersectionObserver to 78 + * send the position of the div when it's near the screen. 79 + */ 80 + function ViewportObserver({ 81 + children, 82 + sendPosition, 83 + isAnyViewActive, 84 + }: { 85 + children: React.ReactNode 86 + sendPosition: (position: number) => void 87 + isAnyViewActive?: boolean 88 + }) { 89 + const ref = useRef<HTMLDivElement>(null) 90 + const [nearScreen, setNearScreen] = useState(false) 91 + 92 + // Send position when scrolling. This is done with an IntersectionObserver 93 + // observing a div of 100vh height 94 + useEffect(() => { 95 + if (!ref.current) return 96 + const observer = new IntersectionObserver( 97 + entries => { 98 + const entry = entries[0] 99 + if (!entry) return 100 + const position = 101 + entry.boundingClientRect.y + entry.boundingClientRect.height / 2 102 + sendPosition(position) 103 + setNearScreen(entry.isIntersecting) 104 + }, 105 + {threshold: Array.from({length: 101}, (_, i) => i / 100)}, 106 + ) 107 + observer.observe(ref.current) 108 + return () => observer.disconnect() 109 + }, [sendPosition]) 110 + 111 + // In case scrolling hasn't started yet, send up the position 112 + useEffect(() => { 113 + if (ref.current && !isAnyViewActive) { 114 + const rect = ref.current.getBoundingClientRect() 115 + const position = rect.y + rect.height / 2 116 + sendPosition(position) 117 + } 118 + }, [isAnyViewActive, sendPosition]) 119 + 120 + return ( 121 + <View style={[a.flex_1, a.flex_row]}> 122 + {nearScreen && children} 123 + <div 124 + ref={ref} 125 + style={{ 126 + position: 'absolute', 127 + top: 'calc(50% - 50vh)', 128 + left: '50%', 129 + height: '100vh', 130 + width: 1, 131 + pointerEvents: 'none', 132 + }} 133 + /> 134 + </View> 135 + ) 136 + } 137 + 138 + function VideoError({error, retry}: {error: unknown; retry: () => void}) { 139 + const t = useTheme() 140 + const {_} = useLingui() 141 + 142 + const isHLS = error instanceof HLSUnsupportedError 143 + 144 + return ( 145 + <View 146 + style={[ 147 + a.flex_1, 148 + t.atoms.bg_contrast_25, 149 + a.justify_center, 150 + a.align_center, 151 + a.px_lg, 152 + a.border, 153 + t.atoms.border_contrast_low, 154 + a.rounded_sm, 155 + a.gap_lg, 156 + ]}> 157 + <Text 158 + style={[ 159 + a.text_center, 160 + t.atoms.text_contrast_high, 161 + a.text_md, 162 + a.leading_snug, 163 + {maxWidth: 300}, 164 + ]}> 165 + {isHLS ? ( 166 + <Trans> 167 + Your browser does not support the video format. Please try a 168 + different browser. 169 + </Trans> 170 + ) : ( 171 + <Trans> 172 + An error occurred while loading the video. Please try again later. 173 + </Trans> 174 + )} 175 + </Text> 176 + {!isHLS && ( 177 + <Button 178 + onPress={retry} 179 + size="small" 180 + color="secondary_inverted" 181 + variant="solid" 182 + label={_(msg`Retry`)}> 183 + <ButtonText> 184 + <Trans>Retry</Trans> 185 + </ButtonText> 186 + </Button> 187 + )} 188 + </View> 189 + ) 190 + }
+6 -1
src/view/com/util/post-embeds/VideoEmbedInner.tsx
··· 13 13 import {Text} from '#/components/Typography' 14 14 import {useVideoPlayer} from './VideoPlayerContext' 15 15 16 - export const VideoEmbedInner = ({}: {source: string}) => { 16 + export function VideoEmbedInner({}: { 17 + source: string 18 + active: boolean 19 + setActive: () => void 20 + onScreen: boolean 21 + }) { 17 22 const player = useVideoPlayer() 18 23 const aref = useAnimatedRef<Animated.View>() 19 24 const {height: windowHeight} = useWindowDimensions()
+79 -38
src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
··· 1 - import React, {useEffect, useRef} from 'react' 1 + import React, {useEffect, useRef, useState} from 'react' 2 + import {View} from 'react-native' 2 3 import Hls from 'hls.js' 3 4 4 5 import {atoms as a} from '#/alf' 6 + import {Controls} from './VideoWebControls' 5 7 6 - export const VideoEmbedInner = ({source}: {source: string}) => { 8 + export function VideoEmbedInner({ 9 + source, 10 + active, 11 + setActive, 12 + onScreen, 13 + }: { 14 + source: string 15 + active: boolean 16 + setActive: () => void 17 + onScreen: boolean 18 + }) { 19 + const containerRef = useRef<HTMLDivElement>(null) 7 20 const ref = useRef<HTMLVideoElement>(null) 21 + const [focused, setFocused] = useState(false) 22 + const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) 8 23 9 - // Use HLS.js to play HLS video 24 + const hlsRef = useRef<Hls | undefined>(undefined) 25 + 10 26 useEffect(() => { 11 - if (ref.current) { 12 - if (ref.current.canPlayType('application/vnd.apple.mpegurl')) { 13 - ref.current.src = source 14 - } else if (Hls.isSupported()) { 15 - var hls = new Hls() 16 - hls.loadSource(source) 17 - hls.attachMedia(ref.current) 18 - } else { 19 - // TODO: fallback 20 - } 21 - } 22 - }, [source]) 27 + if (!ref.current) return 28 + if (!Hls.isSupported()) throw new HLSUnsupportedError() 29 + 30 + const hls = new Hls({capLevelToPlayerSize: true}) 31 + hlsRef.current = hls 23 32 24 - useEffect(() => { 25 - if (ref.current) { 26 - const observer = new IntersectionObserver( 27 - ([entry]) => { 28 - if (ref.current) { 29 - if (entry.isIntersecting) { 30 - if (ref.current.paused) { 31 - ref.current.play() 32 - } 33 - } else { 34 - if (!ref.current.paused) { 35 - ref.current.pause() 36 - } 37 - } 38 - } 39 - }, 40 - {threshold: 0}, 41 - ) 33 + hls.attachMedia(ref.current) 34 + hls.loadSource(source) 42 35 43 - observer.observe(ref.current) 36 + // initial value, later on it's managed by Controls 37 + hls.autoLevelCapping = 0 44 38 45 - return () => { 46 - observer.disconnect() 39 + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => { 40 + if (data.subtitleTracks.length > 0) { 41 + setHasSubtitleTrack(true) 47 42 } 43 + }) 44 + 45 + return () => { 46 + hlsRef.current = undefined 47 + hls.detachMedia() 48 + hls.destroy() 48 49 } 49 - }, []) 50 + }, [source]) 50 51 51 - return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop /> 52 + return ( 53 + <View 54 + style={[ 55 + a.w_full, 56 + a.rounded_sm, 57 + // TODO: get from embed metadata 58 + // max should be 1 / 1 59 + {aspectRatio: 16 / 9}, 60 + a.overflow_hidden, 61 + ]}> 62 + <div 63 + ref={containerRef} 64 + style={{width: '100%', height: '100%', display: 'flex'}}> 65 + <video 66 + ref={ref} 67 + style={{width: '100%', height: '100%', objectFit: 'contain'}} 68 + playsInline 69 + preload="none" 70 + loop 71 + muted={!focused} 72 + /> 73 + <Controls 74 + videoRef={ref} 75 + hlsRef={hlsRef} 76 + active={active} 77 + setActive={setActive} 78 + focused={focused} 79 + setFocused={setFocused} 80 + onScreen={onScreen} 81 + fullscreenRef={containerRef} 82 + hasSubtitleTrack={hasSubtitleTrack} 83 + /> 84 + </div> 85 + </View> 86 + ) 87 + } 88 + 89 + export class HLSUnsupportedError extends Error { 90 + constructor() { 91 + super('HLS is not supported') 92 + } 52 93 }
+1 -9
src/view/com/util/post-embeds/VideoPlayerContext.tsx
··· 1 - import React, {useContext, useEffect} from 'react' 1 + import React, {useContext} from 'react' 2 2 import type {VideoPlayer} from 'expo-video' 3 3 import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video' 4 4 5 5 const VideoPlayerContext = React.createContext<VideoPlayer | null>(null) 6 6 7 7 export function VideoPlayerProvider({ 8 - viewId, 9 8 source, 10 9 children, 11 10 }: { 12 - viewId: string | null 13 11 source: string 14 12 children: React.ReactNode 15 13 }) { ··· 18 16 player.loop = true 19 17 player.play() 20 18 }) 21 - 22 - // make sure we're playing every time the viewId changes 23 - // this means the video is different 24 - useEffect(() => { 25 - player.play() 26 - }, [viewId, player]) 27 19 28 20 return ( 29 21 <VideoPlayerContext.Provider value={player}>
+16
src/view/com/util/post-embeds/VideoWebControls.tsx
··· 1 + import React from 'react' 2 + import type Hls from 'hls.js' 3 + 4 + export function Controls({}: { 5 + videoRef: React.RefObject<HTMLVideoElement> 6 + hlsRef: React.RefObject<Hls | undefined> 7 + active: boolean 8 + setActive: () => void 9 + focused: boolean 10 + setFocused: (focused: boolean) => void 11 + onScreen: boolean 12 + fullscreenRef: React.RefObject<HTMLDivElement> 13 + hasSubtitleTrack: boolean 14 + }): React.ReactElement { 15 + throw new Error('Web-only component') 16 + }
+587
src/view/com/util/post-embeds/VideoWebControls.web.tsx
··· 1 + import React, { 2 + useCallback, 3 + useEffect, 4 + useRef, 5 + useState, 6 + useSyncExternalStore, 7 + } from 'react' 8 + import {Pressable, View} from 'react-native' 9 + import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + import type Hls from 'hls.js' 13 + 14 + import {isIPhoneWeb} from '#/platform/detection' 15 + import { 16 + useAutoplayDisabled, 17 + useSetSubtitlesEnabled, 18 + useSubtitlesEnabled, 19 + } from '#/state/preferences' 20 + import {atoms as a, useTheme, web} from '#/alf' 21 + import {Button} from '#/components/Button' 22 + import {useInteractionState} from '#/components/hooks/useInteractionState' 23 + import { 24 + ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, 25 + ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, 26 + } from '#/components/icons/ArrowsDiagonal' 27 + import { 28 + CC_Filled_Corner0_Rounded as CCActiveIcon, 29 + CC_Stroke2_Corner0_Rounded as CCInactiveIcon, 30 + } from '#/components/icons/CC' 31 + import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 32 + import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 33 + import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 34 + import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 35 + import {Loader} from '#/components/Loader' 36 + import {Text} from '#/components/Typography' 37 + 38 + export function Controls({ 39 + videoRef, 40 + hlsRef, 41 + active, 42 + setActive, 43 + focused, 44 + setFocused, 45 + onScreen, 46 + fullscreenRef, 47 + hasSubtitleTrack, 48 + }: { 49 + videoRef: React.RefObject<HTMLVideoElement> 50 + hlsRef: React.RefObject<Hls | undefined> 51 + active: boolean 52 + setActive: () => void 53 + focused: boolean 54 + setFocused: (focused: boolean) => void 55 + onScreen: boolean 56 + fullscreenRef: React.RefObject<HTMLDivElement> 57 + hasSubtitleTrack: boolean 58 + }) { 59 + const { 60 + play, 61 + pause, 62 + playing, 63 + muted, 64 + toggleMute, 65 + togglePlayPause, 66 + currentTime, 67 + duration, 68 + buffering, 69 + error, 70 + canPlay, 71 + } = useVideoUtils(videoRef) 72 + const t = useTheme() 73 + const {_} = useLingui() 74 + const subtitlesEnabled = useSubtitlesEnabled() 75 + const setSubtitlesEnabled = useSetSubtitlesEnabled() 76 + const { 77 + state: hovered, 78 + onIn: onMouseEnter, 79 + onOut: onMouseLeave, 80 + } = useInteractionState() 81 + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) 82 + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() 83 + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) 84 + 85 + const onKeyDown = useCallback(() => { 86 + setInteractingViaKeypress(true) 87 + }, []) 88 + 89 + useEffect(() => { 90 + if (interactingViaKeypress) { 91 + document.addEventListener('click', () => setInteractingViaKeypress(false)) 92 + return () => { 93 + document.removeEventListener('click', () => 94 + setInteractingViaKeypress(false), 95 + ) 96 + } 97 + } 98 + }, [interactingViaKeypress]) 99 + 100 + // pause + unfocus when another video is active 101 + useEffect(() => { 102 + if (!active) { 103 + pause() 104 + setFocused(false) 105 + } 106 + }, [active, pause, setFocused]) 107 + 108 + // autoplay/pause based on visibility 109 + const autoplayDisabled = useAutoplayDisabled() 110 + useEffect(() => { 111 + if (active && !autoplayDisabled) { 112 + if (onScreen) { 113 + play() 114 + } else { 115 + pause() 116 + } 117 + } 118 + }, [onScreen, pause, active, play, autoplayDisabled]) 119 + 120 + // use minimal quality when not focused 121 + useEffect(() => { 122 + if (!hlsRef.current) return 123 + if (focused) { 124 + // auto decide quality based on network conditions 125 + hlsRef.current.autoLevelCapping = -1 126 + } else { 127 + hlsRef.current.autoLevelCapping = 0 128 + } 129 + }, [hlsRef, focused]) 130 + 131 + useEffect(() => { 132 + if (!hlsRef.current) return 133 + if (hasSubtitleTrack && subtitlesEnabled && canPlay) { 134 + hlsRef.current.subtitleTrack = 0 135 + } else { 136 + hlsRef.current.subtitleTrack = -1 137 + } 138 + }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) 139 + 140 + // clicking on any button should focus the player, if it's not already focused 141 + const drawFocus = useCallback(() => { 142 + if (!active) { 143 + setActive() 144 + } 145 + setFocused(true) 146 + }, [active, setActive, setFocused]) 147 + 148 + const onPressEmptySpace = useCallback(() => { 149 + if (!focused) { 150 + drawFocus() 151 + } else { 152 + togglePlayPause() 153 + } 154 + }, [togglePlayPause, drawFocus, focused]) 155 + 156 + const onPressPlayPause = useCallback(() => { 157 + drawFocus() 158 + togglePlayPause() 159 + }, [drawFocus, togglePlayPause]) 160 + 161 + const onPressSubtitles = useCallback(() => { 162 + drawFocus() 163 + setSubtitlesEnabled(!subtitlesEnabled) 164 + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) 165 + 166 + const onPressMute = useCallback(() => { 167 + drawFocus() 168 + toggleMute() 169 + }, [drawFocus, toggleMute]) 170 + 171 + const onPressFullscreen = useCallback(() => { 172 + drawFocus() 173 + toggleFullscreen() 174 + }, [drawFocus, toggleFullscreen]) 175 + 176 + const showControls = 177 + (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) 178 + 179 + return ( 180 + <div 181 + style={{ 182 + position: 'absolute', 183 + inset: 0, 184 + overflow: 'hidden', 185 + display: 'flex', 186 + flexDirection: 'column', 187 + }} 188 + onClick={evt => { 189 + evt.stopPropagation() 190 + setInteractingViaKeypress(false) 191 + }} 192 + onMouseEnter={onMouseEnter} 193 + onMouseLeave={onMouseLeave} 194 + onFocus={onFocus} 195 + onBlur={onBlur} 196 + onKeyDown={onKeyDown}> 197 + <Pressable 198 + accessibilityRole="button" 199 + accessibilityHint={_( 200 + focused 201 + ? msg`Unmute video` 202 + : playing 203 + ? msg`Pause video` 204 + : msg`Play video`, 205 + )} 206 + style={a.flex_1} 207 + onPress={onPressEmptySpace} 208 + /> 209 + <View 210 + style={[ 211 + a.flex_shrink_0, 212 + a.w_full, 213 + a.px_sm, 214 + a.pt_sm, 215 + a.pb_md, 216 + a.gap_md, 217 + a.flex_row, 218 + a.align_center, 219 + web({ 220 + background: 221 + 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', 222 + }), 223 + showControls ? {opacity: 1} : {opacity: 0}, 224 + ]}> 225 + <Button 226 + label={_(playing ? msg`Pause` : msg`Play`)} 227 + onPress={onPressPlayPause} 228 + {...btnProps}> 229 + {playing ? ( 230 + <PauseIcon fill={t.palette.white} width={20} /> 231 + ) : ( 232 + <PlayIcon fill={t.palette.white} width={20} /> 233 + )} 234 + </Button> 235 + <View style={a.flex_1} /> 236 + <Text style={{color: t.palette.white}}> 237 + {formatTime(currentTime)} / {formatTime(duration)} 238 + </Text> 239 + {hasSubtitleTrack && ( 240 + <Button 241 + label={_( 242 + subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`, 243 + )} 244 + onPress={onPressSubtitles} 245 + {...btnProps}> 246 + {subtitlesEnabled ? ( 247 + <CCActiveIcon fill={t.palette.white} width={20} /> 248 + ) : ( 249 + <CCInactiveIcon fill={t.palette.white} width={20} /> 250 + )} 251 + </Button> 252 + )} 253 + <Button 254 + label={_(muted ? msg`Unmute` : msg`Mute`)} 255 + onPress={onPressMute} 256 + {...btnProps}> 257 + {muted ? ( 258 + <MuteIcon fill={t.palette.white} width={20} /> 259 + ) : ( 260 + <UnmuteIcon fill={t.palette.white} width={20} /> 261 + )} 262 + </Button> 263 + {!isIPhoneWeb && ( 264 + <Button 265 + label={_(muted ? msg`Unmute` : msg`Mute`)} 266 + onPress={onPressFullscreen} 267 + {...btnProps}> 268 + {isFullscreen ? ( 269 + <ArrowsInIcon fill={t.palette.white} width={20} /> 270 + ) : ( 271 + <ArrowsOutIcon fill={t.palette.white} width={20} /> 272 + )} 273 + </Button> 274 + )} 275 + </View> 276 + {(showControls || !focused) && ( 277 + <Animated.View 278 + entering={FadeIn.duration(200)} 279 + exiting={FadeOut.duration(200)} 280 + style={[ 281 + a.absolute, 282 + { 283 + height: 5, 284 + bottom: 0, 285 + left: 0, 286 + right: 0, 287 + backgroundColor: 'rgba(255,255,255,0.4)', 288 + }, 289 + ]}> 290 + {duration > 0 && ( 291 + <View 292 + style={[ 293 + a.h_full, 294 + a.mr_auto, 295 + { 296 + backgroundColor: t.palette.white, 297 + width: `${(currentTime / duration) * 100}%`, 298 + opacity: 0.8, 299 + }, 300 + ]} 301 + /> 302 + )} 303 + </Animated.View> 304 + )} 305 + {(buffering || error) && ( 306 + <Animated.View 307 + pointerEvents="none" 308 + entering={FadeIn.delay(1000).duration(200)} 309 + exiting={FadeOut.duration(200)} 310 + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 311 + {buffering && <Loader fill={t.palette.white} size="lg" />} 312 + {error && ( 313 + <Text style={{color: t.palette.white}}> 314 + <Trans>An error occurred</Trans> 315 + </Text> 316 + )} 317 + </Animated.View> 318 + )} 319 + </div> 320 + ) 321 + } 322 + 323 + const btnProps = { 324 + variant: 'ghost', 325 + shape: 'round', 326 + size: 'medium', 327 + style: a.p_2xs, 328 + hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, 329 + } as const 330 + 331 + function formatTime(time: number) { 332 + if (isNaN(time)) { 333 + return '--' 334 + } 335 + 336 + time = Math.round(time) 337 + 338 + const minutes = Math.floor(time / 60) 339 + const seconds = String(time % 60).padStart(2, '0') 340 + 341 + return `${minutes}:${seconds}` 342 + } 343 + 344 + function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) { 345 + const [playing, setPlaying] = useState(false) 346 + const [muted, setMuted] = useState(true) 347 + const [currentTime, setCurrentTime] = useState(0) 348 + const [duration, setDuration] = useState(0) 349 + const [buffering, setBuffering] = useState(false) 350 + const [error, setError] = useState(false) 351 + const [canPlay, setCanPlay] = useState(false) 352 + const playWhenReadyRef = useRef(false) 353 + 354 + useEffect(() => { 355 + if (!ref.current) return 356 + 357 + let bufferingTimeout: ReturnType<typeof setTimeout> | undefined 358 + 359 + function round(num: number) { 360 + return Math.round(num * 100) / 100 361 + } 362 + 363 + // Initial values 364 + setCurrentTime(round(ref.current.currentTime) || 0) 365 + setDuration(round(ref.current.duration) || 0) 366 + setMuted(ref.current.muted) 367 + setPlaying(!ref.current.paused) 368 + 369 + const handleTimeUpdate = () => { 370 + if (!ref.current) return 371 + setCurrentTime(round(ref.current.currentTime) || 0) 372 + } 373 + 374 + const handleDurationChange = () => { 375 + if (!ref.current) return 376 + setDuration(round(ref.current.duration) || 0) 377 + } 378 + 379 + const handlePlay = () => { 380 + setPlaying(true) 381 + } 382 + 383 + const handlePause = () => { 384 + setPlaying(false) 385 + } 386 + 387 + const handleVolumeChange = () => { 388 + if (!ref.current) return 389 + setMuted(ref.current.muted) 390 + } 391 + 392 + const handleError = () => { 393 + setError(true) 394 + } 395 + 396 + const handleCanPlay = () => { 397 + setBuffering(false) 398 + setCanPlay(true) 399 + 400 + if (!ref.current) return 401 + if (playWhenReadyRef.current) { 402 + ref.current.play() 403 + playWhenReadyRef.current = false 404 + } 405 + } 406 + 407 + const handleCanPlayThrough = () => { 408 + setBuffering(false) 409 + } 410 + 411 + const handleWaiting = () => { 412 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 413 + bufferingTimeout = setTimeout(() => { 414 + setBuffering(true) 415 + }, 200) // Delay to avoid frequent buffering state changes 416 + } 417 + 418 + const handlePlaying = () => { 419 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 420 + setBuffering(false) 421 + setError(false) 422 + } 423 + 424 + const handleSeeking = () => { 425 + setBuffering(true) 426 + } 427 + 428 + const handleSeeked = () => { 429 + setBuffering(false) 430 + } 431 + 432 + const handleStalled = () => { 433 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 434 + bufferingTimeout = setTimeout(() => { 435 + setBuffering(true) 436 + }, 200) // Delay to avoid frequent buffering state changes 437 + } 438 + 439 + const handleEnded = () => { 440 + setPlaying(false) 441 + setBuffering(false) 442 + setError(false) 443 + } 444 + 445 + const abortController = new AbortController() 446 + 447 + ref.current.addEventListener('timeupdate', handleTimeUpdate, { 448 + signal: abortController.signal, 449 + }) 450 + ref.current.addEventListener('durationchange', handleDurationChange, { 451 + signal: abortController.signal, 452 + }) 453 + ref.current.addEventListener('play', handlePlay, { 454 + signal: abortController.signal, 455 + }) 456 + ref.current.addEventListener('pause', handlePause, { 457 + signal: abortController.signal, 458 + }) 459 + ref.current.addEventListener('volumechange', handleVolumeChange, { 460 + signal: abortController.signal, 461 + }) 462 + ref.current.addEventListener('error', handleError, { 463 + signal: abortController.signal, 464 + }) 465 + ref.current.addEventListener('canplay', handleCanPlay, { 466 + signal: abortController.signal, 467 + }) 468 + ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { 469 + signal: abortController.signal, 470 + }) 471 + ref.current.addEventListener('waiting', handleWaiting, { 472 + signal: abortController.signal, 473 + }) 474 + ref.current.addEventListener('playing', handlePlaying, { 475 + signal: abortController.signal, 476 + }) 477 + ref.current.addEventListener('seeking', handleSeeking, { 478 + signal: abortController.signal, 479 + }) 480 + ref.current.addEventListener('seeked', handleSeeked, { 481 + signal: abortController.signal, 482 + }) 483 + ref.current.addEventListener('stalled', handleStalled, { 484 + signal: abortController.signal, 485 + }) 486 + ref.current.addEventListener('ended', handleEnded, { 487 + signal: abortController.signal, 488 + }) 489 + 490 + return () => { 491 + abortController.abort() 492 + clearTimeout(bufferingTimeout) 493 + } 494 + }, [ref]) 495 + 496 + const play = useCallback(() => { 497 + if (!ref.current) return 498 + 499 + if (ref.current.ended) { 500 + ref.current.currentTime = 0 501 + } 502 + 503 + if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { 504 + playWhenReadyRef.current = true 505 + } else { 506 + const promise = ref.current.play() 507 + if (promise !== undefined) { 508 + promise.catch(err => { 509 + console.error('Error playing video:', err) 510 + }) 511 + } 512 + } 513 + }, [ref]) 514 + 515 + const pause = useCallback(() => { 516 + if (!ref.current) return 517 + 518 + ref.current.pause() 519 + playWhenReadyRef.current = false 520 + }, [ref]) 521 + 522 + const togglePlayPause = useCallback(() => { 523 + if (!ref.current) return 524 + 525 + if (ref.current.paused) { 526 + play() 527 + } else { 528 + pause() 529 + } 530 + }, [ref, play, pause]) 531 + 532 + const mute = useCallback(() => { 533 + if (!ref.current) return 534 + 535 + ref.current.muted = true 536 + }, [ref]) 537 + 538 + const unmute = useCallback(() => { 539 + if (!ref.current) return 540 + 541 + ref.current.muted = false 542 + }, [ref]) 543 + 544 + const toggleMute = useCallback(() => { 545 + if (!ref.current) return 546 + 547 + ref.current.muted = !ref.current.muted 548 + }, [ref]) 549 + 550 + return { 551 + play, 552 + pause, 553 + togglePlayPause, 554 + duration, 555 + currentTime, 556 + playing, 557 + muted, 558 + mute, 559 + unmute, 560 + toggleMute, 561 + buffering, 562 + error, 563 + canPlay, 564 + } 565 + } 566 + 567 + function fullscreenSubscribe(onChange: () => void) { 568 + document.addEventListener('fullscreenchange', onChange) 569 + return () => document.removeEventListener('fullscreenchange', onChange) 570 + } 571 + 572 + function useFullscreen(ref: React.RefObject<HTMLElement>) { 573 + const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => 574 + Boolean(document.fullscreenElement), 575 + ) 576 + 577 + const toggleFullscreen = useCallback(() => { 578 + if (isFullscreen) { 579 + document.exitFullscreen() 580 + } else { 581 + if (!ref.current) return 582 + ref.current.requestFullscreen() 583 + } 584 + }, [isFullscreen, ref]) 585 + 586 + return [isFullscreen, toggleFullscreen] as const 587 + }