From 2a01065a3181992c7ad381c80ac893375d055e8b Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Sat, 6 Dec 2025 12:47:42 -0800 Subject: [PATCH] Adds video support to composer draft --- src/view/com/composer/Composer.tsx | 22 ++++- src/view/com/composer/state/composer.ts | 31 +++++- src/view/com/composer/useComposerDraft.ts | 96 +++++++++++++++++-- src/view/com/composer/videos/VideoPreview.tsx | 70 +++++++++----- .../com/composer/videos/VideoPreview.web.tsx | 23 ++++- 5 files changed, 206 insertions(+), 36 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 728fe2ab00..4d6caf5c13 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -272,12 +272,32 @@ export const ComposePost = ({ textLength: parsed.thread.posts[0]?.text.length || 0, }) + // Construct video URLs from blobRefs if we have videos in the draft + if (currentAccount && parsed.thread?.posts) { + parsed.thread.posts = parsed.thread.posts.map((post: any) => { + if (post.embed?.video?.blobRef?.ref?.$link) { + const cid = post.embed.video.blobRef.ref.$link + // Use the video.bsky.app CDN with HLS playlist + post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8` + } + return post + }) + } + return parsed } catch (e) { logger.error('Failed to load initial draft', {error: e}) return null } - }, [draftKey, initText, initMention, initImageUris, initQuote, initVideoUri]) + }, [ + currentAccount, + draftKey, + initText, + initMention, + initImageUris, + initQuote, + initVideoUri, + ]) const [composerState, composerDispatch] = useReducer( composerReducer, diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 2fdbe8647e..12c71ea509 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -506,7 +506,7 @@ export function createComposerState({ mutableNeedsFocusActive: false, thread: { posts: initDraft.thread.posts.map((post: any) => { - let media: ImagesMedia | GifMedia | undefined + let media: ImagesMedia | GifMedia | VideoMedia | undefined if (post.embed?.images?.length) { media = { @@ -528,6 +528,35 @@ export function createComposerState({ gif: post.embed.gif, alt: post.embed.gif.alt || '', } + } else if (post.embed?.video) { + // Restore video from draft (already uploaded to server) + const abortController = new AbortController() + abortController.abort() // Can't resume, already uploaded + const videoUri = post.embed.video.uri || '' // URL constructed from blobRef in Composer.tsx + media = { + type: 'video', + video: { + status: 'done', + progress: 100, + abortController, + asset: { + uri: videoUri, + width: post.embed.video.width, + height: post.embed.video.height, + mimeType: post.embed.video.mimeType, + }, + video: { + uri: videoUri, + mimeType: post.embed.video.mimeType, + size: 0, + }, + pendingPublish: { + blobRef: post.embed.video.blobRef, + }, + altText: post.embed.video.altText || '', + captions: [], + }, + } } return { diff --git a/src/view/com/composer/useComposerDraft.ts b/src/view/com/composer/useComposerDraft.ts index 9450e68e04..473c157669 100644 --- a/src/view/com/composer/useComposerDraft.ts +++ b/src/view/com/composer/useComposerDraft.ts @@ -18,6 +18,14 @@ type SerializedImage = { mime: string } +type SerializedVideo = { + blobRef: any // BlobRef from @atproto/api (server reference) + width: number + height: number + mimeType: string + altText: string +} + type SerializedDraft = { version: 1 timestamp: number @@ -37,8 +45,7 @@ type SerializedDraft = { title: string alt: string } - // Note: Videos are complex with compression/upload state - // For now we skip videos in drafts + video?: SerializedVideo } }> postgate: any @@ -63,6 +70,8 @@ function serializeDraft(state: ComposerState): SerializedDraft { | {id: string; media_formats: any; title: string; alt: string} | undefined + let video: SerializedVideo | undefined + if (media?.type === 'images') { // Serialize images with their local paths // Note: These may not be available if the app was closed and cache was cleared @@ -81,8 +90,34 @@ function serializeDraft(state: ComposerState): SerializedDraft { title: media.gif.title, alt: media.alt, } + } else if (media?.type === 'video') { + logger.debug('Draft: Video found in post', { + status: media.video.status, + hasAsset: !!media.video.asset, + hasPendingPublish: !!(media.video as any).pendingPublish, + }) + if (media.video.status === 'done') { + // Only serialize videos that are fully uploaded + // Don't save the asset.uri - it's local data and can be huge + // The blobRef is all we need since the video is on the server + video = { + blobRef: media.video.pendingPublish.blobRef, + width: media.video.asset.width, + height: media.video.asset.height, + mimeType: media.video.asset.mimeType || 'video/mp4', + altText: media.video.altText, + } + logger.debug('Draft: Serialized video', { + hasBlobRef: !!video.blobRef, + dimensions: `${video.width}x${video.height}`, + }) + } else { + logger.debug('Draft: Skipping video (not done)', { + status: media.video.status, + }) + } } - // Videos are skipped for now due to complexity + // Videos in other states (compressing, uploading, processing) are skipped return { id: post.id, @@ -93,6 +128,7 @@ function serializeDraft(state: ComposerState): SerializedDraft { linkUri: post.embed.link?.uri, images, gif, + video, }, } }), @@ -110,6 +146,7 @@ function deserializeDraft(data: SerializedDraft): Partial { let media: | {type: 'images'; images: any[]} | {type: 'gif'; gif: any; alt: string} + | {type: 'video'; video: any} | undefined // Reconstruct images if available @@ -135,12 +172,45 @@ function deserializeDraft(data: SerializedDraft): Partial { gif: post.embed.gif, alt: post.embed.gif.alt, } + } else if (post.embed.video) { + // Reconstruct video (already uploaded to server) + logger.debug('Draft: Restoring video from draft', { + hasVideo: !!post.embed.video, + hasBlobRef: !!post.embed.video.blobRef, + }) + const abortController = new AbortController() + abortController.abort() // Already uploaded, can't resume + media = { + type: 'video', + video: { + status: 'done', + progress: 100, + abortController, + asset: { + uri: '', // Placeholder - video is on server, we have the blobRef + width: post.embed.video.width, + height: post.embed.video.height, + mimeType: post.embed.video.mimeType, + }, + video: { + uri: '', // Placeholder - not needed for posting + mimeType: post.embed.video.mimeType, + size: 0, + }, + pendingPublish: { + blobRef: post.embed.video.blobRef, + }, + altText: post.embed.video.altText, + captions: [], + }, + } } + const rt = new RichText({text: post.text}) return { id: post.id, - richtext: new RichText({text: post.text}), - shortenedGraphemeLength: post.text.length, // Will be recalculated + richtext: rt, + shortenedGraphemeLength: rt.graphemeLength, labels: post.labels, embed: { quote: post.embed.quoteUri @@ -197,7 +267,15 @@ export function useComposerDraft( try { if (hasContent(state)) { const serialized = serializeDraft(state) - localStorage.setItem(draftKey, JSON.stringify(serialized)) + logger.debug('Draft serialized successfully', { + hasPosts: serialized.thread.posts.length > 0, + hasVideo: !!serialized.thread.posts[0]?.embed?.video, + }) + const jsonString = JSON.stringify(serialized) + logger.debug('Draft JSON stringified', { + size: jsonString.length, + }) + localStorage.setItem(draftKey, jsonString) logger.info('Composer draft saved', { key: draftKey, textLength: state.thread.posts[0]?.richtext.text.length || 0, @@ -208,7 +286,11 @@ export function useComposerDraft( logger.debug('Empty draft removed', {key: draftKey}) } } catch (e) { - logger.error('Failed to save composer draft', {error: e}) + logger.error('Failed to save composer draft', { + error: e, + message: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined, + }) } }, AUTOSAVE_DELAY_MS) }, diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 84cb1dba75..f1321d9f16 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -3,12 +3,14 @@ import {View} from 'react-native' import {Image} from 'expo-image' import {type ImagePickerAsset} from 'expo-image-picker' import {BlueskyVideoView} from '@haileyok/bluesky-video' +import {Trans} from '@lingui/macro' import {type CompressedVideo} from '#/lib/media/video/types' import {clamp} from '#/lib/numbers' import {useAutoplayDisabled} from '#/state/preferences' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' @@ -26,6 +28,8 @@ export function VideoPreview({ const t = useTheme() const playerRef = React.useRef(null) const autoplayDisabled = useAutoplayDisabled() + const isRestoredFromDraft = !video.uri + let aspectRatio = asset.width / asset.height if (isNaN(aspectRatio)) { @@ -45,32 +49,50 @@ export function VideoPreview({ t.atoms.border_contrast_low, {backgroundColor: 'black'}, ]}> - - - - {isActivePost && ( - <> - {video.mimeType === 'image/gif' ? ( - - ) : ( - - )} - + {!isRestoredFromDraft && ( + + + + )} + {isRestoredFromDraft ? ( + + + + Video uploaded and ready to post + + + ) : ( + isActivePost && ( + <> + {video.mimeType === 'image/gif' ? ( + + ) : ( + + )} + + ) )} - {autoplayDisabled && ( + {!isRestoredFromDraft && autoplayDisabled && ( diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx index 979fbf52f8..2cbb4969d3 100644 --- a/src/view/com/composer/videos/VideoPreview.web.tsx +++ b/src/view/com/composer/videos/VideoPreview.web.tsx @@ -1,6 +1,6 @@ import {View} from 'react-native' import {type ImagePickerAsset} from 'expo-image-picker' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {type CompressedVideo} from '#/lib/media/video/types' @@ -8,7 +8,8 @@ import {clamp} from '#/lib/numbers' import {useAutoplayDisabled} from '#/state/preferences' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import * as Toast from '#/view/com/util/Toast' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' export function VideoPreview({ @@ -23,9 +24,11 @@ export function VideoPreview({ clear: () => void }) { const {_} = useLingui() + const t = useTheme() // TODO: figure out how to pause a GIF for reduced motion // it's not possible using an img tag -sfn const autoplayDisabled = useAutoplayDisabled() + const isRestoredFromDraft = !video.uri let aspectRatio = asset.width / asset.height @@ -46,7 +49,21 @@ export function VideoPreview({ a.relative, ]}> - {video.mimeType === 'image/gif' ? ( + {isRestoredFromDraft ? ( + + + + Video uploaded and ready to post + + + ) : video.mimeType === 'image/gif' ? (