Adds video support to composer draft
This commit is contained in:
committed by
Samuel Newman
parent
99f60bfcc2
commit
2a01065a31
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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'}}
|
||||
|
||||
Reference in New Issue
Block a user