Adds video support to composer draft

This commit is contained in:
Alex Benzer
2025-12-06 12:47:42 -08:00
committed by Samuel Newman
parent 99f60bfcc2
commit 2a01065a31
5 changed files with 206 additions and 36 deletions
+21 -1
View File
@@ -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,
+30 -1
View File
@@ -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 {
+89 -7
View File
@@ -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<ComposerState> {
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<ComposerState> {
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)
},
+46 -24
View File
@@ -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<BlueskyVideoView>(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'},
]}>
<View style={[a.absolute, a.inset_0]}>
<VideoTranscodeBackdrop uri={asset.uri} />
</View>
{isActivePost && (
<>
{video.mimeType === 'image/gif' ? (
<Image
style={[a.flex_1]}
autoplay={!autoplayDisabled}
source={{uri: video.uri}}
accessibilityIgnoresInvertColors
cachePolicy="none"
/>
) : (
<BlueskyVideoView
url={video.uri}
autoplay={!autoplayDisabled}
beginMuted={true}
forceTakeover={true}
ref={playerRef}
/>
)}
</>
{!isRestoredFromDraft && (
<View style={[a.absolute, a.inset_0]}>
<VideoTranscodeBackdrop uri={asset.uri} />
</View>
)}
{isRestoredFromDraft ? (
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
a.gap_md,
]}>
<PlayButtonIcon />
<Text style={[a.text_center, {color: t.palette.white}]}>
<Trans>Video uploaded and ready to post</Trans>
</Text>
</View>
) : (
isActivePost && (
<>
{video.mimeType === 'image/gif' ? (
<Image
style={[a.flex_1]}
autoplay={!autoplayDisabled}
source={{uri: video.uri}}
accessibilityIgnoresInvertColors
cachePolicy="none"
/>
) : (
<BlueskyVideoView
url={video.uri}
autoplay={!autoplayDisabled}
beginMuted={true}
forceTakeover={true}
ref={playerRef}
/>
)}
</>
)
)}
<ExternalEmbedRemoveBtn onRemove={clear} />
{autoplayDisabled && (
{!isRestoredFromDraft && autoplayDisabled && (
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon />
</View>
@@ -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,
]}>
<ExternalEmbedRemoveBtn onRemove={clear} />
{video.mimeType === 'image/gif' ? (
{isRestoredFromDraft ? (
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
a.gap_md,
]}>
<PlayButtonIcon />
<Text style={[a.text_center, {color: t.palette.white}]}>
<Trans>Video uploaded and ready to post</Trans>
</Text>
</View>
) : video.mimeType === 'image/gif' ? (
<img
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}