a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

add bluesky video embed playback

Jared Pereira (Apr 9, 2026, 10:55 PM EDT) daa0c057 d42c684a

+138 -16
+7
package-lock.json
··· 46 46 "drizzle-orm": "^0.30.10", 47 47 "feed": "^5.1.0", 48 48 "fractional-indexing": "^3.2.0", 49 + "hls.js": "^1.6.15", 49 50 "hono": "^4.7.11", 50 51 "immer": "^10.2.0", 51 52 "inngest": "^3.52.6", ··· 13358 13359 "dependencies": { 13359 13360 "hermes-estree": "0.25.1" 13360 13361 } 13362 + }, 13363 + "node_modules/hls.js": { 13364 + "version": "1.6.15", 13365 + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", 13366 + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", 13367 + "license": "Apache-2.0" 13361 13368 }, 13362 13369 "node_modules/hono": { 13363 13370 "version": "4.7.11",
+1
package.json
··· 57 57 "drizzle-orm": "^0.30.10", 58 58 "feed": "^5.1.0", 59 59 "fractional-indexing": "^3.2.0", 60 + "hls.js": "^1.6.15", 60 61 "hono": "^4.7.11", 61 62 "immer": "^10.2.0", 62 63 "inngest": "^3.52.6",
+8 -16
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 15 15 OpenPage, 16 16 openPage, 17 17 } from "app/lish/[did]/[publication]/[rkey]/PostPages"; 18 + import { BlueskyVideoPlayer } from "./BlueskyVideoPlayer"; 18 19 19 20 export const BlueskyEmbed = (props: { 20 21 embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; ··· 131 132 ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height 132 133 : 16 / 9; 133 134 return ( 134 - <div 135 - className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className}`} 136 - style={{ aspectRatio: String(videoAspectRatio) }} 137 - > 138 - <img 139 - src={videoEmbed.thumbnail} 140 - alt={ 141 - "Thumbnail from embedded video. Go to Bluesky to see the full post." 142 - } 143 - className="absolute inset-0 w-full h-full object-cover" 144 - /> 145 - <div className="overlay absolute inset-0 bg-primary opacity-65" /> 146 - <div className="absolute w-max top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-border-light rounded-md"> 147 - <SeePostOnBluesky postUrl={props.postUrl} /> 148 - </div> 149 - </div> 135 + <BlueskyVideoPlayer 136 + playlist={videoEmbed.playlist} 137 + thumbnail={videoEmbed.thumbnail} 138 + alt={videoEmbed.alt} 139 + aspectRatio={videoAspectRatio} 140 + className={props.className} 141 + /> 150 142 ); 151 143 case AppBskyEmbedRecord.isView(props.embed): 152 144 let recordEmbed = props.embed;
+122
components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + 3 + type Props = { 4 + playlist: string; 5 + thumbnail?: string; 6 + alt?: string; 7 + aspectRatio: number; 8 + className?: string; 9 + }; 10 + 11 + export const BlueskyVideoPlayer = (props: Props) => { 12 + const [playing, setPlaying] = useState(false); 13 + const [loading, setLoading] = useState(false); 14 + const videoRef = useRef<HTMLVideoElement | null>(null); 15 + const hlsRef = useRef<{ destroy: () => void } | null>(null); 16 + 17 + useEffect(() => { 18 + if (!playing) return; 19 + const video = videoRef.current; 20 + if (!video) return; 21 + 22 + let cancelled = false; 23 + 24 + // Safari (and some iOS browsers) support HLS natively. 25 + if (video.canPlayType("application/vnd.apple.mpegurl")) { 26 + video.src = props.playlist; 27 + video.play().catch(() => {}); 28 + return () => { 29 + cancelled = true; 30 + video.removeAttribute("src"); 31 + video.load(); 32 + }; 33 + } 34 + 35 + setLoading(true); 36 + import("hls.js") 37 + .then(({ default: Hls }) => { 38 + if (cancelled) return; 39 + if (!Hls.isSupported()) { 40 + // Last-resort fallback: try the native element anyway. 41 + video.src = props.playlist; 42 + video.play().catch(() => {}); 43 + setLoading(false); 44 + return; 45 + } 46 + const hls = new Hls(); 47 + hlsRef.current = hls; 48 + hls.loadSource(props.playlist); 49 + hls.attachMedia(video); 50 + hls.on(Hls.Events.MANIFEST_PARSED, () => { 51 + if (cancelled) return; 52 + setLoading(false); 53 + video.play().catch(() => {}); 54 + }); 55 + }) 56 + .catch(() => { 57 + if (cancelled) return; 58 + setLoading(false); 59 + }); 60 + 61 + return () => { 62 + cancelled = true; 63 + if (hlsRef.current) { 64 + hlsRef.current.destroy(); 65 + hlsRef.current = null; 66 + } 67 + }; 68 + }, [playing, props.playlist]); 69 + 70 + return ( 71 + <div 72 + className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className || ""}`} 73 + style={{ aspectRatio: String(props.aspectRatio) }} 74 + > 75 + {!playing && props.thumbnail && ( 76 + <img 77 + src={props.thumbnail} 78 + alt={props.alt || "Video thumbnail"} 79 + className="absolute inset-0 w-full h-full object-cover" 80 + /> 81 + )} 82 + {playing && ( 83 + <video 84 + ref={videoRef} 85 + className="absolute inset-0 w-full h-full object-cover bg-black" 86 + controls 87 + playsInline 88 + poster={props.thumbnail} 89 + /> 90 + )} 91 + {!playing && ( 92 + <button 93 + type="button" 94 + onClick={(e) => { 95 + e.preventDefault(); 96 + e.stopPropagation(); 97 + setPlaying(true); 98 + }} 99 + aria-label="Play video" 100 + className="absolute inset-0 flex items-center justify-center group" 101 + > 102 + <div className="absolute inset-0 bg-primary opacity-40 group-hover:opacity-30 transition-opacity" /> 103 + <div className="relative flex items-center justify-center w-14 h-14 rounded-full bg-bg-page/90 group-hover:bg-bg-page transition-colors"> 104 + <svg 105 + viewBox="0 0 24 24" 106 + className="w-6 h-6 ml-1" 107 + fill="currentColor" 108 + aria-hidden="true" 109 + > 110 + <path d="M8 5v14l11-7z" /> 111 + </svg> 112 + </div> 113 + </button> 114 + )} 115 + {loading && ( 116 + <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> 117 + <div className="text-white text-xs italic">Loading…</div> 118 + </div> 119 + )} 120 + </div> 121 + ); 122 + };