This commit is contained in:
Chris Kruining 2025-06-02 13:18:35 +02:00
parent 35213e1add
commit 6451b8cfb4
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
14 changed files with 158 additions and 130 deletions

View file

@ -15,7 +15,7 @@
inset: 0; inset: 0;
display: block; display: block;
background: linear-gradient( background: linear-gradient(
calc(atan(var(--ratio))), atan(var(--ratio, .2)),
var(--surface-2) 20em, var(--surface-2) 20em,
transparent 90% transparent 90%
); );

View file

@ -11,13 +11,10 @@ export const Details: Component<DetailsProps> = (props) => {
onMount(() => { onMount(() => {
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
const _20em = 20 * 16;
const { inlineSize, blockSize } = entry.contentBoxSize[0]; const { inlineSize, blockSize } = entry.contentBoxSize[0];
console.log(blockSize / inlineSize);
(entry.target as HTMLElement).style.setProperty( (entry.target as HTMLElement).style.setProperty(
"--ratio", "--ratio",
String(_20em / inlineSize), String((blockSize * 0.2) / inlineSize)
); );
}); });

View file

@ -8,9 +8,6 @@ type HeroProps = {
}; };
export function Hero(props: HeroProps) { export function Hero(props: HeroProps) {
const entry = createMemo(() => props.entries.at(0)!);
const slug = createMemo(() => createSlug(entry()));
return ( return (
<div class={`${css.container} ${props.class ?? ""}`}> <div class={`${css.container} ${props.class ?? ""}`}>
<For each={props.entries}>{(entry) => <Page entry={entry} />}</For> <For each={props.entries}>{(entry) => <Page entry={entry} />}</For>

View file

@ -1,4 +1,4 @@
import type { paths } from "./jellyfin.generated"; // generated by openapi-typescript import type { components, paths } from "./jellyfin.generated"; // generated by openapi-typescript
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import { query } from "@solidjs/router"; import { query } from "@solidjs/router";
import { Entry } from "../types"; import { Entry } from "../types";
@ -39,7 +39,7 @@ const getClient = () => {
export const getCurrentUser = query(async () => { export const getCurrentUser = query(async () => {
"use server"; "use server";
const { data, error, response } = await getClient().GET("/Users/Public", { const { data } = await getClient().GET("/Users/Public", {
params: {}, params: {},
}); });
@ -83,7 +83,7 @@ export const listItemIds = query(
return Object.fromEntries( return Object.fromEntries(
data.Items?.map((item) => ([ data.Items?.map((item) => ([
item.ProviderIds!["Tmdb"]!, `${item.MediaType as any}-${item.ProviderIds!["Tmdb"]!}`,
{ jellyfin: item.Id! }, { jellyfin: item.Id! },
])) ?? [] ])) ?? []
); );
@ -118,12 +118,7 @@ export const listItems = query(
} }
return ( return (
data.Items?.map((item) => ({ data.Items?.map((item) => mapToEntry(item)) ?? []
// id: item.Id!,
id: item.ProviderIds!["Tmdb"]!,
title: item.Name!,
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
})) ?? []
); );
}, },
"jellyfin.listItems", "jellyfin.listItems",
@ -164,13 +159,7 @@ export const getRandomItems = query(
}); });
return ( return (
data?.Items?.map((item) => ({ data?.Items?.map((item) => mapToEntry(item)) ?? []
// id: item.Id!,
id: item.ProviderIds!["Tmdb"]!,
title: item.Name!,
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${item.Id!}/Images/Backdrop`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
})) ?? []
); );
}, },
"jellyfin.listRandomItems", "jellyfin.listRandomItems",
@ -205,18 +194,7 @@ export const getItem = query(
return undefined; return undefined;
} }
return { return mapToEntry(data);
// id: data.Id!,
id: data.ProviderIds!["Tmdb"]!,
title: data.Name!,
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", "jellyfin.getItem",
); );
@ -235,7 +213,7 @@ export const getItemStream = query(
}, },
query: { query: {
static: true, static: true,
container: 'mkv', // container: 'mkv',
}, },
}, },
parseAs: 'stream', parseAs: 'stream',
@ -315,8 +293,6 @@ export const queryItems = query(async () => {
}, },
}, },
}); });
console.log(data);
}, "jellyfin.queryItems"); }, "jellyfin.queryItems");
export const getContinueWatching = query( export const getContinueWatching = query(
@ -373,3 +349,22 @@ function assertNoErrors<T>(
}); });
} }
} }
const mapToEntry = (item: components['schemas']['BaseItemDto']): Entry => {
const type = {
Movie: 'movie',
Series: 'tv',
}[item.Type as string] as any;
return {
type,
id: item.ProviderIds!["Tmdb"]!,
title: item.Name!,
overview: item.Overview!,
thumbnail: new URL(`/Items/${item.Id}/Images/Primary`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${item.Id}/Images/Backdrop`, getBaseUrl()),
providers: {
jellyfin: item.Id
}
};
};

View file

@ -28,16 +28,24 @@ const getClients = () => {
}; };
export const getEntry = query( export const getEntry = query(
async (id: string): Promise<Entry | undefined> => { async (type: Entry['type'], id: string): Promise<Entry | undefined> => {
"use server"; "use server";
const [ clientV3 ] = getClients(); const [ clientV3 ] = getClients();
const { data } = await clientV3.GET("/movie/{movie_id}", { const endpoint = ({
movie: "/movie/{movie_id}",
tv: '/tv/{series_id}',
} as const)[type];
const params = ({
movie: { movie_id: Number.parseInt(id) },
tv: { series_id: Number.parseInt(id) },
} as const)[type];
const { data } = await clientV3.GET(endpoint, {
params: { params: {
path: { path: params,
movie_id: Number.parseInt(id),
},
}, },
}); });
@ -46,6 +54,7 @@ export const getEntry = query(
} }
return { return {
type,
id: String(data.id ?? -1), id: String(data.id ?? -1),
title: data.title!, title: data.title!,
overview: data.overview, overview: data.overview,
@ -78,6 +87,7 @@ export const getRecommendations = query(async (): Promise<Entry[]> => {
return data?.results.map( return data?.results.map(
({ id, title, overview, poster_path, backdrop_path }) => ({ ({ id, title, overview, poster_path, backdrop_path }) => ({
type: 'movie',
id: String(id ?? -1), id: String(id ?? -1),
title: title!, title: title!,
overview, overview,
@ -102,7 +112,8 @@ export const getDiscovery = query(async (): Promise<Entry[]> => {
} }
const movieEntries = movies?.results?.slice(0, 10) const movieEntries = movies?.results?.slice(0, 10)
.map(({ id, title, overview, poster_path, backdrop_path }) => ({ .map(({ id, title, overview, poster_path, backdrop_path }): Entry => ({
type: 'movie',
id: String(id ?? -1), id: String(id ?? -1),
title: title!, title: title!,
overview, overview,
@ -111,7 +122,8 @@ export const getDiscovery = query(async (): Promise<Entry[]> => {
})) ?? [] })) ?? []
const seriesEntries = series?.results?.slice(0, 10) const seriesEntries = series?.results?.slice(0, 10)
.map(({ id, name, overview, poster_path, backdrop_path }) => ({ .map(({ id, name, overview, poster_path, backdrop_path }): Entry => ({
type: 'tv',
id: String(id ?? -1), id: String(id ?? -1),
title: name!, title: name!,
overview, overview,
@ -137,7 +149,7 @@ export const searchMulti = query(async (query: string, page: number = 1): Promis
query, query,
page, page,
include_adult: false, include_adult: false,
language: 'en-US' language: 'en-US',
} }
} }
}); });
@ -147,8 +159,13 @@ export const searchMulti = query(async (query: string, page: number = 1): Promis
} }
console.log(`loaded page ${page}, found ${data.results?.length} results`); console.log(`loaded page ${page}, found ${data.results?.length} results`);
console.log(data.results[0]);
return { count: data.total_results!, pages: data.total_pages!, results: data.results?.map(({ id, name, title, media_type, overview, backdrop_path, poster_path }) => ({ return { count: data.total_results!, pages: data.total_pages!, results: data.results?.filter(({ media_type }) => media_type === 'movie' || media_type === 'tv').map(({ id, name, title, media_type, overview, backdrop_path, poster_path }): Entry => ({
type: ({
movie: 'movie',
tv: 'tv',
}[media_type ?? '']) as any,
id: String(id), id: String(id),
title: `${name ?? title ?? ''} (${media_type})`, title: `${name ?? title ?? ''} (${media_type})`,
overview, overview,

View file

@ -1,33 +1,6 @@
import { toSlug } from "~/utilities"; import { toSlug } from "~/utilities";
import type { Entry } from "./types"; import type { Entry } from "./types";
export const entries = new Map<string, Entry>([ export const emptyEntry = Object.freeze<Entry>({ type: 'movie', id: '0', title: '' });
{ id: '1', title: 'Realtime with Bill Maher', thumbnail: 'https://www.themoviedb.org/t/p/w342/pbpoLLp4kvnYVfnEGiEhagpJuVZ.jpg' },
{ id: '2', title: 'Binnelanders', thumbnail: 'https://www.themoviedb.org/t/p/w342/v9nGSRx5lFz6KEgfmgHJMSgaARC.jpg' },
{ id: '3', title: 'Family guy', thumbnail: 'https://www.themoviedb.org/t/p/w342/y0HUz4eUNUe3TeEd8fQWYazPaC7.jpg' },
{ id: '4', title: 'The Simpsons', thumbnail: 'https://www.themoviedb.org/t/p/w342/vHqeLzYl3dEAutojCO26g0LIkom.jpg' },
{ id: '5', title: 'Breaking Bad', thumbnail: 'https://www.themoviedb.org/t/p/w342/ztkUQFLlC19CCMYHW9o1zWhJRNq.jpg' },
{ id: '6', title: 'Loki', thumbnail: 'https://www.themoviedb.org/t/p/w342/oJdVHUYrjdS2IqiNztVIP4GPB1p.jpg' },
{ id: '7', title: 'Dark desire', thumbnail: 'https://www.themoviedb.org/t/p/w342/uxFNAo2A6ZRcgNASLk02hJUbybn.jpg' },
{ id: '8', title: 'Bridgerton', thumbnail: 'https://www.themoviedb.org/t/p/w342/luoKpgVwi1E5nQsi7W0UuKHu2Rq.jpg' },
{ id: '9', title: 'Naruto', thumbnail: 'https://www.themoviedb.org/t/p/w342/xppeysfvDKVx775MFuH8Z9BlpMk.jpg' },
{ id: '11', title: 'Teenwolf', thumbnail: 'https://www.themoviedb.org/t/p/w342/fmlMmxSBgPEunHS5gjokIej048g.jpg' },
{ id: '12', title: 'Record of Ragnarok', thumbnail: 'https://www.themoviedb.org/t/p/w342/kTs2WNZOukpWdNhoRlH94pSJ3xf.jpg' },
{ id: '13', title: 'The Mandalorian', thumbnail: 'https://www.themoviedb.org/t/p/w342/eU1i6eHXlzMOlEq0ku1Rzq7Y4wA.jpg' },
{
id: '14',
title: 'Wednesday',
thumbnail: 'https://www.themoviedb.org/t/p/w342/9PFonBhy4cQy7Jz20NpMygczOkv.jpg',
image: 'https://www.themoviedb.org/t/p/original/iHSwvRVsRyxpX7FE7GbviaDvgGZ.jpg',
summary: 'Wednesday Addams is sent to Nevermore Academy, a bizarre boarding school where she attempts to master her psychic powers, stop a monstrous killing spree of the town citizens, and solve the supernatural mystery that affected her family',
releaseDate: '2022',
sources: [
{ label: 'TMDB', url: 'https://themoviedb.org/tv/119051', rating: { score: 8.5, max: 10 } }
]
},
].map((entry) => [entry.id, entry]));
export const createSlug = (entry: Entry) => toSlug(`${entry.title}-${entry.type}-${entry.id}`);
export const emptyEntry = Object.freeze<Entry>({ id: '0', title: '' });
export const createSlug = (entry: Entry) => toSlug(`${entry.title}-${entry.id}`);

View file

@ -14,12 +14,11 @@ const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
const lookupTable = query(async () => listItemIds(), 'content.lookupTable'); const lookupTable = query(async () => listItemIds(), 'content.lookupTable');
// export const getHighlights = () => getRandomItems(jellyfinUserId);
export const getHighlights = () => getContinueWatching(jellyfinUserId); export const getHighlights = () => getContinueWatching(jellyfinUserId);
export const getStream = query(async (id: string, range: string) => { export const getStream = query(async (type: Entry['type'], id: string, range: string) => {
const table = await lookupTable(); const table = await lookupTable();
return getItemStream(table[id].jellyfin, range); return getItemStream(table[`${type}-${id}`].jellyfin, range);
}, 'content.stream'); }, 'content.stream');
export const listCategories = query(async (): Promise<Category[]> => { export const listCategories = query(async (): Promise<Category[]> => {
@ -34,9 +33,18 @@ export const listCategories = query(async (): Promise<Category[]> => {
]; ];
}, "content.categories.list"); }, "content.categories.list");
export const getEntryFromSlug = query(
async (slug: string): Promise<Entry | undefined> => {
const { type, id } = slug.match(/^.+-(?<type>\w+)-(?<id>\w+)$/)?.groups ?? {};
return getTmdbEntry(type as any, id);
},
"content.getFromSlug",
);
export const getEntry = query( export const getEntry = query(
async (id: Entry["id"]): Promise<Entry | undefined> => { async (type: Entry['type'], id: Entry["id"]): Promise<Entry | undefined> => {
return getTmdbEntry(id); return getTmdbEntry(type, id);
}, },
"content.get", "content.get",
); );

View file

@ -4,6 +4,7 @@ export interface Category {
} }
export interface Entry { export interface Entry {
type: 'movie'|'tv';
id: string; id: string;
title: string; title: string;
overview?: string; overview?: string;

View file

@ -169,6 +169,9 @@ export const [VideoProvider, useVideo] = createContextProvider<
); );
}, },
canplay() { canplay() {
// setStore("loading", false);
},
canplaythrough() {
setStore("loading", false); setStore("loading", false);
}, },
waiting() { waiting() {

View file

@ -34,8 +34,7 @@
max-inline-size: none; max-inline-size: none;
align-content: end; align-content: end;
gap: var(--size-2); background: linear-gradient(to bottom, black, transparent) top left / 100% 20% no-repeat;
padding: var(--size-2);
& > header { & > header {
display: block grid; display: block grid;
@ -60,16 +59,30 @@
} }
& > footer { & > footer {
position: relative;
display: block grid; display: block grid;
grid: auto auto / auto auto auto; grid: auto auto / auto auto auto;
place-content: space-between; place-content: space-between;
gap: var(--size-2);
& > :nth-child(1) { gap: var(--size-2);
grid-column: 1 / -1; padding: var(--size-2);
&::before {
content: '';
position: absolute;
inset: 0;
inset-block-start: -5rem;
backdrop-filter: blur(5px);
mask-image: linear-gradient(to bottom, transparent, black 5rem);
z-index: 0;
} }
& > section { & > section {
z-index: 1;
&:nth-child(1) {
grid-column: 1 / -1;
}
& > button { & > button {
font-size: var(--size-7); font-size: var(--size-7);
text-shadow: 0 0 .5rem #000; text-shadow: 0 0 .5rem #000;

View file

@ -121,7 +121,9 @@ export const Player: Component<PlayerProps> = (props) => {
</section> </section>
<footer> <footer>
<section>
<SeekBar /> <SeekBar />
</section>
<section> <section>
<Volume /> <Volume />
</section> </section>

View file

@ -10,7 +10,12 @@ import {
} from "@solidjs/router"; } from "@solidjs/router";
import { Show } from "solid-js"; import { Show } from "solid-js";
import { Details } from "~/components/details"; import { Details } from "~/components/details";
import { createSlug, Entry, getEntry } from "~/features/content"; import {
createSlug,
Entry,
getEntry,
getEntryFromSlug,
} from "~/features/content";
const healUrl = async (slug: string, entry: Entry) => { const healUrl = async (slug: string, entry: Entry) => {
const actualSlug = createSlug(entry); const actualSlug = createSlug(entry);
@ -47,8 +52,7 @@ export const route = {
export default function Item() { export default function Item() {
const { slug } = useParams<ItemParams>(); const { slug } = useParams<ItemParams>();
const id = slug.slice(slug.lastIndexOf("-") + 1); const entry = createAsync(() => getEntryFromSlug(slug));
const entry = createAsync(() => getEntry(id));
return ( return (
<> <>

View file

@ -1,55 +1,73 @@
import { createInfiniteScroll } from "@solid-primitives/pagination"; import { createInfiniteScroll } from "@solid-primitives/pagination";
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
import { createEffect, createSignal, For, on, onMount, Show, createComputed, batch, createMemo, untrack } from "solid-js"; import {
createEffect,
createSignal,
For,
Show,
createMemo,
untrack,
} from "solid-js";
import { createSlug, search } from "~/features/content"; import { createSlug, search } from "~/features/content";
import { AiOutlineLoading } from "solid-icons/ai"; import { AiOutlineLoading } from "solid-icons/ai";
import css from './index.module.css';
import { debounce } from "@solid-primitives/scheduled"; import { debounce } from "@solid-primitives/scheduled";
import css from "./index.module.css";
const getResults = async (query: string, page: number) => { const getResults = async (query: string, page: number) => {
const { results } = await search(query, page + 1); const { results } = await search(query, page + 1);
return results; return results;
}; };
export default function Index() { export default function Index() {
const [ query, setQuery ] = createSignal(""); // lord of the rings const [query, setQuery] = createSignal(""); // lord of the rings
const [ ref, setRef ] = createSignal<HTMLInputElement>(); const [ref, setRef] = createSignal<HTMLInputElement>();
const KAAS = createMemo(() => { const results = createMemo(() => {
const q = query(); const q = query();
const [pages, setEl, { end }] = createInfiniteScroll((page) => getResults(q, page)); const [pages, setEl, { end }] = createInfiniteScroll((page) =>
getResults(q, page)
);
return { pages, setEl, end }; return { pages, setEl, end };
}); });
// const result = createAsync(() => search(query()), { initialValue: { count: 0, pages: 0, results: [] } }); // const result = createAsync(() => search(query()), { initialValue: { count: 0, pages: 0, results: [] } });
const title = 'Search'; const title = "Search";
createEffect(() => { createEffect(() => {
KAAS(); results();
untrack(ref)?.focus(); untrack(ref)?.focus();
}); });
return <div class={css.container}> return (
<div class={css.container}>
<Title>{title}</Title> <Title>{title}</Title>
<header class={css.header}> <header class={css.header}>
<input ref={setRef} type="search" placeholder={title} value={query()} oninput={debounce(e => setQuery(e.target.value), 300)} /> <input
ref={setRef}
type="search"
placeholder={title}
value={query()}
oninput={debounce((e) => setQuery(e.target.value), 300)}
/>
</header> </header>
<ul class={css.grid}> <ul class={css.grid}>
<For each={KAAS().pages()}>{ <For each={results().pages()}>
item => <a id={`item:${item.id}`} href={`/details/${createSlug(item)}`}> {(item) => (
<a id={`item:${item.id}`} href={`/details/${createSlug(item)}`}>
<img class={css.item} src={item.thumbnail} title={item.title} /> <img class={css.item} src={item.thumbnail} title={item.title} />
</a> </a>
}</For> )}
</For>
<Show when={!KAAS().end()}> <Show when={!results().end()}>
<AiOutlineLoading ref={KAAS().setEl} /> <AiOutlineLoading ref={results().setEl} />
</Show> </Show>
<Show when={KAAS().pages().length === 0}> <Show when={results().pages().length === 0}>
<p>No results</p> <p>No results</p>
</Show> </Show>
</ul> </ul>
@ -63,5 +81,6 @@ export default function Index() {
}</For> }</For>
</ul> </ul>
</output> */} </output> */}
</div>; </div>
);
} }

View file

@ -8,13 +8,13 @@ import {
useParams, useParams,
} from "@solidjs/router"; } from "@solidjs/router";
import { Show } from "solid-js"; import { Show } from "solid-js";
import { createSlug, getEntry } from "~/features/content"; import { createSlug, getEntryFromSlug } from "~/features/content";
import { Player } from "~/features/player"; import { Player } from "~/features/player";
import css from "./slug.module.css";
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
import css from "./slug.module.css";
const healUrl = query(async (slug: string) => { const healUrl = query(async (slug: string) => {
const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1)); const entry = await getEntryFromSlug(slug);
if (entry === undefined) { if (entry === undefined) {
return json(null, { status: 404 }); return json(null, { status: 404 });
@ -44,14 +44,13 @@ export const route = {
await healUrl(slug); await healUrl(slug);
return getEntry(slug.slice(slug.lastIndexOf("-") + 1)); return getEntryFromSlug(slug);
}, },
} satisfies RouteDefinition; } satisfies RouteDefinition;
export default function Item() { export default function Item() {
const { slug } = useParams<ItemParams>(); const { slug } = useParams<ItemParams>();
const id = slug.slice(slug.lastIndexOf("-") + 1); const entry = createAsync(() => getEntryFromSlug(slug));
const entry = createAsync(() => getEntry(id));
return ( return (
<div class={css.page}> <div class={css.page}>