Compare commits

..

10 Commits

Author SHA1 Message Date
Ducky 7462f796a4 Zio Blue: various rebranding changes 2024-09-03 16:33:47 +01:00
Samuel Newman 0e1de19903
[Video] Upload errors and UI improvements (#5092)
* surface errors in UI

* style progress indicator

* remove job status progress

* rm log

* fix webm ext
2024-09-03 15:09:09 +01:00
Samuel Newman f9d736653c
[Video] Hide mouse when inactive (#5094) 2024-09-03 11:51:16 +01:00
Samuel Newman 0469ca6cb4
[Embeds] Format big numbers (#5087) 2024-09-02 13:04:26 -07:00
Fabio Nobre f3f7dfc3e6
Add some Portuguese (PT-BR) translation (#5057) 2024-09-02 04:09:17 -07:00
Hailey 4abcd65ccf
More tweaks to animation (#5082) 2024-09-02 03:15:31 -07:00
Mary 05ac76fc89
Don't eagerly load all JS assets (#3929)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-02 01:59:04 -07:00
Hailey 1225e84485
Improve animations for like button (#5074) 2024-09-02 01:37:24 -07:00
Samuel Newman eb868a042a
[Video] Misc player style tweaks (#5064)
* use actual black rather than theme black

* adjust time/mute indicators
2024-09-02 09:33:46 +01:00
Samuel Newman 17d82a64a6
[Video] Remove hack from scrubber (#5063)
* remove mouseleave, add pointer cancel

* don't show time indicator if duration is 0
2024-09-02 09:32:51 +01:00
34 changed files with 1233 additions and 773 deletions

View File

@ -22,7 +22,7 @@
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^4.0.5",
"typescript": "^5.5.4",
"vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2"
}

View File

@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {CONTENT_LABELS} from '../labels'
import {getRkey, niceDate} from '../utils'
import {getRkey, niceDate, prettyNumber} from '../utils'
import {Container} from './container'
import {Embed} from './embed'
import {Link} from './link'
@ -78,7 +78,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.likeCount}
{prettyNumber(post.likeCount)}
</p>
</div>
)}
@ -86,7 +86,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.repostCount}
{prettyNumber(post.repostCount)}
</p>
</div>
)}
@ -97,7 +97,7 @@ export function Post({thread}: Props) {
<div className="flex-1" />
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
{post.replyCount
? `Read ${post.replyCount} ${
? `Read ${prettyNumber(post.replyCount)} ${
post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky`
: `View on Bluesky`}

View File

@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
roundingMode: 'trunc',
})
export function prettyNumber(number: number) {
return formatter.format(number)
}

View File

@ -20,5 +20,5 @@
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src"]
"include": ["src", "vite.config.ts"]
}

View File

@ -6,5 +6,5 @@
"strict": true,
"outDir": "dist"
},
"include": ["snippet"],
"include": ["snippet"]
}

View File

@ -4024,10 +4024,10 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
typescript@^4.0.5:
version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.5.4:
version "5.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
uint8arrays@3.0.0:
version "3.0.0"

View File

@ -2,7 +2,6 @@ const path = require('path')
const fs = require('fs')
const projectRoot = path.join(__dirname, '..')
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
const templateFile = path.join(
projectRoot,
'bskyweb',
@ -10,18 +9,18 @@ const templateFile = path.join(
'scripts.html',
)
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
jsFiles.sort((a, b) => {
// make sure main is written last
if (a.startsWith('main')) return 1
if (b.startsWith('main')) return -1
return a.localeCompare(b)
})
const {entrypoints} = require(path.join(
projectRoot,
'web-build/asset-manifest.json',
))
console.log(`Found ${jsFiles.length} js files in web-build`)
console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`)
const outputFile = jsFiles
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
const outputFile = entrypoints
.map(name => {
const file = path.basename(name)
return `<script defer="defer" src="/static/js/${file}"></script>`
})
.join('\n')
fs.writeFileSync(templateFile, outputFile)

View File

@ -3,9 +3,9 @@ import {AppBskyActorDefs} from '@atproto/api'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const BSKY_SERVICE = 'https://bsky.social'
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
export const STAGING_SERVICE = 'https://zio.blue
export const BSKY_SERVICE = 'https://zio.blue'
export const PUBLIC_BSKY_SERVICE = 'https://zio.blue'
export const DEFAULT_SERVICE = BSKY_SERVICE
const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`

View File

@ -0,0 +1,177 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
LayoutAnimationConfig,
useReducedMotion,
withTiming,
} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: Easing.out(Easing.cubic),
}
function EnteringUp() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: 18}],
}
return {
animations,
initialValues,
}
}
function EnteringDown() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: -18}],
}
return {
animations,
initialValues,
}
}
function ExitingUp() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [
{
translateY: withTiming(-18, animationConfig),
},
],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
function ExitingDown() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [{translateY: withTiming(18, animationConfig)}],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
export function CountWheel({
likeCount,
big,
isLiked,
}: {
likeCount: number
big?: boolean
isLiked: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion()
const shouldRoll = decideShouldRoll(isLiked, likeCount)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary
const [key, setKey] = React.useState(0)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
setKey(prev => prev + 1)
setPrevCount(newPrevCount)
prevIsLiked.current = isLiked
}, [isLiked, likeCount])
const enteringAnimation =
shouldAnimate && shouldRoll
? isLiked
? EnteringUp
: EnteringDown
: undefined
const exitingAnimation =
shouldAnimate && shouldRoll
? isLiked
? ExitingUp
: ExitingDown
: undefined
return (
<LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? (
<View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<Animated.View
entering={exitingAnimation}
// Add 2 to the key so there are never duplicates
key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</Animated.View>
) : null}
</View>
) : null}
</LayoutAnimationConfig>
)
}

View File

@ -0,0 +1,120 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const enteringUpKeyframe = [
{opacity: 0, transform: 'translateY(18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const enteringDownKeyframe = [
{opacity: 0, transform: 'translateY(-18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const exitingUpKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(-18px)'},
]
const exitingDownKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(18px)'},
]
export function CountWheel({
likeCount,
big,
isLiked,
}: {
likeCount: number
big?: boolean
isLiked: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion()
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const countView = React.useRef<HTMLDivElement>(null)
const prevCountView = React.useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
if (shouldAnimate && shouldRoll) {
countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig,
)
prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig,
)
setPrevCount(newPrevCount)
}
prevIsLiked.current = isLiked
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
if (likeCount < 1) {
return null
}
return (
<View>
<View
// @ts-expect-error is div
ref={countView}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<View
style={{position: 'absolute', opacity: 0}}
aria-disabled={true}
// @ts-expect-error is div
ref={prevCountView}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</View>
) : null}
</View>
)
}

View File

@ -0,0 +1,135 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Keyframe,
LayoutAnimationConfig,
useReducedMotion,
} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const keyframe = new Keyframe({
0: {
transform: [{scale: 1}],
},
10: {
transform: [{scale: 0.7}],
},
40: {
transform: [{scale: 1.2}],
},
100: {
transform: [{scale: 1}],
},
})
const circle1Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 0.4,
},
40: {
transform: [{scale: 1.5}],
},
95: {
opacity: 0.4,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
const circle2Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 1,
},
40: {
transform: [{scale: 0}],
},
95: {
opacity: 1,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
export function AnimatedLikeIcon({
isLiked,
big,
}: {
isLiked: boolean
big?: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion()
return (
<View>
<LayoutAnimationConfig skipEntering>
{isLiked ? (
<Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} />
</Animated.View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
{isLiked ? (
<>
<Animated.View
entering={
shouldAnimate ? circle1Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
<Animated.View
entering={
shouldAnimate ? circle2Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
</>
) : null}
</LayoutAnimationConfig>
</View>
)
}

View File

@ -0,0 +1,117 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const keyframe = [
{transform: 'scale(1)'},
{transform: 'scale(0.7)'},
{transform: 'scale(1.2)'},
{transform: 'scale(1)'},
]
const circle1Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 0.4},
{transform: 'scale(1.5)'},
{opacity: 0.4},
{opacity: 0, transform: 'scale(1.5)'},
]
const circle2Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 1},
{transform: 'scale(0)'},
{opacity: 1},
{opacity: 0, transform: 'scale(1.5)'},
]
export function AnimatedLikeIcon({
isLiked,
big,
}: {
isLiked: boolean
big?: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion()
const prevIsLiked = React.useRef(isLiked)
const likeIconRef = React.useRef<HTMLDivElement>(null)
const circle1Ref = React.useRef<HTMLDivElement>(null)
const circle2Ref = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (prevIsLiked.current === isLiked) {
return
}
if (shouldAnimate && isLiked) {
likeIconRef.current?.animate?.(keyframe, animationConfig)
circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
}
prevIsLiked.current = isLiked
}, [shouldAnimate, isLiked])
return (
<View>
{isLiked ? (
// @ts-expect-error is div
<View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} />
</View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
<View
// @ts-expect-error is div
ref={circle1Ref}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
<View
// @ts-expect-error is div
ref={circle2Ref}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
</View>
)
}

View File

@ -0,0 +1,21 @@
// It should roll when:
// - We're going from 1 to 0 (roll backwards)
// - The count is anywhere between 1 and 999
// - The count is going up and is a multiple of 100
// - The count is going down and is 1 less than a multiple of 100
export function decideShouldRoll(isSet: boolean, count: number) {
let shouldRoll = false
if (!isSet && count === 0) {
shouldRoll = true
} else if (count > 0 && count < 1000) {
shouldRoll = true
} else if (count > 0) {
const mod = count % 100
if (isSet && mod === 0) {
shouldRoll = true
} else if (!isSet && mod === 99) {
shouldRoll = true
}
}
return shouldRoll
}

View File

@ -29,5 +29,6 @@ export async function compressVideo(
)
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size}
return {uri: compressed, size: info.size, mimeType: `video/${info.extension}`}
}

View File

@ -23,6 +23,7 @@ export async function compressVideo(
size: blob.size,
uri,
bytes: await blob.arrayBuffer(),
mimeType,
}
}

View File

@ -4,3 +4,10 @@ export class VideoTooLargeError extends Error {
this.name = 'VideoTooLargeError'
}
}
export class ServerError extends Error {
constructor(message: string) {
super(message)
this.name = 'ServerError'
}
}

View File

@ -1,5 +1,6 @@
export type CompressedVideo = {
uri: string
mimeType: string
size: number
// web only, can fall back to uri if missing
bytes?: ArrayBuffer

File diff suppressed because it is too large Load Diff

View File

@ -24,3 +24,16 @@ export function useVideoAgent() {
})
}, [])
}
export function mimeToExt(mimeType: string) {
switch (mimeType) {
case 'video/mp4':
return 'mp4'
case 'video/webm':
return 'webm'
case 'video/mpeg':
return 'mpeg'
default:
throw new Error(`Unsupported mime type: ${mimeType}`)
}
}

View File

@ -1,11 +1,14 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl} from '#/state/queries/video/util'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
@ -22,13 +25,14 @@ export const useUploadVideoMutation = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.mp4`,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
@ -50,7 +54,7 @@ export const useUploadVideoMutation = ({
video.uri,
{
headers: {
'content-type': 'video/mp4',
'content-type': video.mimeType,
Authorization: `Bearer ${serviceAuth.token}`,
},
httpMethod: 'POST',
@ -65,6 +69,13 @@ export const useUploadVideoMutation = ({
}
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(
responseBody.error || _(msg`Failed to upload video`),
)
}
return responseBody
}, signal),
onError,

View File

@ -1,10 +1,13 @@
import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl} from '#/state/queries/video/util'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
@ -21,13 +24,14 @@ export const useUploadVideoMutation = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
@ -63,23 +67,24 @@ export const useUploadVideoMutation = ({
xhr.responseText,
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
onSuccess(uploadRes)
} else {
reject()
onError(new Error('Failed to upload video'))
reject(new ServerError(_(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject()
onError(new Error('Failed to upload video'))
reject(new ServerError(_(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', 'video/mp4')
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
xhr.send(bytes)
},
)
if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
}
return res
}, signal),
onError,

View File

@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {VideoTooLargeError} from 'lib/media/video/errors'
import {isWeb} from '#/platform/detection'
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
import {CompressedVideo} from 'lib/media/video/types'
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util'
@ -58,7 +59,12 @@ function reducer(queryClient: QueryClient) {
abortController: new AbortController(),
}
} else if (action.type === 'SetAsset') {
updatedState = {...state, asset: action.asset}
updatedState = {
...state,
asset: action.asset,
status: 'compressing',
error: undefined,
}
} else if (action.type === 'SetDimensions') {
updatedState = {
...state,
@ -67,11 +73,11 @@ function reducer(queryClient: QueryClient) {
: undefined,
}
} else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video}
updatedState = {...state, video: action.video, status: 'uploading'}
} else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus}
} else if (action.type === 'SetBlobRef') {
updatedState = {...state, blobRef: action.blobRef}
updatedState = {...state, blobRef: action.blobRef, status: 'done'}
}
return updatedState
}
@ -108,10 +114,6 @@ export function useUploadVideo({
type: 'SetBlobRef',
blobRef,
})
dispatch({
type: 'SetStatus',
status: 'idle',
})
onSuccess()
},
})
@ -125,10 +127,17 @@ export function useUploadVideo({
setJobId(response.jobId)
},
onError: e => {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
if (e instanceof ServerError) {
dispatch({
type: 'SetError',
error: e.message,
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
}
logger.error('Error uploading video', {safeMessage: e})
},
setProgress: p => {
@ -141,6 +150,13 @@ export function useUploadVideo({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
onVideoCompressed(video)
},
onError: e => {
if (e instanceof VideoTooLargeError) {
dispatch({
@ -150,36 +166,28 @@ export function useUploadVideo({
} else {
dispatch({
type: 'SetError',
// @TODO better error message from server, left untranslated on purpose
error: 'An error occurred while compressing the video.',
error: _(msg`An error occurred while compressing the video.`),
})
logger.error('Error compressing video', {safeMessage: e})
}
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
dispatch({
type: 'SetStatus',
status: 'uploading',
})
onVideoCompressed(video)
},
signal: state.abortController.signal,
})
const selectVideo = (asset: ImagePickerAsset) => {
dispatch({
type: 'SetAsset',
asset,
})
dispatch({
type: 'SetStatus',
status: 'compressing',
})
onSelectVideo(asset)
switch (getMimeType(asset)) {
case 'video/mp4':
case 'video/mpeg':
case 'video/webm':
dispatch({
type: 'SetAsset',
asset,
})
onSelectVideo(asset)
break
default:
throw new Error(_(msg`Unsupported video type: ${getMimeType(asset)}`))
}
}
const clearVideo = () => {
@ -241,6 +249,21 @@ const useUploadStatusQuery = ({
isError,
setJobId: (_jobId: string) => {
setJobId(_jobId)
setEnabled(true)
},
}
}
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}

View File

@ -86,9 +86,9 @@ export function ServerInputDialog({
label="Preferences"
values={fixedOption}
onChange={setFixedOption}>
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Zio`)}>
<ToggleButton.ButtonText>
{_(msg`Bluesky`)}
{_(msg`Zio`)}
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button
@ -152,9 +152,24 @@ export function ServerInputDialog({
a.flex_1,
]}>
<Trans>
Bluesky is an open network where you can choose your hosting
provider. Custom hosting is now available in beta for
developers.
A small
<InlineLinkText
label={_(msg`PDS instance`)}
to="https://github.com/bluesky-social/pds"
style={[!gtMobile && a.text_md]}>
<Trans>
PDS instance
</Trans>
</InlineLinkText>
ran by
<InlineLinkText
label={_(msg`Zio`)}
to="https://zio.sh"
style={[!gtMobile && a.text_md]}>
<Trans>
Zio
</Trans>
</InlineLinkText>
</Trans>
</P>
</View>

View File

@ -181,6 +181,7 @@ export const ComposePost = observer(function ComposePost({
clearVideo,
state: videoUploadState,
updateVideoDimensions,
dispatch: videoUploadDispatch,
} = useUploadVideo({
setStatus: setProcessingState,
onSuccess: () => {
@ -313,8 +314,8 @@ export const ComposePost = observer(function ComposePost({
if (
!finishedUploading &&
videoUploadState.status !== 'idle' &&
videoUploadState.asset
videoUploadState.asset &&
videoUploadState.status !== 'done'
) {
setPublishOnUpload(true)
return
@ -607,7 +608,7 @@ export const ComposePost = observer(function ComposePost({
</Text>
</View>
)}
{error !== '' && (
{(error !== '' || videoUploadState.error) && (
<View style={[a.px_lg, a.pb_sm]}>
<View
style={[
@ -623,7 +624,7 @@ export const ComposePost = observer(function ComposePost({
]}>
<CircleInfo fill={t.palette.negative_400} />
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
{error}
{error || videoUploadState.error}
</NewText>
<Button
label={_(msg`Dismiss error`)}
@ -638,7 +639,10 @@ export const ComposePost = observer(function ComposePost({
right: a.px_md.paddingRight,
},
]}
onPress={() => setError('')}>
onPress={() => {
if (error) setError('')
else videoUploadDispatch({type: 'Reset'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
@ -755,7 +759,8 @@ export const ComposePost = observer(function ComposePost({
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
{videoUploadState.status !== 'idle' ? (
{videoUploadState.status !== 'idle' &&
videoUploadState.status !== 'done' ? (
<VideoUploadToolbar state={videoUploadState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
@ -764,6 +769,7 @@ export const ComposePost = observer(function ComposePost({
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
setError={setError}
/>
)}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
@ -1032,15 +1038,33 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme()
const {_} = useLingui()
let text = ''
switch (state.status) {
case 'compressing':
text = _('Compressing video...')
break
case 'uploading':
text = _('Uploading video...')
break
case 'processing':
text = _('Processing video...')
break
case 'done':
text = _('Video uploaded')
break
}
// we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100
const progress =
state.status === 'compressing' || state.status === 'uploading'
? state.progress
: state.jobStatus?.progress ?? 100
: 100
return (
<ToolbarWrapper
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ProgressCircle
size={30}
borderWidth={1}
@ -1048,7 +1072,7 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
color={t.palette.primary_500}
progress={progress}
/>
<Text>{state.status}</Text>
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
</ToolbarWrapper>
)
}

View File

@ -19,9 +19,10 @@ const VIDEO_MAX_DURATION = 90
type Props = {
onSelectVideo: (video: ImagePickerAsset) => void
disabled?: boolean
setError: (error: string) => void
}
export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
const {_} = useLingui()
const t = useTheme()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
@ -41,9 +42,17 @@ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
UIImagePickerPreferredAssetRepresentationMode.Current,
})
if (response.assets && response.assets.length > 0) {
onSelectVideo(response.assets[0])
try {
onSelectVideo(response.assets[0])
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError(_(msg`An error occurred while selecting the video`))
}
}
}
}, [onSelectVideo, requestVideoAccessIfNeeded])
}, [onSelectVideo, requestVideoAccessIfNeeded, setError, _])
return (
<>

View File

@ -43,7 +43,7 @@ export function VideoPreview({
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{backgroundColor: t.palette.black},
{backgroundColor: 'black'},
]}>
<VideoView
player={player}

View File

@ -5,7 +5,7 @@ import {ImagePickerAsset} from 'expo-image-picker'
import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
export function VideoPreview({
asset,
@ -18,7 +18,6 @@ export function VideoPreview({
setDimensions: (width: number, height: number) => void
clear: () => void
}) {
const t = useTheme()
const ref = useRef<HTMLVideoElement>(null)
useEffect(() => {
@ -54,13 +53,13 @@ export function VideoPreview({
a.rounded_sm,
{aspectRatio},
a.overflow_hidden,
{backgroundColor: t.palette.black},
{backgroundColor: 'black'},
]}>
<ExternalEmbedRemoveBtn onRemove={clear} />
<video
ref={ref}
src={video.uri}
style={a.flex_1}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
autoPlay
loop
muted

View File

@ -6,14 +6,6 @@ import {
View,
type ViewStyle,
} from 'react-native'
import Animated, {
Easing,
interpolate,
SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
@ -31,8 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {s} from '#/lib/styles'
import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
@ -45,16 +35,13 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {CountWheel} from 'lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
import * as Prompt from '#/components/Prompt'
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format'
import {Text} from '../text/Text'
@ -120,17 +107,7 @@ let PostCtrls = ({
) as StyleProp<ViewStyle>
const likeValue = post.viewer?.like ? 1 : 0
const likeIconAnimValue = useSharedValue(likeValue)
const likeTextAnimValue = useSharedValue(likeValue)
const nextExpectedLikeValue = React.useRef(likeValue)
React.useEffect(() => {
// Catch nonlocal changes (e.g. shadow update) and always reflect them.
if (likeValue !== nextExpectedLikeValue.current) {
nextExpectedLikeValue.current = likeValue
likeIconAnimValue.value = likeValue
likeTextAnimValue.value = likeValue
}
}, [likeValue, likeIconAnimValue, likeTextAnimValue])
const onPressToggleLike = React.useCallback(async () => {
if (isBlocked) {
@ -144,19 +121,6 @@ let PostCtrls = ({
try {
if (!post.viewer?.like) {
nextExpectedLikeValue.current = 1
if (PlatformInfo.getIsReducedMotionEnabled()) {
likeIconAnimValue.value = 1
likeTextAnimValue.value = 1
} else {
likeIconAnimValue.value = withTiming(1, {
duration: 400,
easing: Easing.out(Easing.cubic),
})
likeTextAnimValue.value = withTiming(1, {
duration: 400,
easing: Easing.out(Easing.cubic),
})
}
playHaptic()
sendInteraction({
item: post.uri,
@ -167,15 +131,6 @@ let PostCtrls = ({
await queueLike()
} else {
nextExpectedLikeValue.current = 0
likeIconAnimValue.value = 0 // Intentionally not animated
if (PlatformInfo.getIsReducedMotionEnabled()) {
likeTextAnimValue.value = 0
} else {
likeTextAnimValue.value = withTiming(0, {
duration: 400,
easing: Easing.out(Easing.cubic),
})
}
await queueUnlike()
}
} catch (e: any) {
@ -185,8 +140,6 @@ let PostCtrls = ({
}
}, [
_,
likeIconAnimValue,
likeTextAnimValue,
playHaptic,
post.uri,
post.viewer?.like,
@ -291,8 +244,8 @@ let PostCtrls = ({
a.gap_xs,
a.rounded_full,
a.flex_row,
a.align_center,
a.justify_center,
a.align_center,
{padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25,
],
@ -364,13 +317,11 @@ let PostCtrls = ({
}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
<AnimatedLikeIcon
big={big ?? false}
likeIconAnimValue={likeIconAnimValue}
likeTextAnimValue={likeTextAnimValue}
defaultCtrlColor={defaultCtrlColor}
isLiked={Boolean(post.viewer?.like)}
<AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} />
<CountWheel
likeCount={post.likeCount ?? 0}
big={big}
isLiked={Boolean(post.viewer?.like)}
/>
</Pressable>
</View>
@ -450,194 +401,3 @@ let PostCtrls = ({
}
PostCtrls = memo(PostCtrls)
export {PostCtrls}
function AnimatedLikeIcon({
big,
likeIconAnimValue,
likeTextAnimValue,
defaultCtrlColor,
isLiked,
likeCount,
}: {
big: boolean
likeIconAnimValue: SharedValue<number>
likeTextAnimValue: SharedValue<number>
defaultCtrlColor: StyleProp<ViewStyle>
isLiked: boolean
likeCount: number
}) {
const t = useTheme()
const {i18n} = useLingui()
const likeStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(
likeIconAnimValue.value,
[0, 0.1, 0.4, 1],
[1, 0.7, 1.2, 1],
'clamp',
),
},
],
}))
const circle1Style = useAnimatedStyle(() => ({
opacity: interpolate(
likeIconAnimValue.value,
[0, 0.1, 0.95, 1],
[0, 0.4, 0.4, 0],
'clamp',
),
transform: [
{
scale: interpolate(
likeIconAnimValue.value,
[0, 0.4, 1],
[0, 1.5, 1.5],
'clamp',
),
},
],
}))
const circle2Style = useAnimatedStyle(() => ({
opacity: interpolate(
likeIconAnimValue.value,
[0, 0.1, 0.95, 1],
[0, 1, 1, 0],
'clamp',
),
transform: [
{
scale: interpolate(
likeIconAnimValue.value,
[0, 0.4, 1],
[0, 0, 1.5],
'clamp',
),
},
],
}))
const countStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(
likeTextAnimValue.value,
[0, 1],
[0, big ? -22 : -18],
'clamp',
),
},
],
}))
const prevFormattedCount = formatCount(
i18n,
isLiked ? likeCount - 1 : likeCount,
)
const nextFormattedCount = formatCount(
i18n,
isLiked ? likeCount : likeCount + 1,
)
const shouldRollLike =
prevFormattedCount !== nextFormattedCount && prevFormattedCount !== '0'
return (
<>
<View>
<Animated.View
style={[
{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: big ? 22 : 18,
height: big ? 22 : 18,
zIndex: -1,
pointerEvents: 'none',
borderRadius: (big ? 22 : 18) / 2,
},
circle1Style,
]}
/>
<Animated.View
style={[
{
position: 'absolute',
backgroundColor: isWeb
? t.atoms.bg_contrast_25.backgroundColor
: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: big ? 22 : 18,
height: big ? 22 : 18,
zIndex: -1,
pointerEvents: 'none',
borderRadius: (big ? 22 : 18) / 2,
},
circle2Style,
]}
/>
<Animated.View style={likeStyle}>
{isLiked ? (
<HeartIconFilled style={s.likeColor} width={big ? 22 : 18} />
) : (
<HeartIconOutline
style={[defaultCtrlColor, {pointerEvents: 'none'}]}
width={big ? 22 : 18}
/>
)}
</Animated.View>
</View>
<View style={{overflow: 'hidden'}}>
<Text
testID="likeCount"
style={[
[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
{opacity: shouldRollLike ? 0 : 1},
],
]}>
{likeCount > 0 ? formatCount(i18n, likeCount) : ''}
</Text>
<Animated.View
aria-hidden={true}
style={[
countStyle,
{
position: 'absolute',
top: 0,
left: 0,
opacity: shouldRollLike ? 1 : 0,
},
]}>
<Text
testID="likeCount"
style={[
[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
{height: big ? 22 : 18},
],
]}>
{prevFormattedCount}
</Text>
<Text
testID="likeCount"
style={[
[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
{height: big ? 22 : 18},
],
]}>
{nextFormattedCount}
</Text>
</Animated.View>
</View>
</>
)
}

View File

@ -50,7 +50,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
a.rounded_sm,
a.overflow_hidden,
{aspectRatio},
{backgroundColor: t.palette.black},
{backgroundColor: 'black'},
a.my_xs,
]}>
<ErrorBoundary renderError={renderError} key={key}>

View File

@ -9,13 +9,12 @@ import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const gate = useGate()
const {active, setActive, sendPosition, currentActiveView} =
@ -64,7 +63,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
style={[
a.w_full,
{aspectRatio},
{backgroundColor: t.palette.black},
{backgroundColor: 'black'},
a.relative,
a.rounded_sm,
a.my_xs,

View File

@ -29,9 +29,9 @@ export function TimeIndicator({time}: {time: number}) {
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
left: 5,
bottom: 5,
minHeight: 20,
left: 6,
bottom: 6,
minHeight: 21,
justifyContent: 'center',
},
]}>

View File

@ -167,17 +167,20 @@ function VideoControls({
/>
<Animated.View
entering={FadeInDown.duration(300)}
style={{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
bottom: 5,
right: 5,
minHeight: 20,
justifyContent: 'center',
}}>
style={[
a.absolute,
a.rounded_full,
a.justify_center,
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingHorizontal: 4,
paddingVertical: 4,
bottom: 6,
right: 6,
minHeight: 21,
minWidth: 21,
},
]}>
<Pressable
onPress={toggleMuted}
style={a.flex_1}
@ -186,9 +189,9 @@ function VideoControls({
accessibilityRole="button"
hitSlop={HITSLOP_30}>
{isMuted ? (
<MuteIcon width={14} fill={t.palette.white} />
<MuteIcon width={13} fill={t.palette.white} />
) : (
<UnmuteIcon width={14} fill={t.palette.white} />
<UnmuteIcon width={13} fill={t.palette.white} />
)}
</Pressable>
</Animated.View>

View File

@ -78,8 +78,8 @@ export function Controls({
const setSubtitlesEnabled = useSetSubtitlesEnabled()
const {
state: hovered,
onIn: onMouseEnter,
onOut: onMouseLeave,
onIn: onHover,
onOut: onEndHover,
} = useInteractionState()
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
@ -220,6 +220,25 @@ export function Controls({
onSeek(clamp(currentTime + 5, 0, duration))
}, [onSeek, videoRef])
const [showCursor, setShowCursor] = useState(true)
const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
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)
}
}, [])
const showControls =
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
@ -236,13 +255,17 @@ export function Controls({
evt.stopPropagation()
setInteractingViaKeypress(false)
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onPointerEnter={onHover}
onPointerMove={onHover}
onPointerLeave={onEndHover}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}>
<Pressable
accessibilityRole="button"
onPointerEnter={onPointerMoveEmptySpace}
onPointerMove={onPointerMoveEmptySpace}
onPointerLeave={onPointerLeaveEmptySpace}
accessibilityHint={_(
!focused
? msg`Unmute video`
@ -250,10 +273,10 @@ export function Controls({
? msg`Pause video`
: msg`Play video`,
)}
style={a.flex_1}
style={[a.flex_1, web({cursor: showCursor ? 'pointer' : 'none'})]}
onPress={onPressEmptySpace}
/>
{active && !showControls && !focused && (
{!showControls && !focused && duration > 0 && (
<TimeIndicator time={Math.floor(duration - currentTime)} />
)}
<View
@ -407,8 +430,8 @@ function Scrubber({
const [scrubberActive, setScrubberActive] = useState(false)
const {
state: hovered,
onIn: onMouseEnter,
onOut: onMouseLeave,
onIn: onStartHover,
onOut: onEndHover,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [seekPosition, setSeekPosition] = useState(0)
@ -475,21 +498,8 @@ function Scrubber({
if (isFirefox && scrubberActive) {
document.body.classList.add('force-no-clicks')
const abortController = new AbortController()
const {signal} = abortController
document.documentElement.addEventListener(
'mouseleave',
() => {
isSeekingRef.current = false
onSeekEnd()
setScrubberActive(false)
},
{signal},
)
return () => {
document.body.classList.remove('force-no-clicks')
abortController.abort()
}
}
}, [scrubberActive, onSeekEnd])
@ -534,9 +544,8 @@ function Scrubber({
<View
testID="scrubber"
style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]}
// @ts-expect-error web only -sfn
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
onPointerEnter={onStartHover}
onPointerLeave={onEndHover}>
<div
ref={barRef}
style={{
@ -548,7 +557,8 @@ function Scrubber({
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}>
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}>
<View
style={[
a.w_full,

View File

@ -9918,14 +9918,14 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
version "1.0.30001596"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz"
integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==
version "1.0.30001655"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
caniuse-lite@^1.0.30001587:
version "1.0.30001620"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4"
integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==
version "1.0.30001655"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
case-anything@^2.1.13:
version "2.1.13"