add video player
deploy / build-and-deploy (push) Successful in 27s

This commit is contained in:
bytedream committed 2026-10-31 01:16:07 +01:00
1 parent 0fc0024cb6
commit 73d4f6af42
9 files changed
+678 -2

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import '@videojs/html/video/player';
import '@videojs/html/video/skin';
import '@videojs/html/i18n';
import '@videojs/html/i18n/locales/de/register';
interface Props {
src: string;
type?: string;
title?: string;
}
let { src, type, title }: Props = $props();
</script>
<media-i18n lang="de">
<video-player content-title={title}>
<video-skin>
<!-- svelte-ignore a11y_media_has_caption -->
<video {src} {type} playsinline preload="metadata">
<a href={src}>Video kann nicht abgespielt werden</a>
</video>
</video-skin>
</video-player>
</media-i18n>
<style>
media-i18n {
display: block;
}
video-player {
--media-accent-color: var(--color-primary);
--media-border-radius: var(--radius-box);
display: block;
width: 100%;
}
video-skin {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
---
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
import VideoPlayer from '@app/website/video/VideoPlayer.svelte';
import { getVideoMetadata, resolveVideo } from '@util/video.ts';
const { name } = Astro.params;
const video = name ? await resolveVideo(name) : null;
if (!video) return new Response(null, { status: 404 });
const metadata = await getVideoMetadata(video);
if (!metadata) return new Response(null, { status: 404 });
const { title, description } = metadata;
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
const streamUrl = `${basePath}/video/${encodeURIComponent(video.name)}/stream`;
---
<WebsiteLayout title={`${title} – Video`}>
<section class="mx-auto flex w-full max-w-6xl flex-col gap-5 px-4 py-8 lg:py-12">
<div>
<p class="text-sm tracking-widest text-base-content/60 uppercase">Video</p>
<h1 class="mt-1 text-2xl font-semibold break-words lg:text-4xl">{title}</h1>
</div>
<VideoPlayer client:load src={streamUrl} type={video.contentType} title={title} />
{description && (
<p class="max-w-3xl text-base leading-relaxed whitespace-pre-line text-base-content/70">{description}</p>
)}
</section>
</WebsiteLayout>
+12
View File
@@ -0,0 +1,12 @@
import type { APIContext, APIRoute } from 'astro';
import { createVideoResponse, resolveVideo } from '@util/video.ts';
async function handle(context: APIContext, headersOnly = false) {
const video = await resolveVideo(context.params.name ?? '');
if (!video) return new Response(null, { status: 404 });
return createVideoResponse(video, context.request, headersOnly);
}
export const HEAD: APIRoute = (context) => handle(context, true);
export const GET: APIRoute = (context) => handle(context);
+185
View File
@@ -0,0 +1,185 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseFile } from 'music-metadata';
import { VIDEO_PATH } from 'astro:env/server';
import { logger } from './log';
const videoTypes = [
['mp4', 'video/mp4'],
['webm', 'video/webm']
];
export interface ResolvedVideo {
name: string;
filename: string;
path: string;
contentType: string;
size: number;
modifiedAt: Date;
}
export interface VideoRange {
start: number;
end: number;
}
export interface VideoMetadata {
title: string;
description: string | null;
}
interface VideoMetadataCacheEntry {
modifiedAtMs: number;
size: number;
title: string;
description: string | null;
}
const videoMetadataCache = new Map<string, VideoMetadataCacheEntry>();
export async function resolveVideo(name: string): Promise<ResolvedVideo | null> {
if (!VIDEO_PATH || !name) return null;
const directory = path.resolve(VIDEO_PATH);
const safeName = path.basename(name);
if (!safeName) return null;
for (const [extension, contentType] of videoTypes) {
const filename = `${safeName}.${extension}`;
const filePath = path.join(directory, filename);
try {
const stats = await fs.promises.stat(filePath);
if (!stats.isFile()) continue;
return {
name: safeName,
filename,
path: filePath,
contentType,
size: stats.size,
modifiedAt: stats.mtime
};
} catch (e: NodeJS.ErrnoException) {
// check if file doesn't exist
if (e.code === 'ENOENT') {
continue;
}
logger.warn(
{
error: e.message
},
'error while reading video file'
);
}
}
return null;
}
export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetadata | null> {
const modifiedAtMs = video.modifiedAt.getTime();
const cached = videoMetadataCache.get(video.path);
if (cached && cached.modifiedAtMs === modifiedAtMs && cached.size === video.size) {
return cached;
}
let metadata;
try {
const parsed = await parseFile(video.path, {
duration: false,
skipCovers: true
});
metadata = {
title: parsed.common.title?.trim() || video.name,
description:
parsed.common.longDescription?.trim() ||
parsed.common.description?.map((value) => value.trim()).find(Boolean) ||
null
};
} catch {
// couldn't read metadata
return null;
}
videoMetadataCache.set(video.path, {
modifiedAtMs,
size: video.size,
...metadata
});
return metadata;
}
export function parseVideoRange(rangeHeader: string | null, size: number): VideoRange | 'invalid' | null {
if (!rangeHeader) return null;
if (size <= 0) return 'invalid';
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!match) return 'invalid';
const [, startValue, endValue] = match;
if (!startValue && !endValue) return 'invalid';
let start: number;
let end: number;
if (!startValue) {
const suffixLength = Number(endValue);
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return 'invalid';
start = Math.max(size - suffixLength, 0);
end = size - 1;
} else {
start = Number(startValue);
if (!Number.isSafeInteger(start) || start < 0 || start >= size) return 'invalid';
end = endValue ? Number(endValue) : size - 1;
if (!Number.isSafeInteger(end) || end < start) return 'invalid';
end = Math.min(end, size - 1);
}
return { start, end };
}
export function createVideoResponse(video: ResolvedVideo, request: Request, headersOnly = false): Response {
const range = parseVideoRange(request.headers.get('range'), video.size);
if (range === 'invalid') {
return new Response(null, {
status: 416,
headers: {
'Accept-Ranges': 'bytes',
'Content-Range': `bytes */${video.size}`
}
});
}
const start = range?.start ?? 0;
const end = range?.end ?? video.size - 1;
const contentLength = video.size === 0 ? 0 : end - start + 1;
const headers: Record<string, string> = {
'Accept-Ranges': 'bytes',
'Content-Length': contentLength.toString(),
'Content-Type': video.contentType,
'Cache-Control': 'public, max-age=3600',
'Last-Modified': video.modifiedAt.toUTCString()
};
if (range) {
headers['Content-Range'] = `bytes ${start}-${end}/${video.size}`;
}
if (headersOnly) {
return new Response(null, { status: range ? 206 : 200, headers });
}
const stream = fs.createReadStream(video.path, video.size === 0 ? undefined : { start, end });
return new Response(stream as any, { status: range ? 206 : 200, headers });
}