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;
display: block;
background: linear-gradient(
calc(atan(var(--ratio))),
atan(var(--ratio, .2)),
var(--surface-2) 20em,
transparent 90%
);

View file

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

View file

@ -8,9 +8,6 @@ type HeroProps = {
};
export function Hero(props: HeroProps) {
const entry = createMemo(() => props.entries.at(0)!);
const slug = createMemo(() => createSlug(entry()));
return (
<div class={`${css.container} ${props.class ?? ""}`}>
<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 { query } from "@solidjs/router";
import { Entry } from "../types";
@ -39,7 +39,7 @@ const getClient = () => {
export const getCurrentUser = query(async () => {
"use server";
const { data, error, response } = await getClient().GET("/Users/Public", {
const { data } = await getClient().GET("/Users/Public", {
params: {},
});
@ -83,7 +83,7 @@ export const listItemIds = query(
return Object.fromEntries(
data.Items?.map((item) => ([
item.ProviderIds!["Tmdb"]!,
`${item.MediaType as any}-${item.ProviderIds!["Tmdb"]!}`,
{ jellyfin: item.Id! },
])) ?? []
);
@ -118,12 +118,7 @@ export const listItems = query(
}
return (
data.Items?.map((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'),
})) ?? []
data.Items?.map((item) => mapToEntry(item)) ?? []
);
},
"jellyfin.listItems",
@ -164,13 +159,7 @@ export const getRandomItems = query(
});
return (
data?.Items?.map((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'),
})) ?? []
data?.Items?.map((item) => mapToEntry(item)) ?? []
);
},
"jellyfin.listRandomItems",
@ -205,18 +194,7 @@ export const getItem = query(
return undefined;
}
return {
// 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,
};
return mapToEntry(data);
},
"jellyfin.getItem",
);
@ -235,7 +213,7 @@ export const getItemStream = query(
},
query: {
static: true,
container: 'mkv',
// container: 'mkv',
},
},
parseAs: 'stream',
@ -315,8 +293,6 @@ export const queryItems = query(async () => {
},
},
});
console.log(data);
}, "jellyfin.queryItems");
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(
async (id: string): Promise<Entry | undefined> => {
async (type: Entry['type'], id: string): Promise<Entry | undefined> => {
"use server";
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: {
path: {
movie_id: Number.parseInt(id),
},
path: params,
},
});
@ -46,6 +54,7 @@ export const getEntry = query(
}
return {
type,
id: String(data.id ?? -1),
title: data.title!,
overview: data.overview,
@ -78,6 +87,7 @@ export const getRecommendations = query(async (): Promise<Entry[]> => {
return data?.results.map(
({ id, title, overview, poster_path, backdrop_path }) => ({
type: 'movie',
id: String(id ?? -1),
title: title!,
overview,
@ -102,7 +112,8 @@ export const getDiscovery = query(async (): Promise<Entry[]> => {
}
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),
title: title!,
overview,
@ -111,7 +122,8 @@ export const getDiscovery = query(async (): Promise<Entry[]> => {
})) ?? []
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),
title: name!,
overview,
@ -137,7 +149,7 @@ export const searchMulti = query(async (query: string, page: number = 1): Promis
query,
page,
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(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),
title: `${name ?? title ?? ''} (${media_type})`,
overview,

View file

@ -1,33 +1,6 @@
import { toSlug } from "~/utilities";
import type { Entry } from "./types";
export const entries = new Map<string, Entry>([
{ 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 emptyEntry = Object.freeze<Entry>({ type: 'movie', id: '0', title: '' });
export const emptyEntry = Object.freeze<Entry>({ id: '0', title: '' });
export const createSlug = (entry: Entry) => toSlug(`${entry.title}-${entry.id}`);
export const createSlug = (entry: Entry) => toSlug(`${entry.title}-${entry.type}-${entry.id}`);

View file

@ -14,12 +14,11 @@ const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
const lookupTable = query(async () => listItemIds(), 'content.lookupTable');
// export const getHighlights = () => getRandomItems(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();
return getItemStream(table[id].jellyfin, range);
return getItemStream(table[`${type}-${id}`].jellyfin, range);
}, 'content.stream');
export const listCategories = query(async (): Promise<Category[]> => {
@ -34,9 +33,18 @@ export const listCategories = query(async (): Promise<Category[]> => {
];
}, "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(
async (id: Entry["id"]): Promise<Entry | undefined> => {
return getTmdbEntry(id);
async (type: Entry['type'], id: Entry["id"]): Promise<Entry | undefined> => {
return getTmdbEntry(type, id);
},
"content.get",
);

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,12 @@ import {
} from "@solidjs/router";
import { Show } from "solid-js";
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 actualSlug = createSlug(entry);
@ -47,8 +52,7 @@ export const route = {
export default function Item() {
const { slug } = useParams<ItemParams>();
const id = slug.slice(slug.lastIndexOf("-") + 1);
const entry = createAsync(() => getEntry(id));
const entry = createAsync(() => getEntryFromSlug(slug));
return (
<>

View file

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

View file

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