lovely. got a couple of partial implementations....

update git ignore

kaas

remove large file

syncy sync
This commit is contained in:
Chris Kruining 2025-04-03 17:27:35 +02:00 committed by Chris Kruining
parent 89f526e9d9
commit 98cd4d630c
Signed by: chris
SSH key fingerprint: SHA256:nG82MUfuVdRVyCKKWqhY+pCrbz9nbX6uzUns4RKa1Pg
24 changed files with 586 additions and 76 deletions

29
src/auth.ts Normal file
View file

@ -0,0 +1,29 @@
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { createAuthClient } from "better-auth/solid";
import { genericOAuthClient } from "better-auth/client/plugins";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: "authelia",
clientId: "streamarr",
clientSecret:
"ZPuiW2gpVV6MGXIJFk5P3EeSW8V_ICgqduF.hJVCKkrnVmRqIQXRk0o~HSA8ZdCf8joA4m_F",
discoveryUrl:
"https://auth.kruining.eu/.well-known/openid-configuration",
scopes: ["openid", "email", "picture", "profile", "groups"],
accessType: "offline",
pkce: true,
},
],
}),
],
});
export const { signIn, signOut, useSession, ...client } = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [genericOAuthClient()],
});

View file

@ -0,0 +1,39 @@
WEBVTT
00:02.170 --> 00:04.136
Emo, close your eyes
00:04.136 --> 00:05.597
Why?
NOW!
00:05.597 --> 00:07.405
Ok
00:07.405 --> 00:08.803
Good
00:08.803 --> 00:11.541
What do you see at your left side Emo?
00:11.541 --> 00:13.287
Well?
00:13.287 --> 00:16.110
Er nothing?
Really?
00:16.110 --> 00:18.514
No, nothing at all!
00:18.514 --> 00:22.669
Really? and at your right? What do you see at your right side Emo?
00:22.669 --> 00:26.111
Umm, the same Proog
00:26.111 --> 00:28.646
Exactly the same! Nothing!
00:28.646 --> 00:30.794
Great

Binary file not shown.

View file

@ -0,0 +1,54 @@
VTT
1
00:00:00.000 --> 00:00:01.000
overview.jpg#xywh=0,0,320,180
2
00:00:01.000 --> 00:00:02.000
overview.jpg#xywh=320,0,320,180
3
00:00:02.000 --> 00:00:03.000
overview.jpg#xywh=640,0,320,180
00:00:03.000 --> 00:00:04.000
overview.jpg#xywh=960,0,320,180
00:00:04.000 --> 00:00:05.000
overview.jpg#xywh=1280,0,320,180
00:00:05.000 --> 00:00:06.000
overview.jpg#xywh=1600,0,320,180
00:00:06.000 --> 00:00:07.000
overview.jpg#xywh=1920,0,320,180
00:00:07.000 --> 00:00:08.000
overview.jpg#xywh=2240,0,320,180
00:00:08.000 --> 00:00:09.000
overview.jpg#xywh=0,180,320,180
00:00:09.000 --> 00:00:10.000
overview.jpg#xywh=320,180,320,180
00:00:10.000 --> 00:00:11.000
overview.jpg#xywh=640,180,320,180
00:00:11.000 --> 00:00:12.000
overview.jpg#xywh=960,180,320,180
00:00:12.000 --> 00:00:13.000
overview.jpg#xywh=1280,180,320,180
00:00:13.000 --> 00:00:14.000
overview.jpg#xywh=1600,180,320,180
00:00:14.000 --> 00:00:15.000
overview.jpg#xywh=1920,180,320,180
00:00:15.000 --> 00:00:16.000
overview.jpg#xywh=2240,180,320,180

View file

@ -0,0 +1,3 @@
.container {
display: block grid;
}

View file

