diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index aa7efc5ebf9..5dc5a9e8a2c 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -258,6 +258,51 @@ .force-no-clicks * { pointer-events: none !important; } + + input[type=range][orient=vertical] { + writing-mode: vertical-lr; + direction: rtl; + appearance: slider-vertical; + width: 16px; + vertical-align: bottom; + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + } + + input[type="range"][orient=vertical]::-webkit-slider-runnable-track { + background: white; + height: 100%; + width: 4px; + border-radius: 4px; + } + + input[type="range"][orient=vertical]::-moz-range-track { + background: white; + height: 100%; + width: 4px; + border-radius: 4px; + } + + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + border-radius: 50%; + background-color: white; + height: 16px; + width: 16px; + margin-left: -6px; + } + + input[type="range"][orient=vertical]::-moz-range-thumb { + border: none; + border-radius: 50%; + background-color: white; + height: 16px; + width: 16px; + margin-left: -6px; + } {% include "scripts.html" %} diff --git a/package.json b/package.json index 08778ec6f7e..b007bf871d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.91.1", + "version": "1.91.2", "private": true, "engines": { "node": ">=18" @@ -68,7 +68,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.2", - "@haileyok/bluesky-video": "0.1.8", + "@haileyok/bluesky-video": "0.1.10", "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.7.1", "@miblanchard/react-native-slider": "^2.3.1", @@ -191,7 +191,7 @@ "react-native-safe-area-context": "4.10.1", "react-native-screens": "~3.31.1", "react-native-svg": "^15.3.0", - "react-native-uitextview": "^1.1.7", + "react-native-uitextview": "^1.3.0", "react-native-url-polyfill": "^1.3.0", "react-native-uuid": "^2.0.2", "react-native-view-shot": "^3.8.0", diff --git a/src/components/LikedByList.tsx b/src/components/LikedByList.tsx index 239a7044f6b..0106a561811 100644 --- a/src/components/LikedByList.tsx +++ b/src/components/LikedByList.tsx @@ -75,6 +75,7 @@ export function LikedByList({uri}: {uri: string}) { isLoading={isUriLoading || isLikedByLoading} isError={isError} emptyType="results" + emptyTitle={_(msg`No likes yet`)} emptyMessage={_( msg`Nobody has liked this yet. Maybe you should be the first!`, )} diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 86cb5c315ac..7836bbef953 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -59,7 +59,9 @@ export function Outer({ export function TitleText({children}: React.PropsWithChildren<{}>) { const {titleId} = React.useContext(Context) return ( - + {children} ) diff --git a/src/components/dialogs/nuxs/TenMillion/Trigger.tsx b/src/components/dialogs/nuxs/TenMillion/Trigger.tsx new file mode 100644 index 00000000000..9616b3b1d3b --- /dev/null +++ b/src/components/dialogs/nuxs/TenMillion/Trigger.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import {View} from 'react-native' +import Svg, {Circle, Path} from 'react-native-svg' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {Nux, useUpsertNuxMutation} from '#/state/queries/nuxs' +import {atoms as a, ViewStyleProp} from '#/alf' +import {Button, ButtonProps} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {InlineLinkText} from '#/components/Link' +import * as Prompt from '#/components/Prompt' +import {TenMillion} from './' + +export function Trigger({children}: {children: ButtonProps['children']}) { + const {_} = useLingui() + const {mutate: upsertNux} = useUpsertNuxMutation() + const [show, setShow] = React.useState(false) + const [fallback, setFallback] = React.useState(false) + const control = Prompt.usePromptControl() + + const handleOnPress = () => { + if (!fallback) { + setShow(true) + upsertNux({ + id: Nux.TenMillionDialog, + completed: true, + data: undefined, + }) + } else { + control.open() + } + } + + const onHandleFallback = () => { + setFallback(true) + control.open() + } + + return ( + <> + + + {show && !fallback && ( + setShow(false)} + onFallback={onHandleFallback} + /> + )} + + + + + Bluesky is celebrating 10 million users! + + + + + Together, we're rebuilding the social internet. We're glad you're + here. + + + + + To learn more,{' '} + { + control.close() + }} + style={[a.text_md, a.leading_snug]}> + check out our post. + + + + + + + ) +} + +export function Icon({width, style}: {width: number} & ViewStyleProp) { + return ( + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/dialogs/nuxs/TenMillion/index.tsx b/src/components/dialogs/nuxs/TenMillion/index.tsx index 8d5511fdf0a..89608240942 100644 --- a/src/components/dialogs/nuxs/TenMillion/index.tsx +++ b/src/components/dialogs/nuxs/TenMillion/index.tsx @@ -87,7 +87,15 @@ function Frame({children}: {children: React.ReactNode}) { ) } -export function TenMillion() { +export function TenMillion({ + showTimeout, + onClose, + onFallback, +}: { + showTimeout?: number + onClose?: () => void + onFallback?: () => void +}) { const agent = useAgent() const nuxDialogs = useNuxDialogContext() const [userNumber, setUserNumber] = React.useState(0) @@ -115,12 +123,16 @@ export function TenMillion() { const data = await res.json() - if (data.number) { + if (data.number && data.number <= 10_000_000) { setUserNumber(data.number) } else { // should be rare nuxDialogs.dismissActiveNux() + onFallback?.() } + } else { + nuxDialogs.dismissActiveNux() + onFallback?.() } } @@ -128,6 +140,7 @@ export function TenMillion() { fetching.current = true networkRetry(3, fetchUserNumber).catch(() => { nuxDialogs.dismissActiveNux() + onFallback?.() }) } }, [ @@ -136,12 +149,27 @@ export function TenMillion() { setUserNumber, nuxDialogs.dismissActiveNux, nuxDialogs, + onFallback, ]) - return userNumber ? : null + return userNumber ? ( + + ) : null } -export function TenMillionInner({userNumber}: {userNumber: number}) { +export function TenMillionInner({ + userNumber, + showTimeout, + onClose: onCloseOuter, +}: { + userNumber: number + showTimeout: number + onClose?: () => void +}) { const t = useTheme() const lightTheme = useTheme('light') const {_, i18n} = useLingui() @@ -169,6 +197,27 @@ export function TenMillionInner({userNumber}: {userNumber: number}) { const isLoadingData = isProfileLoading || !moderation || !profile const isLoadingImage = !uri + const displayName = React.useMemo(() => { + if (!profile || !moderation) return '' + return sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + }, [profile, moderation]) + const handle = React.useMemo(() => { + if (!profile) return '' + return sanitizeHandle(profile.handle, '@') + }, [profile]) + const joinedDate = React.useMemo(() => { + if (!profile || !profile.createdAt) return '' + const date = i18n.date(profile.createdAt, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + return date + }, [i18n, profile]) + const error: string = React.useMemo(() => { if (profileError) { return _( @@ -184,14 +233,15 @@ export function TenMillionInner({userNumber}: {userNumber: number}) { React.useEffect(() => { const timeout = setTimeout(() => { control.open() - }, 3e3) + }, showTimeout) return () => { clearTimeout(timeout) } - }, [control]) + }, [control, showTimeout]) const onClose = React.useCallback(() => { nuxDialogs.dismissActiveNux() - }, [nuxDialogs]) + onCloseOuter?.() + }, [nuxDialogs, onCloseOuter]) /* * Actions @@ -206,19 +256,34 @@ export function TenMillionInner({userNumber}: {userNumber: number}) { msg`Bluesky now has over 10 million users, and I was #${i18n.number( userNumber, )}!`, - ), // TODO + ), imageUris: [ { uri, width: WIDTH, height: HEIGHT, + altText: _( + msg`A virtual certificate with text "Celebrating 10M users on Bluesky, #${i18n.number( + userNumber, + )}, ${displayName} ${handle}, joined on ${joinedDate}"`, + ), }, ], }) }, 1e3) }) } - }, [_, i18n, control, openComposer, uri, userNumber]) + }, [ + _, + i18n, + control, + openComposer, + uri, + userNumber, + displayName, + handle, + joinedDate, + ]) const onNativeShare = React.useCallback(() => { if (uri) { control.close(() => { @@ -358,6 +423,7 @@ export function TenMillionInner({userNumber}: {userNumber: number}) { }, ]}> - {sanitizeDisplayName( - profile.displayName || - sanitizeHandle(profile.handle), - moderation.ui('displayName'), - )} + {displayName} - {sanitizeHandle(profile.handle, '@')} + {handle} {profile.createdAt && ( - - Joined{' '} - {i18n.date(profile.createdAt, { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - + Joined on {joinedDate} )} @@ -582,6 +642,7 @@ export function TenMillionInner({userNumber}: {userNumber: number}) { - - Brag a little! - + {gtMobile && ( + + Brag a little! + + )} - ) -} - -function Scrubber({ - duration, - currentTime, - onSeek, - onSeekEnd, - onSeekStart, - seekLeft, - seekRight, - togglePlayPause, - drawFocus, -}: { - duration: number - currentTime: number - onSeek: (time: number) => void - onSeekEnd: () => void - onSeekStart: () => void - seekLeft: () => void - seekRight: () => void - togglePlayPause: () => void - drawFocus: () => void -}) { - const {_} = useLingui() - const t = useTheme() - const [scrubberActive, setScrubberActive] = useState(false) - const { - state: hovered, - onIn: onStartHover, - onOut: onEndHover, - } = useInteractionState() - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const [seekPosition, setSeekPosition] = useState(0) - const isSeekingRef = useRef(false) - const barRef = useRef(null) - const circleRef = useRef(null) - - const seek = useCallback( - (evt: React.PointerEvent) => { - if (!barRef.current) return - const {left, width} = barRef.current.getBoundingClientRect() - const x = evt.clientX - const percent = clamp((x - left) / width, 0, 1) * duration - onSeek(percent) - setSeekPosition(percent) - }, - [duration, onSeek], - ) - - const onPointerDown = useCallback( - (evt: React.PointerEvent) => { - const target = evt.target - if (target instanceof Element) { - evt.preventDefault() - target.setPointerCapture(evt.pointerId) - isSeekingRef.current = true - seek(evt) - setScrubberActive(true) - onSeekStart() - } - }, - [seek, onSeekStart], - ) - - const onPointerMove = useCallback( - (evt: React.PointerEvent) => { - if (isSeekingRef.current) { - evt.preventDefault() - seek(evt) - } - }, - [seek], - ) - - const onPointerUp = useCallback( - (evt: React.PointerEvent) => { - const target = evt.target - if (isSeekingRef.current && target instanceof Element) { - evt.preventDefault() - target.releasePointerCapture(evt.pointerId) - isSeekingRef.current = false - onSeekEnd() - setScrubberActive(false) - } - }, - [onSeekEnd], - ) - - useEffect(() => { - // HACK: there's divergent browser behaviour about what to do when - // a pointerUp event is fired outside the element that captured the - // pointer. Firefox clicks on the element the mouse is over, so we have - // to make everything unclickable while seeking -sfn - if (isFirefox && scrubberActive) { - document.body.classList.add('force-no-clicks') - - return () => { - document.body.classList.remove('force-no-clicks') - } - } - }, [scrubberActive, onSeekEnd]) - - useEffect(() => { - if (!circleRef.current) return - if (focused) { - const abortController = new AbortController() - const {signal} = abortController - circleRef.current.addEventListener( - 'keydown', - evt => { - // space: play/pause - // arrow left: seek backward - // arrow right: seek forward - - if (evt.key === ' ') { - evt.preventDefault() - drawFocus() - togglePlayPause() - } else if (evt.key === 'ArrowLeft') { - evt.preventDefault() - drawFocus() - seekLeft() - } else if (evt.key === 'ArrowRight') { - evt.preventDefault() - drawFocus() - seekRight() - } - }, - {signal}, - ) - - return () => abortController.abort() - } - }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) - - const progress = scrubberActive ? seekPosition : currentTime - const progressPercent = (progress / duration) * 100 - - return ( - -
- - {duration > 0 && ( - - )} - -
- -
-
-
- ) -} - -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) { - 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 | 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 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('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, - } -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx new file mode 100644 index 00000000000..36b32a07250 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import {SvgProps} from 'react-native-svg' + +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' + +export function ControlButton({ + active, + activeLabel, + inactiveLabel, + activeIcon: ActiveIcon, + inactiveIcon: InactiveIcon, + onPress, +}: { + active: boolean + activeLabel: string + inactiveLabel: string + activeIcon: React.ComponentType> + inactiveIcon: React.ComponentType> + onPress: () => void +}) { + const t = useTheme() + return ( + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx new file mode 100644 index 00000000000..44978ad51e9 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx @@ -0,0 +1,232 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isFirefox} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {atoms as a, useTheme, web} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {formatTime} from './utils' + +export function Scrubber({ + duration, + currentTime, + onSeek, + onSeekEnd, + onSeekStart, + seekLeft, + seekRight, + togglePlayPause, + drawFocus, +}: { + duration: number + currentTime: number + onSeek: (time: number) => void + onSeekEnd: () => void + onSeekStart: () => void + seekLeft: () => void + seekRight: () => void + togglePlayPause: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const [scrubberActive, setScrubberActive] = useState(false) + const { + state: hovered, + onIn: onStartHover, + onOut: onEndHover, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [seekPosition, setSeekPosition] = useState(0) + const isSeekingRef = useRef(false) + const barRef = useRef(null) + const circleRef = useRef(null) + + const seek = useCallback( + (evt: React.PointerEvent) => { + if (!barRef.current) return + const {left, width} = barRef.current.getBoundingClientRect() + const x = evt.clientX + const percent = clamp((x - left) / width, 0, 1) * duration + onSeek(percent) + setSeekPosition(percent) + }, + [duration, onSeek], + ) + + const onPointerDown = useCallback( + (evt: React.PointerEvent) => { + const target = evt.target + if (target instanceof Element) { + evt.preventDefault() + target.setPointerCapture(evt.pointerId) + isSeekingRef.current = true + seek(evt) + setScrubberActive(true) + onSeekStart() + } + }, + [seek, onSeekStart], + ) + + const onPointerMove = useCallback( + (evt: React.PointerEvent) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent) => { + const target = evt.target + if (isSeekingRef.current && target instanceof Element) { + evt.preventDefault() + target.releasePointerCapture(evt.pointerId) + isSeekingRef.current = false + onSeekEnd() + setScrubberActive(false) + } + }, + [onSeekEnd], + ) + + useEffect(() => { + // HACK: there's divergent browser behaviour about what to do when + // a pointerUp event is fired outside the element that captured the + // pointer. Firefox clicks on the element the mouse is over, so we have + // to make everything unclickable while seeking -sfn + if (isFirefox && scrubberActive) { + document.body.classList.add('force-no-clicks') + + return () => { + document.body.classList.remove('force-no-clicks') + } + } + }, [scrubberActive, onSeekEnd]) + + useEffect(() => { + if (!circleRef.current) return + if (focused) { + const abortController = new AbortController() + const {signal} = abortController + circleRef.current.addEventListener( + 'keydown', + evt => { + // space: play/pause + // arrow left: seek backward + // arrow right: seek forward + + if (evt.key === ' ') { + evt.preventDefault() + drawFocus() + togglePlayPause() + } else if (evt.key === 'ArrowLeft') { + evt.preventDefault() + drawFocus() + seekLeft() + } else if (evt.key === 'ArrowRight') { + evt.preventDefault() + drawFocus() + seekRight() + } + }, + {signal}, + ) + + return () => abortController.abort() + } + }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) + + const progress = scrubberActive ? seekPosition : currentTime + const progressPercent = (progress / duration) * 100 + + return ( + +
+ + {duration > 0 && ( + + )} + +
+ +
+
+
+ ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx new file mode 100644 index 00000000000..5bd7e0d179b --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx @@ -0,0 +1,423 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type Hls from 'hls.js' + +import {isTouchDevice} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {isIPhoneWeb} from '#/platform/detection' +import { + useAutoplayDisabled, + useSetSubtitlesEnabled, + useSubtitlesEnabled, +} from '#/state/preferences' +import {atoms as a, useTheme, web} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useFullscreen} from '#/components/hooks/useFullscreen' +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 {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {TimeIndicator} from '../TimeIndicator' +import {ControlButton} from './ControlButton' +import {Scrubber} from './Scrubber' +import {formatTime, useVideoElement} from './utils' +import {VolumeControl} from './VolumeControl' + +export function Controls({ + videoRef, + hlsRef, + active, + setActive, + focused, + setFocused, + onScreen, + fullscreenRef, + hasSubtitleTrack, +}: { + videoRef: React.RefObject + hlsRef: React.RefObject + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + fullscreenRef: React.RefObject + hasSubtitleTrack: boolean +}) { + const { + play, + pause, + playing, + muted, + changeMuted, + togglePlayPause, + currentTime, + duration, + buffering, + error, + canPlay, + } = useVideoElement(videoRef) + const t = useTheme() + const {_} = useLingui() + const subtitlesEnabled = useSubtitlesEnabled() + const setSubtitlesEnabled = useSetSubtitlesEnabled() + const { + state: hovered, + onIn: onHover, + onOut: onEndHover, + } = useInteractionState() + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + const { + state: volumeHovered, + onIn: onVolumeHover, + onOut: onVolumeEndHover, + } = useInteractionState() + + const onKeyDown = useCallback(() => { + setInteractingViaKeypress(true) + }, []) + + useEffect(() => { + if (interactingViaKeypress) { + document.addEventListener('click', () => setInteractingViaKeypress(false)) + return () => { + document.removeEventListener('click', () => + setInteractingViaKeypress(false), + ) + } + } + }, [interactingViaKeypress]) + + useEffect(() => { + if (isFullscreen) { + document.documentElement.style.scrollbarGutter = 'unset' + return () => { + document.documentElement.style.removeProperty('scrollbar-gutter') + } + } + }, [isFullscreen]) + + // pause + unfocus when another video is active + useEffect(() => { + if (!active) { + pause() + setFocused(false) + } + }, [active, pause, setFocused]) + + // autoplay/pause based on visibility + const isWithinMessage = useIsWithinMessage() + const autoplayDisabled = useAutoplayDisabled() || isWithinMessage + useEffect(() => { + if (active) { + if (onScreen) { + if (!autoplayDisabled) 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 + // allow 30s of buffering + hlsRef.current.config.maxMaxBufferLength = 30 + } else { + // back to what we initially set + hlsRef.current.autoLevelCapping = 0 + hlsRef.current.config.maxMaxBufferLength = 10 + } + }, [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() + if (autoplayDisabled) play() + } else { + togglePlayPause() + } + }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) + + const onPressPlayPause = useCallback(() => { + drawFocus() + togglePlayPause() + }, [drawFocus, togglePlayPause]) + + const onPressSubtitles = useCallback(() => { + drawFocus() + setSubtitlesEnabled(!subtitlesEnabled) + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) + + const onPressFullscreen = useCallback(() => { + drawFocus() + toggleFullscreen() + }, [drawFocus, toggleFullscreen]) + + const onSeek = useCallback( + (time: number) => { + if (!videoRef.current) return + if (videoRef.current.fastSeek) { + videoRef.current.fastSeek(time) + } else { + videoRef.current.currentTime = time + } + }, + [videoRef], + ) + + const playStateBeforeSeekRef = useRef(false) + + const onSeekStart = useCallback(() => { + drawFocus() + playStateBeforeSeekRef.current = playing + pause() + }, [playing, pause, drawFocus]) + + const onSeekEnd = useCallback(() => { + if (playStateBeforeSeekRef.current) { + play() + } + }, [play]) + + const seekLeft = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime - 5, 0, duration)) + }, [onSeek, videoRef]) + + const seekRight = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime + 5, 0, duration)) + }, [onSeek, videoRef]) + + const [showCursor, setShowCursor] = useState(true) + const cursorTimeoutRef = useRef>() + const onPointerMoveEmptySpace = useCallback(() => { + setShowCursor(true) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + cursorTimeoutRef.current = setTimeout(() => { + setShowCursor(false) + onEndHover() + }, 2000) + }, [onEndHover]) + const onPointerLeaveEmptySpace = useCallback(() => { + setShowCursor(false) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + }, []) + + // these are used to trigger the hover state. on mobile, the hover state + // should stick around for a bit after they tap, and if the controls aren't + // present this initial tab should *only* show the controls and not activate anything + + const onPointerDown = useCallback( + (evt: React.PointerEvent) => { + if (evt.pointerType !== 'mouse' && !hovered) { + evt.preventDefault() + } + clearTimeout(timeoutRef.current) + }, + [hovered], + ) + + const timeoutRef = useRef>() + + const onHoverWithTimeout = useCallback(() => { + onHover() + clearTimeout(timeoutRef.current) + }, [onHover]) + + const onEndHoverWithTimeout = useCallback( + (evt: React.PointerEvent) => { + // if touch, end after 3s + // if mouse, end immediately + if (evt.pointerType !== 'mouse') { + setTimeout(onEndHover, 3000) + } else { + onEndHover() + } + }, + [onEndHover], + ) + + const showControls = + ((focused || autoplayDisabled) && !playing) || + (interactingViaKeypress ? hasFocus : hovered) + + return ( +
{ + evt.stopPropagation() + setInteractingViaKeypress(false) + }} + onPointerEnter={onHoverWithTimeout} + onPointerMove={onHoverWithTimeout} + onPointerLeave={onEndHoverWithTimeout} + onPointerDown={onPointerDown} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + + {!showControls && !focused && duration > 0 && ( + + )} + + {(!volumeHovered || isTouchDevice) && ( + + )} + + + + + {formatTime(currentTime)} / {formatTime(duration)} + + {hasSubtitleTrack && ( + + )} + + {!isIPhoneWeb && ( + + )} + + + {(buffering || error) && ( + + {buffering && } + {error && ( + + An error occurred + + )} + + )} +
+ ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx new file mode 100644 index 00000000000..63ac32b1020 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -0,0 +1,109 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isSafari, isTouchDevice} from '#/lib/browser' +import {atoms as a} from '#/alf' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {useVideoVolumeState} from '../../VideoVolumeContext' +import {ControlButton} from './ControlButton' + +export function VolumeControl({ + muted, + changeMuted, + hovered, + onHover, + onEndHover, + drawFocus, +}: { + muted: boolean + changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void + hovered: boolean + onHover: () => void + onEndHover: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const [volume, setVolume] = useVideoVolumeState() + + const onVolumeChange = useCallback( + (evt: React.ChangeEvent) => { + drawFocus() + const vol = sliderVolumeToVideoVolume(Number(evt.target.value)) + setVolume(vol) + changeMuted(vol === 0) + }, + [setVolume, drawFocus, changeMuted], + ) + + const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume) + + const isZeroVolume = volume === 0 + const onPressMute = useCallback(() => { + drawFocus() + if (isZeroVolume) { + setVolume(1) + changeMuted(false) + } else { + changeMuted(prevMuted => !prevMuted) + } + }, [drawFocus, setVolume, isZeroVolume, changeMuted]) + + return ( + + {hovered && !isTouchDevice && ( + + + + + + )} + + + ) +} + +function sliderVolumeToVideoVolume(value: number) { + return Math.pow(value / 100, 4) +} + +function videoVolumeToSliderVolume(value: number) { + return Math.round(Math.pow(value, 1 / 4) * 100) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx new file mode 100644 index 00000000000..8aa2d3f7da2 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx @@ -0,0 +1,235 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' + +import {isSafari} from '#/lib/browser' +import {useVideoVolumeState} from '../../VideoVolumeContext' + +export function useVideoElement(ref: React.RefObject) { + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [volume, setVolume] = useVideoVolumeState() + 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 + ref.current.volume = volume + }, [ref, volume]) + + useEffect(() => { + if (!ref.current) return + + let bufferingTimeout: ReturnType | 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) + setVolume(ref.current.volume) + + const handleTimeUpdate = () => { + if (!ref.current) return + setCurrentTime(round(ref.current.currentTime) || 0) + // HACK: Safari randomly fires `stalled` events when changing between segments + // let's just clear the buffering state if the video is still progressing -sfn + if (isSafari) { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + } + + 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 = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setCanPlay(true) + + if (!ref.current) return + if (playWhenReadyRef.current) { + ref.current.play() + playWhenReadyRef.current = false + } + } + + const handleCanPlayThrough = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + + const handleWaiting = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // Delay to avoid frequent buffering state changes + } + + const handlePlaying = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setError(false) + } + + const handleStalled = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // 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('stalled', handleStalled, { + signal: abortController.signal, + }) + ref.current.addEventListener('ended', handleEnded, { + signal: abortController.signal, + }) + ref.current.addEventListener('volumechange', handleVolumeChange, { + signal: abortController.signal, + }) + + return () => { + abortController.abort() + clearTimeout(bufferingTimeout) + } + }, [ref, setVolume]) + + 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 changeMuted = useCallback( + (newMuted: boolean | ((prev: boolean) => boolean)) => { + if (!ref.current) return + + const value = + typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted + ref.current.muted = value + }, + [ref], + ) + + return { + play, + pause, + togglePlayPause, + duration, + currentTime, + playing, + muted, + changeMuted, + buffering, + error, + canPlay, + } +} + +export 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}` +} diff --git a/src/view/com/util/post-embeds/VideoVolumeContext.tsx b/src/view/com/util/post-embeds/VideoVolumeContext.tsx index cccb93ba8b1..6343081da84 100644 --- a/src/view/com/util/post-embeds/VideoVolumeContext.tsx +++ b/src/view/com/util/post-embeds/VideoVolumeContext.tsx @@ -1,21 +1,26 @@ import React from 'react' -const Context = React.createContext( - {} as { - muted: boolean - setMuted: (muted: boolean) => void - }, -) +const Context = React.createContext<{ + // native + muted: boolean + setMuted: React.Dispatch> + // web + volume: number + setVolume: React.Dispatch> +} | null>(null) export function Provider({children}: {children: React.ReactNode}) { const [muted, setMuted] = React.useState(true) + const [volume, setVolume] = React.useState(1) const value = React.useMemo( () => ({ muted, setMuted, + volume, + setVolume, }), - [muted, setMuted], + [muted, setMuted, volume, setVolume], ) return {children} @@ -28,5 +33,15 @@ export function useVideoVolumeState() { 'useVideoVolumeState must be used within a VideoVolumeProvider', ) } - return context + return [context.volume, context.setVolume] as const +} + +export function useVideoMuteState() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useVideoMuteState must be used within a VideoVolumeProvider', + ) + } + return [context.muted, context.setMuted] as const } diff --git a/web/index.html b/web/index.html index 825d15968ef..8902f7b6e00 100644 --- a/web/index.html +++ b/web/index.html @@ -262,6 +262,51 @@ .force-no-clicks * { pointer-events: none !important; } + + input[type=range][orient=vertical] { + writing-mode: vertical-lr; + direction: rtl; + appearance: slider-vertical; + width: 16px; + vertical-align: bottom; + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + } + + input[type="range"][orient=vertical]::-webkit-slider-runnable-track { + background: white; + height: 100%; + width: 4px; + border-radius: 4px; + } + + input[type="range"][orient=vertical]::-moz-range-track { + background: white; + height: 100%; + width: 4px; + border-radius: 4px; + } + + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + border-radius: 50%; + background-color: white; + height: 16px; + width: 16px; + margin-left: -6px; + } + + input[type="range"][orient=vertical]::-moz-range-thumb { + border: none; + border-radius: 50%; + background-color: white; + height: 16px; + width: 16px; + margin-left: -6px; + } diff --git a/yarn.lock b/yarn.lock index cd833f8ceb3..75922d1efe2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4104,10 +4104,10 @@ resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== -"@haileyok/bluesky-video@0.1.8": - version "0.1.8" - resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.1.8.tgz#26fc6ec64993d593e7a0ecb96febff96c5037ebd" - integrity sha512-jMtGSMU5jpacLvAFRWGC5gVvVKuKHrGH3gluz9MsBEbSRvS8bb9FhNtC1VHb+A4UUgLufOyzrSbDw9fa8hR8lg== +"@haileyok/bluesky-video@0.1.10": + version "0.1.10" + resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.1.10.tgz#2756e8c83a78caeb6b120a175578eac1eb6889a9" + integrity sha512-W8+DNdek+xjAqTO1zmuuSrkVFxDepcP8+Gs8MvIcYSgXEJlpQimZpcMwAduiDI/jZMn/2U6cnMahx7YuiZlZ7g== "@hapi/accept@^6.0.3": version "6.0.3" @@ -19127,10 +19127,10 @@ react-native-svg@^15.3.0: css-select "^5.1.0" css-tree "^1.1.3" -react-native-uitextview@^1.1.7: - version "1.1.7" - resolved "https://registry.yarnpkg.com/react-native-uitextview/-/react-native-uitextview-1.1.7.tgz#ee60ca279b0f4d081451f0df707b0ceac4580de1" - integrity sha512-7NaeizflafRvdsuomR6F4UxGV3XY8ilgntX0npLkE3YyH0YJTikBAHunrRWEPtj1vjybYN2DuThMYRELcVqyuA== +react-native-uitextview@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/react-native-uitextview/-/react-native-uitextview-1.3.0.tgz#78b24e2b790e1197c73758df30ed2bfb4b8a0c98" + integrity sha512-UXW1xIg1qeYvwBvdL8BLIkqZhbQ5TVVcHJho3vKDjOK+FW4ZLPygefcDXaCTX56ztisV4ARxJNdzblRAytFCHQ== react-native-url-polyfill@^1.3.0: version "1.3.0"