Add video player support to bskyembed with HLS.js
Adds real video playback to the bskyembed iframe embed system: - Lazy-load the video player using preact/compat lazy/Suspense so hls.js and all player code are in a separate chunk, keeping the default bundle small - Full video controls: play/pause, scrubber, volume, fullscreen, time display - HLS.js integration with bandwidth estimation and error handling - GIF presentation support with simplified controls - Thumbnail + play button shown until user clicks to activate the player - Ported patterns from the main app's web video player, adapted for Preact + Tailwind (no React Native, no ALF, no Lingui) https://claude.ai/code/session_01Mva57VZnFgdaQ7LPPoVNsP
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.25",
|
||||
"hls.js": "^1.6.2",
|
||||
"preact": "^10.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
AppBskyLabelerDefs,
|
||||
} from '@atproto/api'
|
||||
import {ComponentChildren, h} from 'preact'
|
||||
import {useMemo} from 'preact/hooks'
|
||||
import {lazy, Suspense} from 'preact/compat'
|
||||
import {useMemo, useState} from 'preact/hooks'
|
||||
|
||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||
@@ -23,6 +24,8 @@ import {getVerificationState} from '../util/verification-state'
|
||||
import {Link} from './link'
|
||||
import {VerificationCheck} from './verification-check'
|
||||
|
||||
const LazyVideoPlayer = lazy(() => import('./video-player'))
|
||||
|
||||
export function Embed({
|
||||
content,
|
||||
labels,
|
||||
@@ -389,26 +392,66 @@ function GenericWithImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
// just the thumbnail and a play button
|
||||
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||
let aspectRatio = 1
|
||||
const [activated, setActivated] = useState(content.presentation === 'gif')
|
||||
|
||||
let aspectRatio = 1
|
||||
if (content.aspectRatio) {
|
||||
const {width, height} = content.aspectRatio
|
||||
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
if (activated) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<VideoEmbedFallback
|
||||
content={content}
|
||||
aspectRatio={aspectRatio}
|
||||
loading
|
||||
/>
|
||||
}>
|
||||
<LazyVideoPlayer content={content} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoEmbedFallback
|
||||
content={content}
|
||||
aspectRatio={aspectRatio}
|
||||
onPress={() => setActivated(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoEmbedFallback({
|
||||
content,
|
||||
aspectRatio,
|
||||
onPress,
|
||||
loading,
|
||||
}: {
|
||||
content: AppBskyEmbedVideo.View
|
||||
aspectRatio: number
|
||||
onPress?: () => void
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-xl aspect-square relative"
|
||||
style={{aspectRatio: `${aspectRatio} / 1`}}>
|
||||
className="w-full overflow-hidden rounded-xl relative cursor-pointer"
|
||||
style={{aspectRatio: `${aspectRatio} / 1`}}
|
||||
onClick={onPress}>
|
||||
<img
|
||||
src={content.thumbnail}
|
||||
alt={content.alt}
|
||||
className="object-cover size-full"
|
||||
/>
|
||||
<div className="size-24 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<img src={playIcon} className="object-cover size-3/5" />
|
||||
{loading ? (
|
||||
<div className="video-spinner" />
|
||||
) : (
|
||||
<img src={playIcon} className="object-cover size-3/5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,35 @@
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* Video player spinner */
|
||||
@keyframes video-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.video-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: video-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Firefox scrubber workaround */
|
||||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Volume slider vertical orientation */
|
||||
input[type='range'][orient='vertical'] {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
appearance: slider-vertical;
|
||||
-webkit-appearance: slider-vertical;
|
||||
}
|
||||
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='black' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
|
||||
Reference in New Issue
Block a user