my website at ewancroft.uk
6
fork

Configure Feed

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

feat(video): support HLS streams using hls.js

Adds `hls.js` and updates BlueskyPostCard to initialise/destroy HLS instances
for `.m3u8` video URLs. Falls back to native playback when supported.

+71 -3
+8 -1
package-lock.json
··· 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.17.2", 12 - "@lucide/svelte": "^0.545.0" 12 + "@lucide/svelte": "^0.545.0", 13 + "hls.js": "^1.5.19" 13 14 }, 14 15 "devDependencies": { 15 16 "@sveltejs/adapter-auto": "^6.1.0", ··· 1570 1571 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 1571 1572 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 1572 1573 "license": "MIT" 1574 + }, 1575 + "node_modules/hls.js": { 1576 + "version": "1.6.14", 1577 + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.14.tgz", 1578 + "integrity": "sha512-CSpT2aXsv71HST8C5ETeVo+6YybqCpHBiYrCRQSn3U5QUZuLTSsvtq/bj+zuvjLVADeKxoebzo16OkH8m1+65Q==", 1579 + "license": "Apache-2.0" 1573 1580 }, 1574 1581 "node_modules/is-reference": { 1575 1582 "version": "3.0.3",
+2 -1
package.json
··· 30 30 }, 31 31 "dependencies": { 32 32 "@atproto/api": "^0.17.2", 33 - "@lucide/svelte": "^0.545.0" 33 + "@lucide/svelte": "^0.545.0", 34 + "hls.js": "^1.5.19" 34 35 } 35 36 }
+61 -1
src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 5 5 import { formatRelativeTime } from '$lib/utils/formatDate'; 6 6 import { formatCompactNumber } from '$lib/utils/formatNumber'; 7 7 import { Heart, Repeat2, MessageCircle, ExternalLink, X } from '@lucide/svelte'; 8 + import Hls from 'hls.js'; 8 9 9 10 let post: BlueskyPost | null = null; 10 11 let loading = true; 11 12 let error: string | null = null; 12 13 let lightboxImage: { url: string; alt: string } | null = null; 13 14 let pollInterval: ReturnType<typeof setInterval> | null = null; 15 + let videoElements = new Map<string, { element: HTMLVideoElement; hls: Hls | null }>(); 14 16 15 17 // Detect system locale, fallback to en-GB 16 18 const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; ··· 51 53 clearInterval(pollInterval); 52 54 pollInterval = null; 53 55 } 56 + // Clean up all HLS instances 57 + videoElements.forEach(({ hls }) => { 58 + if (hls) { 59 + hls.destroy(); 60 + } 61 + }); 62 + videoElements.clear(); 54 63 }); 55 64 56 65 function getPostUrl(uri: string): string { ··· 113 122 div.textContent = text; 114 123 return div.innerHTML; 115 124 } 125 + 126 + function setupVideo(videoElement: HTMLVideoElement, videoUrl: string) { 127 + if (!videoElement || !videoUrl) return; 128 + 129 + // Clean up existing HLS instance for this video 130 + const existing = videoElements.get(videoUrl); 131 + if (existing?.hls) { 132 + existing.hls.destroy(); 133 + } 134 + 135 + let hls: Hls | null = null; 136 + 137 + // Check if HLS is supported 138 + if (videoUrl.includes('.m3u8')) { 139 + if (Hls.isSupported()) { 140 + hls = new Hls({ 141 + enableSoftwareAES: true, 142 + maxBufferLength: 30, 143 + maxMaxBufferLength: 600 144 + }); 145 + hls.loadSource(videoUrl); 146 + hls.attachMedia(videoElement); 147 + hls.on(Hls.Events.MANIFEST_PARSED, () => { 148 + console.log('[HLS] Video ready to play'); 149 + }); 150 + hls.on(Hls.Events.ERROR, (event, data) => { 151 + if (data.fatal) { 152 + console.error('[HLS] Fatal error:', data); 153 + } 154 + }); 155 + videoElements.set(videoUrl, { element: videoElement, hls }); 156 + } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { 157 + // Native HLS support (Safari) 158 + videoElement.src = videoUrl; 159 + videoElements.set(videoUrl, { element: videoElement, hls: null }); 160 + } 161 + } else { 162 + // Regular video file 163 + videoElement.src = videoUrl; 164 + videoElements.set(videoUrl, { element: videoElement, hls: null }); 165 + } 166 + 167 + return { 168 + destroy() { 169 + if (hls) { 170 + hls.destroy(); 171 + } 172 + videoElements.delete(videoUrl); 173 + } 174 + }; 175 + } 116 176 </script> 117 177 118 178 {#snippet postContent(postData: BlueskyPost, depth: number = 0, isReplyParent: boolean = false)} ··· 199 259 {#if postData.hasVideo && postData.videoUrl} 200 260 <div class="{isReplyParent ? 'mb-2' : 'mb-3'} max-w-full overflow-hidden rounded-xl bg-black border border-canvas-300 dark:border-canvas-700"> 201 261 <video 202 - src={postData.videoUrl} 262 + use:setupVideo={postData.videoUrl} 203 263 controls 204 264 class="w-full max-w-full" 205 265 preload="metadata"