diff --git a/bun.lock b/bun.lock index 67e065e..1ed7c1e 100644 --- a/bun.lock +++ b/bun.lock @@ -7,30 +7,31 @@ "@solid-primitives/context": "^0.3.1", "@solid-primitives/deep": "^0.3.2", "@solid-primitives/event-listener": "^2.4.1", + "@solid-primitives/fullscreen": "^1.3.1", "@solid-primitives/pagination": "^0.4.1", "@solid-primitives/scheduled": "^1.5.1", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.3", "@solidjs/start": "^1.1.4", - "better-auth": "^1.2.7", + "better-auth": "^1.2.8", "bindings": "^1.5.0", "open-props": "^1.7.15", "openapi-fetch": "^0.13.8", "sitemap": "^8.0.0", "solid-icons": "^1.1.0", - "solid-js": "^1.9.6", + "solid-js": "^1.9.7", "vinxi": "^0.5.6", }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", "browserslist": "^4.24.5", - "bun-types": "^1.2.13", + "bun-types": "^1.2.14", "lightningcss": "^1.30.1", "openapi-typescript": "^7.8.0", "solid-devtools": "^0.33.0", "vite-plugin-solid-svg": "^0.8.1", "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/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/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=="], diff --git a/package.json b/package.json index ccd3469..b234ce0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@solid-primitives/context": "^0.3.1", "@solid-primitives/deep": "^0.3.2", "@solid-primitives/event-listener": "^2.4.1", + "@solid-primitives/fullscreen": "^1.3.1", "@solid-primitives/pagination": "^0.4.1", "@solid-primitives/scheduled": "^1.5.1", "@solidjs/meta": "^0.29.4", diff --git a/public/favicon-dark.svg b/public/favicon-dark.svg new file mode 100644 index 0000000..00fe969 --- /dev/null +++ b/public/favicon-dark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/favicon-light.svg b/public/favicon-light.svg new file mode 100644 index 0000000..a16a7d5 --- /dev/null +++ b/public/favicon-light.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/hero/hero.tsx b/src/components/hero/hero.tsx index e3bc516..5e058da 100644 --- a/src/components/hero/hero.tsx +++ b/src/components/hero/hero.tsx @@ -21,10 +21,6 @@ export function Hero(props: HeroProps) { const Page: Component<{ entry: Entry }> = (props) => { const slug = createMemo(() => createSlug(props.entry)); - createEffect(() => { - // console.log(props.entry); - }); - return (
( - + + {assets} diff --git a/src/features/content/apis/jellyfin.ts b/src/features/content/apis/jellyfin.ts index eb96c37..a0879ab 100644 --- a/src/features/content/apis/jellyfin.ts +++ b/src/features/content/apis/jellyfin.ts @@ -57,6 +57,41 @@ export const listUsers = query(async () => { return data ?? []; }, "jellyfin.listUsers"); +export const listItemIds = query( + async (): Promise> => { + "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 => { "use server"; @@ -189,28 +224,28 @@ export const getItem = query( export const getItemStream = query( - async (userId: string, itemId: string): Promise => { + async (itemId: string, range: 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", { + // 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 => { "use server"; diff --git a/src/features/content/apis/tmdb.ts b/src/features/content/apis/tmdb.ts index 1461ec7..3f0625c 100644 --- a/src/features/content/apis/tmdb.ts +++ b/src/features/content/apis/tmdb.ts @@ -41,8 +41,6 @@ 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 4e0d3a7..12d3af9 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, 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 => { return [ @@ -31,7 +37,6 @@ export const listCategories = query(async (): Promise => { export const getEntry = query( async (id: Entry["id"]): Promise => { return getTmdbEntry(id); - // return getItem(jellyfinUserId, id); }, "content.get", ); diff --git a/src/features/player/context.tsx b/src/features/player/context.tsx index 6255b4d..83a64bc 100644 --- a/src/features/player/context.tsx +++ b/src/features/player/context.tsx @@ -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; + readonly setFullscreen: Setter; + + readonly loading: Accessor; readonly duration: Accessor; readonly buffered: Accessor; readonly currentTime: Accessor; + 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({ + 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, diff --git a/src/features/player/controls/fullscreen.tsx b/src/features/player/controls/fullscreen.tsx new file mode 100644 index 0000000..5c68808 --- /dev/null +++ b/src/features/player/controls/fullscreen.tsx @@ -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 ( + + ); +}; diff --git a/src/features/player/controls/playState.module.css b/src/features/player/controls/playState.module.css deleted file mode 100644 index 4d5a1ee..0000000 --- a/src/features/player/controls/playState.module.css +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/src/features/player/controls/playState.tsx b/src/features/player/controls/playState.tsx index 65974f9..a91b629 100644 --- a/src/features/player/controls/playState.tsx +++ b/src/features/player/controls/playState.tsx @@ -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 ( + ); +}; diff --git a/src/features/player/controls/volume.tsx b/src/features/player/controls/volume.tsx index d195cbc..87dbee1 100644 --- a/src/features/player/controls/volume.tsx +++ b/src/features/player/controls/volume.tsx @@ -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 = (props) => { const video = useVideo(); diff --git a/src/features/player/player.module.css b/src/features/player/player.module.css index d753d74..cc6a070 100644 --- a/src/features/player/player.module.css +++ b/src/features/player/player.module.css @@ -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; + } +} diff --git a/src/features/player/player.tsx b/src/features/player/player.tsx index 35a0585..d956d5d 100644 --- a/src/features/player/player.tsx +++ b/src/features/player/player.tsx @@ -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 = (props) => { + const [player, setPlayer] = createSignal( + undefined as unknown as HTMLElement + ); const [video, setVideo] = createSignal( undefined as unknown as HTMLVideoElement ); @@ -74,64 +81,15 @@ export const Player: Component = (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 ( <> -
+
{/*

{props.entry.title}

*/}
- - - - { - video().volume = volume; - video().muted = muted; - }} - /> + +
+

{props.entry.title}

+
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ + +
+
); }; + +const Loader: Component = () => { + const video = useVideo(); + + return ( + + + + ); +}; diff --git a/src/features/shell/shell.module.css b/src/features/shell/shell.module.css index af4167f..5d318ab 100644 --- a/src/features/shell/shell.module.css +++ b/src/features/shell/shell.module.css @@ -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 { diff --git a/src/routes/(shell)/details/[slug].tsx b/src/routes/(shell)/details/[slug].tsx index 292632d..fb99e99 100644 --- a/src/routes/(shell)/details/[slug].tsx +++ b/src/routes/(shell)/details/[slug].tsx @@ -1,3 +1,4 @@ +import { Title } from "@solidjs/meta"; import { createAsync, json, @@ -51,9 +52,10 @@ export default function Item() { return ( <> - { - entry =>
- } + {entry()?.title} + + {(entry) =>
} + ); } diff --git a/src/routes/(shell)/watch/[slug].tsx b/src/routes/(shell)/watch/[slug].tsx index 49679a9..d6d48dd 100644 --- a/src/routes/(shell)/watch/[slug].tsx +++ b/src/routes/(shell)/watch/[slug].tsx @@ -11,6 +11,7 @@ import { Show } from "solid-js"; import { createSlug, getEntry } from "~/features/content"; import { Player } from "~/features/player"; import css from "./slug.module.css"; +import { Title } from "@solidjs/meta"; const healUrl = query(async (slug: string) => { const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1)); @@ -54,6 +55,7 @@ export default function Item() { return (
+ {entry()?.title} {(entry) => } diff --git a/src/routes/api/content/[id]/stream.ts b/src/routes/api/content/[id]/stream.ts index 07f71fa..2fd90bd 100644 --- a/src/routes/api/content/[id]/stream.ts +++ b/src/routes/api/content/[id]/stream.ts @@ -1,50 +1,46 @@ import { APIEvent } from "@solidjs/start/server"; import { getStream } from "~/features/content"; -const CHUNK_SIZE = 1 * 1e6; // 1MB +// const CHUNK_SIZE = 1 * 1e6; // 1MB 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) { return new Response("Requires Range header", { status: 400 }); } - try { - const file = Bun.file( - import.meta.dirname + "/SampleVideo_1280x720_10mb.mp4", - ); + return getStream(params.id, range); - if ((await file.exists()) !== true) { - return new Response("File not found", { status: 404 }); - } + // try { + // const file = Bun.file( + // import.meta.dirname + "/SampleVideo_1280x720_10mb.mp4", + // ); - const videoSize = file.size; - const start = Number.parseInt(range.replace(/\D/g, "")); - const end = Math.min(start + CHUNK_SIZE - 1, videoSize - 1); + // if ((await file.exists()) !== true) { + // return new Response("File not found", { status: 404 }); + // } - 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), { - 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); + // console.log(`streaming slice(${start}, ${end})`); - 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; + // } };