This commit is contained in:
Chris Kruining 2025-05-18 18:12:42 +02:00
parent 873677ea04
commit d683b051b6
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
17 changed files with 244 additions and 273 deletions

View file

@ -2,10 +2,8 @@ import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { createAuthClient } from "better-auth/solid";
import { genericOAuthClient } from "better-auth/client/plugins";
import Database from "better-sqlite3";
export const auth = betterAuth({
database: Database('auth.sqlite'),
appName: "Streamarr",
basePath: "/api/auth",
logger: {

View file

@ -1,13 +1,46 @@
@property --thumb-image {
syntax: '<image>';
inherits: true;
}
.container {
display: block grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
overflow: hidden visible;
scroll-snap-type: inline mandatory;
scroll-behavior: smooth;
scroll-marker-group: after;
&::scroll-marker-group {
display: block grid;
grid-auto-flow: column;
grid-auto-columns: 5em;
gap: 1em;
place-content: end center;
inline-size: 100%;
block-size: 8.333333em;
z-index: 1;
}
}
.page {
scroll-snap-align: center;
position: relative;
display: grid;
grid: repeat(3, auto) / 15em 1fr;
grid-template-areas:
"thumbnail ."
"thumbnail title"
"thumbnail detail"
"thumbnail summary";
"thumbnail . ."
"thumbnail title cta"
"thumbnail detail detail"
"thumbnail summary summary";
align-content: end;
align-items: center;
gap: 1em;
padding: 2em;
block-size: 80vh;
@ -20,7 +53,31 @@
position: absolute;
inset: 0;
display: block;
background: linear-gradient(transparent 50%, #0007 75%);
background: linear-gradient(185deg, transparent 20%, var(--surface-2) 90%), linear-gradient(transparent 50%, #0007 75%);
}
&::scroll-marker {
display: block;
content: ' ';
inline-size: 15em;
aspect-ratio: 3 / 5;
background: var(--thumb-image) center / cover no-repeat;
background-color: cornflowerblue;
border-radius: var(--radius-3);
transform: scale(.333333);
transition: .3s;
}
&::scroll-marker:target-current {
/* outline: 1px solid white; */
position: absolute;
top: -29em;
left: 2em;
transform: scale(1);
}
}
@ -31,11 +88,22 @@
filter: contrast(9);
}
.cta {
grid-area: cta;
z-index: 1;
border-radius: var(--radius-2);
background-color: var(--gray-2);
color: var(--gray-8);
text-decoration-color: var(--gray-8);
padding: var(--size-3);
font-weight: var(--font-weight-9);
}
.thumbnail {
grid-area: thumbnail;
inline-size: 15em;
aspect-ratio: 3 / 5;
border-radius: 1em;
border-radius: var(--radius-3);
object-fit: cover;
object-position: center;
z-index: 1;

View file

@ -1,17 +1,38 @@
import { Index } from "solid-js";
import { Entry } from "~/features/content";
import { Component, createEffect, createMemo, For, Index } from "solid-js";
import { createSlug, Entry } from "~/features/content";
import css from "./hero.module.css";
type HeroProps = {
entry: Entry;
entries: Entry[];
class?: string;
};
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>
</div>
);
}
const Page: Component<{ entry: Entry }> = (props) => {
const slug = createMemo(() => createSlug(props.entry));
createEffect(() => {
console.log(props.entry);
});
return (
<div class={`${css.page}`} style={{ '--thumb-image': `url(${props.entry.thumbnail})` }}>
<h2 class={css.title}>{props.entry.title}</h2>
<a class={css.cta} href={`/watch/${slug()}`}>Continue</a>
<img src={props.entry.thumbnail} class={css.thumbnail} />
<img src={props.entry.image} class={css.background} />
@ -31,7 +52,7 @@ export function Hero(props: HeroProps) {
</Index>
</span>
<p class={css.summary}>{props.entry.summary}</p>
<p class={css.summary}>{props.entry.synopsis}</p>
</div>
);
}
};

View file

@ -1,12 +1,10 @@
"use server";
import type { paths } from "./jellyfin.generated"; // generated by openapi-typescript
import createClient from "openapi-fetch";
import type { paths, components } from "./jellyfin.generated"; // generated by openapi-typescript
import { query } from "@solidjs/router";
import { Entry } from "../types";
// ===============================
'use server';
// ===============================
type ItemImageType = "Primary" | "Art" | "Backdrop" | "Banner" | "Logo" | "Thumb" | "Disc" | "Box" | "Screenshot" | "Menu" | "Chapter" | "BoxRear" | "Profile";
const baseUrl = process.env.JELLYFIN_BASE_URL;
@ -58,7 +56,71 @@ export const listUsers = query(async () => {
return data ?? [];
}, "jellyfin.listUsers");
export const listItems = query(async (userId: string): Promise<Entry[] | undefined> => {
const { data, error } = await client.GET("/Items", {
params: {
query: {
userId,
hasTmdbInfo: true,
recursive: true,
includeItemTypes: ["Movie", "Series"],
fields: [
"ProviderIds",
"Genres",
"DateLastMediaAdded",
"DateCreated",
"MediaSources",
],
},
},
});
if (data === undefined) {
return undefined;
}
return data.Items?.map(item => ({
id: item.Id!,
title: item.Name!,
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
})) ?? [];
}, "jellyfin.listItems");
export const getRandomItem = query(async (userId: string): Promise<Entry | undefined> => getRandomItems(userId, 1).then(items => items?.at(0)), "jellyfin.listRandomItem");
export const getRandomItems = query(async (userId: string, limit: number = 10): Promise<Entry[]> => {
const { data, error } = await client.GET("/Items", {
params: {
query: {
userId,
hasTmdbInfo: true,
recursive: true,
limit,
sortBy: ["Random"],
includeItemTypes: ["Movie", "Series"],
imageTypes: ["Primary", "Backdrop", "Thumb"],
fields: [
"ProviderIds",
"Genres",
"DateLastMediaAdded",
"DateCreated",
"MediaSources",
],
},
},
});
return data?.Items?.map(item => ({
id: item.Id!,
title: item.Name!,
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${item.Id!}/Images/Backdrop`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
})) ?? [];
}, "jellyfin.listRandomItems");
export const getItem = query(async (userId: string, itemId: string): Promise<Entry | undefined> => {
console.log('baseUrl', baseUrl);
const { data, error } = await client.GET("/Items/{itemId}", {
params: {
path: {
@ -87,7 +149,10 @@ export const getItem = query(async (userId: string, itemId: string): Promise<Ent
return {
id: data.Id!,
title: data.Name!,
synopsis: data.Overview!,
thumbnail: new URL(`/Items/${itemId}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${itemId}/Images/Backdrop`, baseUrl),
// ...data,
};
}, "jellyfin.getItem");
@ -142,32 +207,29 @@ export const queryItems = query(async () => {
}, 'jellyfin.queryItems');
export const getContinueWatching = query(
async (userId: string): Promise<Entry[]> => {
const { data, error } = await client.GET("/UserItems/Resume", {
params: {
query: {
userId,
mediaTypes: ["Video"],
// fields: ["ProviderIds", "Genres"],
// includeItemTypes: ["Series", "Movie"]
},
export const getContinueWatching = query(async (userId: string): Promise<Entry[]> => {
const { data, error } = await client.GET("/UserItems/Resume", {
params: {
query: {
userId,
mediaTypes: ["Video"],
// fields: ["ProviderIds", "Genres"],
// includeItemTypes: ["Series", "Movie"]
},
});
},
});
if (Array.isArray(data?.Items) !== true) {
return [];
}
if (Array.isArray(data?.Items) !== true) {
return [];
}
const uniqueIds = new Set<string>(data.Items.map(item => item.Type === 'Episode' ? item.SeriesId! : item.Id));
const results = await Promise.allSettled(uniqueIds.values().map(id => getItem(userId, id)).toArray());
const uniqueIds = new Set<string>(data.Items.map(item => item.Type === 'Episode' ? item.SeriesId! : item.Id!));
const results = await Promise.allSettled(uniqueIds.values().map(id => getItem(userId, id)).toArray());
assertNoErrors(results);
assertNoErrors(results);
return results.filter((result): result is PromiseFulfilledResult<Entry> => result.value !== undefined).map(({ value }) => value);
},
"jellyfin.continueWatching",
);
return results.filter((result): result is PromiseFulfilledResult<Entry> => result.value !== undefined).map(({ value }) => value);
}, "jellyfin.continueWatching");
function assertNoErrors<T>(results: PromiseSettledResult<T>[]): asserts results is PromiseFulfilledResult<T>[] {
if (results.some(({ status }) => status !== 'fulfilled')) {

View file

@ -1,18 +1,19 @@
"use server";
import type { Category, Entry } from "./types";
import { query } from "@solidjs/router";
import { entries } from "./data";
import { getContinueWatching, getItem, TEST } from "./apis/jellyfin";
import { getContinueWatching, getItem, getRandomItems } from "./apis/jellyfin";
const jellyfinUserId = "a9c51af8-4bf5-4578-a99a-b4dd0ebf0763";
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
// export const getHighlights = () => getRandomItems(jellyfinUserId);
export const getHighlights = () => getContinueWatching(jellyfinUserId);
export const listCategories = query(async (): Promise<Category[]> => {
"use server";
// await TEST()
// console.log(await getItemPlaybackInfo(jellyfinUserId, 'a69c0c0ab66177a7adb671f126335d16'));
return [
{ label: "Continue", entries: await getContinueWatching(jellyfinUserId) },
// { label: "Continue", entries: await getContinueWatching(jellyfinUserId) },
{ label: "Random", entries: await getRandomItems(jellyfinUserId) },
{
label: "Popular",
entries: [
@ -70,11 +71,9 @@ export const listCategories = query(async (): Promise<Category[]> => {
export const getEntry = query(
async (id: Entry["id"]): Promise<Entry | undefined> => {
"use server";
return getItem(jellyfinUserId, id);
},
"series.get",
);
export { listUsers, getContinueWatching } from "./apis/jellyfin";
export { listUsers, getContinueWatching, listItems } from "./apis/jellyfin";

View file

@ -7,11 +7,13 @@ export interface Category {
export interface Entry {
id: string;
title: string;
summary?: string;
synopsis?: string;
releaseDate?: string;
sources?: Entry.Source[];
thumbnail?: URL | string;
image?: string;
image?: URL | string;
[prop: string]: any;
}
export namespace Entry {

View file

@ -22,21 +22,16 @@
box-shadow: var(--shadow-2);
background:
/* Dot */
radial-gradient(circle at 25% 30% #7772 #7774 1em transparent 1em),
/* Dot */
radial-gradient(circle at 85% 15% #7772 #7774 1em transparent 1em),
/* Bottom fade */ linear-gradient(165deg transparent 60% #555 60% #333),
/* wave dark part */
radial-gradient(circle at 25% 30%, #7772, #7774 1em, transparent 1em),
radial-gradient(circle at 85% 15%, #7772, #7774 1em, transparent 1em),
linear-gradient(165deg, transparent 60%, #555 60%, #333),
radial-gradient(
ellipse 5em 2.25em at 0.5em calc(50% - 1em) #333 100% transparent 100%
ellipse 5em 2.25em at 0.5em calc(50% - 1em), #333 100%, transparent 100%
),
/* wave light part */
radial-gradient(
ellipse 5em 2.25em at calc(100% - 0.5em) calc(50% + 1em) #555 100%
transparent 100%
ellipse 5em 2.25em at calc(100% - 0.5em) calc(50% + 1em), #555 100%, transparent 100%
),
/* Base */ linear-gradient(to bottom #333 50% #555 50%);
linear-gradient(to bottom, #333 50%, #555 50%);
transform-origin: 50% 0;
transform: scale(1.1) translateY(calc(-4 * var(--padding)));

View file

@ -10,6 +10,7 @@ export const ListItem: Component<{ entry: Entry }> = (props) => {
<figure class={css.listItem} data-id={props.entry.id}>
<img src={props.entry.thumbnail ?? ''} alt={props.entry.title} />
<figcaption>
<strong>{props.entry.title}</strong>

View file

@ -12,16 +12,14 @@ import { Hero } from "~/components/hero";
import css from "./overview.module.css";
type OverviewProps = {
highlight: Entry;
highlights: Entry[];
categories: Category[];
};
export const Overview: Component<OverviewProps> = (props) => {
const [container, setContainer] = createSignal<HTMLElement>();
return (
<div ref={setContainer} class={css.container}>
<Hero class={css.hero} entry={props.highlight}></Hero>
<div class={css.container}>
<Hero class={css.hero} entries={props.highlights}></Hero>
<Index each={props.categories}>
{(category) => (

View file

@ -140,7 +140,7 @@ export const Player: Component<PlayerProps> = (props) => {
console.log("pause", e);
},
suspend(e) {
console.log("suspend", e);
// console.log("suspend", e);
},
volumechange(e) {
@ -152,7 +152,7 @@ export const Player: Component<PlayerProps> = (props) => {
},
progress(e) {
console.log(e);
// console.log(e);
},
// timeupdate(e) {

View file

@ -1,121 +0,0 @@
.carousel {
display: block grid;
grid: auto 1fr / 100%;
& > header {
anchor-name: --carousel;
padding-inline: 3rem;
font-size: 1.75rem;
font-weight: 900;
}
& > ul {
list-style-type: none;
container-type: size;
inline-size: 100%;
block-size: min(60svh, 720px);
display: grid;
grid-auto-flow: column;
overflow: visible auto;
scroll-snap-type: inline mandatory;
overscroll-behavior-inline: contain;
justify-self: center;
gap: 1em;
padding-inline: 2em;
scroll-padding-inline: 2em;
padding-block: 2em 4em;
margin-block-end: 5em;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
/* the before and afters have unsnappable elements that create bouncy edges to the scroll */
&::before,
&::after {
content: "";
display: block;
}
&::before {
order: 0;
inline-size: 15cqi;
}
&::after {
order: 11;
inline-size: 50cqi;
}
&::scroll-button(*) {
z-index: 20;
background: oklch(from var(--surface-1) l c h / 50%);
backdrop-filter: blur(10px);
}
&::scroll-button(inline-start) {
position-area: center span-inline-start;
content: "◄" / "Previous";
}
&::scroll-button(inline-end) {
position-area: center span-inline-end;
content: "►" / "Next";
}
& > li {
scroll-snap-align: start;
container-type: scroll-state;
padding: 0;
position: relative;
order: calc(var(--sibling-count) - var(--sibling-index));
z-index: var(--sibling-index);
& > figure {
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
animation: slide-in linear both;
animation-timeline: view(inline);
animation-range: cover -100cqi contain 25cqi;
}
}
@container scroll-state(snapped: inline) {
outline: 1px solid var(--gray-1);
outline-offset: 10px;
}
flex-shrink: 0;
block-size: 100cqb;
aspect-ratio: 9/16;
background: light-dark(#ccc, #444);
box-shadow: var(--shadow-5);
border-radius: 20px;
overflow: clip;
display: flex;
@container (width < 480px) {
block-size: 50cqb;
}
& > img {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
}
}
}
}
@keyframes slide-in {
from {
transform: translateX(-100cqi) scale(0.75);
}
}

View file

@ -2,84 +2,28 @@ import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { Overview } from "~/features/overview";
import {
getHighlights,
listCategories,
getEntry,
getContinueWatching,
} from "~/features/content";
import { Show } from "solid-js";
import css from "./index.module.css";
const highlightId = 'c97185ed-e0cf-4945-9120-9d15bb8e5998';
export const route = {
preload: async () => ({
highlight: await getEntry(highlightId),
highlight: await getHighlights(),
categories: await listCategories(),
}),
};
export default function Home() {
const highlight = createAsync(() => getEntry(highlightId));
const highlights = createAsync(() => getHighlights());
const categories = createAsync(() => listCategories());
return (
<>
<Title>Home</Title>
{/* <div class={css.carousel}>
<header>some category</header>
<ul>
<li>
<figure>
<img src="https://assets.codepen.io/2585/1.jpg" alt="Item 1" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/2.avif" alt="Item 2" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/3.avif" alt="Item 3" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/4.avif" alt="Item 4" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/5.avif" alt="Item 5" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/6.avif" alt="Item 6" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/7.avif" alt="Item 7" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/8.avif" alt="Item 8" />
</figure>
</li>
<li>
<figure>
<img src="https://assets.codepen.io/2585/9.avif" alt="Item 9" />
</figure>
</li>
</ul>
</div> */}
<Show when={highlight() && categories()}>
<Overview highlight={highlight()!} categories={categories()!} />
<Show when={highlights() && categories()}>
<Overview highlights={highlights()!} categories={categories()!} />
</Show>
</>
);

View file

@ -1,5 +1,4 @@
import {
createAsync,
json,
Params,
query,
@ -7,10 +6,8 @@ import {
RouteDefinition,
useParams,
} from "@solidjs/router";
import { createEffect } from "solid-js";
import { createSlug, getEntry } from "~/features/content";
import { Player } from "~/features/player";
import { toSlug } from "~/utilities";
const healUrl = query(async (slug: string) => {
const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1));
@ -34,9 +31,15 @@ interface ItemParams extends Params {
export const route = {
async preload({ params }) {
await healUrl(params.slug);
const slug = params.slug;
return getEntry(params.slug.slice(params.slug.lastIndexOf("-") + 1));
if (!slug) {
return;
}
await healUrl(slug);
return getEntry(slug.slice(slug.lastIndexOf("-") + 1));
},
} satisfies RouteDefinition;

View file

@ -16,7 +16,7 @@ export const splitAt = (
};
export const toSlug = (subject: string) =>
subject.toLowerCase().replaceAll(" ", "-");
subject.toLowerCase().replaceAll(" ", "-").replaceAll(/[^\w-]/gi, "");
export const toHex = (subject: number) => subject.toString(16).padStart(2, "0");
const encoder = new TextEncoder();