[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>
zio/stable
Samuel Newman 2024-08-07 18:47:51 +01:00 committed by GitHub
parent b701e8c68c
commit fff2c079c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1087 additions and 87 deletions

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 369 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 384 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 247 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 183 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 315 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 326 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 184 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 276 B

View File

@ -0,0 +1,17 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})
export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})
export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
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',
})
export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
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',
})

View File

@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})
export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
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',
})

View File

@ -0,0 +1,17 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})
export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({
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',
})
export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({
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',
})
export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({
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',
})

View File

@ -1,5 +1,13 @@
import {createSinglePathSVG} from './TEMPLATE' import {createSinglePathSVG} from './TEMPLATE'
export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})
export const Play_Filled_Corner0_Rounded = createSinglePathSVG({
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',
})
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({ export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
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', 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',
}) })

View File

@ -14,6 +14,7 @@ export const isMobileWeb =
isWeb && isWeb &&
// @ts-ignore we know window exists -prf // @ts-ignore we know window exists -prf
global.window.matchMedia(isMobileWebMediaQuery)?.matches global.window.matchMedia(isMobileWebMediaQuery)?.matches
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
export const deviceLocales = dedupArray( export const deviceLocales = dedupArray(
getLocales?.() getLocales?.()

View File

@ -387,9 +387,6 @@ export function MessagesList({
renderItem={renderItem} renderItem={renderItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
disableFullWindowScroll={true} disableFullWindowScroll={true}
// Prevents wrong position in Firefox when sending a message
// as well as scroll getting stuck on Chome when scrolling upwards.
disableContainStyle={true}
disableVirtualization={true} disableVirtualization={true}
style={animatedListStyle} style={animatedListStyle}
// The extra two items account for the header and the footer components // The extra two items account for the header and the footer components

View File

@ -91,6 +91,7 @@ const schema = z.object({
disableAutoplay: z.boolean().optional(), disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(), kawaii: z.boolean().optional(),
hasCheckedForStarterPack: z.boolean().optional(), hasCheckedForStarterPack: z.boolean().optional(),
subtitlesEnabled: z.boolean().optional(),
/** @deprecated */ /** @deprecated */
mutedThreads: z.array(z.string()), mutedThreads: z.array(z.string()),
}) })
@ -133,6 +134,7 @@ export const defaults: Schema = {
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
kawaii: false, kawaii: false,
hasCheckedForStarterPack: false, hasCheckedForStarterPack: false,
subtitlesEnabled: true,
} }
export function tryParse(rawData: string): Schema | undefined { export function tryParse(rawData: string): Schema | undefined {

View File

@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
import {Provider as KawaiiProvider} from './kawaii' import {Provider as KawaiiProvider} from './kawaii'
import {Provider as LanguagesProvider} from './languages' import {Provider as LanguagesProvider} from './languages'
import {Provider as LargeAltBadgeProvider} from './large-alt-badge' import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
import {Provider as SubtitlesProvider} from './subtitles'
import {Provider as UsedStarterPacksProvider} from './used-starter-packs' import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
export { export {
@ -24,6 +25,7 @@ export {
export * from './hidden-posts' export * from './hidden-posts'
export {useLabelDefinitions} from './label-defs' export {useLabelDefinitions} from './label-defs'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
return ( return (
@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<DisableHapticsProvider> <DisableHapticsProvider>
<AutoplayProvider> <AutoplayProvider>
<UsedStarterPacksProvider> <UsedStarterPacksProvider>
<SubtitlesProvider>
<KawaiiProvider>{children}</KawaiiProvider> <KawaiiProvider>{children}</KawaiiProvider>
</SubtitlesProvider>
</UsedStarterPacksProvider> </UsedStarterPacksProvider>
</AutoplayProvider> </AutoplayProvider>
</DisableHapticsProvider> </DisableHapticsProvider>

View File

@ -0,0 +1,42 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type StateContext = boolean
type SetContext = (v: boolean) => void
const stateContext = React.createContext<StateContext>(
Boolean(persisted.defaults.subtitlesEnabled),
)
const setContext = React.createContext<SetContext>((_: boolean) => {})
export function Provider({children}: {children: React.ReactNode}) {
const [state, setState] = React.useState(
Boolean(persisted.get('subtitlesEnabled')),
)
const setStateWrapped = React.useCallback(
(subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => {
setState(Boolean(subtitlesEnabled))
persisted.write('subtitlesEnabled', subtitlesEnabled)
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => {
setState(Boolean(nextSubtitlesEnabled))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export const useSubtitlesEnabled = () => React.useContext(stateContext)
export const useSetSubtitlesEnabled = () => React.useContext(setContext)

View File

@ -507,7 +507,6 @@ const styles = StyleSheet.create({
paddingRight: 15, paddingRight: 15,
// @ts-ignore web only -prf // @ts-ignore web only -prf
cursor: 'pointer', cursor: 'pointer',
overflow: 'hidden',
}, },
replyLine: { replyLine: {
width: 2, width: 2,

View File

@ -28,8 +28,6 @@ export type ListProps<ItemT> = Omit<
// Web only prop to contain the scroll to the container rather than the window // Web only prop to contain the scroll to the container rather than the window
disableFullWindowScroll?: boolean disableFullWindowScroll?: boolean
sideBorders?: boolean sideBorders?: boolean
// Web only prop to disable a perf optimization (which would otherwise be on).
disableContainStyle?: boolean
} }
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>

View File

@ -4,11 +4,10 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
import {batchedUpdates} from '#/lib/batchedUpdates' import {batchedUpdates} from '#/lib/batchedUpdates'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useScrollHandlers} from '#/lib/ScrollContext' import {useScrollHandlers} from '#/lib/ScrollContext'
import {isSafari} from 'lib/browser' import {addStyle} from '#/lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {addStyle} from 'lib/styles'
export type ListMethods = any // TODO: Better types. export type ListMethods = any // TODO: Better types.
export type ListProps<ItemT> = Omit< export type ListProps<ItemT> = Omit<
@ -26,8 +25,6 @@ export type ListProps<ItemT> = Omit<
// Web only prop to contain the scroll to the container rather than the window // Web only prop to contain the scroll to the container rather than the window
disableFullWindowScroll?: boolean disableFullWindowScroll?: boolean
sideBorders?: boolean sideBorders?: boolean
// Web only prop to disable a perf optimization (which would otherwise be on).
disableContainStyle?: boolean
} }
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
@ -60,7 +57,6 @@ function ListImpl<ItemT>(
extraData, extraData,
style, style,
sideBorders = true, sideBorders = true,
disableContainStyle,
...props ...props
}: ListProps<ItemT>, }: ListProps<ItemT>,
ref: React.Ref<ListMethods>, ref: React.Ref<ListMethods>,
@ -364,7 +360,6 @@ function ListImpl<ItemT>(
renderItem={renderItem} renderItem={renderItem}
extraData={extraData} extraData={extraData}
onItemSeen={onItemSeen} onItemSeen={onItemSeen}
disableContainStyle={disableContainStyle}
/> />
) )
})} })}
@ -442,7 +437,6 @@ let Row = function RowImpl<ItemT>({
renderItem, renderItem,
extraData: _unused, extraData: _unused,
onItemSeen, onItemSeen,
disableContainStyle,
}: { }: {
item: ItemT item: ItemT
index: number index: number
@ -452,7 +446,6 @@ let Row = function RowImpl<ItemT>({
| ((data: {index: number; item: any; separators: any}) => React.ReactNode) | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
extraData: any extraData: any
onItemSeen: ((item: any) => void) | undefined onItemSeen: ((item: any) => void) | undefined
disableContainStyle?: boolean
}): React.ReactNode { }): React.ReactNode {
const rowRef = React.useRef(null) const rowRef = React.useRef(null)
const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined) const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined)
@ -501,11 +494,8 @@ let Row = function RowImpl<ItemT>({
return null return null
} }
const shouldDisableContainStyle = disableContainStyle || isSafari
return ( return (
<View <View ref={rowRef}>
style={shouldDisableContainStyle ? undefined : styles.contain}
ref={rowRef}>
{renderItem({item, index, separators: null as any})} {renderItem({item, index, separators: null as any})}
</View> </View>
) )
@ -576,10 +566,6 @@ const styles = StyleSheet.create({
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
}, },
contain: {
// @ts-ignore web only
contain: 'layout paint',
},
minHeightViewport: { minHeightViewport: {
// @ts-ignore web only // @ts-ignore web only
minHeight: '100vh', minHeight: '100vh',

View File

@ -1,37 +1,103 @@
import React, {useCallback, useId, useMemo, useState} from 'react' import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import {useWindowDimensions} from 'react-native'
import {isNative} from '#/platform/detection'
import {VideoPlayerProvider} from './VideoPlayerContext' import {VideoPlayerProvider} from './VideoPlayerContext'
const ActiveVideoContext = React.createContext<{ const ActiveVideoContext = React.createContext<{
activeViewId: string | null activeViewId: string | null
setActiveView: (viewId: string, src: string) => void setActiveView: (viewId: string, src: string) => void
sendViewPosition: (viewId: string, y: number) => void
} | null>(null) } | null>(null)
export function ActiveVideoProvider({children}: {children: React.ReactNode}) { export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
const [activeViewId, setActiveViewId] = useState<string | null>(null) const [activeViewId, setActiveViewId] = useState<string | null>(null)
const activeViewLocationRef = useRef(Infinity)
const [source, setSource] = useState<string | null>(null) const [source, setSource] = useState<string | null>(null)
const {height: windowHeight} = useWindowDimensions()
// minimising re-renders by using refs
const manuallySetRef = useRef(false)
const activeViewIdRef = useRef(activeViewId)
useEffect(() => {
activeViewIdRef.current = activeViewId
}, [activeViewId])
const setActiveView = useCallback(
(viewId: string, src: string) => {
setActiveViewId(viewId)
setSource(src)
manuallySetRef.current = true
// we don't know the exact position, but it's definitely on screen
// so just guess that it's in the middle. Any value is fine
// so long as it's not offscreen
activeViewLocationRef.current = windowHeight / 2
},
[windowHeight],
)
const sendViewPosition = useCallback(
(viewId: string, y: number) => {
if (isNative) return
if (viewId === activeViewIdRef.current) {
activeViewLocationRef.current = y
} else {
if (
distanceToIdealPosition(y) <
distanceToIdealPosition(activeViewLocationRef.current)
) {
// if the old view was manually set, only usurp if the old view is offscreen
if (
manuallySetRef.current &&
withinViewport(activeViewLocationRef.current)
) {
return
}
setActiveViewId(viewId)
activeViewLocationRef.current = y
manuallySetRef.current = false
}
}
function distanceToIdealPosition(yPos: number) {
return Math.abs(yPos - windowHeight / 2.5)
}
function withinViewport(yPos: number) {
return yPos > 0 && yPos < windowHeight
}
},
[windowHeight],
)
const value = useMemo( const value = useMemo(
() => ({ () => ({
activeViewId, activeViewId,
setActiveView: (viewId: string, src: string) => { setActiveView,
setActiveViewId(viewId) sendViewPosition,
setSource(src)
},
}), }),
[activeViewId], [activeViewId, setActiveView, sendViewPosition],
) )
return ( return (
<ActiveVideoContext.Provider value={value}> <ActiveVideoContext.Provider value={value}>
<VideoPlayerProvider source={source ?? ''} viewId={activeViewId}> <VideoPlayerProvider source={source ?? ''}>
{children} {children}
</VideoPlayerProvider> </VideoPlayerProvider>
</ActiveVideoContext.Provider> </ActiveVideoContext.Provider>
) )
} }
export function useActiveVideoView() { export function useActiveVideoView({source}: {source: string}) {
const context = React.useContext(ActiveVideoContext) const context = React.useContext(ActiveVideoContext)
if (!context) { if (!context) {
throw new Error('useActiveVideo must be used within a ActiveVideoProvider') throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
@ -41,7 +107,12 @@ export function useActiveVideoView() {
return { return {
active: context.activeViewId === id, active: context.activeViewId === id,
setActive: useCallback( setActive: useCallback(
(source: string) => context.setActiveView(id, source), () => context.setActiveView(id, source),
[context, id, source],
),
currentActiveView: context.activeViewId,
sendPosition: useCallback(
(y: number) => context.sendViewPosition(id, y),
[context, id], [context, id],
), ),
} }

View File

@ -11,10 +11,10 @@ import {VideoEmbedInner} from './VideoEmbedInner'
export function VideoEmbed({source}: {source: string}) { export function VideoEmbed({source}: {source: string}) {
const t = useTheme() const t = useTheme()
const {active, setActive} = useActiveVideoView() const {active, setActive} = useActiveVideoView({source})
const {_} = useLingui() const {_} = useLingui()
const onPress = useCallback(() => setActive(source), [setActive, source]) const onPress = useCallback(() => setActive(), [setActive])
return ( return (
<View <View
@ -27,7 +27,13 @@ export function VideoEmbed({source}: {source: string}) {
a.my_xs, a.my_xs,
]}> ]}>
{active ? ( {active ? (
<VideoEmbedInner source={source} /> <VideoEmbedInner
source={source}
// web only
active={active}
setActive={setActive}
onScreen={true}
/>
) : ( ) : (
<Button <Button
style={[a.flex_1, t.atoms.bg_contrast_25]} style={[a.flex_1, t.atoms.bg_contrast_25]}

View File

@ -0,0 +1,190 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoView} from './ActiveVideoContext'
import {VideoEmbedInner} from './VideoEmbedInner'
import {HLSUnsupportedError} from './VideoEmbedInner.web'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {active, setActive, sendPosition, currentActiveView} =
useActiveVideoView({source})
const [onScreen, setOnScreen] = useState(false)
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
sendPosition(
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
)
},
{threshold: 0.5},
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition])
const [key, setKey] = useState(0)
const renderError = useCallback(
(error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} />
),
[key],
)
return (
<View
style={[
a.w_full,
{aspectRatio: 16 / 9},
t.atoms.bg_contrast_25,
a.rounded_sm,
a.my_xs,
]}>
<div
ref={ref}
style={{display: 'flex', flex: 1, cursor: 'default'}}
onClick={evt => evt.stopPropagation()}>
<ErrorBoundary renderError={renderError} key={key}>
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
<VideoEmbedInner
source={source}
active={active}
setActive={setActive}
onScreen={onScreen}
/>
</ViewportObserver>
</ErrorBoundary>
</div>
</View>
)
}
/**
* Renders a 100vh tall div and watches it with an IntersectionObserver to
* send the position of the div when it's near the screen.
*/
function ViewportObserver({
children,
sendPosition,
isAnyViewActive,
}: {
children: React.ReactNode
sendPosition: (position: number) => void
isAnyViewActive?: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
const [nearScreen, setNearScreen] = useState(false)
// Send position when scrolling. This is done with an IntersectionObserver
// observing a div of 100vh height
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
if (!entry) return
const position =
entry.boundingClientRect.y + entry.boundingClientRect.height / 2
sendPosition(position)
setNearScreen(entry.isIntersecting)
},
{threshold: Array.from({length: 101}, (_, i) => i / 100)},
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition])
// In case scrolling hasn't started yet, send up the position
useEffect(() => {
if (ref.current && !isAnyViewActive) {
const rect = ref.current.getBoundingClientRect()
const position = rect.y + rect.height / 2
sendPosition(position)
}
}, [isAnyViewActive, sendPosition])
return (
<View style={[a.flex_1, a.flex_row]}>
{nearScreen && children}
<div
ref={ref}
style={{
position: 'absolute',
top: 'calc(50% - 50vh)',
left: '50%',
height: '100vh',
width: 1,
pointerEvents: 'none',
}}
/>
</View>
)
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
const t = useTheme()
const {_} = useLingui()
const isHLS = error instanceof HLSUnsupportedError
return (
<View
style={[
a.flex_1,
t.atoms.bg_contrast_25,
a.justify_center,
a.align_center,
a.px_lg,
a.border,
t.atoms.border_contrast_low,
a.rounded_sm,
a.gap_lg,
]}>
<Text
style={[
a.text_center,
t.atoms.text_contrast_high,
a.text_md,
a.leading_snug,
{maxWidth: 300},
]}>
{isHLS ? (
<Trans>
Your browser does not support the video format. Please try a
different browser.
</Trans>
) : (
<Trans>
An error occurred while loading the video. Please try again later.
</Trans>
)}
</Text>
{!isHLS && (
<Button
onPress={retry}
size="small"
color="secondary_inverted"
variant="solid"
label={_(msg`Retry`)}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
)}
</View>
)
}

View File

@ -13,7 +13,12 @@ import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useVideoPlayer} from './VideoPlayerContext' import {useVideoPlayer} from './VideoPlayerContext'
export const VideoEmbedInner = ({}: {source: string}) => { export function VideoEmbedInner({}: {
source: string
active: boolean
setActive: () => void
onScreen: boolean
}) {
const player = useVideoPlayer() const player = useVideoPlayer()
const aref = useAnimatedRef<Animated.View>() const aref = useAnimatedRef<Animated.View>()
const {height: windowHeight} = useWindowDimensions() const {height: windowHeight} = useWindowDimensions()

View File

@ -1,52 +1,93 @@
import React, {useEffect, useRef} from 'react' import React, {useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import Hls from 'hls.js' import Hls from 'hls.js'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Controls} from './VideoWebControls'
export const VideoEmbedInner = ({source}: {source: string}) => { export function VideoEmbedInner({
source,
active,
setActive,
onScreen,
}: {
source: string
active: boolean
setActive: () => void
onScreen: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const ref = useRef<HTMLVideoElement>(null) const ref = useRef<HTMLVideoElement>(null)
const [focused, setFocused] = useState(false)
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const hlsRef = useRef<Hls | undefined>(undefined)
// Use HLS.js to play HLS video
useEffect(() => { useEffect(() => {
if (ref.current) { if (!ref.current) return
if (ref.current.canPlayType('application/vnd.apple.mpegurl')) { if (!Hls.isSupported()) throw new HLSUnsupportedError()
ref.current.src = source
} else if (Hls.isSupported()) { const hls = new Hls({capLevelToPlayerSize: true})
var hls = new Hls() hlsRef.current = hls
hls.loadSource(source)
hls.attachMedia(ref.current) hls.attachMedia(ref.current)
} else { hls.loadSource(source)
// TODO: fallback
// initial value, later on it's managed by Controls
hls.autoLevelCapping = 0
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
if (data.subtitleTracks.length > 0) {
setHasSubtitleTrack(true)
} }
})
return () => {
hlsRef.current = undefined
hls.detachMedia()
hls.destroy()
} }
}, [source]) }, [source])
useEffect(() => { return (
if (ref.current) { <View
const observer = new IntersectionObserver( style={[
([entry]) => { a.w_full,
if (ref.current) { a.rounded_sm,
if (entry.isIntersecting) { // TODO: get from embed metadata
if (ref.current.paused) { // max should be 1 / 1
ref.current.play() {aspectRatio: 16 / 9},
} a.overflow_hidden,
} else { ]}>
if (!ref.current.paused) { <div
ref.current.pause() ref={containerRef}
} style={{width: '100%', height: '100%', display: 'flex'}}>
} <video
} ref={ref}
}, style={{width: '100%', height: '100%', objectFit: 'contain'}}
{threshold: 0}, playsInline
preload="none"
loop
muted={!focused}
/>
<Controls
videoRef={ref}
hlsRef={hlsRef}
active={active}
setActive={setActive}
focused={focused}
setFocused={setFocused}
onScreen={onScreen}
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
/>
</div>
</View>
) )
}
observer.observe(ref.current)
export class HLSUnsupportedError extends Error {
return () => { constructor() {
observer.disconnect() super('HLS is not supported')
} }
}
}, [])
return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop />
} }

View File

@ -1,15 +1,13 @@
import React, {useContext, useEffect} from 'react' import React, {useContext} from 'react'
import type {VideoPlayer} from 'expo-video' import type {VideoPlayer} from 'expo-video'
import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video' import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
const VideoPlayerContext = React.createContext<VideoPlayer | null>(null) const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
export function VideoPlayerProvider({ export function VideoPlayerProvider({
viewId,
source, source,
children, children,
}: { }: {
viewId: string | null
source: string source: string
children: React.ReactNode children: React.ReactNode
}) { }) {
@ -19,12 +17,6 @@ export function VideoPlayerProvider({
player.play() player.play()
}) })
// make sure we're playing every time the viewId changes
// this means the video is different
useEffect(() => {
player.play()
}, [viewId, player])
return ( return (
<VideoPlayerContext.Provider value={player}> <VideoPlayerContext.Provider value={player}>
{children} {children}

View File

@ -0,0 +1,16 @@
import React from 'react'
import type Hls from 'hls.js'
export function Controls({}: {
videoRef: React.RefObject<HTMLVideoElement>
hlsRef: React.RefObject<Hls | undefined>
active: boolean
setActive: () => void
focused: boolean
setFocused: (focused: boolean) => void
onScreen: boolean
fullscreenRef: React.RefObject<HTMLDivElement>
hasSubtitleTrack: boolean
}): React.ReactElement {
throw new Error('Web-only component')
}

View File

@ -0,0 +1,587 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {Pressable, View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type Hls from 'hls.js'
import {isIPhoneWeb} from '#/platform/detection'
import {
useAutoplayDisabled,
useSetSubtitlesEnabled,
useSubtitlesEnabled,
} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
} from '#/components/icons/ArrowsDiagonal'
import {
CC_Filled_Corner0_Rounded as CCActiveIcon,
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
} from '#/components/icons/CC'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Controls({
videoRef,
hlsRef,
active,
setActive,
focused,
setFocused,
onScreen,
fullscreenRef,
hasSubtitleTrack,
}: {
videoRef: React.RefObject<HTMLVideoElement>
hlsRef: React.RefObject<Hls | undefined>
active: boolean
setActive: () => void
focused: boolean
setFocused: (focused: boolean) => void
onScreen: boolean
fullscreenRef: React.RefObject<HTMLDivElement>
hasSubtitleTrack: boolean
}) {
const {
play,
pause,
playing,
muted,
toggleMute,
togglePlayPause,
currentTime,
duration,
buffering,
error,
canPlay,
} = useVideoUtils(videoRef)
const t = useTheme()
const {_} = useLingui()
const subtitlesEnabled = useSubtitlesEnabled()
const setSubtitlesEnabled = useSetSubtitlesEnabled()
const {
state: hovered,
onIn: onMouseEnter,
onOut: onMouseLeave,
} = useInteractionState()
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
const onKeyDown = useCallback(() => {
setInteractingViaKeypress(true)
}, [])
useEffect(() => {
if (interactingViaKeypress) {
document.addEventListener('click', () => setInteractingViaKeypress(false))
return () => {
document.removeEventListener('click', () =>
setInteractingViaKeypress(false),
)
}
}
}, [interactingViaKeypress])
// pause + unfocus when another video is active
useEffect(() => {
if (!active) {
pause()
setFocused(false)
}
}, [active, pause, setFocused])
// autoplay/pause based on visibility
const autoplayDisabled = useAutoplayDisabled()
useEffect(() => {
if (active && !autoplayDisabled) {
if (onScreen) {
play()
} else {
pause()
}
}
}, [onScreen, pause, active, play, autoplayDisabled])
// use minimal quality when not focused
useEffect(() => {
if (!hlsRef.current) return
if (focused) {
// auto decide quality based on network conditions
hlsRef.current.autoLevelCapping = -1
} else {
hlsRef.current.autoLevelCapping = 0
}
}, [hlsRef, focused])
useEffect(() => {
if (!hlsRef.current) return
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
hlsRef.current.subtitleTrack = 0
} else {
hlsRef.current.subtitleTrack = -1
}
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
// clicking on any button should focus the player, if it's not already focused
const drawFocus = useCallback(() => {
if (!active) {
setActive()
}
setFocused(true)
}, [active, setActive, setFocused])
const onPressEmptySpace = useCallback(() => {
if (!focused) {
drawFocus()
} else {
togglePlayPause()
}
}, [togglePlayPause, drawFocus, focused])
const onPressPlayPause = useCallback(() => {
drawFocus()
togglePlayPause()
}, [drawFocus, togglePlayPause])
const onPressSubtitles = useCallback(() => {
drawFocus()
setSubtitlesEnabled(!subtitlesEnabled)
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
const onPressMute = useCallback(() => {
drawFocus()
toggleMute()
}, [drawFocus, toggleMute])
const onPressFullscreen = useCallback(() => {
drawFocus()
toggleFullscreen()
}, [drawFocus, toggleFullscreen])
const showControls =
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
return (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
onClick={evt => {
evt.stopPropagation()
setInteractingViaKeypress(false)
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}>
<Pressable
accessibilityRole="button"
accessibilityHint={_(
focused
? msg`Unmute video`
: playing
? msg`Pause video`
: msg`Play video`,
)}
style={a.flex_1}
onPress={onPressEmptySpace}
/>
<View
style={[
a.flex_shrink_0,
a.w_full,
a.px_sm,
a.pt_sm,
a.pb_md,
a.gap_md,
a.flex_row,
a.align_center,
web({
background:
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
}),
showControls ? {opacity: 1} : {opacity: 0},
]}>
<Button
label={_(playing ? msg`Pause` : msg`Play`)}
onPress={onPressPlayPause}
{...btnProps}>
{playing ? (
<PauseIcon fill={t.palette.white} width={20} />
) : (
<PlayIcon fill={t.palette.white} width={20} />
)}
</Button>
<View style={a.flex_1} />
<Text style={{color: t.palette.white}}>
{formatTime(currentTime)} / {formatTime(duration)}
</Text>
{hasSubtitleTrack && (
<Button
label={_(
subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`,
)}
onPress={onPressSubtitles}
{...btnProps}>
{subtitlesEnabled ? (
<CCActiveIcon fill={t.palette.white} width={20} />
) : (
<CCInactiveIcon fill={t.palette.white} width={20} />
)}
</Button>
)}
<Button
label={_(muted ? msg`Unmute` : msg`Mute`)}
onPress={onPressMute}
{...btnProps}>
{muted ? (
<MuteIcon fill={t.palette.white} width={20} />
) : (
<UnmuteIcon fill={t.palette.white} width={20} />
)}
</Button>
{!isIPhoneWeb && (
<Button
label={_(muted ? msg`Unmute` : msg`Mute`)}
onPress={onPressFullscreen}
{...btnProps}>
{isFullscreen ? (
<ArrowsInIcon fill={t.palette.white} width={20} />
) : (
<ArrowsOutIcon fill={t.palette.white} width={20} />
)}
</Button>
)}
</View>
{(showControls || !focused) && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={[
a.absolute,
{
height: 5,
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(255,255,255,0.4)',
},
]}>
{duration > 0 && (
<View
style={[
a.h_full,
a.mr_auto,
{
backgroundColor: t.palette.white,
width: `${(currentTime / duration) * 100}%`,
opacity: 0.8,
},
]}
/>
)}
</Animated.View>
)}
{(buffering || error) && (
<Animated.View
pointerEvents="none"
entering={FadeIn.delay(1000).duration(200)}
exiting={FadeOut.duration(200)}
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
{buffering && <Loader fill={t.palette.white} size="lg" />}
{error && (
<Text style={{color: t.palette.white}}>
<Trans>An error occurred</Trans>
</Text>
)}
</Animated.View>
)}
</div>
)
}
const btnProps = {
variant: 'ghost',
shape: 'round',
size: 'medium',
style: a.p_2xs,
hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
} as const
function formatTime(time: number) {
if (isNaN(time)) {
return '--'
}
time = Math.round(time)
const minutes = Math.floor(time / 60)
const seconds = String(time % 60).padStart(2, '0')
return `${minutes}:${seconds}`
}
function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
const [playing, setPlaying] = useState(false)
const [muted, setMuted] = useState(true)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [buffering, setBuffering] = useState(false)
const [error, setError] = useState(false)
const [canPlay, setCanPlay] = useState(false)
const playWhenReadyRef = useRef(false)
useEffect(() => {
if (!ref.current) return
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
function round(num: number) {
return Math.round(num * 100) / 100
}
// Initial values
setCurrentTime(round(ref.current.currentTime) || 0)
setDuration(round(ref.current.duration) || 0)
setMuted(ref.current.muted)
setPlaying(!ref.current.paused)
const handleTimeUpdate = () => {
if (!ref.current) return
setCurrentTime(round(ref.current.currentTime) || 0)
}
const handleDurationChange = () => {
if (!ref.current) return
setDuration(round(ref.current.duration) || 0)
}
const handlePlay = () => {
setPlaying(true)
}
const handlePause = () => {
setPlaying(false)
}
const handleVolumeChange = () => {
if (!ref.current) return
setMuted(ref.current.muted)
}
const handleError = () => {
setError(true)
}
const handleCanPlay = () => {
setBuffering(false)
setCanPlay(true)
if (!ref.current) return
if (playWhenReadyRef.current) {
ref.current.play()
playWhenReadyRef.current = false
}
}
const handleCanPlayThrough = () => {
setBuffering(false)
}
const handleWaiting = () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
bufferingTimeout = setTimeout(() => {
setBuffering(true)
}, 200) // Delay to avoid frequent buffering state changes
}
const handlePlaying = () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
setBuffering(false)
setError(false)
}
const handleSeeking = () => {
setBuffering(true)
}
const handleSeeked = () => {
setBuffering(false)
}
const handleStalled = () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
bufferingTimeout = setTimeout(() => {
setBuffering(true)
}, 200) // Delay to avoid frequent buffering state changes
}
const handleEnded = () => {
setPlaying(false)
setBuffering(false)
setError(false)
}
const abortController = new AbortController()
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
signal: abortController.signal,
})
ref.current.addEventListener('durationchange', handleDurationChange, {
signal: abortController.signal,
})
ref.current.addEventListener('play', handlePlay, {
signal: abortController.signal,
})
ref.current.addEventListener('pause', handlePause, {
signal: abortController.signal,
})
ref.current.addEventListener('volumechange', handleVolumeChange, {
signal: abortController.signal,
})
ref.current.addEventListener('error', handleError, {
signal: abortController.signal,
})
ref.current.addEventListener('canplay', handleCanPlay, {
signal: abortController.signal,
})
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
signal: abortController.signal,
})
ref.current.addEventListener('waiting', handleWaiting, {
signal: abortController.signal,
})
ref.current.addEventListener('playing', handlePlaying, {
signal: abortController.signal,
})
ref.current.addEventListener('seeking', handleSeeking, {
signal: abortController.signal,
})
ref.current.addEventListener('seeked', handleSeeked, {
signal: abortController.signal,
})
ref.current.addEventListener('stalled', handleStalled, {
signal: abortController.signal,
})
ref.current.addEventListener('ended', handleEnded, {
signal: abortController.signal,
})
return () => {
abortController.abort()
clearTimeout(bufferingTimeout)
}
}, [ref])
const play = useCallback(() => {
if (!ref.current) return
if (ref.current.ended) {
ref.current.currentTime = 0
}
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
playWhenReadyRef.current = true
} else {
const promise = ref.current.play()
if (promise !== undefined) {
promise.catch(err => {
console.error('Error playing video:', err)
})
}
}
}, [ref])
const pause = useCallback(() => {
if (!ref.current) return
ref.current.pause()
playWhenReadyRef.current = false
}, [ref])
const togglePlayPause = useCallback(() => {
if (!ref.current) return
if (ref.current.paused) {
play()
} else {
pause()
}
}, [ref, play, pause])
const mute = useCallback(() => {
if (!ref.current) return
ref.current.muted = true
}, [ref])
const unmute = useCallback(() => {
if (!ref.current) return
ref.current.muted = false
}, [ref])
const toggleMute = useCallback(() => {
if (!ref.current) return
ref.current.muted = !ref.current.muted
}, [ref])
return {
play,
pause,
togglePlayPause,
duration,
currentTime,
playing,
muted,
mute,
unmute,
toggleMute,
buffering,
error,
canPlay,
}
}
function fullscreenSubscribe(onChange: () => void) {
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}
function useFullscreen(ref: React.RefObject<HTMLElement>) {
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
Boolean(document.fullscreenElement),
)
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
document.exitFullscreen()
} else {
if (!ref.current) return
ref.current.requestFullscreen()
}
}, [isFullscreen, ref])
return [isFullscreen, toggleFullscreen] as const
}