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

@ -57,6 +57,41 @@ export const listUsers = query(async () => {
return data ?? [];
}, "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(
async (userId: string): Promise<Entry[] | undefined> => {
"use server";
@ -189,28 +224,28 @@ export const getItem = query(
export const getItemStream = query(
async (userId: string, itemId: string): Promise<string | undefined> => {
async (itemId: string, range: string): Promise<Response> => {
"use server";
const item = await getItem(userId, itemId);
console.log(item);
if (item === undefined) {
return undefined;
}
const { data, error } = await getClient().GET("/Videos/{itemId}/stream", {
// 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
const { response } = await getClient().GET("/Videos/{itemId}/stream", {
params: {
path: {
itemId: item.providers.jellyfin,
itemId,
},
query: {
static: true,
container: 'mkv',
},
},
parseAs: 'stream',
headers: {
Range: range
}
});
return data;
return response;
},
"jellyfin.getItemStream",
);
@ -267,7 +302,7 @@ export const getItemPlaybackInfo = query(
);
export const queryItems = query(async () => {
"use server";
"use server";
const { data, error } = await getClient().GET("/Items", {
params: {
@ -285,23 +320,6 @@ export const queryItems = query(async () => {
console.log(data);
}, "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(
async (userId: string): Promise<Entry[]> => {
"use server";

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import {
import { Accessor, createEffect, onMount, Setter } from "solid-js";
import { createStore } from "solid-js/store";
import { createEventListenerMap } from "@solid-primitives/event-listener";
import { createFullscreen } from "@solid-primitives/fullscreen";
type State = "playing" | "paused";
@ -15,9 +16,14 @@ type Volume = {
};
export interface VideoAPI {
readonly fullscreen: Accessor<boolean>;
readonly setFullscreen: Setter<boolean>;
readonly loading: Accessor<boolean>;
readonly duration: Accessor<number>;
readonly buffered: Accessor<number>;
readonly currentTime: Accessor<number>;
setTime(time: number): void;
readonly state: {
@ -37,10 +43,13 @@ export interface VideoAPI {
}
interface VideoProviderProps extends ContextProviderProps {
root: HTMLElement | undefined;
video: HTMLVideoElement | undefined;
}
interface VideoStore {
fullscreen: boolean;
loading: boolean;
duration: number;
buffered: number;
currentTime: number;
@ -55,6 +64,8 @@ export const [VideoProvider, useVideo] = createContextProvider<
(props) => {
const video = props.video;
const [store, setStore] = createStore<VideoStore>({
fullscreen: false,
loading: true,
duration: 0,
buffered: 0,
currentTime: 0,
@ -65,7 +76,17 @@ export const [VideoProvider, useVideo] = createContextProvider<
},
});
const fullscreen = createFullscreen(
() => props.root,
() => store.fullscreen
);
const api: VideoAPI = {
fullscreen,
setFullscreen: setStore.bind(null, "fullscreen"),
loading: () => store.loading,
duration: () => store.duration,
buffered: () => store.buffered,
currentTime: () => store.currentTime,
@ -147,11 +168,22 @@ export const [VideoProvider, useVideo] = createContextProvider<
timeRanges.length > 0 ? timeRanges.end(timeRanges.length - 1) : 0
);
},
canplay() {
setStore("loading", false);
},
waiting() {
setStore("loading", true);
},
});
return api;
},
{
fullscreen: () => false,
setFullscreen() {},
loading: () => false,
duration: () => 0,
buffered: () => 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 { useVideo } from "../context";
import { FaSolidPause, FaSolidPlay } from "solid-icons/fa";
import css from "./playState.module.css";
export const PlayState: Component<{}> = (props) => {
const video = useVideo();
return (
<button
class={css.play}
onclick={(e) =>
video.state.setState((last) =>
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 css from "./volume.module.css";
import { useVideo } from "../context";
import { FaSolidVolumeOff, FaSolidVolumeXmark } from "solid-icons/fa";
import css from "./volume.module.css";
interface VolumeProps {
value: number;
muted?: boolean;
onInput?: (next: { volume: number; muted: boolean }) => any;
}
interface VolumeProps {}
export const Volume: Component<VolumeProps> = (props) => {
const video = useVideo();

View file

@ -10,6 +10,7 @@
block-size: max-content;
background-color: black;
color: var(--gray-3);
& > video::cue {
font-size: 1.5rem;
@ -25,8 +26,8 @@
& > figcaption {
grid-area: 1 / 1;
display: block flex;
flex-flow: row wrap;
display: block grid;
grid: auto 1fr auto / 100%;
position: absolute;
inline-size: 100%;
block-size: 100%;
@ -36,12 +37,63 @@
gap: var(--size-2);
padding: var(--size-2);
& > * {
flex: 0 0 auto;
& > header {
display: block grid;
place-items: center;
& > h1 {
text-align: center;
font-size: 8rem;
text-shadow: 0 0 .5rem #000;
}
}
& > :nth-child(1) {
inline-size: 100%;
& > 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) {
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,
createSignal,
on,
Show,
} from "solid-js";
import css from "./player.module.css";
import { Volume } from "./controls/volume";
@ -16,8 +17,11 @@ import { Entry, getEntry } from "../content";
import { PlayState } from "./controls/playState";
import { createContextProvider } from "@solid-primitives/context";
import { isServer } from "solid-js/web";
import { VideoProvider } from "./context";
import { useVideo, VideoProvider } from "./context";
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) => {
"use server";
@ -51,6 +55,9 @@ interface PlayerProps {
}
export const Player: Component<PlayerProps> = (props) => {
const [player, setPlayer] = createSignal<HTMLElement>(
undefined as unknown as HTMLElement
);
const [video, setVideo] = createSignal<HTMLVideoElement>(
undefined as unknown as HTMLVideoElement
);
@ -74,64 +81,15 @@ export const Player: Component<PlayerProps> = (props) => {
: "";
});
createEffect(
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);
},
});
createEffect(on(thumbnails, (thumbnails) => {}));
return (
<>
<figure class={css.player}>
<figure ref={setPlayer} class={css.player}>
{/* <h1>{props.entry.title}</h1> */}
<video
ref={setVideo}
playsinline
src={`/api/content/${props.entry.id}/stream`}
// src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
poster={props.entry.image}
@ -153,20 +111,41 @@ export const Player: Component<PlayerProps> = (props) => {
</video>
<figcaption>
<VideoProvider video={video()}>
<SeekBar />
<PlayState />
<Volume
value={video()?.volume ?? 0}
muted={video()?.muted ?? false}
onInput={({ volume, muted }) => {
video().volume = volume;
video().muted = muted;
}}
/>
<VideoProvider root={player()} video={video()}>
<header>
<h1>{props.entry.title}</h1>
</header>
<section>
<Loader />
</section>
<footer>
<SeekBar />
<section>
<Volume />
</section>
<section>
<PlayState />
</section>
<section>
<Fullscreen />
<Settings />
</section>
</footer>
</VideoProvider>
</figcaption>
</figure>
</>
);
};
const Loader: Component = () => {
const video = useVideo();
return (
<Show when={video.loading()}>
<FaSolidSpinner />
</Show>
);
};

View file

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