woooot, got streaming via the jellyfin api working!

This commit is contained in:
Chris Kruining 2025-05-28 13:16:54 +02:00
parent 826a30f95f
commit d96f89d4b3
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
21 changed files with 282 additions and 169 deletions

View file

@ -7,30 +7,31 @@
"@solid-primitives/context": "^0.3.1", "@solid-primitives/context": "^0.3.1",
"@solid-primitives/deep": "^0.3.2", "@solid-primitives/deep": "^0.3.2",
"@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/event-listener": "^2.4.1",
"@solid-primitives/fullscreen": "^1.3.1",
"@solid-primitives/pagination": "^0.4.1", "@solid-primitives/pagination": "^0.4.1",
"@solid-primitives/scheduled": "^1.5.1", "@solid-primitives/scheduled": "^1.5.1",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.1.4", "@solidjs/start": "^1.1.4",
"better-auth": "^1.2.7", "better-auth": "^1.2.8",
"bindings": "^1.5.0", "bindings": "^1.5.0",
"open-props": "^1.7.15", "open-props": "^1.7.15",
"openapi-fetch": "^0.13.8", "openapi-fetch": "^0.13.8",
"sitemap": "^8.0.0", "sitemap": "^8.0.0",
"solid-icons": "^1.1.0", "solid-icons": "^1.1.0",
"solid-js": "^1.9.6", "solid-js": "^1.9.7",
"vinxi": "^0.5.6", "vinxi": "^0.5.6",
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"browserslist": "^4.24.5", "browserslist": "^4.24.5",
"bun-types": "^1.2.13", "bun-types": "^1.2.14",
"lightningcss": "^1.30.1", "lightningcss": "^1.30.1",
"openapi-typescript": "^7.8.0", "openapi-typescript": "^7.8.0",
"solid-devtools": "^0.33.0", "solid-devtools": "^0.33.0",
"vite-plugin-solid-svg": "^0.8.1", "vite-plugin-solid-svg": "^0.8.1",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.3", "vitest": "^3.1.4",
}, },
}, },
}, },
@ -344,6 +345,8 @@
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.1", "", { "dependencies": { "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Xc/lBCeuh9LwzR4lYbMDtopwWK7N9b4o+FmI4uoI8DOtVGYi0Ip20DG8PtwHk+g31lHgvwtFFVKfnUx2UaqZJg=="], "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.1", "", { "dependencies": { "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Xc/lBCeuh9LwzR4lYbMDtopwWK7N9b4o+FmI4uoI8DOtVGYi0Ip20DG8PtwHk+g31lHgvwtFFVKfnUx2UaqZJg=="],
"@solid-primitives/fullscreen": ["@solid-primitives/fullscreen@1.3.1", "", { "dependencies": { "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMHIPekaNuBQk8YVuHQa28ExZnC7xdGLhBI7MTEY38pYcIQGcTMQ4B6zpZhEkRcoYudaFHSGDBLCcHlELV7f3g=="],
"@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/rootless": "^1.5.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-0QX9O3eUaQorNNmXZn8a4efSByayIScVq+iGSwheD7m3SL/ACLM5oZlCNpTPLcemnVVfUPAHFiViEj86XpN5qw=="], "@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/rootless": "^1.5.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-0QX9O3eUaQorNNmXZn8a4efSByayIScVq+iGSwheD7m3SL/ACLM5oZlCNpTPLcemnVVfUPAHFiViEj86XpN5qw=="],
"@solid-primitives/media": ["@solid-primitives/media@2.3.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/rootless": "^1.5.0", "@solid-primitives/static-store": "^0.1.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-7+C3wfbWnGE/WPoNsqcp/EeOP2aNNB92RCpsWhBth8E5lZo/J+rK6jMb7umVsK0zguT8HBpeXp1pFyFbcsHStA=="], "@solid-primitives/media": ["@solid-primitives/media@2.3.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/rootless": "^1.5.0", "@solid-primitives/static-store": "^0.1.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-7+C3wfbWnGE/WPoNsqcp/EeOP2aNNB92RCpsWhBth8E5lZo/J+rK6jMb7umVsK0zguT8HBpeXp1pFyFbcsHStA=="],

