+2
-1
package.json
+2
-1
package.json
+8
pnpm-lock.yaml
+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
+6
src/app.d.ts
+2
-2
src/lib/components/embeds/embeds.svelte
+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
+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
-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
+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
+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
+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
+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
+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
+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
+
})();