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,
|
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
|
return parsed
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to load initial draft', {error: e})
|
logger.error('Failed to load initial draft', {error: e})
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}, [draftKey, initText, initMention, initImageUris, initQuote, initVideoUri])
|
}, [
|
||||||
|
currentAccount,
|
||||||
|
draftKey,
|
||||||
|
initText,
|
||||||
|
initMention,
|
||||||
|
initImageUris,
|
||||||
|
initQuote,
|
||||||
|
initVideoUri,
|
||||||
|
])
|
||||||
|
|
||||||
const [composerState, composerDispatch] = useReducer(
|
const [composerState, composerDispatch] = useReducer(
|
||||||
composerReducer,
|
composerReducer,
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ export function createComposerState({
|
|||||||
mutableNeedsFocusActive: false,
|
mutableNeedsFocusActive: false,
|
||||||
thread: {
|
thread: {
|
||||||
posts: initDraft.thread.posts.map((post: any) => {
|
posts: initDraft.thread.posts.map((post: any) => {
|
||||||
let media: ImagesMedia | GifMedia | undefined
|
let media: ImagesMedia | GifMedia | VideoMedia | undefined
|
||||||
|
|
||||||
if (post.embed?.images?.length) {
|
if (post.embed?.images?.length) {
|
||||||
media = {
|
media = {
|
||||||
@@ -528,6 +528,35 @@ export function createComposerState({
|
|||||||
gif: post.embed.gif,
|
gif: post.embed.gif,
|
||||||
alt: post.embed.gif.alt || '',
|
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 {
|
return {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ type SerializedImage = {
|
|||||||
mime: string
|
mime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SerializedVideo = {
|
||||||
|
blobRef: any // BlobRef from @atproto/api (server reference)
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
mimeType: string
|
||||||
|
altText: string
|
||||||
|
}
|
||||||
|
|
||||||
type SerializedDraft = {
|
type SerializedDraft = {
|
||||||
version: 1
|
version: 1
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -37,8 +45,7 @@ type SerializedDraft = {
|
|||||||
title: string
|
title: string
|
||||||
alt: string
|
alt: string
|
||||||
}
|
}
|
||||||
// Note: Videos are complex with compression/upload state
|
video?: SerializedVideo
|
||||||
// For now we skip videos in drafts
|
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
postgate: any
|
postgate: any
|
||||||
@@ -63,6 +70,8 @@ function serializeDraft(state: ComposerState): SerializedDraft {
|
|||||||
| {id: string; media_formats: any; title: string; alt: string}
|
| {id: string; media_formats: any; title: string; alt: string}
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
|
let video: SerializedVideo | undefined
|
||||||
|
|
||||||
if (media?.type === 'images') {
|
if (media?.type === 'images') {
|
||||||
// Serialize images with their local paths
|
// Serialize images with their local paths
|
||||||
// Note: These may not be available if the app was closed and cache was cleared
|
// 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,
|
title: media.gif.title,
|
||||||
alt: media.alt,
|
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 {
|
return {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@@ -93,6 +128,7 @@ function serializeDraft(state: ComposerState): SerializedDraft {
|
|||||||
linkUri: post.embed.link?.uri,
|
linkUri: post.embed.link?.uri,
|
||||||
images,
|
images,
|
||||||
gif,
|
gif,
|
||||||
|
video,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -110,6 +146,7 @@ function deserializeDraft(data: SerializedDraft): Partial<ComposerState> {
|
|||||||
let media:
|
let media:
|
||||||
| {type: 'images'; images: any[]}
|
| {type: 'images'; images: any[]}
|
||||||
| {type: 'gif'; gif: any; alt: string}
|
| {type: 'gif'; gif: any; alt: string}
|
||||||
|
| {type: 'video'; video: any}
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
// Reconstruct images if available
|
// Reconstruct images if available
|
||||||
@@ -135,12 +172,45 @@ function deserializeDraft(data: SerializedDraft): Partial<ComposerState> {
|
|||||||
gif: post.embed.gif,
|
gif: post.embed.gif,
|
||||||
alt: post.embed.gif.alt,
|
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 {
|
return {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
richtext: new RichText({text: post.text}),
|
richtext: rt,
|
||||||
shortenedGraphemeLength: post.text.length, // Will be recalculated
|
shortenedGraphemeLength: rt.graphemeLength,
|
||||||
labels: post.labels,
|
labels: post.labels,
|
||||||
embed: {
|
embed: {
|
||||||
quote: post.embed.quoteUri
|
quote: post.embed.quoteUri
|
||||||
@@ -197,7 +267,15 @@ export function useComposerDraft(
|
|||||||
try {
|
try {
|
||||||
if (hasContent(state)) {
|
if (hasContent(state)) {
|
||||||
const serialized = serializeDraft(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', {
|
logger.info('Composer draft saved', {
|
||||||
key: draftKey,
|
key: draftKey,
|
||||||
textLength: state.thread.posts[0]?.richtext.text.length || 0,
|
textLength: state.thread.posts[0]?.richtext.text.length || 0,
|
||||||
@@ -208,7 +286,11 @@ export function useComposerDraft(
|
|||||||
logger.debug('Empty draft removed', {key: draftKey})
|
logger.debug('Empty draft removed', {key: draftKey})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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)
|
}, AUTOSAVE_DELAY_MS)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import {View} from 'react-native'
|
|||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {BlueskyVideoView} from '@haileyok/bluesky-video'
|
import {BlueskyVideoView} from '@haileyok/bluesky-video'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
import {useAutoplayDisabled} from '#/state/preferences'
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ export function VideoPreview({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const playerRef = React.useRef<BlueskyVideoView>(null)
|
const playerRef = React.useRef<BlueskyVideoView>(null)
|
||||||
const autoplayDisabled = useAutoplayDisabled()
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
|
const isRestoredFromDraft = !video.uri
|
||||||
|
|
||||||
let aspectRatio = asset.width / asset.height
|
let aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
if (isNaN(aspectRatio)) {
|
if (isNaN(aspectRatio)) {
|
||||||
@@ -45,32 +49,50 @@ export function VideoPreview({
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{backgroundColor: 'black'},
|
{backgroundColor: 'black'},
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.absolute, a.inset_0]}>
|
{!isRestoredFromDraft && (
|
||||||
<VideoTranscodeBackdrop uri={asset.uri} />
|
<View style={[a.absolute, a.inset_0]}>
|
||||||
</View>
|
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||||
{isActivePost && (
|
</View>
|
||||||
<>
|
)}
|
||||||
{video.mimeType === 'image/gif' ? (
|
{isRestoredFromDraft ? (
|
||||||
<Image
|
<View
|
||||||
style={[a.flex_1]}
|
style={[
|
||||||
autoplay={!autoplayDisabled}
|
a.absolute,
|
||||||
source={{uri: video.uri}}
|
a.inset_0,
|
||||||
accessibilityIgnoresInvertColors
|
a.justify_center,
|
||||||
cachePolicy="none"
|
a.align_center,
|
||||||
/>
|
a.gap_md,
|
||||||
) : (
|
]}>
|
||||||
<BlueskyVideoView
|
<PlayButtonIcon />
|
||||||
url={video.uri}
|
<Text style={[a.text_center, {color: t.palette.white}]}>
|
||||||
autoplay={!autoplayDisabled}
|
<Trans>Video uploaded and ready to post</Trans>
|
||||||
beginMuted={true}
|
</Text>
|
||||||
forceTakeover={true}
|
</View>
|
||||||
ref={playerRef}
|
) : (
|
||||||
/>
|
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} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
{autoplayDisabled && (
|
{!isRestoredFromDraft && autoplayDisabled && (
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
<PlayButtonIcon />
|
<PlayButtonIcon />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
@@ -8,7 +8,8 @@ import {clamp} from '#/lib/numbers'
|
|||||||
import {useAutoplayDisabled} from '#/state/preferences'
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
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'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
@@ -23,9 +24,11 @@ export function VideoPreview({
|
|||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
// TODO: figure out how to pause a GIF for reduced motion
|
// TODO: figure out how to pause a GIF for reduced motion
|
||||||
// it's not possible using an img tag -sfn
|
// it's not possible using an img tag -sfn
|
||||||
const autoplayDisabled = useAutoplayDisabled()
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
|
const isRestoredFromDraft = !video.uri
|
||||||
|
|
||||||
let aspectRatio = asset.width / asset.height
|
let aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
@@ -46,7 +49,21 @@ export function VideoPreview({
|
|||||||
a.relative,
|
a.relative,
|
||||||
]}>
|
]}>
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<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
|
<img
|
||||||
src={video.uri}
|
src={video.uri}
|
||||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||||
|
|||||||
Reference in New Issue
Block a user