JavaScript-optional public web frontend for Bluesky anartia.kelinci.net
sveltekit atcute bluesky typescript svelte
7
fork

Configure Feed

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

refactor: lazily-load hls.js

Mary 1eb1193f 0bdc6615

+176 -62
+94
src/routes/_hls/[actor=did]/[cid=cidRaw]/playlist.m3u8/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + 3 + import type { RequestHandler } from './$types'; 4 + 5 + // Bluesky serves video through two stacks with different tradeoffs: 6 + // 7 + // 1. `video.bsky.app/watch/{actor}/{cid}/playlist.m3u8` — the "middleware" 8 + // service. Always hits origin (no cache-control), injects a rotating 9 + // `?session_id=…` into variant URIs, and is the only source that 10 + // carries `#EXT-X-MEDIA TYPE=SUBTITLES` entries for caption tracks. 11 + // Slow, but complete. 12 + // 13 + // 2. `video.cdn.bsky.app/hls/{actor}/{cid}/…` — BunnyCDN serving the raw 14 + // playlists. Cached (`max-age=10800`), session_id-free, and fast. But 15 + // the stored master playlist omits caption track definitions. 16 + // 17 + // We proxy the middleware master playlist so we don't lose captions, then 18 + // rewrite every variant URI to point at the CDN directly. After our one 19 + // rewrite, the browser fetches variant playlists and `.ts` segments straight 20 + // from BunnyCDN — our worker never sees segment traffic. 21 + // 22 + // Input line shapes we care about: 23 + // 24 + // #EXT-X-STREAM-INF:… (attr line, pass through) 25 + // 360p/video.m3u8?session_id=xxx (variant URI, rewrite to CDN) 26 + // #EXT-X-MEDIA:TYPE=SUBTITLES,…,URI="subs/en.m3u8?session_id=xxx" 27 + // (caption track, strip session_id, 28 + // leave host as-is since captions 29 + // only live on the middleware) 30 + // 31 + // Variant URIs in the middleware master are relative to the master's URL, 32 + // so we resolve against the upstream base before swapping hosts. 33 + 34 + const UPSTREAM_BASE = 'https://video.bsky.app/watch'; 35 + const CDN_BASE = 'https://video.cdn.bsky.app/hls'; 36 + 37 + const URI_ATTR_RE = /URI="([^"]*)"/; 38 + 39 + export const GET: RequestHandler = async ({ params, fetch }) => { 40 + const { actor, cid } = params; 41 + 42 + const upstreamUrl = `${UPSTREAM_BASE}/${actor}/${cid}/playlist.m3u8`; 43 + const upstream = await fetch(upstreamUrl); 44 + 45 + if (!upstream.ok) { 46 + error(upstream.status, `upstream playlist failed: ${upstream.statusText}`); 47 + } 48 + 49 + const source = await upstream.text(); 50 + const rewritten = rewriteMasterPlaylist(source, upstreamUrl); 51 + 52 + return new Response(rewritten, { 53 + headers: { 54 + 'content-type': 'application/vnd.apple.mpegurl', 55 + // cid-addressed content is immutable; cache for a day at the client, 56 + // a week at the edge 57 + 'cache-control': 'public, max-age=86400, s-maxage=604800', 58 + 'access-control-allow-origin': '*', 59 + }, 60 + }); 61 + }; 62 + 63 + const rewriteMasterPlaylist = (source: string, upstreamUrl: string): string => { 64 + const lines = source.split('\n'); 65 + const out: string[] = []; 66 + 67 + for (const line of lines) { 68 + if (line.startsWith('#EXT-X-MEDIA')) { 69 + out.push(line.replace(URI_ATTR_RE, (_, uri) => `URI="${cleanUpstreamUri(uri, upstreamUrl)}"`)); 70 + continue; 71 + } 72 + 73 + if (line === '' || line.startsWith('#')) { 74 + out.push(line); 75 + continue; 76 + } 77 + 78 + out.push(rewriteVariantUri(line, upstreamUrl)); 79 + } 80 + 81 + return out.join('\n'); 82 + }; 83 + 84 + const rewriteVariantUri = (uri: string, upstreamUrl: string): string => { 85 + const url = new URL(uri, upstreamUrl); 86 + url.searchParams.delete('session_id'); 87 + return url.toString().replace(`${UPSTREAM_BASE}/`, `${CDN_BASE}/`); 88 + }; 89 + 90 + const cleanUpstreamUri = (uri: string, upstreamUrl: string): string => { 91 + const url = new URL(uri, upstreamUrl); 92 + url.searchParams.delete('session_id'); 93 + return url.toString(); 94 + };
+77 -56
src/routes/watch/[actor=did]/[cid=cidRaw]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import Hls, { type Fragment as HlsFragment } from 'hls.js'; 2 + import type { default as HlsCtor, Fragment as HlsFragment } from 'hls.js'; 3 3 4 4 import type { PageProps } from './$types'; 5 5 ··· 13 13 return; 14 14 } 15 15 16 - const hls = new Hls({ 17 - capLevelToPlayerSize: true, 18 - startLevel: 1, 19 - xhrSetup(xhr, urlString) { 20 - if (!urlString.endsWith('/playlist.m3u8')) { 21 - urlString = urlString.replace('https://video.bsky.app/watch/', 'https://video.cdn.bsky.app/hls/'); 22 - } 16 + const el = video; 17 + // Safari, iOS, and Chromium desktop 142+ play HLS natively. Everywhere 18 + // else we fall back to hls.js, loaded on demand. 19 + const canPlayNative = el.canPlayType('application/vnd.apple.mpegurl') !== ''; 23 20 24 - const url = new URL(urlString); 21 + let teardown: (() => void) | undefined; 25 22 26 - // Remove `session_id` everywhere 27 - url.searchParams.delete('session_id'); 23 + if (canPlayNative) { 24 + el.src = data.playlistUrl; 25 + teardown = () => { 26 + el.removeAttribute('src'); 27 + el.load(); 28 + }; 29 + } else { 30 + let cancelled = false; 31 + let destroy: (() => void) | undefined; 28 32 29 - xhr.open('get', url.toString()); 30 - }, 31 - }); 33 + import('hls.js').then(({ default: Hls }) => { 34 + if (cancelled || !Hls.isSupported()) { 35 + return; 36 + } 37 + destroy = setupHls(Hls, el, data.playlistUrl); 38 + }); 32 39 33 - hls.loadSource(data.playlistUrl); 34 - hls.attachMedia(video); 40 + teardown = () => { 41 + cancelled = true; 42 + destroy?.(); 43 + }; 44 + } 35 45 36 46 $effect(() => { 37 47 if (!playing) { ··· 43 53 (entries) => { 44 54 const entry = entries[0]; 45 55 if (!entry.isIntersecting) { 46 - video!.pause(); 56 + el.pause(); 47 57 } 48 58 }, 49 59 { threshold: 0.5 }, 50 60 ); 51 61 52 - observer.observe(video!); 62 + observer.observe(el); 53 63 54 64 channel.postMessage('play'); 55 65 channel.addEventListener('message', (event) => { 56 66 if (event.data === 'play') { 57 - video!.pause(); 67 + el.pause(); 58 68 } 59 69 }); 60 70 ··· 64 74 }; 65 75 }); 66 76 67 - // Low-quality fragment flushing 68 - { 69 - let lowQualityFragments: HlsFragment[] = []; 77 + return () => { 78 + playing = false; 79 + teardown?.(); 80 + }; 81 + }); 70 82 71 - hls.on(Hls.Events.FRAG_BUFFERED, (_event, { frag }) => { 72 - if (frag.level === 0) { 73 - lowQualityFragments.push(frag); 74 - } 75 - }); 83 + const setupHls = (Hls: typeof HlsCtor, video: HTMLVideoElement, playlistUrl: string) => { 84 + const hls = new Hls({ 85 + capLevelToPlayerSize: true, 86 + startLevel: 1, 87 + }); 88 + 89 + hls.loadSource(playlistUrl); 90 + hls.attachMedia(video); 76 91 77 - hls.on(Hls.Events.FRAG_CHANGED, (_event, { frag }) => { 78 - if (hls.nextAutoLevel > 0) { 79 - const flushed: HlsFragment[] = []; 92 + // drop buffered low-quality fragments once a higher level takes over, so 93 + // seeking back doesn't reveal the level-0 frames 94 + let lowQualityFragments: HlsFragment[] = []; 80 95 81 - for (const lowQualFrag of lowQualityFragments) { 82 - if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { 83 - continue; 84 - } 96 + hls.on(Hls.Events.FRAG_BUFFERED, (_event, { frag }) => { 97 + if (frag.level === 0) { 98 + lowQualityFragments.push(frag); 99 + } 100 + }); 85 101 86 - hls.trigger(Hls.Events.BUFFER_FLUSHING, { 87 - startOffset: lowQualFrag.start, 88 - endOffset: lowQualFrag.end, 89 - type: 'video', 90 - }); 102 + hls.on(Hls.Events.FRAG_CHANGED, (_event, { frag }) => { 103 + if (hls.nextAutoLevel > 0) { 104 + const flushed: HlsFragment[] = []; 91 105 92 - flushed.push(lowQualFrag); 106 + for (const lowQualFrag of lowQualityFragments) { 107 + if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { 108 + continue; 93 109 } 94 110 95 - lowQualityFragments = lowQualityFragments.filter((f) => !flushed.includes(f)); 96 - } 97 - }); 98 - 99 - video.addEventListener('ended', () => { 100 - if (hls.nextAutoLevel > 0 && lowQualityFragments.length === 1 && lowQualityFragments[0].start === 0) { 101 - const lowQualFrag = lowQualityFragments[0]; 102 - 103 111 hls.trigger(Hls.Events.BUFFER_FLUSHING, { 104 112 startOffset: lowQualFrag.start, 105 113 endOffset: lowQualFrag.end, 106 114 type: 'video', 107 115 }); 108 116 109 - lowQualityFragments = []; 117 + flushed.push(lowQualFrag); 110 118 } 111 - }); 112 - } 119 + 120 + lowQualityFragments = lowQualityFragments.filter((f) => !flushed.includes(f)); 121 + } 122 + }); 123 + 124 + video.addEventListener('ended', () => { 125 + if (hls.nextAutoLevel > 0 && lowQualityFragments.length === 1 && lowQualityFragments[0].start === 0) { 126 + const lowQualFrag = lowQualityFragments[0]; 127 + 128 + hls.trigger(Hls.Events.BUFFER_FLUSHING, { 129 + startOffset: lowQualFrag.start, 130 + endOffset: lowQualFrag.end, 131 + type: 'video', 132 + }); 113 133 114 - return () => { 115 - playing = false; 116 - hls.destroy(); 117 - }; 118 - }); 134 + lowQualityFragments = []; 135 + } 136 + }); 137 + 138 + return () => hls.destroy(); 139 + }; 119 140 </script> 120 141 121 142 {#key data.playlistUrl}
+5 -6
src/routes/watch/[actor=did]/[cid=cidRaw]/+page.ts
··· 1 1 import type { PageLoad } from './$types'; 2 2 3 + import { base } from '$app/paths'; 4 + 3 5 export const ssr = false; 4 6 export const csr = true; 5 7 6 8 export const load: PageLoad = async ({ params }) => { 7 9 return { 8 - // Ideally we should just be using `video.cdn.bsky.app` here for the playlist, 9 - // the problem is that the original M3U8 playlist stored by the CDN doesn't contain 10 - // the caption definitions, they're added in by the middleware service. 11 - // 12 - // We'll replace the subsequent playlist and segment URLs when setting up the player. 13 - playlistUrl: `https://video.bsky.app/watch/${params.actor}/${params.cid}/playlist.m3u8`, 10 + // served by our `/_hls/` proxy route — see +server.ts there for why 11 + // we can't just point at `video.cdn.bsky.app` directly 12 + playlistUrl: `${base}/_hls/${params.actor}/${params.cid}/playlist.m3u8`, 14 13 thumbnailUrl: `https://video.cdn.bsky.app/hls/${params.actor}/${params.cid}/thumbnail.jpg`, 15 14 }; 16 15 };