View file

@ -17,6 +17,7 @@
"@solid-primitives/context": "^0.3.1", "@solid-primitives/context": "^0.3.1",
"@solid-primitives/deep": "^0.3.2", "@solid-primitives/deep": "^0.3.2",
"@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/event-listener": "^2.4.1",
"@solid-primitives/fullscreen": "^1.3.1",
"@solid-primitives/pagination": "^0.4.1", "@solid-primitives/pagination": "^0.4.1",
"@solid-primitives/scheduled": "^1.5.1", "@solid-primitives/scheduled": "^1.5.1",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",

3
public/favicon-dark.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="white">
<path d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM48 368l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zm368-16c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zM48 240l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zm368-16c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zM48 112l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16L64 96c-8.8 0-16 7.2-16 16zM416 96c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zM160 128l0 64c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32L192 96c-17.7 0-32 14.3-32 32zm32 160c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-128 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
public/favicon-light.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="black">
<path d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM48 368l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zm368-16c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zM48 240l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zm368-16c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zM48 112l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16L64 96c-8.8 0-16 7.2-16 16zM416 96c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zM160 128l0 64c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32L192 96c-17.7 0-32 14.3-32 32zm32 160c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-128 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -21,10 +21,6 @@ export function Hero(props: HeroProps) {
const Page: Component<{ entry: Entry }> = (props) => { const Page: Component<{ entry: Entry }> = (props) => {
const slug = createMemo(() => createSlug(props.entry)); const slug = createMemo(() => createSlug(props.entry));
createEffect(() => {
// console.log(props.entry);
});
return ( return (
<div <div
class={`${css.page}`} class={`${css.page}`}

View file

@ -8,7 +8,16 @@ export default createHandler(() => (
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link
rel="icon"
href="/favicon-light.svg"
media="(prefers-color-scheme:light)"
/>
<link
rel="icon"
href="/favicon-dark.svg"
media="(prefers-color-scheme:dark)"
/>
{assets} {assets}
</head> </head>
<body> <body>

View file

@ -57,6 +57,41 @@ export const listUsers = query(async () => {
return data ?? []; return data ?? [];
}, "jellyfin.listUsers"); }, "jellyfin.listUsers");
export const listItemIds = query(
async (): Promise<Record<string, { jellyfin: string }>> => {
"use server";
const { data, error } = await getClient().GET("/Items", {
params: {
query: {
hasImdbId: true,
recursive: true,
includeItemTypes: ["Movie", "Series"],
fields: [
"ProviderIds",
"Genres",
"DateLastMediaAdded",
"DateCreated",
"MediaSources",
],
},
},
});
if (data === undefined) {
return {};
}
return Object.fromEntries(
data.Items?.map((item) => ([
item.ProviderIds!["Tmdb"]!,
{ jellyfin: item.Id! },
])) ?? []
);
},
"jellyfin.listItemIds",
);
export const listItems = query( export const listItems = query(
async (userId: string): Promise<Entry[] | undefined> => { async (userId: string): Promise<Entry[] | undefined> => {
"use server"; "use server";
@ -189,28 +224,28 @@ export const getItem = query(
export const getItemStream = query( export const getItemStream = query(
async (userId: string, itemId: string): Promise<string | undefined> => { async (itemId: string, range: string): Promise<Response> => {
"use server"; "use server";
const item = await getItem(userId, itemId); // I don't really know what is the big difference between mp4 and mkv.
// But mkv is able to use ranges an can report the video's length, whereas mp4 doesn't
console.log(item); const { response } = await getClient().GET("/Videos/{itemId}/stream", {
if (item === undefined) {
return undefined;
}
const { data, error } = await getClient().GET("/Videos/{itemId}/stream", {
params: { params: {
path: { path: {
itemId: item.providers.jellyfin, itemId,
}, },
query: { query: {
static: true,
container: 'mkv',
}, },
}, },
parseAs: 'stream',
headers: {
Range: range
}
}); });
return data; return response;
}, },
"jellyfin.getItemStream", "jellyfin.getItemStream",
); );
@ -285,23 +320,6 @@ export const queryItems = query(async () => {
console.log(data); console.log(data);
}, "jellyfin.queryItems"); }, "jellyfin.queryItems");
export const getItemIds = query(async () => {
"use server";
const { data, error } = await getClient().GET("/Items", {
params: {
query: {
mediaTypes: ["Video"],
fields: ["ProviderIds"],
includeItemTypes: ["Series", "Movie"],
recursive: true,
},
},
});
console.log(data);
}, "jellyfin.getItemIds");
export const getContinueWatching = query( export const getContinueWatching = query(
async (userId: string): Promise<Entry[]> => { async (userId: string): Promise<Entry[]> => {
"use server"; "use server";

View file

@ -41,8 +41,6 @@ export const getEntry = query(
}, },
}); });
console.log(data);
if (data === undefined) { if (data === undefined) {
return undefined; return undefined;
} }

View file

@ -2,7 +2,7 @@
import type { Category, Entry } from "./types"; import type { Category, Entry } from "./types";
import { query } from "@solidjs/router"; import { query } from "@solidjs/router";
import { getContinueWatching, getItemStream, getRandomItems } from "./apis/jellyfin"; import { listItemIds, getContinueWatching, getItemStream, getRandomItems } from "./apis/jellyfin";
import { import {
getDiscovery, getDiscovery,
getRecommendations, getRecommendations,
@ -12,9 +12,15 @@ import {
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763"; const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
const lookupTable = query(async () => listItemIds(), 'content.lookupTable');
// export const getHighlights = () => getRandomItems(jellyfinUserId); // export const getHighlights = () => getRandomItems(jellyfinUserId);
export const getHighlights = () => getContinueWatching(jellyfinUserId); export const getHighlights = () => getContinueWatching(jellyfinUserId);
export const getStream = (id: string) => getItemStream(jellyfinUserId, id); export const getStream = query(async (id: string, range: string) => {
const table = await lookupTable();
return getItemStream(table[id].jellyfin, range);
}, 'content.stream');
export const listCategories = query(async (): Promise<Category[]> => { export const listCategories = query(async (): Promise<Category[]> => {
return [ return [
@ -31,7 +37,6 @@ export const listCategories = query(async (): Promise<Category[]> => {
export const getEntry = query( export const getEntry = query(
async (id: Entry["id"]): Promise<Entry | undefined> => { async (id: Entry["id"]): Promise<Entry | undefined> => {
return getTmdbEntry(id); return getTmdbEntry(id);
// return getItem(jellyfinUserId, id);
}, },
"content.get", "content.get",
); );

View file

@ -6,6 +6,7 @@ import {
import { Accessor, createEffect, onMount, Setter } from "solid-js"; import { Accessor, createEffect, onMount, Setter } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { createEventListenerMap } from "@solid-primitives/event-listener"; import { createEventListenerMap } from "@solid-primitives/event-listener";
import { createFullscreen } from "@solid-primitives/fullscreen";
type State = "playing" | "paused"; type State = "playing" | "paused";
@ -15,9 +16,14 @@ type Volume = {
}; };
export interface VideoAPI { export interface VideoAPI {
readonly fullscreen: Accessor<boolean>;
readonly setFullscreen: Setter<boolean>;
readonly loading: Accessor<boolean>;
readonly duration: Accessor<number>; readonly duration: Accessor<number>;
readonly buffered: Accessor<number>; readonly buffered: Accessor<number>;
readonly currentTime: Accessor<number>; readonly currentTime: Accessor<number>;
setTime(time: number): void; setTime(time: number): void;
readonly state: { readonly state: {
@ -37,10 +43,13 @@ export interface VideoAPI {
} }
interface VideoProviderProps extends ContextProviderProps { interface VideoProviderProps extends ContextProviderProps {
root: HTMLElement | undefined;
video: HTMLVideoElement | undefined; video: HTMLVideoElement | undefined;
} }
interface VideoStore { interface VideoStore {
fullscreen: boolean;
loading: boolean;
duration: number; duration: number;
buffered: number; buffered: number;
currentTime: number; currentTime: number;
@ -55,6 +64,8 @@ export const [VideoProvider, useVideo] = createContextProvider<
(props) => { (props) => {
const video = props.video; const video = props.video;
const [store, setStore] = createStore<VideoStore>({ const [store, setStore] = createStore<VideoStore>({
fullscreen: false,
loading: true,
duration: 0, duration: 0,
buffered: 0, buffered: 0,
currentTime: 0, currentTime: 0,
@ -65,7 +76,17 @@ export const [VideoProvider, useVideo] = createContextProvider<
}, },
}); });
const fullscreen = createFullscreen(
() => props.root,
() => store.fullscreen
);
const api: VideoAPI = { const api: VideoAPI = {
fullscreen,
setFullscreen: setStore.bind(null, "fullscreen"),
loading: () => store.loading,
duration: () => store.duration, duration: () => store.duration,
buffered: () => store.buffered, buffered: () => store.buffered,
currentTime: () => store.currentTime, currentTime: () => store.currentTime,
@ -147,11 +168,22 @@ export const [VideoProvider, useVideo] = createContextProvider<
timeRanges.length > 0 ? timeRanges.end(timeRanges.length - 1) : 0 timeRanges.length > 0 ? timeRanges.end(timeRanges.length - 1) : 0
); );
}, },
canplay() {
setStore("loading", false);
},
waiting() {
setStore("loading", true);
},
}); });
return api; return api;
}, },
{ {
fullscreen: () => false,
setFullscreen() {},
loading: () => false,
duration: () => 0, duration: () => 0,
buffered: () => 0, buffered: () => 0,
currentTime: () => 0, currentTime: () => 0,

View file

@ -0,0 +1,15 @@
import { Component, Show } from "solid-js";
import { useVideo } from "../context";
import { FaSolidCompress, FaSolidExpand } from "solid-icons/fa";
export const Fullscreen: Component<{}> = (props) => {
const video = useVideo();
return (
<button onclick={(e) => video.setFullscreen((last) => !last)}>
<Show when={video.fullscreen()} fallback={<FaSolidExpand />}>
<FaSolidCompress />
</Show>
</button>
);
};

View file

@ -1,13 +0,0 @@
.play {
font-size: var(--size-7);
text-shadow: 0 0 .5rem #000;
aspect-ratio: 1;
background-color: transparent;
border-radius: var(--radius-2);
transition: background-color .2s var(--ease-in-out-1);
&:hover {
background-color: rgba(from var(--gray-2) r g b / .25);
}
}

View file

@ -1,14 +1,12 @@
import { Component, Show } from "solid-js"; import { Component, Show } from "solid-js";
import { useVideo } from "../context"; import { useVideo } from "../context";
import { FaSolidPause, FaSolidPlay } from "solid-icons/fa"; import { FaSolidPause, FaSolidPlay } from "solid-icons/fa";
import css from "./playState.module.css";
export const PlayState: Component<{}> = (props) => { export const PlayState: Component<{}> = (props) => {
const video = useVideo(); const video = useVideo();
return ( return (
<button <button
class={css.play}
onclick={(e) => onclick={(e) =>
video.state.setState((last) => video.state.setState((last) =>
last === "playing" ? "paused" : "playing" last === "playing" ? "paused" : "playing"

View file

@ -0,0 +1,19 @@
import { Component } from "solid-js";
import { useVideo } from "../context";
import { FaSolidEllipsisVertical } from "solid-icons/fa";
export const Settings: Component<{}> = (props) => {
const video = useVideo();
return (
<button
onclick={(e) =>
video.state.setState((last) =>
last === "playing" ? "paused" : "playing"
)
}
>
<FaSolidEllipsisVertical />
</button>
);
};

View file

@ -1,13 +1,9 @@
import { Component, Show } from "solid-js"; import { Component, Show } from "solid-js";
import css from "./volume.module.css";
import { useVideo } from "../context"; import { useVideo } from "../context";
import { FaSolidVolumeOff, FaSolidVolumeXmark } from "solid-icons/fa"; import { FaSolidVolumeOff, FaSolidVolumeXmark } from "solid-icons/fa";
import css from "./volume.module.css";
interface VolumeProps { interface VolumeProps {}
value: number;
muted?: boolean;
onInput?: (next: { volume: number; muted: boolean }) => any;
}
export const Volume: Component<VolumeProps> = (props) => { export const Volume: Component<VolumeProps> = (props) => {
const video = useVideo(); const video = useVideo();

View file

@ -10,6 +10,7 @@
block-size: max-content; block-size: max-content;
background-color: black; background-color: black;
color: var(--gray-3);
& > video::cue { & > video::cue {
font-size: 1.5rem; font-size: 1.5rem;
@ -25,8 +26,8 @@
& > figcaption { & > figcaption {
grid-area: 1 / 1; grid-area: 1 / 1;
display: block flex; display: block grid;
flex-flow: row wrap; grid: auto 1fr auto / 100%;
position: absolute; position: absolute;
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
@ -36,12 +37,63 @@
gap: var(--size-2); gap: var(--size-2);
padding: var(--size-2); padding: var(--size-2);
& > * { & > header {
flex: 0 0 auto; display: block grid;
place-items: center;
& > h1 {
text-align: center;
font-size: 8rem;
text-shadow: 0 0 .5rem #000;
}
} }
& > section {
display: block grid;
place-items: center;
& > svg {
font-size: 10rem;
animation: spin 1s infinite steps(8) normal;
filter: drop-shadow(0 0 .5rem #000);
}
}
& > footer {
display: block grid;
grid: auto auto / auto auto auto;
place-content: space-between;
gap: var(--size-2);
& > :nth-child(1) { & > :nth-child(1) {
inline-size: 100%; grid-column: 1 / -1;
}
& > section {
& > button {
font-size: var(--size-7);
text-shadow: 0 0 .5rem #000;
aspect-ratio: 1;
background-color: transparent;
border-radius: var(--radius-2);
transition: background-color .2s var(--ease-in-out-1);
&:hover {
background-color: rgba(from var(--gray-2) r g b / .25);
} }
} }
} }
}
}
}
@keyframes spin {
0% {
rotate: 0deg;
}
100% {
rotate: 360deg;
}
}

View file

@ -9,6 +9,7 @@ import {
createMemo, createMemo,
createSignal, createSignal,
on, on,
Show,
} from "solid-js"; } from "solid-js";
import css from "./player.module.css"; import css from "./player.module.css";
import { Volume } from "./controls/volume"; import { Volume } from "./controls/volume";
@ -16,8 +17,11 @@ import { Entry, getEntry } from "../content";
import { PlayState } from "./controls/playState"; import { PlayState } from "./controls/playState";
import { createContextProvider } from "@solid-primitives/context"; import { createContextProvider } from "@solid-primitives/context";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import { VideoProvider } from "./context"; import { useVideo, VideoProvider } from "./context";
import { SeekBar } from "./controls/seekBar"; import { SeekBar } from "./controls/seekBar";
import { Fullscreen } from "./controls/fullscreen";
import { Settings } from "./controls/settings";
import { FaSolidCompress, FaSolidExpand, FaSolidSpinner } from "solid-icons/fa";
const metadata = query(async (id: string) => { const metadata = query(async (id: string) => {
"use server"; "use server";
@ -51,6 +55,9 @@ interface PlayerProps {
} }
export const Player: Component<PlayerProps> = (props) => { export const Player: Component<PlayerProps> = (props) => {
const [player, setPlayer] = createSignal<HTMLElement>(
undefined as unknown as HTMLElement
);
const [video, setVideo] = createSignal<HTMLVideoElement>( const [video, setVideo] = createSignal<HTMLVideoElement>(
undefined as unknown as HTMLVideoElement undefined as unknown as HTMLVideoElement
); );
@ -74,64 +81,15 @@ export const Player: Component<PlayerProps> = (props) => {
: ""; : "";
}); });
createEffect( createEffect(on(thumbnails, (thumbnails) => {}));
on(thumbnails, (thumbnails) => {
// console.log(thumbnails, video()!.textTracks.getTrackById("thumbnails")?.cues);
// const captions = el.addTextTrack("captions", "English", "en");
// captions.
})
);
createEventListenerMap(() => video()!, {
durationchange(e) {
// console.log("durationchange", e);
},
loadeddata(e) {
// console.log("loadeddata", e);
},
loadedmetadata(e) {
// console.log("loadedmetadata", e);
},
ratechange(e) {
// console.log("ratechange", e);
},
seeked(e) {
// console.log("seeked", "completed the seek interaction", e);
},
seeking(e) {
// console.log(
// "seeking",
// "the time on the video has been set, now the content will be loaded, the seeked event will fire when this is done",
// e
// );
},
stalled(e) {
// console.log(
// "stalled (meaning downloading data failed)",
// e,
// video()!.error,
// );
},
// suspend(e) {
// console.log("suspend", e);
// },
// canplay(e) {
// console.log("canplay", e);
// },
waiting(e) {
// console.log("waiting", e);
},
});
return ( return (
<> <>
<figure class={css.player}> <figure ref={setPlayer} class={css.player}>
{/* <h1>{props.entry.title}</h1> */} {/* <h1>{props.entry.title}</h1> */}
<video <video
ref={setVideo} ref={setVideo}
playsinline
src={`/api/content/${props.entry.id}/stream`} src={`/api/content/${props.entry.id}/stream`}
// src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4" // src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
poster={props.entry.image} poster={props.entry.image}
@ -153,20 +111,41 @@ export const Player: Component<PlayerProps> = (props) => {
</video> </video>
<figcaption> <figcaption>
<VideoProvider video={video()}> <VideoProvider root={player()} video={video()}>
<header>
<h1>{props.entry.title}</h1>
</header>
<section>
<Loader />
</section>
<footer>
<SeekBar /> <SeekBar />
<section>
<Volume />
</section>
<section>
<PlayState /> <PlayState />
<Volume </section>
value={video()?.volume ?? 0} <section>
muted={video()?.muted ?? false} <Fullscreen />
onInput={({ volume, muted }) => { <Settings />
video().volume = volume; </section>
video().muted = muted; </footer>
}}
/>
</VideoProvider> </VideoProvider>
</figcaption> </figcaption>
</figure> </figure>
</> </>
); );
}; };
const Loader: Component = () => {
const video = useVideo();
return (
<Show when={video.loading()}>
<FaSolidSpinner />
</Show>
);
};

View file

@ -39,7 +39,6 @@
overflow: clip auto; overflow: clip auto;
padding-inline-start: 5em; padding-inline-start: 5em;
transition: filter var(--duration-moderate-1) var(--ease-3); transition: filter var(--duration-moderate-1) var(--ease-3);
contain: layout style paint;
container-type: size; container-type: size;
& > div { & > div {

View file

@ -1,3 +1,4 @@
import { Title } from "@solidjs/meta";
import { import {
createAsync, createAsync,
json, json,
@ -51,9 +52,10 @@ export default function Item() {
return ( return (
<> <>
<Show when={entry()} fallback="Some kind of pretty 404 page I guess">{ <Title>{entry()?.title}</Title>
entry => <Details entry={entry()} /> <Show when={entry()} fallback="Some kind of pretty 404 page I guess">
}</Show> {(entry) => <Details entry={entry()} />}
</Show>
</> </>
); );
} }

View file

@ -11,6 +11,7 @@ import { Show } from "solid-js";
import { createSlug, getEntry } from "~/features/content"; import { createSlug, getEntry } from "~/features/content";
import { Player } from "~/features/player"; import { Player } from "~/features/player";
import css from "./slug.module.css"; import css from "./slug.module.css";
import { Title } from "@solidjs/meta";
const healUrl = query(async (slug: string) => { const healUrl = query(async (slug: string) => {
const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1)); const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1));
@ -54,6 +55,7 @@ export default function Item() {
return ( return (
<div class={css.page}> <div class={css.page}>
<Title>{entry()?.title}</Title>
<Show when={entry()} fallback="Some kind of pretty 404 page I guess"> <Show when={entry()} fallback="Some kind of pretty 404 page I guess">
{(entry) => <Player entry={entry()} />} {(entry) => <Player entry={entry()} />}
</Show> </Show>

View file

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