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:
Claude
2026-02-15 22:07:37 +00:00
parent 06fcea988e
commit 38d8c03f17
4 changed files with 1135 additions and 6 deletions
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@atproto/api": "^0.15.25",
"hls.js": "^1.6.2",
"preact": "^10.4.8"
},
"devDependencies": {
+49 -6
View File
@@ -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
+29
View File
@@ -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;