APP-1645: Improve video upload progress indicator (#11248)
This commit is contained in:
@@ -25,7 +25,6 @@ import {Circle as ProgressCircle} from 'react-native-progress'
|
|||||||
import Animated, {
|
import Animated, {
|
||||||
type AnimatedRef,
|
type AnimatedRef,
|
||||||
type AnimatedStyle,
|
type AnimatedStyle,
|
||||||
Easing,
|
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
@@ -37,7 +36,6 @@ import Animated, {
|
|||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useDerivedValue,
|
useDerivedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
withTiming,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
@@ -127,6 +125,7 @@ import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
|
|||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||||
|
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||||
@@ -180,6 +179,7 @@ import {
|
|||||||
processVideo,
|
processVideo,
|
||||||
type VideoState,
|
type VideoState,
|
||||||
} from './state/video'
|
} from './state/video'
|
||||||
|
import {videoProgressWithinPhase} from './state/videoProgress'
|
||||||
import {type TextInputRef} from './text-input/TextInput.types'
|
import {type TextInputRef} from './text-input/TextInput.types'
|
||||||
import {getVideoMetadata} from './videos/metadata'
|
import {getVideoMetadata} from './videos/metadata'
|
||||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||||
@@ -1996,7 +1996,10 @@ function ComposerEmbeds({
|
|||||||
(video.status === 'compressing' ? (
|
(video.status === 'compressing' ? (
|
||||||
<VideoTranscodeProgress
|
<VideoTranscodeProgress
|
||||||
asset={video.asset}
|
asset={video.asset}
|
||||||
progress={video.progress}
|
progress={videoProgressWithinPhase(
|
||||||
|
'compressing',
|
||||||
|
video.progress,
|
||||||
|
)}
|
||||||
clear={clearVideo}
|
clear={clearVideo}
|
||||||
/>
|
/>
|
||||||
) : video.video ? (
|
) : video.video ? (
|
||||||
@@ -2672,28 +2675,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const progress = state.progress
|
const progress = state.progress
|
||||||
const shouldRotate =
|
let wheelProgress = progress
|
||||||
state.status === 'processing' && (progress === 0 || progress === 1)
|
|
||||||
let wheelProgress = shouldRotate ? 0.33 : progress
|
|
||||||
|
|
||||||
const rotate = useDerivedValue(() => {
|
|
||||||
if (shouldRotate) {
|
|
||||||
return withRepeat(
|
|
||||||
withTiming(360, {
|
|
||||||
duration: 2500,
|
|
||||||
easing: Easing.out(Easing.cubic),
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{rotateZ: `${rotate.get()}deg`}],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let text = ''
|
let text = ''
|
||||||
|
|
||||||
@@ -2723,7 +2705,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
|||||||
break
|
break
|
||||||
case 'error':
|
case 'error':
|
||||||
text = l`Error`
|
text = l`Error`
|
||||||
wheelProgress = 100
|
wheelProgress = 1
|
||||||
break
|
break
|
||||||
case 'done':
|
case 'done':
|
||||||
if (isGif) {
|
if (isGif) {
|
||||||
@@ -2736,7 +2718,13 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||||
<Animated.View style={[animatedStyle]}>
|
{state.status === 'done' ? (
|
||||||
|
<Animated.View
|
||||||
|
entering={ZoomIn.duration(300)}
|
||||||
|
style={[a.align_center, a.justify_center, {height: 30, width: 30}]}>
|
||||||
|
<CircleCheckIcon size="lg" fill={t.palette.primary_500} />
|
||||||
|
</Animated.View>
|
||||||
|
) : (
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
size={30}
|
size={30}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
@@ -2748,7 +2736,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
|||||||
}
|
}
|
||||||
progress={wheelProgress}
|
progress={wheelProgress}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
)}
|
||||||
<Text style={[a.font_semi_bold, a.ml_sm]}>{text}</Text>
|
<Text style={[a.font_semi_bold, a.ml_sm]}>{text}</Text>
|
||||||
</ToolbarWrapper>
|
</ToolbarWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {uploadVideo} from '#/lib/media/video/upload'
|
|||||||
import {createVideoAgent} from '#/lib/media/video/util'
|
import {createVideoAgent} from '#/lib/media/video/util'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {
|
||||||
|
advanceVideoProgress,
|
||||||
|
didSkipVideoCompression,
|
||||||
|
videoProgressForPhase,
|
||||||
|
} from './videoProgress'
|
||||||
|
|
||||||
type CaptionsTrack = {lang: string; file: File}
|
type CaptionsTrack = {lang: string; file: File}
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ export type VideoAction =
|
|||||||
| {
|
| {
|
||||||
type: 'compressing_to_uploading'
|
type: 'compressing_to_uploading'
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
|
compressionSkipped: boolean
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -74,7 +80,7 @@ export type NoVideoState = typeof NO_VIDEO
|
|||||||
|
|
||||||
type ErrorState = {
|
type ErrorState = {
|
||||||
status: 'error'
|
status: 'error'
|
||||||
progress: 100
|
progress: number
|
||||||
abortController: AbortController
|
abortController: AbortController
|
||||||
asset: ImagePickerAsset | null
|
asset: ImagePickerAsset | null
|
||||||
video: CompressedVideo | null
|
video: CompressedVideo | null
|
||||||
@@ -102,6 +108,7 @@ type CompressingState = {
|
|||||||
type UploadingState = {
|
type UploadingState = {
|
||||||
status: 'uploading'
|
status: 'uploading'
|
||||||
progress: number
|
progress: number
|
||||||
|
compressionSkipped: boolean
|
||||||
abortController: AbortController
|
abortController: AbortController
|
||||||
asset: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
@@ -128,7 +135,7 @@ type ProcessingState = {
|
|||||||
|
|
||||||
type DoneState = {
|
type DoneState = {
|
||||||
status: 'done'
|
status: 'done'
|
||||||
progress: 100
|
progress: 1
|
||||||
abortController: AbortController
|
abortController: AbortController
|
||||||
asset: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
@@ -173,7 +180,7 @@ export function videoReducer(
|
|||||||
if (action.type === 'to_error') {
|
if (action.type === 'to_error') {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
progress: 100,
|
progress: state.progress,
|
||||||
abortController: state.abortController,
|
abortController: state.abortController,
|
||||||
error: action.error,
|
error: action.error,
|
||||||
asset: state.asset ?? null,
|
asset: state.asset ?? null,
|
||||||
@@ -185,9 +192,13 @@ export function videoReducer(
|
|||||||
}
|
}
|
||||||
} else if (action.type === 'update_progress') {
|
} else if (action.type === 'update_progress') {
|
||||||
if (state.status === 'compressing' || state.status === 'uploading') {
|
if (state.status === 'compressing' || state.status === 'uploading') {
|
||||||
|
const phase =
|
||||||
|
state.status === 'uploading' && state.compressionSkipped
|
||||||
|
? 'uploadingWithoutCompression'
|
||||||
|
: state.status
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
progress: action.progress,
|
progress: advanceVideoProgress(state.progress, phase, action.progress),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (action.type === 'update_alt_text') {
|
} else if (action.type === 'update_alt_text') {
|
||||||
@@ -204,7 +215,13 @@ export function videoReducer(
|
|||||||
if (state.status === 'compressing') {
|
if (state.status === 'compressing') {
|
||||||
return {
|
return {
|
||||||
status: 'uploading',
|
status: 'uploading',
|
||||||
progress: 0,
|
progress: videoProgressForPhase(
|
||||||
|
action.compressionSkipped
|
||||||
|
? 'uploadingWithoutCompression'
|
||||||
|
: 'uploading',
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
compressionSkipped: action.compressionSkipped,
|
||||||
abortController: state.abortController,
|
abortController: state.abortController,
|
||||||
asset: state.asset,
|
asset: state.asset,
|
||||||
video: action.video,
|
video: action.video,
|
||||||
@@ -218,7 +235,7 @@ export function videoReducer(
|
|||||||
if (state.status === 'uploading') {
|
if (state.status === 'uploading') {
|
||||||
return {
|
return {
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
progress: 0,
|
progress: videoProgressForPhase('processing', 0),
|
||||||
abortController: state.abortController,
|
abortController: state.abortController,
|
||||||
asset: state.asset,
|
asset: state.asset,
|
||||||
video: state.video,
|
video: state.video,
|
||||||
@@ -236,7 +253,11 @@ export function videoReducer(
|
|||||||
jobStatus: action.jobStatus,
|
jobStatus: action.jobStatus,
|
||||||
progress:
|
progress:
|
||||||
action.jobStatus.progress !== undefined
|
action.jobStatus.progress !== undefined
|
||||||
? action.jobStatus.progress / 100
|
? advanceVideoProgress(
|
||||||
|
state.progress,
|
||||||
|
'processing',
|
||||||
|
action.jobStatus.progress / 100,
|
||||||
|
)
|
||||||
: state.progress,
|
: state.progress,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +265,7 @@ export function videoReducer(
|
|||||||
if (state.status === 'processing') {
|
if (state.status === 'processing') {
|
||||||
return {
|
return {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
progress: 100,
|
progress: 1,
|
||||||
abortController: state.abortController,
|
abortController: state.abortController,
|
||||||
asset: state.asset,
|
asset: state.asset,
|
||||||
video: state.video,
|
video: state.video,
|
||||||
@@ -314,6 +335,7 @@ export async function processVideo(
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: 'compressing_to_uploading',
|
type: 'compressing_to_uploading',
|
||||||
video,
|
video,
|
||||||
|
compressionSkipped: didSkipVideoCompression(video.passthroughReason),
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
advanceVideoProgress,
|
||||||
|
didSkipVideoCompression,
|
||||||
|
videoProgressForPhase,
|
||||||
|
videoProgressWithinPhase,
|
||||||
|
} from './videoProgress'
|
||||||
|
|
||||||
|
describe('didSkipVideoCompression', () => {
|
||||||
|
it('distinguishes a real skip from a failed compression attempt', () => {
|
||||||
|
expect(didSkipVideoCompression(undefined)).toBe(false)
|
||||||
|
expect(didSkipVideoCompression('below-byte-threshold')).toBe(true)
|
||||||
|
expect(didSkipVideoCompression('no-webcodecs')).toBe(true)
|
||||||
|
expect(didSkipVideoCompression('gif')).toBe(true)
|
||||||
|
expect(didSkipVideoCompression('compress-error-fallback')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('videoProgressForPhase', () => {
|
||||||
|
it('maps each phase onto one continuous timeline', () => {
|
||||||
|
expect(videoProgressForPhase('compressing', 0)).toBe(0)
|
||||||
|
expect(videoProgressForPhase('compressing', 1)).toBe(0.3)
|
||||||
|
expect(videoProgressForPhase('uploading', 0)).toBe(0.3)
|
||||||
|
expect(videoProgressForPhase('uploading', 1)).toBe(0.5)
|
||||||
|
expect(videoProgressForPhase('uploadingWithoutCompression', 0)).toBe(0)
|
||||||
|
expect(videoProgressForPhase('uploadingWithoutCompression', 1)).toBe(0.5)
|
||||||
|
expect(videoProgressForPhase('processing', 0)).toBe(0.5)
|
||||||
|
expect(videoProgressForPhase('processing', 0.5)).toBe(0.75)
|
||||||
|
expect(videoProgressForPhase('processing', 1)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps invalid phase progress', () => {
|
||||||
|
expect(videoProgressForPhase('uploading', -1)).toBe(0.3)
|
||||||
|
expect(videoProgressForPhase('uploading', 2)).toBe(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('videoProgressWithinPhase', () => {
|
||||||
|
it('maps global progress back to phase-local progress', () => {
|
||||||
|
expect(videoProgressWithinPhase('compressing', 0)).toBe(0)
|
||||||
|
expect(videoProgressWithinPhase('compressing', 0.15)).toBe(0.5)
|
||||||
|
expect(videoProgressWithinPhase('compressing', 0.3)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps progress outside the phase', () => {
|
||||||
|
expect(videoProgressWithinPhase('compressing', -1)).toBe(0)
|
||||||
|
expect(videoProgressWithinPhase('compressing', 1)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('advanceVideoProgress', () => {
|
||||||
|
it('does not move backwards when a transport retries or falls back', () => {
|
||||||
|
const progressed = advanceVideoProgress(0.5, 'uploading', 0.8)
|
||||||
|
expect(advanceVideoProgress(progressed, 'uploading', 0)).toBe(progressed)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
|
||||||
|
|
||||||
|
export type VideoProgressPhase =
|
||||||
|
| 'compressing'
|
||||||
|
| 'uploading'
|
||||||
|
| 'uploadingWithoutCompression'
|
||||||
|
| 'processing'
|
||||||
|
|
||||||
|
// Keep progress monotonic across the full client pipeline instead of showing
|
||||||
|
// three separate 0 -> 100 cycles. Backend processing progress covers the
|
||||||
|
// entire server-side job, so it maps directly onto the final half.
|
||||||
|
const PHASE_RANGES: Record<VideoProgressPhase, [number, number]> = {
|
||||||
|
compressing: [0, 0.3],
|
||||||
|
uploading: [0.3, 0.5],
|
||||||
|
uploadingWithoutCompression: [0, 0.5],
|
||||||
|
processing: [0.5, 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function didSkipVideoCompression(
|
||||||
|
passthroughReason: VideoCompressSkipReason | undefined,
|
||||||
|
): boolean {
|
||||||
|
// The web compressor can return the original file after reporting partial
|
||||||
|
// compression progress. Keep that case on the post-compression upload band
|
||||||
|
// so the global indicator cannot jump backwards.
|
||||||
|
return (
|
||||||
|
passthroughReason !== undefined &&
|
||||||
|
passthroughReason !== 'compress-error-fallback'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoProgressForPhase(
|
||||||
|
phase: VideoProgressPhase,
|
||||||
|
phaseProgress: number,
|
||||||
|
): number {
|
||||||
|
const [start, end] = PHASE_RANGES[phase]
|
||||||
|
const clamped = Math.min(1, Math.max(0, phaseProgress))
|
||||||
|
return start + (end - start) * clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoProgressWithinPhase(
|
||||||
|
phase: VideoProgressPhase,
|
||||||
|
progress: number,
|
||||||
|
): number {
|
||||||
|
const [start, end] = PHASE_RANGES[phase]
|
||||||
|
return Math.min(1, Math.max(0, (progress - start) / (end - start)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceVideoProgress(
|
||||||
|
currentProgress: number,
|
||||||
|
phase: VideoProgressPhase,
|
||||||
|
phaseProgress: number,
|
||||||
|
): number {
|
||||||
|
return Math.max(currentProgress, videoProgressForPhase(phase, phaseProgress))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user