This commit is contained in:
Chris Kruining 2025-05-27 16:15:56 +02:00
parent fbc040c317
commit 826a30f95f
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
19 changed files with 430 additions and 170 deletions

View file

@ -178,12 +178,43 @@ export const getItem = query(
overview: data.Overview!,
thumbnail: new URL(`/Items/${itemId}/Images/Primary`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${itemId}/Images/Backdrop`, getBaseUrl()),
providers: {
jellyfin: data.Id
}
// ...data,
};
},
"jellyfin.getItem",
);
export const getItemStream = query(
async (userId: string, itemId: string): Promise<string | undefined> => {
"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", {
params: {
path: {
itemId: item.providers.jellyfin,
},
query: {
},
},
});
return data;
},
"jellyfin.getItemStream",
);
export const getItemImage = query(
async (
itemId: string,
@ -254,6 +285,23 @@ 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,6 +41,8 @@ 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, getRandomItems } from "./apis/jellyfin";
import { getContinueWatching, getItemStream, getRandomItems } from "./apis/jellyfin";
import {
getDiscovery,
getRecommendations,
@ -14,6 +14,7 @@ const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
// export const getHighlights = () => getRandomItems(jellyfinUserId);
export const getHighlights = () => getContinueWatching(jellyfinUserId);
export const getStream = (id: string) => getItemStream(jellyfinUserId, id);
export const listCategories = query(async (): Promise<Category[]> => {
return [

View file

@ -3,24 +3,37 @@ import {
ContextProviderProps,
createContextProvider,
} from "@solid-primitives/context";
import { Accessor, createMemo } from "solid-js";
import { Accessor, createEffect, onMount, Setter } from "solid-js";
import { createStore } from "solid-js/store";
import { createEventListenerMap } from "@solid-primitives/event-listener";
type State = "playing" | "paused";
interface Volume {
type Volume = {
value: number;
muted: boolean;
}
};
export interface VideoAPI {
readonly state: Accessor<State>;
readonly volume: Accessor<Volume>;
readonly duration: Accessor<number>;
readonly buffered: Accessor<number>;
readonly currentTime: Accessor<number>;
setTime(time: number): void;
play(): void;
pause(): void;
togglePlayState(): void;
readonly state: {
readonly state: Accessor<State>;
readonly setState: Setter<State>;
pause(): void;
play(): void;
};
readonly volume: {
readonly value: Accessor<number>;
readonly muted: Accessor<boolean>;
readonly setValue: Setter<number>;
readonly setMuted: Setter<boolean>;
unmute(): void;
mute(): void;
};
}
interface VideoProviderProps extends ContextProviderProps {
@ -28,6 +41,9 @@ interface VideoProviderProps extends ContextProviderProps {
}
interface VideoStore {
duration: number;
buffered: number;
currentTime: number;
state: State;
volume: Volume;
}
@ -39,28 +55,50 @@ export const [VideoProvider, useVideo] = createContextProvider<
(props) => {
const video = props.video;
const [store, setStore] = createStore<VideoStore>({
duration: 0,
buffered: 0,
currentTime: 0,
state: "paused",
volume: {
value: 0.5,
value: 0.1,
muted: false,
},
});
const api: VideoAPI = {
state: createMemo(() => store.state),
duration: () => store.duration,
buffered: () => store.buffered,
currentTime: () => store.currentTime,
play() {
setStore("state", "playing");
setTime(time) {
video!.currentTime = time;
},
pause() {
setStore("state", "paused");
},
state: {
state: () => store.state,
setState: setStore.bind(null, "state"),
togglePlayState() {
setStore("state", (state) =>
state === "playing" ? "paused" : "playing"
);
play() {
setStore("state", "playing");
},
pause() {
setStore("state", "paused");
},
},
volume: {
value: () => store.volume.value,
muted: () => store.volume.muted,
setValue: setStore.bind(null, "volume", "value"),
setMuted: setStore.bind(null, "volume", "muted"),
mute() {
setStore("volume", "muted", true);
},
unmute() {
setStore("volume", "muted", false);
},
},
};
@ -68,6 +106,23 @@ export const [VideoProvider, useVideo] = createContextProvider<
return api;
}
createEffect(() => {
video[store.state === "playing" ? "play" : "pause"]();
});
createEffect(() => {
video.muted = store.volume.muted;
});
createEffect(() => {
video.volume = store.volume.value;
});
onMount(() => {
setStore("duration", video.duration);
setStore("currentTime", video.currentTime);
});
createEventListenerMap(video, {
play(e) {
setStore("state", "playing");
@ -75,9 +130,49 @@ export const [VideoProvider, useVideo] = createContextProvider<
pause(e) {
setStore("state", "paused");
},
durationchange(e) {
setStore("duration", video.duration);
},
timeupdate(e) {
setStore("currentTime", video.currentTime);
},
volumeChange() {
setStore("volume", { muted: video.muted, value: video.volume });
},
progress(e) {
const timeRanges = video.buffered;
setStore(
"buffered",
timeRanges.length > 0 ? timeRanges.end(timeRanges.length - 1) : 0
);
},
});
return api;
},
{ state: () => "paused" }
{
duration: () => 0,
buffered: () => 0,
currentTime: () => 0,
setTime() {},
state: {
state: () => "playing",
setState() {},
play() {},
pause() {},
},
volume: {
value: () => 0.5,
muted: () => false,
setValue() {},
setMuted() {},
mute() {},
unmute() {},
},
}
);

View file

@ -0,0 +1,13 @@
.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,15 +1,23 @@
import { Component, createMemo } from "solid-js";
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();
const icon = createMemo(() => {
return {
playing: "⏵",
paused: "⏸",
}[video.state()];
});
return <button onclick={(e) => video.togglePlayState()}>{icon()}</button>;
return (
<button
class={css.play}
onclick={(e) =>
video.state.setState((last) =>
last === "playing" ? "paused" : "playing"
)
}
>
<Show when={video.state.state() === "playing"} fallback={<FaSolidPlay />}>
<FaSolidPause />
</Show>
</button>
);
};

View file

@ -0,0 +1,68 @@
.container {
position: relative;
display: block grid;
grid: auto var(--size-2) / auto auto;
place-content: space-between;
gap: var(--size-2);
}
.time {
grid-area: 1 / 1;
}
.duration {
grid-area: 1 / 2;
}
.bar {
--_v: calc(1% * attr(data-value type(<number>), 0));
grid-area: 2 / span 2;
position: absolute;
inline-size: 100%;
block-size: 100%;
z-index: 1;
appearance: none;
background: linear-gradient(var(--blue-3)) top left / var(--_v) 100% no-repeat transparent;
border-radius: var(--radius-round);
&::-webkit-slider-thumb {
appearance: none;
display: block;
inline-size: var(--size-3);
block-size: var(--size-3);
background-color: var(--blue-7);
border-radius: var(--radius-round);
box-shadow: var(--shadow-2);
/* No clue why this offset is what works... */
margin-top: -.8rem;
}
}
.buffered {
grid-area: 2 / span 2;
position: absolute;
inline-size: 100%;
block-size: 100%;
appearance: none;
background: transparent;
&::-webkit-progress-bar {
background-color: rgba(from var(--gray-4) r g b / .5);
border-radius: var(--radius-round);
}
&::-webkit-progress-value {
background-color: rgba(from var(--gray-2) r g b / .75);
border-radius: var(--radius-round);
}
&::-moz-progress-bar {
background-color: rgba(from var(--surface-4) r g b / .5);
border-radius: var(--radius-round);
}
}

View file

@ -1,19 +1,32 @@
import { Component } from "solid-js";
import { useVideo } from "../context";
import css from "./seekBar.module.css";
interface SeekBarProps {
video: HTMLVideoElement | undefined;
}
interface SeekBarProps {}
export const SeekBar: Component<SeekBarProps> = () => {
const video = useVideo();
return (
<>
<div class={css.container}>
<span class={css.time}>{formatTime(video.currentTime())}</span>
<span class={css.duration}>{formatTime(video.duration())}</span>
<input
class={css.bar}
list="chapters"
type="range"
max={duration().toFixed(0)}
value={currentTime().toFixed(0)}
oninput={(e) => setTime(e.target.valueAsNumber)}
step="1"
max={video.duration().toFixed(2)}
value={video.currentTime().toFixed(2)}
data-value={((video.currentTime() / video.duration()) * 100).toFixed(2)}
oninput={(e) => video.setTime(e.target.valueAsNumber)}
step="0.01"
/>
<progress
class={css.buffered}
max={video.duration().toFixed(2)}
value={video.buffered().toFixed(2)}
/>
<datalist id="chapters">
@ -21,6 +34,20 @@ export const SeekBar: Component<SeekBarProps> = () => {
<option value="200">Chapter 2</option>
<option value="300">Chapter 3</option>
</datalist>
</>
</div>
);
};
const formatTime = (subject: number) => {
if (Number.isNaN(subject)) {
return "";
}
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,3 +1,18 @@
.container {
display: block grid;
grid: 100% / auto 1fr;
& > 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);
}
}
}

View file

@ -1,8 +1,7 @@
import { Component, createEffect, createSignal, Show } from "solid-js";
import { Component, Show } from "solid-js";
import css from "./volume.module.css";
import { createStore, unwrap } from "solid-js/store";
import { trackDeep } from "@solid-primitives/deep";
import { useVideo } from "../context";
import { FaSolidVolumeOff, FaSolidVolumeXmark } from "solid-icons/fa";
interface VolumeProps {
value: number;
@ -13,31 +12,23 @@ interface VolumeProps {
export const Volume: Component<VolumeProps> = (props) => {
const video = useVideo();
const [state, setState] = createStore({
volume: props.value,
muted: props.muted ?? false,
});
createEffect(() => {
props.onInput?.(unwrap(trackDeep(state)));
});
return (
<div class={css.container}>
<button onClick={() => setState("muted", (m) => !m)}>
<Show when={state.muted} fallback="mute">
unmute
<button onClick={() => video.volume.setMuted((m) => !m)}>
<Show when={video.volume.muted()} fallback={<FaSolidVolumeOff />}>
<FaSolidVolumeXmark />
</Show>
</button>
<input
type="range"
value={state.volume}
value={video.volume.value()}
min="0"
max="1"
step="0.01"
onInput={(e) =>
setState({ muted: false, volume: e.target.valueAsNumber })
}
onInput={(e) => {
video.volume.setValue(e.target.valueAsNumber);
video.volume.setMuted(false);
}}
/>
</div>
);

View file

@ -1,5 +1,47 @@
.player {
position: relative;
container-type: inline-size;
isolation: isolate;
display: block grid;
grid: 100% / 100%;
overflow: clip;
block-size: max-content;
background-color: black;
& > video::cue {
font-size: 1.5rem;
}
& > video {
grid-area: 1 / 1;
inline-size: 100%;
block-size: 100%;
object-position: center;
object-fit: contain;
}
& > figcaption {
grid-area: 1 / 1;
display: block flex;
flex-flow: row wrap;
position: absolute;
inline-size: 100%;
block-size: 100%;
max-inline-size: none;
align-content: end;
gap: var(--size-2);
padding: var(--size-2);
& > * {
flex: 0 0 auto;
}
& > :nth-child(1) {
inline-size: 100%;
}
}
}

View file

@ -17,6 +17,7 @@ import { PlayState } from "./controls/playState";
import { createContextProvider } from "@solid-primitives/context";
import { isServer } from "solid-js/web";
import { VideoProvider } from "./context";
import { SeekBar } from "./controls/seekBar";
const metadata = query(async (id: string) => {
"use server";
@ -81,21 +82,6 @@ export const Player: Component<PlayerProps> = (props) => {
})
);
const onDurationChange = createEventSignal(video, "durationchange");
const onTimeUpdate = createEventSignal(video, "timeupdate");
const duration = createMemo(() => {
onDurationChange();
return video()?.duration ?? 0;
});
const currentTime = createMemo(() => {
onTimeUpdate();
return video()?.currentTime ?? 0;
});
createEventListenerMap(() => video()!, {
durationchange(e) {
// console.log("durationchange", e);
@ -110,14 +96,14 @@ export const Player: Component<PlayerProps> = (props) => {
// console.log("ratechange", e);
},
seeked(e) {
console.log("seeked", "completed the seek interaction", 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
);
// 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(
@ -126,72 +112,29 @@ export const Player: Component<PlayerProps> = (props) => {
// video()!.error,
// );
},
play(e) {
// console.log("play", e);
},
canplay(e) {
// console.log("canplay", e);
},
playing(e) {
// console.log("playing", e);
},
pause(e) {
// console.log("pause", e);
},
suspend(e) {
// console.log("suspend", e);
},
volumechange(e) {
// console.log("volumechange", e);
},
// suspend(e) {
// console.log("suspend", e);
// },
// canplay(e) {
// console.log("canplay", e);
// },
waiting(e) {
console.log("waiting", e);
// console.log("waiting", e);
},
progress(e) {
console.log(e);
},
// timeupdate(e) {
// console.log("timeupdate", e);
// },
});
const toggle = () => {
const el = video();
if (!el) {
return;
}
el[el.paused ? "play" : "pause"]();
};
const setTime = (time: number) => {
const el = video();
if (!el) {
return;
}
el.currentTime = time;
};
return (
<>
<figure class={css.player}>
<h1>{props.entry?.title}</h1>
{/* <h1>{props.entry.title}</h1> */}
<video
ref={setVideo}
muted
autoplay
src={`/api/content/stream?id=${props.id}`}
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}
poster={props.entry.image}
lang="en"
>
<track
@ -211,6 +154,7 @@ export const Player: Component<PlayerProps> = (props) => {
<figcaption>
<VideoProvider video={video()}>
<SeekBar />
<PlayState />
<Volume
value={video()?.volume ?? 0}
@ -222,23 +166,7 @@ export const Player: Component<PlayerProps> = (props) => {
/>
</VideoProvider>
</figcaption>
<button onclick={toggle}>play/pause</button>
<span>
{formatTime(currentTime())} / {formatTime(duration())}
</span>
</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

@ -11,6 +11,7 @@
overflow: clip;
container-type: inline-size;
background-color: var(--surface-1);
contain: layout style paint;
&::after {
content: '';
@ -38,10 +39,13 @@
overflow: clip auto;
padding-inline-start: 5em;
transition: filter var(--duration-moderate-1) var(--ease-3);
contain: layout style paint;
container-type: size;
& > div {
background-color: var(--surface-2);
isolation: isolate;
container-type: inline-size;
contain: layout style paint;
inline-size: 100%;
block-size: fit-content;
min-block-size: 100%;

View file

@ -22,6 +22,7 @@
grid: 100% / 100%;
inline-size: 100%;
block-size: 100%;
contain: layout style paint;
margin: 0;
font-family: sans-serif;

View file

@ -8,9 +8,9 @@ import {
useParams,
} from "@solidjs/router";
import { Show } from "solid-js";
import { Details } from "~/components/details";
import { createSlug, getEntry } from "~/features/content";
import { Player } from "~/features/player";
import css from "./slug.module.css";
const healUrl = query(async (slug: string) => {
const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1));
@ -53,10 +53,10 @@ export default function Item() {
const entry = createAsync(() => getEntry(id));
return (
<>
<Show when={entry()} fallback="Some kind of pretty 404 page I guess">{
entry => <Player entry={entry()} />
}</Show>
</>
<div class={css.page}>
<Show when={entry()} fallback="Some kind of pretty 404 page I guess">
{(entry) => <Player entry={entry()} />}
</Show>
</div>
);
}

View file

@ -0,0 +1,12 @@
.page {
contain: layout style paint;
display: block grid;
grid: 100cqb / 100%;
inline-size: 100%;
block-size: 100%;
& > figure {
max-block-size: 100cqb;
}
}

View file

@ -1,10 +1,17 @@
import { APIEvent } from "@solidjs/start/server";
import { getStream } from "~/features/content";
const CHUNK_SIZE = 1 * 1e6; // 1MB
export const GET = async ({ request, ...event }: APIEvent) => {
export const GET = async ({ request, params }: APIEvent) => {
"use server";
console.log('api endpoind called')
const res = await getStream(params.id);
return res;
const range = request.headers.get("range");
if (range === null) {
@ -21,22 +28,20 @@ export const GET = async ({ request, ...event }: APIEvent) => {
}
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;
const end = Math.min(start + CHUNK_SIZE - 1, videoSize - 1);
return new Response(file.stream());
console.log(`streaming slice(${start}, ${end})`);
// 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',
// },
// });
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);