@ -0,0 +1,17 @@
import { Component, createSignal } from "solid-js";
import css from "./volume.module.css";
interface VolumeProps {
value: number;
}
export const Volume: Component<VolumeProps> = (props) => {
const [volume, setVolume] = createSignal(props.value);
return (
<div class={css.container}>
<button>mute</button>
<input type="range" value={volume()} min="0" max="1" step="0.01" />
</div>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -0,0 +1,5 @@
.player {
& > video::cue {
font-size: 1.5rem;
}
}

View file

@ -12,22 +12,90 @@ import {
onMount,
untrack,
} from "solid-js";
import css from "./player.module.css";
import { Volume } from "./controls/volume";
const metadata = query(async (id: string) => {
"use server";
// thumbnail sprite image created with
// ```bash
// mkdir -p thumbs \
// && ffmpeg -i SampleVideo_1280x720_10mb.mp4 -r 1 -s 320x180 -f image2 thumbs/thumb-%d.jpg \
// && montage thumbs/*.jpg -geometry 320x180 -tile 8x overview.jpg \
// && rm -rf thumbs
// ```
//
// 1. create thumbs directory
// 2. create image every 1 second
// 3. create sprite from images
// 4. remove thumbs
const path = `${import.meta.dirname}/SampleVideo_1280x720_10mb`;
return json({
captions: await Bun.file(`${path}.captions.vtt`).bytes(),
thumbnails: {
track: await Bun.file(`${path}.thumbnails.vtt`).text(),
image: await Bun.file(`${import.meta.dirname}/overview.jpg`).bytes(),
},
});
}, "player.metadata");
interface PlayerProps {
id: string;
}
export const Player: Component<PlayerProps> = (props) => {
const [video, setVideo] = createSignal<HTMLVideoElement>(undefined as unknown as HTMLVideoElement);
const [video, setVideo] = createSignal<HTMLVideoElement>(
undefined as unknown as HTMLVideoElement,
);
const data = createAsync(() => metadata(props.id), {
deferStream: true,
initialValue: {},
});
const captionUrl = createMemo(() => {
const { captions } = data();
const onDurationChange = createEventSignal(video, 'durationchange');
const onTimeUpdate = createEventSignal(video, 'timeupdate');
return captions !== undefined
? URL.createObjectURL(new Blob([captions], { type: "text/vtt" }))
: "";
});
const thumbnails = createMemo(() => {
const { thumbnails } = data();
return thumbnails !== undefined
? URL.createObjectURL(new Blob([thumbnails.track], { type: "text/vtt" }))
: "";
});
createEffect(() => {
const metadata = data();
const el = video();
if (metadata === undefined || el === undefined) {
return;
}
console.log(metadata);
});
createEffect(() => {
thumbnails();
console.log(video()!.textTracks.getTrackById("thumbnails")?.cues);
// const captions = el.addTextTrack("captions", "English", "en");
// captions.
});
const onDurationChange = createEventSignal(video, "durationchange");
const onTimeUpdate = createEventSignal(video, "timeupdate");
const duration = createMemo(() => {
onDurationChange();
onTimeUpdate();
return video()?.duration ?? 100;
return video()?.duration ?? 0;
});
const currentTime = createMemo(() => {
@ -36,10 +104,6 @@ export const Player: Component<PlayerProps> = (props) => {
return video()?.currentTime ?? 0;
});
createEffect(() => {
console.log(duration(), currentTime());
});
createEventListenerMap(() => video()!, {
durationchange(e) {
console.log("durationchange", e);
@ -60,7 +124,11 @@ export const Player: Component<PlayerProps> = (props) => {
console.log("seeking", e);
},
stalled(e) {
console.log("stalled (meaning downloading data failed)", e, video()!.error);
console.log(
"stalled (meaning downloading data failed)",
e,
video()!.error,
);
},
play(e) {
@ -107,17 +175,52 @@ export const Player: Component<PlayerProps> = (props) => {
};
return (
<>
<figure class={css.player}>
<h1>{props.id}</h1>
<video ref={setVideo} width="1280px" height="720px" muted src="/api/stream/video" />
<video
ref={setVideo}
muted
autoplay
controls
src={`/api/content/stream?id=${props.id}`}
lang="en"
>
<track
default
kind="captions"
label="English"
srclang="en"
src={captionUrl()}
/>
<track default kind="chapters" src={thumbnails()} id="thumbnails" />
{/* <track kind="captions" />
<track kind="chapters" />
<track kind="descriptions" />
<track kind="metadata" />
<track kind="subtitles" /> */}
</video>
<figcaption>
<Volume value={0.5} />
</figcaption>
<button onclick={toggle}>play/pause</button>
<span style={{ '--duration': duration(), '--current-time': currentTime() }} />
<span data-duration={duration()} data-current-time={currentTime()} />
<progress max={duration()} value={currentTime()} />
</>
<span>
{formatTime(currentTime())} / {formatTime(duration())}
</span>
<progress max={duration().toFixed(0)} value={currentTime().toFixed(0)} />
</figure>
);
};
const formatTime = (subject: number) => {
const hours = Math.floor(subject / 3600);
const minutes = Math.floor((subject % 3600) / 60);
const seconds = Math.floor(subject % 60);
const sections = hours !== 0 ? [hours, minutes, seconds] : [minutes, seconds];
return sections.map((section) => String(section).padStart(2, "0")).join(":");
};

View file

@ -1,10 +1,64 @@
import { Component } from "solid-js";
import { Component, createEffect, createMemo, Show } from "solid-js";
import { ColorSchemePicker } from "../theme";
import { signIn, signOut, useSession } from "~/auth";
import { hash } from "~/utilities";
import css from "./top.module.css";
export const Top: Component = (props) => {
const session = useSession();
const hashedEmail = hash("SHA-256", () => session().data?.user.email);
const login = async () => {
const response = await signIn.oauth2({
providerId: "authelia",
callbackURL: "/",
});
console.log("signin response", response);
};
const logout = async () => {
const response = await signOut();
console.log("signout response", response);
};
createEffect(() => {
console.log(hashedEmail());
});
return (
<aside class={css.top}>
<Show
when={session().isPending === false && session().isRefetching === false}
>
<Show
when={session().data?.user}
fallback={
<form method="post" onSubmit={login}>
<button type="submit">Sign in</button>
</form>
}
>
{(user) => (
<>
<div>
<img
src={
user().image ??
`https://www.gravatar.com/avatar/${hashedEmail()}`
}
/>
<span>{user().name}</span>
<span>{user().email}</span>
</div>
<form method="post" onSubmit={logout}>
<button type="submit">Log out</button>
</form>
</>
)}
</Show>
</Show>
<ColorSchemePicker />
</aside>
);

View file

@ -1,43 +1,69 @@
import { WiMoonAltFirstQuarter, WiMoonAltFull, WiMoonAltNew } from "solid-icons/wi";
import { Component, createEffect, For, Match, on, Setter, Switch } from "solid-js";
import {
WiMoonAltFirstQuarter,
WiMoonAltFull,
WiMoonAltNew,
} from "solid-icons/wi";
import {
Component,
createEffect,
For,
Match,
on,
Setter,
Switch,
} from "solid-js";
import { ColorScheme, useTheme } from "./context";
import css from './picker.module.css';
import css from "./picker.module.css";
import { Select } from "~/components/select";
const colorSchemes: Record<ColorScheme, keyof typeof ColorScheme> = Object.fromEntries(Object.entries(ColorScheme).map(([k, v]) => [v, k])) as any;
const colorSchemes: Record<ColorScheme, keyof typeof ColorScheme> =
Object.fromEntries(
Object.entries(ColorScheme).map(([k, v]) => [v, k]),
) as any;
export const ColorSchemePicker: Component = (props) => {
const themeContext = useTheme();
const themeContext = useTheme();
const setScheme: Setter<ColorScheme> = (next) => {
const setScheme: Setter<ColorScheme> = (next) => {
if (typeof next === "function") {
next = next();
}
if (typeof next === 'function') {
next = next();
}
themeContext.setColorScheme(next);
};
themeContext.setColorScheme(next);
};
return (
<>
<label aria-label="Color scheme picker">
<Select
id="color-scheme-picker"
class={css.picker}
value={themeContext.theme.colorScheme}
setValue={setScheme}
values={colorSchemes}
>
{(k, v) => (
<>
<Switch>
<Match when={k === ColorScheme.Auto}>
<WiMoonAltFirstQuarter />
</Match>
<Match when={k === ColorScheme.Light}>
<WiMoonAltNew />
</Match>
<Match when={k === ColorScheme.Dark}>
<WiMoonAltFull />
</Match>
</Switch>
{v}
</>
)}
</Select>
</label>
createEffect(on(() => themeContext.theme.colorScheme, (colorScheme) => {
console.log(colorScheme);
}));
return <>
<label aria-label="Color scheme picker">
<Select id="color-scheme-picker" class={css.picker} value={themeContext.theme.colorScheme} setValue={setScheme} values={colorSchemes}>{
(k, v) => <>
<Switch>
<Match when={k === ColorScheme.Auto}><WiMoonAltFirstQuarter /></Match>
<Match when={k === ColorScheme.Light}><WiMoonAltNew /></Match>
<Match when={k === ColorScheme.Dark}><WiMoonAltFull /></Match>
</Switch>
{v}
</>
}</Select>
</label>
{/* <label class={css.hue} aria-label="Hue slider">
{/* <label class={css.hue} aria-label="Hue slider">
<input type="range" min="0" max="360" value={theme.hue} onInput={e => setHue(e.target.valueAsNumber)} />
</label> */}
</>;
};
</>
);
};

View file

@ -0,0 +1,11 @@
import { Component, createMemo, Show } from "solid-js";
export const Avatar: Component = (props) => {
const src = createMemo(() => "");
return (
<Show when={src()}>
<img src={src()} />
</Show>
);
};

View file

@ -0,0 +1 @@
export { Avatar } from "./avatar";

View file

@ -0,0 +1,5 @@
export interface User {
name: string;
email: string;
image: string;
}

View file

@ -1,5 +1,5 @@
import { Meta } from "@solidjs/meta";
import { query, createAsync, action } from "@solidjs/router";
import { createEffect, on, ParentProps } from "solid-js";
import { Shell } from "~/features/shell";
import { useTheme } from "~/features/theme";
@ -7,13 +7,20 @@ import { useTheme } from "~/features/theme";
export default function ShellPage(props: ParentProps) {
const themeContext = useTheme();
createEffect(on(() => themeContext.theme.colorScheme, (colorScheme) => {
document.documentElement.dataset.theme = colorScheme;
}));
createEffect(
on(
() => themeContext.theme.colorScheme,
(colorScheme) => {
document.documentElement.dataset.theme = colorScheme;
},
),
);
return <Shell>
<Meta name="color-scheme" content={themeContext.theme.colorScheme} />
return (
<Shell>
<Meta name="color-scheme" content={themeContext.theme.colorScheme} />
{props.children}
</Shell>;
{props.children}
</Shell>
);
}

View file

@ -1,10 +1,17 @@
import { json, Params, query, redirect, RouteDefinition, useParams } from "@solidjs/router";
import {
json,
Params,
query,
redirect,
RouteDefinition,
useParams,
} from "@solidjs/router";
import { createSlug, getEntry } from "~/features/content";
import { Player } from "~/features/player";
import { toSlug } from "~/utilities";
const healUrl = query(async (slug: string) => {
const entry = await getEntry(slug.slice(slug.lastIndexOf('-') + 1));
const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1));
if (entry === undefined) {
return json(null, { status: 404 });
@ -17,11 +24,10 @@ const healUrl = query(async (slug: string) => {
}
throw redirect(`/watch/${actualSlug}`);
}, 'watch.heal');
}, "watch.heal");
interface ItemParams extends Params {
title: string;
id: string;
slug: string;
}
export const route = {
@ -31,11 +37,12 @@ export const route = {
} satisfies RouteDefinition;
export default function Item() {
const params = useParams<ItemParams>();
const { slug } = useParams<ItemParams>();
const id = slug.slice(slug.lastIndexOf("-") + 1);
return (
<>
<Player id={params.id} />
<Player id={id} />
</>
);
}

View file

@ -0,0 +1,4 @@
import { auth } from "~/auth";
import { toSolidStartHandler } from "better-auth/solid-start";
export const { GET, POST } = toSolidStartHandler(auth);

Binary file not shown.

View file

@ -0,0 +1,16 @@
import { json } from "@solidjs/router";
import { APIEvent } from "@solidjs/start/server";
export const GET = async (event: APIEvent) => {
console.log(event.params);
const path = `${import.meta.dirname}/SampleVideo_1280x720_10mb`;
return json({
captions: await Bun.file(`${path}.captions.vtt`).bytes(),
thumbnails: {
track: await Bun.file(`${path}.thumbnails.vtt`).text(),
image: await Bun.file(`${import.meta.dirname}/overview.jpg`).bytes(),
},
});
};

View file

@ -0,0 +1,45 @@
import { APIEvent } from "@solidjs/start/server";
const CHUNK_SIZE = 1 * 1e6; // 1MB
export const GET = async ({ request, ...event }: APIEvent) => {
"use server";
const range = request.headers.get("range");
if (range === null) {
return new Response("Requires Range header", { status: 400 });
}
try {
const file = Bun.file(
import.meta.dirname + "/SampleVideo_1280x720_10mb.mp4",
);
if ((await file.exists()) !== true) {
return new Response("File not found", { status: 404 });
}
const videoSize = file.size;
const start = Number.parseInt(range.replace(/\D/g, ""));
const end = Math.min(start + CHUNK_SIZE, videoSize - 1);
const contentLength = end - start + 1;
return new Response(file.stream());
// return new Response(video.slice(start, end).stream(), {
// status: 206,
// headers: {
// 'Accept-Ranges': 'bytes',
// 'Content-Range': `bytes ${start}-${end}/${videoSize}`,
// 'Content-Length': `${contentLength}`,
// 'Content-type': 'video/mp4',
// },
// });
} catch (e) {
console.error(e);
throw e;
}
};

View file

@ -1,15 +1,46 @@
export const splitAt = (subject: string, index: number): readonly [string, string] => {
if (index < 0) {
return [subject, ''];
}
import { Accessor, createEffect, createSignal, on } from "solid-js";
if (index > subject.length) {
return [subject, ''];
}
export const splitAt = (
subject: string,
index: number,
): readonly [string, string] => {
if (index < 0) {
return [subject, ""];
}
return [subject.slice(0, index), subject.slice(index + 1)];
if (index > subject.length) {
return [subject, ""];
}
return [subject.slice(0, index), subject.slice(index + 1)];
};
export const toSlug = (subject: string) => {
return subject.toLowerCase().replaceAll(' ', '-');
};
export const toSlug = (subject: string) =>
subject.toLowerCase().replaceAll(" ", "-");
export const toHex = (subject: number) => subject.toString(16).padStart(2, "0");
const encoder = new TextEncoder();
export const hash = (
algorithm: AlgorithmIdentifier,
subject: Accessor<string | null | undefined>,
) => {
const [hash, setHash] = createSignal<string>();
createEffect(
on(subject, async (subject) => {
if (subject === null || subject === undefined || subject.length === 0) {
setHash(undefined);
return;
}
const buffer = new Uint8Array(
await crypto.subtle.digest(algorithm, encoder.encode(subject)),
);
setHash(Array.from(buffer).map(toHex).join(""));
}),
);
return hash;
};