woooot, got streaming via the jellyfin api working!
This commit is contained in:
parent
826a30f95f
commit
d96f89d4b3
21 changed files with 282 additions and 169 deletions
|
@ -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";
|
||||
|
|
|
@ -41,8 +41,6 @@ export const getEntry = query(
|
|||
},
|
||||
});
|
||||
|
||||
console.log(data);
|
||||
|
||||
if (data === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
15
src/features/player/controls/fullscreen.tsx
Normal file
15
src/features/player/controls/fullscreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
19
src/features/player/controls/settings.tsx
Normal file
19
src/features/player/controls/settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue