diff --git a/src/features/content/apis/jellyfin.ts b/src/features/content/apis/jellyfin.ts index 8aad8d6..eb96c37 100644 --- a/src/features/content/apis/jellyfin.ts +++ b/src/features/content/apis/jellyfin.ts @@ -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 => { + "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 => { "use server"; diff --git a/src/features/content/apis/tmdb.ts b/src/features/content/apis/tmdb.ts index 3f0625c..1461ec7 100644 --- a/src/features/content/apis/tmdb.ts +++ b/src/features/content/apis/tmdb.ts @@ -41,6 +41,8 @@ export const getEntry = query( }, }); + console.log(data); + if (data === undefined) { return undefined; } diff --git a/src/features/content/service.ts b/src/features/content/service.ts index b8661f7..4e0d3a7 100644 --- a/src/features/content/service.ts +++ b/src/features/content/service.ts @@ -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 => { return [ diff --git a/src/features/player/context.tsx b/src/features/player/context.tsx index eec04a5..6255b4d 100644 --- a/src/features/player/context.tsx +++ b/src/features/player/context.tsx @@ -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; - readonly volume: Accessor; + readonly duration: Accessor; + readonly buffered: Accessor; + readonly currentTime: Accessor; + setTime(time: number): void; - play(): void; - pause(): void; - togglePlayState(): void; + readonly state: { + readonly state: Accessor; + readonly setState: Setter; + pause(): void; + play(): void; + }; + readonly volume: { + readonly value: Accessor; + readonly muted: Accessor; + readonly setValue: Setter; + readonly setMuted: Setter; + 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({ + 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() {}, + }, + } ); diff --git a/src/features/player/controls/playState.module.css b/src/features/player/controls/playState.module.css new file mode 100644 index 0000000..4d5a1ee --- /dev/null +++ b/src/features/player/controls/playState.module.css @@ -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); + } +} \ No newline at end of file diff --git a/src/features/player/controls/playState.tsx b/src/features/player/controls/playState.tsx index 846b198..65974f9 100644 --- a/src/features/player/controls/playState.tsx +++ b/src/features/player/controls/playState.tsx @@ -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 ; + return ( + + ); }; diff --git a/src/features/player/controls/seekBar.module.css b/src/features/player/controls/seekBar.module.css new file mode 100644 index 0000000..10121a0 --- /dev/null +++ b/src/features/player/controls/seekBar.module.css @@ -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(), 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); + } +} \ No newline at end of file diff --git a/src/features/player/controls/seekBar.tsx b/src/features/player/controls/seekBar.tsx index 6c3dc05..bc83143 100644 --- a/src/features/player/controls/seekBar.tsx +++ b/src/features/player/controls/seekBar.tsx @@ -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 = () => { + const video = useVideo(); + return ( - <> +
+ {formatTime(video.currentTime())} + {formatTime(video.duration())} + 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" + /> + + @@ -21,6 +34,20 @@ export const SeekBar: Component = () => { - +
); }; + +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(":"); +}; diff --git a/src/features/player/controls/volume.module.css b/src/features/player/controls/volume.module.css index bffc843..af47e37 100644 --- a/src/features/player/controls/volume.module.css +++ b/src/features/player/controls/volume.module.css @@ -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); + } + } } diff --git a/src/features/player/controls/volume.tsx b/src/features/player/controls/volume.tsx index 7175af6..d195cbc 100644 --- a/src/features/player/controls/volume.tsx +++ b/src/features/player/controls/volume.tsx @@ -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 = (props) => { const video = useVideo(); - const [state, setState] = createStore({ - volume: props.value, - muted: props.muted ?? false, - }); - - createEffect(() => { - props.onInput?.(unwrap(trackDeep(state))); - }); - return (
- - setState({ muted: false, volume: e.target.valueAsNumber }) - } + onInput={(e) => { + video.volume.setValue(e.target.valueAsNumber); + video.volume.setMuted(false); + }} />
); diff --git a/src/features/player/player.module.css b/src/features/player/player.module.css index 1623fd5..d753d74 100644 --- a/src/features/player/player.module.css +++ b/src/features/player/player.module.css @@ -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%; + } + } } diff --git a/src/features/player/player.tsx b/src/features/player/player.tsx index 9d66f10..35a0585 100644 --- a/src/features/player/player.tsx +++ b/src/features/player/player.tsx @@ -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 = (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 = (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 = (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 ( <>
-

{props.entry?.title}

+ {/*

{props.entry.title}

*/}
); }; - -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(":"); -}; diff --git a/src/features/shell/shell.module.css b/src/features/shell/shell.module.css index dd900e8..af4167f 100644 --- a/src/features/shell/shell.module.css +++ b/src/features/shell/shell.module.css @@ -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%; diff --git a/src/index.css b/src/index.css index da000c0..aecfeb6 100644 --- a/src/index.css +++ b/src/index.css @@ -22,6 +22,7 @@ grid: 100% / 100%; inline-size: 100%; block-size: 100%; + contain: layout style paint; margin: 0; font-family: sans-serif; diff --git a/src/routes/(shell)/watch/[slug].tsx b/src/routes/(shell)/watch/[slug].tsx index 55f19bb..49679a9 100644 --- a/src/routes/(shell)/watch/[slug].tsx +++ b/src/routes/(shell)/watch/[slug].tsx @@ -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 ( - <> - { - entry => - } - +
+ + {(entry) => } + +
); } diff --git a/src/routes/(shell)/watch/slug.module.css b/src/routes/(shell)/watch/slug.module.css new file mode 100644 index 0000000..5b7ce64 --- /dev/null +++ b/src/routes/(shell)/watch/slug.module.css @@ -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; + } +} \ No newline at end of file diff --git a/src/routes/api/content/SampleVideo_1280x720_10mb.mp4 b/src/routes/api/content/[id]/SampleVideo_1280x720_10mb.mp4 similarity index 100% rename from src/routes/api/content/SampleVideo_1280x720_10mb.mp4 rename to src/routes/api/content/[id]/SampleVideo_1280x720_10mb.mp4 diff --git a/src/routes/api/content/metadata.ts b/src/routes/api/content/[id]/metadata.ts similarity index 100% rename from src/routes/api/content/metadata.ts rename to src/routes/api/content/[id]/metadata.ts diff --git a/src/routes/api/content/stream.ts b/src/routes/api/content/[id]/stream.ts similarity index 50% rename from src/routes/api/content/stream.ts rename to src/routes/api/content/[id]/stream.ts index 4c55f86..07f71fa 100644 --- a/src/routes/api/content/stream.ts +++ b/src/routes/api/content/[id]/stream.ts @@ -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);