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

feat: hls video player

mary.my.id 637cb47d 11a13c9d

verified
+2 -1
package.json
··· 30 30 "@atcute/bluesky-richtext-parser": "^1.0.7", 31 31 "@atcute/bluesky-richtext-segmenter": "^1.0.5", 32 32 "@atcute/client": "^2.0.7", 33 - "@badrap/valita": "^0.4.2" 33 + "@badrap/valita": "^0.4.2", 34 + "hls.js": "^1.5.20" 34 35 } 35 36 }
+8
pnpm-lock.yaml
··· 23 23 '@badrap/valita': 24 24 specifier: ^0.4.2 25 25 version: 0.4.2 26 + hls.js: 27 + specifier: ^1.5.20 28 + version: 1.5.20 26 29 devDependencies: 27 30 '@sveltejs/adapter-cloudflare': 28 31 specifier: ^5.0.2 ··· 697 700 698 701 glob-to-regexp@0.4.1: 699 702 resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} 703 + 704 + hls.js@1.5.20: 705 + resolution: {integrity: sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==} 700 706 701 707 import-meta-resolve@4.1.0: 702 708 resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} ··· 1432 1438 source-map: 0.6.1 1433 1439 1434 1440 glob-to-regexp@0.4.1: {} 1441 + 1442 + hls.js@1.5.20: {} 1435 1443 1436 1444 import-meta-resolve@4.1.0: {} 1437 1445
+6
src/app.d.ts
··· 12 12 } 13 13 } 14 14 15 + declare module 'svelte/elements' { 16 + export interface AriaAttributes { 17 + 'aria-description'?: string; 18 + } 19 + } 20 + 15 21 export {};
+2 -2
src/lib/components/embeds/embeds.svelte
··· 35 35 import ListEmbed from './list-embed.svelte'; 36 36 import QuoteEmbed from './quote-embed.svelte'; 37 37 import StarterpackEmbed from './starterpack-embed.svelte'; 38 - import VideoEmbed from './video-embed.svelte'; 38 + import VideoStandaloneEmbed from './video-standalone-embed.svelte'; 39 39 40 40 type Embed = NonNullable<AppBskyFeedDefs.PostView['embed']>; 41 41 type MediaEmbed = Brand.Union<AppBskyEmbedExternal.View | AppBskyEmbedImages.View | AppBskyEmbedVideo.View>; ··· 66 66 {:else if embed.$type === 'app.bsky.embed.images#view'} 67 67 <ImageEmbed {embed} standalone /> 68 68 {:else if embed.$type === 'app.bsky.embed.video#view'} 69 - <VideoEmbed {embed} standalone /> 69 + <VideoStandaloneEmbed {embed} /> 70 70 {:else} 71 71 {@render Message(`Unsupported media embed`)} 72 72 {/if}
+3 -3
src/lib/components/embeds/quote-embed.svelte
··· 40 40 import RelativeTime from '$lib/components/islands/relative-time.svelte'; 41 41 42 42 import ImageEmbed from './image-embed.svelte'; 43 - import VideoEmbed from './video-embed.svelte'; 43 + import VideoThumbnailEmbed from './video-thumbnail-embed.svelte'; 44 44 45 45 interface Props { 46 46 embed: AppBskyEmbedRecord.ViewRecord; ··· 94 94 </div> 95 95 {:else if video} 96 96 <div class="aside"> 97 - <VideoEmbed embed={video} blur={false} /> 97 + <VideoThumbnailEmbed embed={video} blur={false} /> 98 98 </div> 99 99 {/if} 100 100 {/if} ··· 109 109 {#if image} 110 110 <ImageEmbed embed={image} borderless blur={false} /> 111 111 {:else if video} 112 - <VideoEmbed embed={video} borderless blur={false} /> 112 + <VideoThumbnailEmbed embed={video} borderless blur={false} /> 113 113 {/if} 114 114 {/if} 115 115 </a>
-108
src/lib/components/embeds/video-embed.svelte
··· 1 - <script lang="ts"> 2 - import type { AppBskyEmbedVideo } from '@atcute/client/lexicons'; 3 - 4 - import PlaySolid from '$lib/components/central-icons/play-solid.svelte'; 5 - 6 - interface Props { 7 - embed: AppBskyEmbedVideo.View; 8 - borderless?: boolean; 9 - standalone?: boolean; 10 - blur?: boolean; 11 - } 12 - 13 - const { embed: video, borderless, standalone, blur }: Props = $props(); 14 - 15 - const ratio = standalone && video.aspectRatio; 16 - </script> 17 - 18 - {#if standalone} 19 - <div class={['video-embed', !borderless && 'is-bordered', standalone && 'is-standalone']}> 20 - <div class="constrainer" style={ratio ? `aspect-ratio: ${ratio.width}/${ratio.height}` : ``}> 21 - {@render Content()} 22 - </div> 23 - </div> 24 - {:else} 25 - <div 26 - class={['video-embed', !borderless && 'is-bordered']} 27 - style={ratio ? `aspect-ratio: ${ratio.width}/${ratio.height}` : ``} 28 - > 29 - {@render Content()} 30 - </div> 31 - {/if} 32 - 33 - {#snippet Content()} 34 - <img loading="lazy" src={video.thumbnail} alt="" class={['thumbnail', blur && 'is-blurred']} /> 35 - 36 - {#if ratio} 37 - <div class="placeholder"></div> 38 - {/if} 39 - 40 - <div class="play"> 41 - <PlaySolid /> 42 - </div> 43 - {/snippet} 44 - 45 - <style> 46 - .video-embed { 47 - position: relative; 48 - background: var(--bg-secondary); 49 - aspect-ratio: 16 / 9; 50 - overflow: hidden; 51 - } 52 - .is-bordered { 53 - border: 1px solid var(--divider-md); 54 - border-radius: 6px; 55 - } 56 - .is-standalone { 57 - align-self: baseline; 58 - aspect-ratio: auto; 59 - max-width: 100%; 60 - } 61 - 62 - .constrainer { 63 - min-width: 64px; 64 - max-width: 100%; 65 - min-height: 64px; 66 - max-height: 320px; 67 - } 68 - 69 - .thumbnail { 70 - width: 100%; 71 - height: 100%; 72 - object-fit: contain; 73 - } 74 - .is-blurred { 75 - scale: 125%; 76 - filter: blur(24px); 77 - } 78 - 79 - .placeholder { 80 - width: 100vw; 81 - height: 100vh; 82 - } 83 - 84 - .play { 85 - display: grid; 86 - position: absolute; 87 - top: 50%; 88 - left: 50%; 89 - place-items: center; 90 - translate: -50% -50%; 91 - border-radius: 50%; 92 - background: rgba(64, 64, 64, 0.6); 93 - aspect-ratio: 1 / 1; 94 - height: 40%; 95 - max-height: 48px; 96 - color: #ffffff; 97 - font-size: 20px; 98 - 99 - :global(.sv-icon) { 100 - width: 40%; 101 - height: 40%; 102 - } 103 - 104 - .is-standalone &:hover { 105 - background: rgba(64, 64, 64, 0.8); 106 - } 107 - } 108 - </style>
+134
src/lib/components/embeds/video-standalone-embed.svelte
··· 1 + <script lang="ts" module> 2 + const MATCH_RE = /\/(did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])\/(bafkrei[2-7a-z]{52})\//; 3 + </script> 4 + 5 + <script lang="ts"> 6 + import { dev } from '$app/environment'; 7 + import { base } from '$app/paths'; 8 + 9 + import type { AppBskyEmbedVideo } from '@atcute/client/lexicons'; 10 + 11 + import PlaySolid from '$lib/components/central-icons/play-solid.svelte'; 12 + import Island from '$lib/components/island.svelte'; 13 + 14 + interface Props { 15 + embed: AppBskyEmbedVideo.View; 16 + blur?: boolean; 17 + } 18 + 19 + const { embed: video }: Props = $props(); 20 + 21 + const ratio = $derived.by(() => { 22 + const aspectRatio = video.aspectRatio; 23 + if (!aspectRatio) { 24 + return undefined; 25 + } 26 + 27 + return `${aspectRatio.width}/${aspectRatio.height}`; 28 + }); 29 + 30 + const videoUrl = $derived.by(() => { 31 + const match = MATCH_RE.exec(decodeURIComponent(video.playlist)); 32 + if (!match) { 33 + return undefined; 34 + } 35 + 36 + return `${base}/watch/${match[1]}/${match[2]}`; 37 + }); 38 + </script> 39 + 40 + {#snippet Content()} 41 + <div class={['video-standalone-embed', 'isl-video-embed', ratio && 'has-ratio']}> 42 + <div class="constrainer" style:aspect-ratio={ratio}> 43 + <a href={videoUrl} aria-label="Play video" class="link"> 44 + <img loading="lazy" src={video.thumbnail} alt={video.alt} class="thumbnail" /> 45 + 46 + <div class="play"> 47 + <PlaySolid /> 48 + </div> 49 + </a> 50 + 51 + <!-- svelte-ignore a11y_missing_attribute --> 52 + <!-- <iframe src={videoUrl} allow="autoplay; fullscreen" height="320" width="180"></iframe> --> 53 + 54 + <div hidden={!ratio} class="hack"></div> 55 + </div> 56 + </div> 57 + {/snippet} 58 + 59 + {#if dev} 60 + {@render Content()} 61 + {:else} 62 + <Island scriptUrl="{base}/_scripts/video-embed.js" fetchPriority="auto"> 63 + {@render Content()} 64 + </Island> 65 + {/if} 66 + 67 + <style> 68 + .video-standalone-embed { 69 + position: relative; 70 + border: 1px solid var(--divider-md); 71 + border-radius: 6px; 72 + background: var(--bg-secondary); 73 + overflow: hidden; 74 + 75 + :global(iframe) { 76 + border: 0; 77 + width: 100%; 78 + height: 100%; 79 + } 80 + } 81 + .has-ratio { 82 + align-self: start; 83 + max-width: 100%; 84 + } 85 + 86 + .constrainer { 87 + aspect-ratio: 16 / 9; 88 + 89 + .has-ratio & { 90 + min-width: 64px; 91 + max-width: 100%; 92 + min-height: 64px; 93 + max-height: 320px; 94 + } 95 + } 96 + .link { 97 + display: block; 98 + width: 100%; 99 + height: 100%; 100 + } 101 + 102 + .thumbnail { 103 + width: 100%; 104 + height: 100%; 105 + object-fit: cover; 106 + font-size: 0; 107 + } 108 + 109 + .play { 110 + display: grid; 111 + position: absolute; 112 + top: 50%; 113 + left: 50%; 114 + place-items: center; 115 + translate: -50% -50%; 116 + border-radius: 50%; 117 + background: rgba(64, 64, 64, 0.6); 118 + aspect-ratio: 1 / 1; 119 + height: 40%; 120 + max-height: 48px; 121 + color: #ffffff; 122 + font-size: 20px; 123 + 124 + :global(.sv-icon) { 125 + width: 40%; 126 + height: 40%; 127 + } 128 + } 129 + 130 + .hack { 131 + width: 100vw; 132 + height: 100vh; 133 + } 134 + </style>
+77
src/lib/components/embeds/video-thumbnail-embed.svelte
··· 1 + <script lang="ts"> 2 + // This is meant to be used inside quote embeds, so it's non-standalone. 3 + 4 + import type { AppBskyEmbedVideo } from '@atcute/client/lexicons'; 5 + 6 + import PlaySolid from '$lib/components/central-icons/play-solid.svelte'; 7 + 8 + interface Props { 9 + embed: AppBskyEmbedVideo.View; 10 + borderless?: boolean; 11 + blur?: boolean; 12 + } 13 + 14 + const { embed: video, borderless }: Props = $props(); 15 + </script> 16 + 17 + <div class={['video-thumbnail-embed', !borderless && 'is-bordered']}> 18 + <div class="constrainer"> 19 + <img loading="lazy" src={video.thumbnail} alt={video.alt} class="thumbnail" /> 20 + 21 + <div class="play"> 22 + <PlaySolid /> 23 + </div> 24 + 25 + <div class="hack"></div> 26 + </div> 27 + </div> 28 + 29 + <style> 30 + .video-thumbnail-embed { 31 + position: relative; 32 + background: var(--bg-secondary); 33 + overflow: hidden; 34 + } 35 + .is-bordered { 36 + border: 1px solid var(--divider-md); 37 + border-radius: 6px; 38 + } 39 + 40 + .constrainer { 41 + aspect-ratio: 16 / 9; 42 + overflow: hidden; 43 + } 44 + 45 + .thumbnail { 46 + width: 100%; 47 + height: 100%; 48 + object-fit: cover; 49 + font-size: 0; 50 + } 51 + 52 + .play { 53 + display: grid; 54 + position: absolute; 55 + top: 50%; 56 + left: 50%; 57 + place-items: center; 58 + translate: -50% -50%; 59 + border-radius: 50%; 60 + background: rgba(64, 64, 64, 0.6); 61 + aspect-ratio: 1 / 1; 62 + height: 40%; 63 + max-height: 48px; 64 + color: #ffffff; 65 + font-size: 20px; 66 + 67 + :global(.sv-icon) { 68 + width: 40%; 69 + height: 40%; 70 + } 71 + } 72 + 73 + .hack { 74 + width: 100vw; 75 + height: 100vh; 76 + } 77 + </style>
+8
src/params/cidRaw.ts
··· 1 + import type { ParamMatcher } from '@sveltejs/kit'; 2 + 3 + // cidv1; multibase=base32; multihash=sha2-256; multicodec=raw 4 + const RAW_CID_RE = /^bafkrei[2-7a-z]{52}$/; 5 + 6 + export const match = ((param: string): param is string => { 7 + return RAW_CID_RE.test(param); 8 + }) as ParamMatcher;
+152
src/routes/watch/[actor=did]/[cid=cidRaw]/+page.svelte
··· 1 + <script lang="ts"> 2 + import Hls, { type Fragment as HlsFragment } from 'hls.js'; 3 + 4 + import type { PageProps } from './$types'; 5 + 6 + const { data }: PageProps = $props(); 7 + 8 + let video: HTMLVideoElement | undefined = $state(); 9 + let playing = $state(false); 10 + 11 + $effect(() => { 12 + if (!video) { 13 + return; 14 + } 15 + 16 + const hls = new Hls({ 17 + capLevelToPlayerSize: true, 18 + startLevel: 1, 19 + }); 20 + 21 + hls.loadSource(data.playlistUrl); 22 + hls.attachMedia(video); 23 + 24 + $effect(() => { 25 + if (!playing) { 26 + return; 27 + } 28 + 29 + const channel = new BroadcastChannel('anartia:video-player'); 30 + const observer = new IntersectionObserver( 31 + (entries) => { 32 + const entry = entries[0]; 33 + if (!entry.isIntersecting) { 34 + video!.pause(); 35 + } 36 + }, 37 + { threshold: 0.5 }, 38 + ); 39 + 40 + observer.observe(video!); 41 + 42 + channel.postMessage('play'); 43 + channel.addEventListener('message', (event) => { 44 + if (event.data === 'play') { 45 + video!.pause(); 46 + } 47 + }); 48 + 49 + return () => { 50 + channel.close(); 51 + observer.disconnect(); 52 + }; 53 + }); 54 + 55 + // Low-quality fragment flushing 56 + { 57 + let lowQualityFragments: HlsFragment[] = []; 58 + 59 + hls.on(Hls.Events.FRAG_BUFFERED, (_event, { frag }) => { 60 + if (frag.level === 0) { 61 + lowQualityFragments.push(frag); 62 + } 63 + }); 64 + 65 + hls.on(Hls.Events.FRAG_CHANGED, (_event, { frag }) => { 66 + if (hls.nextAutoLevel > 0) { 67 + const flushed: HlsFragment[] = []; 68 + 69 + for (const lowQualFrag of lowQualityFragments) { 70 + if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { 71 + continue; 72 + } 73 + 74 + hls.trigger(Hls.Events.BUFFER_FLUSHING, { 75 + startOffset: lowQualFrag.start, 76 + endOffset: lowQualFrag.end, 77 + type: 'video', 78 + }); 79 + 80 + flushed.push(lowQualFrag); 81 + } 82 + 83 + lowQualityFragments = lowQualityFragments.filter((f) => !flushed.includes(f)); 84 + } 85 + }); 86 + 87 + video.addEventListener('ended', () => { 88 + if (hls.nextAutoLevel > 0 && lowQualityFragments.length === 1 && lowQualityFragments[0].start === 0) { 89 + const lowQualFrag = lowQualityFragments[0]; 90 + 91 + hls.trigger(Hls.Events.BUFFER_FLUSHING, { 92 + startOffset: lowQualFrag.start, 93 + endOffset: lowQualFrag.end, 94 + type: 'video', 95 + }); 96 + 97 + lowQualityFragments = []; 98 + } 99 + }); 100 + } 101 + 102 + return () => { 103 + playing = false; 104 + hls.destroy(); 105 + }; 106 + }); 107 + </script> 108 + 109 + {#key data.playlistUrl} 110 + <!-- svelte-ignore a11y_media_has_caption --> 111 + <video 112 + bind:this={video} 113 + poster={data.thumbnailUrl} 114 + controls 115 + playsinline 116 + autoplay 117 + onplay={() => { 118 + playing = true; 119 + }} 120 + onpause={() => { 121 + playing = false; 122 + }} 123 + onloadedmetadata={() => { 124 + const hasAudio = 125 + // @ts-expect-error: Mozilla-specific 126 + video.mozHasAudio || 127 + // @ts-expect-error: WebKit/Blink-specific 128 + !!video.webkitAudioDecodedByteCount || 129 + // @ts-expect-error: WebKit-specific 130 + !!(video.audioTracks && video.audioTracks.length); 131 + 132 + video!.loop = !hasAudio || video!.duration <= 6; 133 + }} 134 + > 135 + </video> 136 + {/key} 137 + 138 + <style> 139 + :global(body) { 140 + margin: 0; 141 + width: 100dvw; 142 + height: 100dvh; 143 + overflow: hidden; 144 + } 145 + 146 + video { 147 + background: #000000; 148 + width: 100%; 149 + height: 100%; 150 + object-fit: contain; 151 + } 152 + </style>
+14
src/routes/watch/[actor=did]/[cid=cidRaw]/+page.ts
··· 1 + import type { PageLoad } from './$types'; 2 + 3 + export const ssr = false; 4 + export const csr = true; 5 + 6 + export const load: PageLoad = async ({ params }) => { 7 + const CDN_URL = `https://video.cdn.bsky.app`; 8 + const BASE_URL = `${CDN_URL}/hls/${params.actor}/${params.cid}`; 9 + 10 + return { 11 + playlistUrl: `${BASE_URL}/playlist.m3u8`, 12 + thumbnailUrl: `${BASE_URL}/thumbnail.jpg`, 13 + }; 14 + };
+67
static/_scripts/video-embed.js
··· 1 + // @ts-check 2 + 3 + /** @type {Map<Element, (entry: ResizeObserverEntry) => void>} */ 4 + const callbacks = new Map(); 5 + 6 + const observer = new ResizeObserver((entries) => { 7 + for (let idx = 0, len = entries.length; idx < len; idx++) { 8 + const entry = entries[idx]; 9 + 10 + const target = entry.target; 11 + const callback = callbacks.get(target); 12 + 13 + if (callback) { 14 + callback(entry); 15 + } else { 16 + observer.unobserve(target); 17 + } 18 + } 19 + }); 20 + 21 + (() => { 22 + /** @type {NodeListOf<HTMLAnchorElement>} */ 23 + const nodes = document.querySelectorAll('.isl-video-embed > .constrainer > .link'); 24 + 25 + for (const anchor of nodes) { 26 + const parent = /** @type {HTMLDivElement} */ (anchor.parentElement); 27 + 28 + // listen for clicks on the anchor 29 + anchor.addEventListener('click', (event) => { 30 + event.preventDefault(); 31 + 32 + // replace the anchor with an iframe 33 + const iframe = document.createElement('iframe'); 34 + iframe.src = anchor.href; 35 + 36 + anchor.replaceWith(iframe); 37 + 38 + // observe the parent element to resize the iframe 39 + callbacks.set(parent, (entry) => { 40 + iframe.width = '' + entry.contentRect.width; 41 + iframe.height = '' + entry.contentRect.width; 42 + }); 43 + 44 + observer.observe(parent); 45 + }); 46 + 47 + // prefetch on hover 48 + { 49 + const controller = new AbortController(); 50 + const signal = controller.signal; 51 + 52 + const prefetch = () => { 53 + const link = document.createElement('link'); 54 + link.rel = 'prefetch'; 55 + link.as = 'document'; 56 + link.href = anchor.href; 57 + 58 + document.head.appendChild(link); 59 + 60 + controller.abort(); 61 + }; 62 + 63 + anchor.addEventListener('mouseover', prefetch, { signal }); 64 + anchor.addEventListener('touchstart', prefetch, { signal }); 65 + } 66 + } 67 + })();