* 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
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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',
|
||||||
|
})
|
|
@ -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',
|
||||||
|
})
|
|
@ -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',
|
||||||
|
})
|
|
@ -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',
|
||||||
})
|
})
|
||||||
|
|
|
@ -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?.()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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],
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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 {
|
||||||
|
constructor() {
|
||||||
return () => {
|
super('HLS is not supported')
|
||||||
observer.disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
|
||||||
return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop />
|
|
||||||
}
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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')
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|