From 03704e2b48e6cdc348ce7277f2bcae0c61519d1e Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 3 Oct 2024 14:26:38 +0900 Subject: [PATCH] Manage video reducer from composer reducer (#5573) * Move video state into composer state * Represent video as embed This is slightly broken. In particular, we can't remove video yet because there's no action that results in video embed being removed. * Properly represent video as embed This aligns the video state lifetime with the embed lifetime. Video can now be properly added and removed. * Disable Add Video when we have images * Ignore empty image pick --- src/state/queries/video/video.ts | 67 +++++++++++--------------- src/view/com/composer/Composer.tsx | 49 +++++++++++-------- src/view/com/composer/state.ts | 75 +++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 62 deletions(-) diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index fabee6ad1a..dbbb6c2026 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -16,13 +16,7 @@ import {logger} from '#/logger' import {createVideoAgent} from '#/state/queries/video/util' import {uploadVideo} from '#/state/queries/video/video-upload' -type Action = - | {type: 'to_idle'; nextController: AbortController} - | { - type: 'idle_to_compressing' - asset: ImagePickerAsset - signal: AbortSignal - } +export type VideoAction = | { type: 'compressing_to_uploading' video: CompressedVideo @@ -52,15 +46,20 @@ type Action = signal: AbortSignal } -type IdleState = { - status: 'idle' - progress: 0 - abortController: AbortController - asset?: undefined - video?: undefined - jobId?: undefined - pendingPublish?: undefined -} +const noopController = new AbortController() +noopController.abort() + +export const NO_VIDEO = Object.freeze({ + status: 'idle', + progress: 0, + abortController: noopController, + asset: undefined, + video: undefined, + jobId: undefined, + pendingPublish: undefined, +}) + +export type NoVideoState = typeof NO_VIDEO type ErrorState = { status: 'error' @@ -114,8 +113,7 @@ type DoneState = { pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} } -export type State = - | IdleState +export type VideoState = | ErrorState | CompressingState | UploadingState @@ -123,19 +121,21 @@ export type State = | DoneState export function createVideoState( - abortController: AbortController = new AbortController(), -): IdleState { + asset: ImagePickerAsset, + abortController: AbortController, +): CompressingState { return { - status: 'idle', + status: 'compressing', progress: 0, abortController, + asset, } } -export function videoReducer(state: State, action: Action): State { - if (action.type === 'to_idle') { - return createVideoState(action.nextController) - } +export function videoReducer( + state: VideoState, + action: VideoAction, +): VideoState { if (action.signal.aborted || action.signal !== state.abortController.signal) { // This action is stale and the process that spawned it is no longer relevant. return state @@ -157,15 +157,6 @@ export function videoReducer(state: State, action: Action): State { progress: action.progress, } } - } else if (action.type === 'idle_to_compressing') { - if (state.status === 'idle') { - return { - status: 'compressing', - progress: 0, - abortController: state.abortController, - asset: action.asset, - } - } } else if (action.type === 'update_dimensions') { if (state.asset) { return { @@ -238,18 +229,12 @@ function trunc2dp(num: number) { export async function processVideo( asset: ImagePickerAsset, - dispatch: (action: Action) => void, + dispatch: (action: VideoAction) => void, agent: BskyAgent, did: string, signal: AbortSignal, _: I18n['_'], ) { - dispatch({ - type: 'idle_to_compressing', - asset, - signal, - }) - let video: CompressedVideo | undefined try { video = await compressVideo(asset, { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 185a57fc35..59aae29516 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,11 +82,12 @@ import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' +import {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' import { - createVideoState, processVideo, - State as VideoUploadState, - videoReducer, + VideoAction, + VideoState, + VideoState as VideoUploadState, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -192,24 +193,38 @@ export const ComposePost = ({ const [videoAltText, setVideoAltText] = useState('') const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - const [videoUploadState, videoDispatch] = useReducer( - videoReducer, - undefined, - createVideoState, + // TODO: Move more state here. + const [composerState, dispatch] = useReducer( + composerReducer, + {initImageUris}, + createComposerState, + ) + + let videoUploadState: VideoState | NoVideoState = NO_VIDEO + if (composerState.embed.media?.type === 'video') { + videoUploadState = composerState.embed.media.video + } + const videoDispatch = useCallback( + (videoAction: VideoAction) => { + dispatch({type: 'embed_update_video', videoAction}) + }, + [dispatch], ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { + const abortController = new AbortController() + dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, videoDispatch, agent, currentDid, - videoUploadState.abortController.signal, + abortController.signal, _, ) }, - [_, videoUploadState.abortController, videoDispatch, agent, currentDid], + [_, videoDispatch, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -221,8 +236,8 @@ export const ComposePost = ({ const clearVideo = React.useCallback(() => { videoUploadState.abortController.abort() - videoDispatch({type: 'to_idle', nextController: new AbortController()}) - }, [videoUploadState.abortController, videoDispatch]) + dispatch({type: 'embed_remove_video'}) + }, [videoUploadState.abortController, dispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { @@ -233,7 +248,7 @@ export const ComposePost = ({ signal: videoUploadState.abortController.signal, }) }, - [videoUploadState.abortController], + [videoUploadState.abortController, videoDispatch], ) const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) @@ -249,12 +264,6 @@ export const ComposePost = ({ ) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) - // TODO: Move more state here. - const [composerState, dispatch] = useReducer( - composerReducer, - {initImageUris}, - createComposerState, - ) let images = NO_IMAGES if (composerState.embed.media?.type === 'images') { images = composerState.embed.media.images @@ -857,7 +866,7 @@ export const ComposePost = ({ /> 0} setError={setError} /> @@ -1117,7 +1126,7 @@ function ErrorBanner({ clearVideo, }: { error: string - videoUploadState: VideoUploadState + videoUploadState: VideoUploadState | NoVideoState clearError: () => void clearVideo: () => void }) { diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state.ts index 5588de1aa3..8e974ad7a5 100644 --- a/src/view/com/composer/state.ts +++ b/src/view/com/composer/state.ts @@ -1,4 +1,12 @@ +import {ImagePickerAsset} from 'expo-image-picker' + import {ComposerImage, createInitialImages} from '#/state/gallery' +import { + createVideoState, + VideoAction, + videoReducer, + VideoState, +} from '#/state/queries/video/video' import {ComposerOpts} from '#/state/shell/composer' type PostRecord = { @@ -11,11 +19,16 @@ type ImagesMedia = { labels: string[] } +type VideoMedia = { + type: 'video' + video: VideoState +} + type ComposerEmbed = { // TODO: Other record types. record: PostRecord | undefined // TODO: Other media types. - media: ImagesMedia | undefined + media: ImagesMedia | VideoMedia | undefined } export type ComposerState = { @@ -27,6 +40,13 @@ export type ComposerAction = | {type: 'embed_add_images'; images: ComposerImage[]} | {type: 'embed_update_image'; image: ComposerImage} | {type: 'embed_remove_image'; image: ComposerImage} + | { + type: 'embed_add_video' + asset: ImagePickerAsset + abortController: AbortController + } + | {type: 'embed_remove_video'} + | {type: 'embed_update_video'; videoAction: VideoAction} const MAX_IMAGES = 4 @@ -36,6 +56,9 @@ export function composerReducer( ): ComposerState { switch (action.type) { case 'embed_add_images': { + if (action.images.length === 0) { + return state + } const prevMedia = state.embed.media let nextMedia = prevMedia if (!prevMedia) { @@ -104,6 +127,55 @@ export function composerReducer( } return state } + case 'embed_add_video': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (!prevMedia) { + nextMedia = { + type: 'video', + video: createVideoState(action.asset, action.abortController), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_update_video': { + const videoAction = action.videoAction + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'video') { + nextMedia = { + ...prevMedia, + video: videoReducer(prevMedia.video, videoAction), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_remove_video': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'video') { + nextMedia = undefined + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } default: return state } @@ -122,6 +194,7 @@ export function createComposerState({ labels: [], } } + // TODO: initial video. return { embed: { record: undefined,