lovely. got a couple of partial implementations....
update git ignore kaas remove large file syncy sync
This commit is contained in:
parent
89f526e9d9
commit
98cd4d630c
24 changed files with 586 additions and 76 deletions
29
src/auth.ts
Normal file
29
src/auth.ts
Normal 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()],
|
||||
});
|
39
src/features/player/SampleVideo_1280x720_10mb.captions.vtt
Normal file
39
src/features/player/SampleVideo_1280x720_10mb.captions.vtt
Normal 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
|
BIN
src/features/player/SampleVideo_1280x720_10mb.mp4
Normal file
BIN
src/features/player/SampleVideo_1280x720_10mb.mp4
Normal file
Binary file not shown.
54
src/features/player/SampleVideo_1280x720_10mb.thumbnails.vtt
Normal file
54
src/features/player/SampleVideo_1280x720_10mb.thumbnails.vtt
Normal 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
|
3
src/features/player/controls/volume.module.css
Normal file
3
src/features/player/controls/volume.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.container {
|
||||
display: block grid;
|
||||
}
|
17
src/features/player/controls/volume.tsx
Normal file
17
src/features/player/controls/volume.tsx
Normal 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>
|
||||
);
|
||||
};
|
BIN
src/features/player/overview.jpg
Normal file
BIN
src/features/player/overview.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
|
@ -0,0 +1,5 @@
|
|||
.player {
|
||||
& > video::cue {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
|
@ -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(":");
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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> */}
|
||||
</>;
|
||||
};
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
11
src/features/user/avatar.tsx
Normal file
11
src/features/user/avatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
src/features/user/index.ts
Normal file
1
src/features/user/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Avatar } from "./avatar";
|
5
src/features/user/user.ts
Normal file
5
src/features/user/user.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface User {
|
||||
name: string;
|
||||
email: string;
|
||||
image: string;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
4
src/routes/api/auth/[...action].ts
Normal file
4
src/routes/api/auth/[...action].ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { auth } from "~/auth";
|
||||
import { toSolidStartHandler } from "better-auth/solid-start";
|
||||
|
||||
export const { GET, POST } = toSolidStartHandler(auth);
|
BIN
src/routes/api/content/SampleVideo_1280x720_10mb.mp4
Normal file
BIN
src/routes/api/content/SampleVideo_1280x720_10mb.mp4
Normal file
Binary file not shown.
16
src/routes/api/content/metadata.ts
Normal file
16
src/routes/api/content/metadata.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
};
|
45
src/routes/api/content/stream.ts
Normal file
45
src/routes/api/content/stream.ts
Normal 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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue