diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "drizzle-orm": "^0.30.10", "feed": "^5.1.0", "fractional-indexing": "^3.2.0", + "hls.js": "^1.6.15", "hono": "^4.7.11", "immer": "^10.2.0", "inngest": "^3.52.6", @@ -13358,6 +13359,12 @@ "dependencies": { "hermes-estree": "0.25.1" } + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" }, "node_modules/hono": { "version": "4.7.11", diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "drizzle-orm": "^0.30.10", "feed": "^5.1.0", "fractional-indexing": "^3.2.0", + "hls.js": "^1.6.15", "hono": "^4.7.11", "immer": "^10.2.0", "inngest": "^3.52.6", diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx --- a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -15,6 +15,7 @@ OpenPage, openPage, } from "app/lish/[did]/[publication]/[rkey]/PostPages"; +import { BlueskyVideoPlayer } from "./BlueskyVideoPlayer"; export const BlueskyEmbed = (props: { embed: Exclude; @@ -131,22 +132,13 @@ ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height : 16 / 9; return ( -
- { -
-
- -
-
+ ); case AppBskyEmbedRecord.isView(props.embed): let recordEmbed = props.embed; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx b/components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx new file mode 100644 --- /dev/null +++ b/components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from "react"; + +type Props = { + playlist: string; + thumbnail?: string; + alt?: string; + aspectRatio: number; + className?: string; +}; + +export const BlueskyVideoPlayer = (props: Props) => { + const [playing, setPlaying] = useState(false); + const [loading, setLoading] = useState(false); + const videoRef = useRef(null); + const hlsRef = useRef<{ destroy: () => void } | null>(null); + + useEffect(() => { + if (!playing) return; + const video = videoRef.current; + if (!video) return; + + let cancelled = false; + + // Safari (and some iOS browsers) support HLS natively. + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = props.playlist; + video.play().catch(() => {}); + return () => { + cancelled = true; + video.removeAttribute("src"); + video.load(); + }; + } + + setLoading(true); + import("hls.js") + .then(({ default: Hls }) => { + if (cancelled) return; + if (!Hls.isSupported()) { + // Last-resort fallback: try the native element anyway. + video.src = props.playlist; + video.play().catch(() => {}); + setLoading(false); + return; + } + const hls = new Hls(); + hlsRef.current = hls; + hls.loadSource(props.playlist); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (cancelled) return; + setLoading(false); + video.play().catch(() => {}); + }); + }) + .catch(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + }; + }, [playing, props.playlist]); + + return ( +
+ {!playing && props.thumbnail && ( + {props.alt + )} + {playing && ( +
+ ); +};