diff --git a/bun.lock b/bun.lock index add5bca..7d18788 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "streamarr", "dependencies": { "@solid-primitives/context": "^0.3.0", + "@solid-primitives/deep": "^0.3.1", "@solid-primitives/event-listener": "^2.4.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.3", @@ -313,12 +314,16 @@ "@solid-primitives/cursor": ["@solid-primitives/cursor@0.0.115", "", { "dependencies": { "@solid-primitives/utils": "^6.2.3" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-8nEmUN/sacXPChwuJOAi6Yi6VnxthW/Jk8VGvvcF38AenjUvOA6FHI6AkJILuFXjQw1PGxia1YbH/Mn77dPiOA=="], + "@solid-primitives/deep": ["@solid-primitives/deep@0.3.1", "", { "dependencies": { "@solid-primitives/memo": "^1.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-DaJ2iuHmYmHyGhA5EACfqVKXNivGlLA+zYborz1pLzKOpyMZq0LvmNoBgwFwN/ncOq5FhGP0Hvr6U7QP+hmLXw=="], + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TSfR1PNTfojFEYGSxSMCnUhXsaYWBo4p+cm73QmWODa9YnaQAk6PB7VjzG2bOT2D817VlvuOqTj0Qdq+MZrdGg=="], "@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=="], + "@solid-primitives/memo": ["@solid-primitives/memo@1.4.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MzNCJNpXidQdLOZUsEkwpuq52uwT8zrFrBxEVMEr9N35yIIvGhjqwrI1M6xzPmJGzuVUe8anCk57q+N5gyRk0Q=="], + "@solid-primitives/platform": ["@solid-primitives/platform@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sSxcZfuUrtxcwV0vdjmGnZQcflACzMfLriVeIIWXKp8hzaS3Or3tO6EFQkTd3L8T5dTq+kTtLvPscXIpL0Wzdg=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-QJ3bTSQOlPdHBP2m6llrT13FvVzAwZfx41lTN8lQrRwwcZoWb7kfCAjhaohPnwkAsQ6nJpLjtGfT5GOyuCA4tA=="], diff --git a/package.json b/package.json index 490bce2..8f79e4f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@solid-primitives/context": "^0.3.0", + "@solid-primitives/deep": "^0.3.1", "@solid-primitives/event-listener": "^2.4.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.3", diff --git a/src/components/list/list.module.css b/src/components/list/list.module.css index 7e92297..823003a 100644 --- a/src/components/list/list.module.css +++ b/src/components/list/list.module.css @@ -53,6 +53,10 @@ z-index: calc(var(--sibling-count) - var(--sibling-index)); + &:has(> :hover, > :focus-within) { + z-index: calc(var(--sibling-count) + 1); + } + & > * { @supports (animation-timeline: view()) { @media (prefers-reduced-motion: no-preference) { @@ -69,4 +73,4 @@ from { transform: translateX(-100cqi) scale(0.5); } -} +} \ No newline at end of file diff --git a/src/features/content/apis/jellyfin.ts b/src/features/content/apis/jellyfin.ts index ce67776..d67a43e 100644 --- a/src/features/content/apis/jellyfin.ts +++ b/src/features/content/apis/jellyfin.ts @@ -1,16 +1,55 @@ import createClient from "openapi-fetch"; -import type { paths } from "./jellyfin.generated"; // generated by openapi-typescript +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; const client = createClient({ baseUrl, headers: { - Authorization: `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`, + 'Authorization': `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`, + 'Content-Type': 'application/json; profile="CamelCase"', }, }); +export const TEST = query(async () => { + const userId = "a9c51af84bf54578a99ab4dd0ebf0763"; + const itemId = "919dfa97e4dad2758a925d056e590a28"; + const seriesId = "5230ddbcd9400733dc07e5b8cb7a4f49"; + + const { data: seriesData } = await client.GET("/UserItems/{itemId}/UserData", { + params: { + path: { itemId: seriesId }, + query: { userId } + } + }); + + const { data: epData } = await client.GET("/UserItems/{itemId}/UserData", { + params: { + path: { itemId }, + query: { userId } + } + }); + + console.log(seriesData, epData) +}, "jellyfin.TEST"); + +export const getCurrentUser = query(async () => { + const { data, error, response } = await client.GET("/Users/Public", { + params: {}, + }); + + console.log(data, error, response) + + return data; +}, "jellyfin.getCurrentUser"); + export const listUsers = query(async () => { const { data, error } = await client.GET("/Users", { params: {}, @@ -19,7 +58,7 @@ export const listUsers = query(async () => { return data ?? []; }, "jellyfin.listUsers"); -export const getItem = query(async (userId: string, itemId: string) => { +export const getItem = query(async (userId: string, itemId: string): Promise => { const { data, error } = await client.GET("/Items/{itemId}", { params: { path: { @@ -41,30 +80,97 @@ export const getItem = query(async (userId: string, itemId: string) => { }, }); - return data?.Items ?? []; + if (data === undefined) { + return undefined; + } + + return { + id: data.Id!, + title: data.Name!, + thumbnail: new URL(`/Items/${itemId}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'), + }; }, "jellyfin.getItem"); +export const getItemImage = query(async (itemId: string, imageType: ItemImageType): Promise => { + const { data, error } = await client.GET("/Items/{itemId}/Images/{imageType}", { + parseAs: 'blob', + params: { + path: { + itemId, + imageType + }, + query: { + }, + }, + }); + + return data; +}, "jellyfin.getItemImage"); + +export const getItemPlaybackInfo = query(async (userId: string, itemId: string): Promise => { + const { data, error, response } = await client.GET("/Items/{itemId}/PlaybackInfo", { + parseAs: 'text', + + params: { + path: { + itemId, + }, + query: { + userId, + }, + }, + }); + + return undefined; +}, "jellyfin.getItemPlaybackInfo"); + +export const queryItems = query(async () => { + const { data, error } = await client.GET("/Items", { + params: { + query: { + mediaTypes: ["Video"], + isUnaired: true, + limit: 10, + // fields: ["ProviderIds", "Genres"], + includeItemTypes: ["Series", "Movie"], + recursive: true, + }, + }, + }); + + console.log(data); + +}, 'jellyfin.queryItems'); + export const getContinueWatching = query( async (userId: string): Promise => { - const { data, error } = await client.GET("/Users/{userId}/Items/Resume", { + const { data, error } = await client.GET("/UserItems/Resume", { params: { - path: { - userId, - }, query: { + userId, mediaTypes: ["Video"], - fields: ["ProviderIds", "Genres"], + // fields: ["ProviderIds", "Genres"], + // includeItemTypes: ["Series", "Movie"] }, }, }); - const items = (data?.Items ?? []).map(({ Id, Name }) => ({ - id: Id, - title: Name, - thumbnail: `${baseUrl}/Items/${Id}/Images/Primary`, - })); + if (Array.isArray(data?.Items) !== true) { + return []; + } - return items; + const uniqueIds = new Set(data.Items.map(item => item.Type === 'Episode' ? item.SeriesId! : 'MOVIE_ID')); + const results = await Promise.allSettled(uniqueIds.values().map(id => getItem(userId, id)).toArray()); + + assertNoErrors(results); + + return results.filter((result): result is PromiseFulfilledResult => result.value !== undefined).map(({ value }) => value); }, "jellyfin.continueWatching", ); + +function assertNoErrors(results: PromiseSettledResult[]): asserts results is PromiseFulfilledResult[] { + if (results.some(({ status }) => status !== 'fulfilled')) { + throw new Error('one or more promices failed', { cause: results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason) }); + } +} \ No newline at end of file diff --git a/src/features/content/service.ts b/src/features/content/service.ts index 05c9786..1829937 100644 --- a/src/features/content/service.ts +++ b/src/features/content/service.ts @@ -1,12 +1,15 @@ import type { Category, Entry } from "./types"; import { query } from "@solidjs/router"; import { entries } from "./data"; -import { getContinueWatching } from "./apis/jellyfin"; +import { getContinueWatching, getItem, TEST } from "./apis/jellyfin"; + +const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763"; export const listCategories = query(async (): Promise => { "use server"; - const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763"; + // await TEST() + // console.log(await getItemPlaybackInfo(jellyfinUserId, 'a69c0c0ab66177a7adb671f126335d16')); return [ { label: "Continue", entries: await getContinueWatching(jellyfinUserId) }, @@ -69,9 +72,9 @@ export const getEntry = query( async (id: Entry["id"]): Promise => { "use server"; - return entries.get(id); + return getItem(jellyfinUserId, id); }, "series.get", ); -export { listUsers, getItem, getContinueWatching } from "./apis/jellyfin"; +export { listUsers, getContinueWatching } from "./apis/jellyfin"; diff --git a/src/features/content/types.ts b/src/features/content/types.ts index d1314b2..8546a5d 100644 --- a/src/features/content/types.ts +++ b/src/features/content/types.ts @@ -10,7 +10,7 @@ export interface Entry { summary?: string; releaseDate?: string; sources?: Entry.Source[]; - thumbnail?: string; + thumbnail?: URL | string; image?: string; } diff --git a/src/features/overview/list-item.tsx b/src/features/overview/list-item.tsx index 000bcf9..252dd2f 100644 --- a/src/features/overview/list-item.tsx +++ b/src/features/overview/list-item.tsx @@ -7,8 +7,8 @@ export const ListItem: Component<{ entry: Entry }> = (props) => { const slug = createMemo(() => createSlug(props.entry)); return ( -
- {props.entry.title} +
+ {props.entry.title}
{props.entry.title} diff --git a/src/features/player/controls/volume.tsx b/src/features/player/controls/volume.tsx index 1647997..a99a803 100644 --- a/src/features/player/controls/volume.tsx +++ b/src/features/player/controls/volume.tsx @@ -1,17 +1,25 @@ -import { Component, createSignal } from "solid-js"; +import { Component, createEffect, createSignal, Show } from "solid-js"; import css from "./volume.module.css"; +import { createStore, unwrap } from "solid-js/store"; +import { trackDeep } from "@solid-primitives/deep"; interface VolumeProps { value: number; + muted?: boolean; + onInput?: (next: { volume: number, muted: boolean }) => any; } export const Volume: Component = (props) => { - const [volume, setVolume] = createSignal(props.value); + const [state, setState] = createStore({ volume: props.value, muted: props.muted ?? false }); + + createEffect(() => { + props.onInput?.(unwrap(trackDeep(state))); + }); return (
- - + + setState('volume', e.target.valueAsNumber)} />
); }; diff --git a/src/features/player/player.tsx b/src/features/player/player.tsx index c15e21d..ffcf797 100644 --- a/src/features/player/player.tsx +++ b/src/features/player/player.tsx @@ -6,6 +6,7 @@ import { createAsync, json, query } from "@solidjs/router"; import { Component, createEffect, createMemo, createSignal } from "solid-js"; import css from "./player.module.css"; import { Volume } from "./controls/volume"; +import { getEntry } from "../content"; const metadata = query(async (id: string) => { "use server"; @@ -42,9 +43,12 @@ export const Player: Component = (props) => { const [video, setVideo] = createSignal( undefined as unknown as HTMLVideoElement, ); + + const entry = createAsync(() => getEntry(props.id)); + const data = createAsync(() => metadata(props.id), { deferStream: true, - initialValue: {}, + initialValue: {} as any, }); const captionUrl = createMemo(() => { const { captions } = data(); @@ -168,7 +172,7 @@ export const Player: Component = (props) => { return (
-

{props.id}

+

{entry()?.title}

- + { + video().volume = volume; + video().muted = muted; + }} />
diff --git a/src/index.css b/src/index.css index f972d71..02488f7 100644 --- a/src/index.css +++ b/src/index.css @@ -115,63 +115,63 @@ --sibling-index: 15; } - :has(> :last-child:nth-child(1)) { - --sibbling-count: 1; + :has(> :last-child:nth-child(1)) > * { + --sibling-count: 1; } - :has(> :last-child:nth-child(2)) { - --sibbling-count: 2; + :has(> :last-child:nth-child(2)) > * { + --sibling-count: 2; } - :has(> :last-child:nth-child(3)) { - --sibbling-count: 3; + :has(> :last-child:nth-child(3)) > * { + --sibling-count: 3; } - :has(> :last-child:nth-child(4)) { - --sibbling-count: 4; + :has(> :last-child:nth-child(4)) > * { + --sibling-count: 4; } - :has(> :last-child:nth-child(5)) { - --sibbling-count: 5; + :has(> :last-child:nth-child(5)) > * { + --sibling-count: 5; } - :has(> :last-child:nth-child(6)) { - --sibbling-count: 6; + :has(> :last-child:nth-child(6)) > * { + --sibling-count: 6; } - :has(> :last-child:nth-child(7)) { - --sibbling-count: 7; + :has(> :last-child:nth-child(7)) > * { + --sibling-count: 7; } - :has(> :last-child:nth-child(8)) { - --sibbling-count: 8; + :has(> :last-child:nth-child(8)) > * { + --sibling-count: 8; } - :has(> :last-child:nth-child(9)) { - --sibbling-count: 9; + :has(> :last-child:nth-child(9)) > * { + --sibling-count: 9; } - :has(> :last-child:nth-child(10)) { - --sibbling-count: 10; + :has(> :last-child:nth-child(10)) > * { + --sibling-count: 10; } - :has(> :last-child:nth-child(11)) { - --sibbling-count: 11; + :has(> :last-child:nth-child(11)) > * { + --sibling-count: 11; } - :has(> :last-child:nth-child(12)) { - --sibbling-count: 12; + :has(> :last-child:nth-child(12)) > * { + --sibling-count: 12; } - :has(> :last-child:nth-child(13)) { - --sibbling-count: 13; + :has(> :last-child:nth-child(13)) > * { + --sibling-count: 13; } - :has(> :last-child:nth-child(14)) { - --sibbling-count: 14; + :has(> :last-child:nth-child(14)) > * { + --sibling-count: 14; } - :has(> :last-child:nth-child(15)) { - --sibbling-count: 15; + :has(> :last-child:nth-child(15)) > * { + --sibling-count: 15; } } \ No newline at end of file diff --git a/src/routes/(shell)/watch/[slug].tsx b/src/routes/(shell)/watch/[slug].tsx index 10974ee..b3aeeb6 100644 --- a/src/routes/(shell)/watch/[slug].tsx +++ b/src/routes/(shell)/watch/[slug].tsx @@ -1,4 +1,5 @@ import { + createAsync, json, Params, query, @@ -6,6 +7,7 @@ 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"; @@ -33,6 +35,8 @@ interface ItemParams extends Params { export const route = { async preload({ params }) { await healUrl(params.slug); + + return getEntry(params.slug.slice(params.slug.lastIndexOf("-") + 1)); }, } satisfies RouteDefinition;