From db2928ada297f596fe6d280db20c21012e082940 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 12 Aug 2026 17:14:06 -0400 Subject: [PATCH] APP-1645: Improve video upload progress indicator (#11248) --- src/view/com/composer/Composer.tsx | 44 ++++++--------- src/view/com/composer/state/video.ts | 38 ++++++++++--- .../com/composer/state/videoProgress.test.ts | 55 +++++++++++++++++++ src/view/com/composer/state/videoProgress.ts | 54 ++++++++++++++++++ 4 files changed, 155 insertions(+), 36 deletions(-) create mode 100644 src/view/com/composer/state/videoProgress.test.ts create mode 100644 src/view/com/composer/state/videoProgress.ts diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b080441230..f9129c56e9 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -25,7 +25,6 @@ import {Circle as ProgressCircle} from 'react-native-progress' import Animated, { type AnimatedRef, type AnimatedStyle, - Easing, FadeIn, FadeOut, interpolateColor, @@ -37,7 +36,6 @@ import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, - withRepeat, withTiming, ZoomIn, ZoomOut, @@ -127,6 +125,7 @@ import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' @@ -180,6 +179,7 @@ import { processVideo, type VideoState, } from './state/video' +import {videoProgressWithinPhase} from './state/videoProgress' import {type TextInputRef} from './text-input/TextInput.types' import {getVideoMetadata} from './videos/metadata' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' @@ -1996,7 +1996,10 @@ function ComposerEmbeds({ (video.status === 'compressing' ? ( ) : video.video ? ( @@ -2672,28 +2675,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) { const t = useTheme() const {t: l} = useLingui() const progress = state.progress - const shouldRotate = - 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 wheelProgress = progress let text = '' @@ -2723,7 +2705,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) { break case 'error': text = l`Error` - wheelProgress = 100 + wheelProgress = 1 break case 'done': if (isGif) { @@ -2736,7 +2718,13 @@ function VideoUploadToolbar({state}: {state: VideoState}) { return ( - + {state.status === 'done' ? ( + + + + ) : ( - + )} {text} ) diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 13bd71f885..eeca7dca47 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -17,6 +17,11 @@ import {uploadVideo} from '#/lib/media/video/upload' import {createVideoAgent} from '#/lib/media/video/util' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' +import { + advanceVideoProgress, + didSkipVideoCompression, + videoProgressForPhase, +} from './videoProgress' type CaptionsTrack = {lang: string; file: File} @@ -24,6 +29,7 @@ export type VideoAction = | { type: 'compressing_to_uploading' video: CompressedVideo + compressionSkipped: boolean signal: AbortSignal } | { @@ -74,7 +80,7 @@ export type NoVideoState = typeof NO_VIDEO type ErrorState = { status: 'error' - progress: 100 + progress: number abortController: AbortController asset: ImagePickerAsset | null video: CompressedVideo | null @@ -102,6 +108,7 @@ type CompressingState = { type UploadingState = { status: 'uploading' progress: number + compressionSkipped: boolean abortController: AbortController asset: ImagePickerAsset video: CompressedVideo @@ -128,7 +135,7 @@ type ProcessingState = { type DoneState = { status: 'done' - progress: 100 + progress: 1 abortController: AbortController asset: ImagePickerAsset video: CompressedVideo @@ -173,7 +180,7 @@ export function videoReducer( if (action.type === 'to_error') { return { status: 'error', - progress: 100, + progress: state.progress, abortController: state.abortController, error: action.error, asset: state.asset ?? null, @@ -185,9 +192,13 @@ export function videoReducer( } } else if (action.type === 'update_progress') { if (state.status === 'compressing' || state.status === 'uploading') { + const phase = + state.status === 'uploading' && state.compressionSkipped + ? 'uploadingWithoutCompression' + : state.status return { ...state, - progress: action.progress, + progress: advanceVideoProgress(state.progress, phase, action.progress), } } } else if (action.type === 'update_alt_text') { @@ -204,7 +215,13 @@ export function videoReducer( if (state.status === 'compressing') { return { status: 'uploading', - progress: 0, + progress: videoProgressForPhase( + action.compressionSkipped + ? 'uploadingWithoutCompression' + : 'uploading', + 0, + ), + compressionSkipped: action.compressionSkipped, abortController: state.abortController, asset: state.asset, video: action.video, @@ -218,7 +235,7 @@ export function videoReducer( if (state.status === 'uploading') { return { status: 'processing', - progress: 0, + progress: videoProgressForPhase('processing', 0), abortController: state.abortController, asset: state.asset, video: state.video, @@ -236,7 +253,11 @@ export function videoReducer( jobStatus: action.jobStatus, progress: action.jobStatus.progress !== undefined - ? action.jobStatus.progress / 100 + ? advanceVideoProgress( + state.progress, + 'processing', + action.jobStatus.progress / 100, + ) : state.progress, } } @@ -244,7 +265,7 @@ export function videoReducer( if (state.status === 'processing') { return { status: 'done', - progress: 100, + progress: 1, abortController: state.abortController, asset: state.asset, video: state.video, @@ -314,6 +335,7 @@ export async function processVideo( dispatch({ type: 'compressing_to_uploading', video, + compressionSkipped: didSkipVideoCompression(video.passthroughReason), signal, }) diff --git a/src/view/com/composer/state/videoProgress.test.ts b/src/view/com/composer/state/videoProgress.test.ts new file mode 100644 index 0000000000..92d8fc0835 --- /dev/null +++ b/src/view/com/composer/state/videoProgress.test.ts @@ -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) + }) +}) diff --git a/src/view/com/composer/state/videoProgress.ts b/src/view/com/composer/state/videoProgress.ts new file mode 100644 index 0000000000..bee9be68fa --- /dev/null +++ b/src/view/com/composer/state/videoProgress.ts @@ -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 = { + 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)) +}