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))
+}