mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-08 18:54:22 -04:00
Rework images & skeletons
This commit is contained in:
parent
886b33d5a7
commit
e63e3605c6
@ -7,15 +7,13 @@ import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import type { Serie } from "~/models";
|
||||
import { WatchStatusV } from "~/models";
|
||||
import { HR, IconButton, Menu, tooltip } from "~/primitives";
|
||||
import { useAccount } from "~/providers/account-context";
|
||||
import { useMutation } from "~/query";
|
||||
import { watchListIcon } from "./watchlist-info";
|
||||
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
|
||||
|
||||
type WatchStatusV = NonNullable<Serie["watchStatus"]>["status"];
|
||||
|
||||
export const EpisodesContext = ({
|
||||
type = "episode",
|
||||
slug,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { type ImageStyle, Platform, View } from "react-native";
|
||||
import { type Stylable, type Theme, percent, px, useYoshiki } from "yoshiki/native";
|
||||
import type { KyooImage, WatchStatusV } from "~/models";
|
||||
import type { KImage, WatchStatusV } from "~/models";
|
||||
import {
|
||||
Link,
|
||||
P,
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from "~/primitives";
|
||||
import type { Layout } from "~/query";
|
||||
import { ItemWatchStatus } from "./item-helpers";
|
||||
import { ItemContext } from "./context-menus";
|
||||
|
||||
export const ItemProgress = ({ watchPercent }: { watchPercent: number }) => {
|
||||
const { css } = useYoshiki("episodebox");
|
||||
@ -59,7 +60,7 @@ export const ItemGrid = ({
|
||||
slug: string;
|
||||
name: string;
|
||||
subtitle: string | null;
|
||||
poster: KyooImage | null;
|
||||
poster: KImage | null;
|
||||
watchStatus: WatchStatusV | null;
|
||||
watchPercent: number | null;
|
||||
type: "movie" | "serie" | "collection";
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { Genre } from "./utils/genre";
|
||||
import { Image } from "./utils/images";
|
||||
import { KImage } from "./utils/images";
|
||||
import { Metadata } from "./utils/metadata";
|
||||
import { zdate } from "./utils/utils";
|
||||
|
||||
@ -24,10 +24,10 @@ export const Collection = z
|
||||
genres: z.array(Genre),
|
||||
externalId: Metadata,
|
||||
|
||||
poster: Image.nullable(),
|
||||
thumbnail: Image.nullable(),
|
||||
banner: Image.nullable(),
|
||||
logo: Image.nullable(),
|
||||
poster: KImage.nullable(),
|
||||
thumbnail: KImage.nullable(),
|
||||
banner: KImage.nullable(),
|
||||
logo: KImage.nullable(),
|
||||
|
||||
createdAt: zdate(),
|
||||
updatedAt: zdate(),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const Entry = z.object({
|
||||
id: z.string(),
|
||||
|
@ -8,3 +8,5 @@ export * from "./show";
|
||||
export * from "./entry";
|
||||
export * from "./studio";
|
||||
export * from "./video";
|
||||
|
||||
export * from "./utils/images";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { Studio } from "./studio";
|
||||
import { Genre } from "./utils/genre";
|
||||
import { Image } from "./utils/images";
|
||||
import { KImage } from "./utils/images";
|
||||
import { Metadata } from "./utils/metadata";
|
||||
import { zdate } from "./utils/utils";
|
||||
import { EmbeddedVideo } from "./video";
|
||||
@ -27,10 +27,10 @@ export const Movie = z
|
||||
genres: z.array(Genre),
|
||||
externalId: Metadata,
|
||||
|
||||
poster: Image.nullable(),
|
||||
thumbnail: Image.nullable(),
|
||||
banner: Image.nullable(),
|
||||
logo: Image.nullable(),
|
||||
poster: KImage.nullable(),
|
||||
thumbnail: KImage.nullable(),
|
||||
banner: KImage.nullable(),
|
||||
logo: KImage.nullable(),
|
||||
trailerUrl: z.string().optional().nullable(),
|
||||
|
||||
isAvailable: z.boolean(),
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { Entry } from "./entry";
|
||||
import { Studio } from "./studio";
|
||||
import { Genre } from "./utils/genre";
|
||||
import { Image } from "./utils/images";
|
||||
import { KImage } from "./utils/images";
|
||||
import { Metadata } from "./utils/metadata";
|
||||
import { zdate } from "./utils/utils";
|
||||
|
||||
@ -28,10 +28,10 @@ export const Serie = z
|
||||
runtime: z.number().nullable(),
|
||||
externalId: Metadata,
|
||||
|
||||
poster: Image.nullable(),
|
||||
thumbnail: Image.nullable(),
|
||||
banner: Image.nullable(),
|
||||
logo: Image.nullable(),
|
||||
poster: KImage.nullable(),
|
||||
thumbnail: KImage.nullable(),
|
||||
banner: KImage.nullable(),
|
||||
logo: KImage.nullable(),
|
||||
trailerUrl: z.string().optional().nullable(),
|
||||
|
||||
entriesCount: z.number().int(),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { Collection } from "./collection";
|
||||
import { Movie } from "./movie";
|
||||
import { Serie } from "./serie";
|
||||
@ -9,3 +9,6 @@ export const Show = z.union([
|
||||
Collection.and(z.object({ kind: z.literal("collection") })),
|
||||
]);
|
||||
export type Show = z.infer<typeof Show>;
|
||||
|
||||
export type WatchStatusV = NonNullable<Serie["watchStatus"]>["status"];
|
||||
export const WatchStatusV = ["completed", "watching", "rewatching", "dropped", "planned"] as const;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { Image } from "./utils/images";
|
||||
import { z } from "zod/v4";
|
||||
import { KImage } from "./utils/images";
|
||||
import { Metadata } from "./utils/metadata";
|
||||
import { zdate } from "./utils/utils";
|
||||
|
||||
@ -7,7 +7,7 @@ export const Studio = z.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
logo: Image.nullable(),
|
||||
logo: KImage.nullable(),
|
||||
externalId: Metadata,
|
||||
createdAt: zdate(),
|
||||
updatedAt: zdate(),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import z from "zod";
|
||||
import z from "zod/v4";
|
||||
|
||||
export const Genre = z.enum([
|
||||
"action",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const Image = z
|
||||
export const KImage = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
@ -13,4 +13,4 @@ export const Image = z
|
||||
high: `/images/${x.id}?quality=high`,
|
||||
}));
|
||||
|
||||
export type Image = z.infer<typeof Image>;
|
||||
export type KImage = z.infer<typeof KImage>;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const Metadata = z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
dataId: z.string(),
|
||||
link: z.string().nullable(),
|
||||
|
@ -1,20 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { type ZodType, z } from "zod/v4";
|
||||
|
||||
export interface Page<T> {
|
||||
export type Page<T> = {
|
||||
this: string;
|
||||
first: string;
|
||||
next: string | null;
|
||||
count: number;
|
||||
items: T[];
|
||||
}
|
||||
};
|
||||
|
||||
export const Paged = <Item>(item: z.ZodType<Item>): z.ZodSchema<Page<Item>> =>
|
||||
export const Paged = <Parser extends ZodType>(ItemParser: Parser) =>
|
||||
z.object({
|
||||
this: z.string(),
|
||||
first: z.string(),
|
||||
next: z.string().nullable(),
|
||||
count: z.number(),
|
||||
items: z.array(item),
|
||||
items: z.array(ItemParser),
|
||||
});
|
||||
|
||||
export const isPage = <T = unknown>(obj: unknown): obj is Page<T> =>
|
||||
|
@ -1,3 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const zdate = z.coerce.date;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const EmbeddedVideo = z.object({
|
||||
id: z.string(),
|
||||
|
@ -1 +0,0 @@
|
||||
export const imageBorderRadius = 10;
|
95
front/src/primitives/image-background.tsx
Normal file
95
front/src/primitives/image-background.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { ImageBackground as EImageBackground } from "expo-image";
|
||||
import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { ImageStyle } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import type { KImage } from "~/models";
|
||||
import { useToken } from "~/providers/account-context";
|
||||
import type { ImageLayout, YoshikiEnhanced } from "./image";
|
||||
|
||||
// This should stay in think with `Image`.
|
||||
// (copy-pasted but change `EImage` with `EImageBackground`)
|
||||
// ALSO, remove `border-radius` (it's weird otherwise)
|
||||
export const ImageBackground = ({
|
||||
src,
|
||||
quality,
|
||||
alt,
|
||||
layout,
|
||||
...props
|
||||
}: {
|
||||
src: KImage | null;
|
||||
quality: "low" | "medium" | "high";
|
||||
alt?: string;
|
||||
style?: ImageStyle;
|
||||
layout: ImageLayout;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { apiUrl, authToken } = useToken();
|
||||
|
||||
return (
|
||||
<EImageBackground
|
||||
source={{
|
||||
uri: src ? `${apiUrl}${src[quality ?? "high"]}` : null,
|
||||
headers: authToken
|
||||
? {
|
||||
Authorization: authToken,
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
placeholder={{ blurhash: src?.blurhash }}
|
||||
accessibilityLabel={alt}
|
||||
{...(css(layout, props) as any)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const PosterBackground = ({
|
||||
alt,
|
||||
layout,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof ImageBackground>, "layout"> & {
|
||||
style?: ImageStyle;
|
||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<ImageBackground
|
||||
alt={alt!}
|
||||
layout={{ aspectRatio: 2 / 3, ...layout }}
|
||||
{...css({ borderRadius: 6 }, props)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const GradientImageBackground = ({
|
||||
gradient,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof ImageBackground> & {
|
||||
gradient?: Partial<LinearGradientProps>;
|
||||
}) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
|
||||
return (
|
||||
<ImageBackground {...props}>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.25 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={["transparent", theme.darkOverlay]}
|
||||
{...css(
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
typeof gradient === "object" ? gradient : undefined,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
</ImageBackground>
|
||||
);
|
||||
};
|
76
front/src/primitives/image.tsx
Normal file
76
front/src/primitives/image.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { Image as EImage } from "expo-image";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { ImageStyle, ViewStyle } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import type { YoshikiStyle } from "yoshiki/src/type";
|
||||
import type { KImage } from "~/models";
|
||||
import { useToken } from "~/providers/account-context";
|
||||
import { Skeleton } from "./skeleton";
|
||||
|
||||
export type YoshikiEnhanced<Style> = Style extends any
|
||||
? {
|
||||
[key in keyof Style]: YoshikiStyle<Style[key]>;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type ImageLayout = YoshikiEnhanced<
|
||||
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
|
||||
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
|
||||
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
|
||||
>;
|
||||
|
||||
// This should stay in think with `ImageBackground`.
|
||||
// (copy-pasted but change `EImageBackground` with `EImage`)
|
||||
export const Image = ({
|
||||
src,
|
||||
quality,
|
||||
alt,
|
||||
layout,
|
||||
...props
|
||||
}: {
|
||||
src: KImage | null;
|
||||
quality: "low" | "medium" | "high";
|
||||
alt?: string;
|
||||
style?: ImageStyle;
|
||||
layout: ImageLayout;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { apiUrl, authToken } = useToken();
|
||||
|
||||
return (
|
||||
<EImage
|
||||
source={{
|
||||
uri: src ? `${apiUrl}${src[quality ?? "high"]}` : null,
|
||||
headers: authToken
|
||||
? {
|
||||
Authorization: authToken,
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
placeholder={{ blurhash: src?.blurhash }}
|
||||
accessibilityLabel={alt}
|
||||
{...(css([layout, { borderRadius: 6 }], props) as any)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => {
|
||||
const { css } = useYoshiki();
|
||||
const border = { borderRadius: 6 } satisfies ViewStyle;
|
||||
|
||||
return <Skeleton variant="custom" {...css([layout, border], props)} />;
|
||||
};
|
||||
|
||||
export const Poster = ({
|
||||
layout,
|
||||
...props
|
||||
}: ComponentProps<typeof Image> & {
|
||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||
}) => <Image layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||
|
||||
Poster.Loader = ({
|
||||
layout,
|
||||
...props
|
||||
}: {
|
||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import type { ImageStyle } from "react-native";
|
||||
import type { YoshikiStyle } from "yoshiki/src/type";
|
||||
import type { KyooImage } from "~/models";
|
||||
|
||||
export type YoshikiEnhanced<Style> = Style extends any
|
||||
? {
|
||||
[key in keyof Style]: YoshikiStyle<Style[key]>;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type Props = {
|
||||
src?: KyooImage | null;
|
||||
quality: "low" | "medium" | "high";
|
||||
alt?: string;
|
||||
Err?: ReactElement | null;
|
||||
forcedLoading?: boolean;
|
||||
};
|
||||
|
||||
export type ImageLayout = YoshikiEnhanced<
|
||||
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
|
||||
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
|
||||
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
|
||||
>;
|
@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Blurhash } from "react-native-blurhash";
|
||||
import { type Stylable, useYoshiki } from "yoshiki/native";
|
||||
|
||||
export const BlurhashContainer = ({
|
||||
blurhash,
|
||||
children,
|
||||
...props
|
||||
}: { blurhash: string; children?: ReactElement | ReactElement[] } & Stylable) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Blurhash
|
||||
blurhash={blurhash}
|
||||
resizeMode="cover"
|
||||
{...css({ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 })}
|
||||
/>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
@ -1,322 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { decode } from "blurhash";
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useYoshiki } from "yoshiki";
|
||||
import { nativeStyleToCss } from "yoshiki/native";
|
||||
|
||||
// The blurhashToUrl has been stolen from https://gist.github.com/mattiaz9/53cb67040fa135cb395b1d015a200aff
|
||||
export function blurHashToDataURL(hash: string | undefined): string | undefined {
|
||||
if (!hash) return undefined;
|
||||
|
||||
const pixels = decode(hash, 32, 32);
|
||||
const dataURL = parsePixels(pixels, 32, 32);
|
||||
return dataURL;
|
||||
}
|
||||
|
||||
// thanks to https://github.com/wheany/js-png-encoder
|
||||
function parsePixels(pixels: Uint8ClampedArray, width: number, height: number) {
|
||||
const pixelsString = Array.from(pixels)
|
||||
.map((byte) => String.fromCharCode(byte))
|
||||
.join("");
|
||||
const pngString = generatePng(width, height, pixelsString);
|
||||
const dataURL =
|
||||
typeof Buffer !== "undefined"
|
||||
? Buffer.from(getPngArray(pngString)).toString("base64")
|
||||
: btoa(pngString);
|
||||
return `data:image/png;base64,${dataURL}`;
|
||||
}
|
||||
|
||||
function getPngArray(pngString: string) {
|
||||
const pngArray = new Uint8Array(pngString.length);
|
||||
for (let i = 0; i < pngString.length; i++) {
|
||||
pngArray[i] = pngString.charCodeAt(i);
|
||||
}
|
||||
return pngArray;
|
||||
}
|
||||
|
||||
function generatePng(width: number, height: number, rgbaString: string) {
|
||||
const DEFLATE_METHOD = String.fromCharCode(0x78, 0x01);
|
||||
const CRC_TABLE: number[] = [];
|
||||
const SIGNATURE = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10);
|
||||
const NO_FILTER = String.fromCharCode(0);
|
||||
|
||||
// biome-ignore lint: not gonna fix stackowerflow code that works
|
||||
let n, c, k;
|
||||
|
||||
// make crc table
|
||||
for (n = 0; n < 256; n++) {
|
||||
c = n;
|
||||
for (k = 0; k < 8; k++) {
|
||||
if (c & 1) {
|
||||
c = 0xedb88320 ^ (c >>> 1);
|
||||
} else {
|
||||
c = c >>> 1;
|
||||
}
|
||||
}
|
||||
CRC_TABLE[n] = c;
|
||||
}
|
||||
|
||||
// Functions
|
||||
function inflateStore(data: string) {
|
||||
const MAX_STORE_LENGTH = 65535;
|
||||
let storeBuffer = "";
|
||||
// biome-ignore lint: not gonna fix stackowerflow code that works
|
||||
let remaining;
|
||||
// biome-ignore lint: not gonna fix stackowerflow code that works
|
||||
let blockType;
|
||||
|
||||
for (let i = 0; i < data.length; i += MAX_STORE_LENGTH) {
|
||||
remaining = data.length - i;
|
||||
blockType = "";
|
||||
|
||||
if (remaining <= MAX_STORE_LENGTH) {
|
||||
blockType = String.fromCharCode(0x01);
|
||||
} else {
|
||||
remaining = MAX_STORE_LENGTH;
|
||||
blockType = String.fromCharCode(0x00);
|
||||
}
|
||||
// little-endian
|
||||
storeBuffer += blockType + String.fromCharCode(remaining & 0xff, (remaining & 0xff00) >>> 8);
|
||||
storeBuffer += String.fromCharCode(~remaining & 0xff, (~remaining & 0xff00) >>> 8);
|
||||
|
||||
storeBuffer += data.substring(i, i + remaining);
|
||||
}
|
||||
|
||||
return storeBuffer;
|
||||
}
|
||||
|
||||
function adler32(data: string) {
|
||||
const MOD_ADLER = 65521;
|
||||
let a = 1;
|
||||
let b = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
a = (a + data.charCodeAt(i)) % MOD_ADLER;
|
||||
b = (b + a) % MOD_ADLER;
|
||||
}
|
||||
|
||||
return (b << 16) | a;
|
||||
}
|
||||
|
||||
function updateCrc(crc: number, buf: string) {
|
||||
let c = crc;
|
||||
let b: number;
|
||||
|
||||
for (let n = 0; n < buf.length; n++) {
|
||||
b = buf.charCodeAt(n);
|
||||
c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function crc(buf: string) {
|
||||
return updateCrc(0xffffffff, buf) ^ 0xffffffff;
|
||||
}
|
||||
|
||||
function dwordAsString(dword: number) {
|
||||
return String.fromCharCode(
|
||||
(dword & 0xff000000) >>> 24,
|
||||
(dword & 0x00ff0000) >>> 16,
|
||||
(dword & 0x0000ff00) >>> 8,
|
||||
dword & 0x000000ff,
|
||||
);
|
||||
}
|
||||
|
||||
function createChunk(length: number, type: string, data: string) {
|
||||
const CRC = crc(type + data);
|
||||
|
||||
return dwordAsString(length) + type + data + dwordAsString(CRC);
|
||||
}
|
||||
|
||||
function createIHDR(width: number, height: number) {
|
||||
const IHDRdata =
|
||||
dwordAsString(width) +
|
||||
dwordAsString(height) +
|
||||
// bit depth
|
||||
String.fromCharCode(8) +
|
||||
// color type: 6=truecolor with alpha
|
||||
String.fromCharCode(6) +
|
||||
// compression method: 0=deflate, only allowed value
|
||||
String.fromCharCode(0) +
|
||||
// filtering: 0=adaptive, only allowed value
|
||||
String.fromCharCode(0) +
|
||||
// interlacing: 0=none
|
||||
String.fromCharCode(0);
|
||||
|
||||
return createChunk(13, "IHDR", IHDRdata);
|
||||
}
|
||||
|
||||
// PNG creations
|
||||
|
||||
const IEND = createChunk(0, "IEND", "");
|
||||
const IHDR = createIHDR(width, height);
|
||||
|
||||
let scanlines = "";
|
||||
let scanline: string;
|
||||
|
||||
for (let y = 0; y < rgbaString.length; y += width * 4) {
|
||||
scanline = NO_FILTER;
|
||||
if (Array.isArray(rgbaString)) {
|
||||
for (let x = 0; x < width * 4; x++) {
|
||||
scanline += String.fromCharCode(rgbaString[y + x] & 0xff);
|
||||
}
|
||||
} else {
|
||||
scanline += rgbaString.substr(y, width * 4);
|
||||
}
|
||||
scanlines += scanline;
|
||||
}
|
||||
|
||||
const compressedScanlines =
|
||||
DEFLATE_METHOD + inflateStore(scanlines) + dwordAsString(adler32(scanlines));
|
||||
const IDAT = createChunk(compressedScanlines.length, "IDAT", compressedScanlines);
|
||||
|
||||
const pngString = SIGNATURE + IHDR + IDAT + IEND;
|
||||
return pngString;
|
||||
}
|
||||
|
||||
const BlurhashCanvas = forwardRef<
|
||||
HTMLCanvasElement,
|
||||
{
|
||||
blurhash: string;
|
||||
} & HTMLAttributes<HTMLCanvasElement>
|
||||
>(function BlurhashCanvas({ blurhash, ...props }, forwardedRef) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ref.current!, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const pixels = decode(blurhash, 32, 32);
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const imageData = ctx.createImageData(32, 32);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}, [blurhash]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
width={32}
|
||||
height={32}
|
||||
{...css(
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
props,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const BlurhashDiv = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ blurhash: string } & HTMLAttributes<HTMLDivElement>
|
||||
>(function BlurhashDiv({ blurhash, ...props }, ref) {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
// Use a blurhash here to nicely fade the NextImage when it is loaded completly
|
||||
// (this prevents loading the image line by line which is ugly and buggy on firefox)
|
||||
backgroundImage: `url(${blurHashToDataURL(blurhash)})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "50% 50%",
|
||||
}}
|
||||
{...css(
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
props,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const useRenderType = () => {
|
||||
const [renderType, setRenderType] = useState<"ssr" | "hydratation" | "client">(
|
||||
typeof window === "undefined" ? "ssr" : "hydratation",
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setRenderType("client");
|
||||
}, []);
|
||||
|
||||
return renderType;
|
||||
};
|
||||
|
||||
export const BlurhashContainer = ({
|
||||
blurhash,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
blurhash: string;
|
||||
children?: ReactElement | ReactElement[];
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const renderType = useRenderType();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...css(
|
||||
{
|
||||
// To reproduce view's behavior
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
},
|
||||
nativeStyleToCss(props),
|
||||
)}
|
||||
>
|
||||
{renderType === "ssr" && <BlurhashDiv blurhash={blurhash} />}
|
||||
{renderType === "client" && <BlurhashCanvas blurhash={blurhash} />}
|
||||
{renderType === "hydratation" && (
|
||||
<div dangerouslySetInnerHTML={{ __html: "" }} suppressHydrationWarning />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getCurrentToken } from "@kyoo/models";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
|
||||
import { Blurhash } from "react-native-blurhash";
|
||||
import FastImage from "react-native-fast-image";
|
||||
import { percent, useYoshiki } from "yoshiki/native";
|
||||
import { Skeleton } from "../skeleton";
|
||||
import type { ImageLayout, Props } from "./base-image";
|
||||
|
||||
export const Image = ({
|
||||
src,
|
||||
quality,
|
||||
alt,
|
||||
forcedLoading = false,
|
||||
layout,
|
||||
Err,
|
||||
...props
|
||||
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
|
||||
const { css } = useYoshiki();
|
||||
const [state, setState] = useState<"loading" | "errored" | "finished">(
|
||||
src ? "loading" : "errored",
|
||||
);
|
||||
|
||||
// This could be done with a key but this makes the API easier to use.
|
||||
// This unsures that the state is resetted when the source change (useful for recycler lists.)
|
||||
const [oldSource, setOldSource] = useState(src);
|
||||
if (oldSource !== src) {
|
||||
setState("loading");
|
||||
setOldSource(src);
|
||||
}
|
||||
|
||||
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
|
||||
|
||||
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
|
||||
if (!src || state === "errored") {
|
||||
return Err !== undefined ? (
|
||||
Err
|
||||
) : (
|
||||
<View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />
|
||||
);
|
||||
}
|
||||
|
||||
quality ??= "high";
|
||||
const token = getCurrentToken();
|
||||
return (
|
||||
<View {...css([layout, border], props)}>
|
||||
{state !== "finished" && (
|
||||
<Blurhash
|
||||
blurhash={src.blurhash}
|
||||
resizeMode="cover"
|
||||
{...css({ width: percent(100), height: percent(100) })}
|
||||
/>
|
||||
)}
|
||||
<FastImage
|
||||
source={{
|
||||
uri: src[quality],
|
||||
headers: token
|
||||
? {
|
||||
Authorization: token,
|
||||
}
|
||||
: {},
|
||||
priority: FastImage.priority[quality === "medium" ? "normal" : quality],
|
||||
}}
|
||||
accessibilityLabel={alt}
|
||||
onLoad={() => setState("finished")}
|
||||
onError={() => setState("errored")}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
{...(css({
|
||||
width: percent(100),
|
||||
height: percent(100),
|
||||
}) as { style: FlexStyle })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
|
||||
const { css } = useYoshiki();
|
||||
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
|
||||
|
||||
return <Skeleton variant="custom" show {...css([layout, border], props)} />;
|
||||
};
|
@ -1,89 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { type ImageStyle, View, type ViewStyle } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { imageBorderRadius } from "../constants";
|
||||
import { Skeleton } from "../skeleton";
|
||||
import type { ImageLayout, Props } from "./base-image";
|
||||
import { BlurhashContainer, useRenderType } from "./blurhash.web";
|
||||
|
||||
export const Image = ({
|
||||
src,
|
||||
quality,
|
||||
alt,
|
||||
forcedLoading = false,
|
||||
layout,
|
||||
Err,
|
||||
...props
|
||||
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
|
||||
const { css } = useYoshiki();
|
||||
const [state, setState] = useState<"loading" | "errored" | "finished">(
|
||||
typeof window === "undefined" ? "finished" : "loading",
|
||||
);
|
||||
|
||||
const border = { borderRadius: imageBorderRadius } satisfies ViewStyle;
|
||||
|
||||
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
|
||||
if (!src || state === "errored") {
|
||||
return Err !== undefined ? (
|
||||
Err
|
||||
) : (
|
||||
<View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlurhashContainer blurhash={src.blurhash} {...css([layout, border], props)}>
|
||||
<img
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
color: "transparent",
|
||||
objectFit: "cover",
|
||||
opacity: state === "loading" ? 0 : 1,
|
||||
transition: "opacity .2s ease-out",
|
||||
}}
|
||||
// It's intended to keep `loading` before `src` because React updates
|
||||
// props in order which causes Safari/Firefox to not lazy load properly.
|
||||
// See https://github.com/facebook/react/issues/25883
|
||||
loading={quality === "high" ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
fetchpriority={quality === "high" ? "high" : undefined}
|
||||
src={src[quality ?? "high"]}
|
||||
alt={alt!}
|
||||
onLoad={() => setState("finished")}
|
||||
onError={() => setState("errored")}
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
</BlurhashContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
|
||||
const { css } = useYoshiki();
|
||||
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
|
||||
|
||||
return <Skeleton variant="custom" show {...css([layout, border], props)} />;
|
||||
};
|
@ -1,170 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
|
||||
import type { ComponentProps, ComponentType, ReactElement, ReactNode } from "react";
|
||||
import { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
|
||||
import { percent } from "yoshiki/native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { imageBorderRadius } from "../constants";
|
||||
import { ContrastArea } from "../themes";
|
||||
import type { ImageLayout, Props, YoshikiEnhanced } from "./base-image";
|
||||
import { Image } from "./image";
|
||||
|
||||
export { Sprite } from "./sprite";
|
||||
export { BlurhashContainer } from "./blurhash";
|
||||
export { type Props as ImageProps, Image };
|
||||
|
||||
export const Poster = ({
|
||||
alt,
|
||||
layout,
|
||||
...props
|
||||
}: Props & { style?: ImageStyle } & {
|
||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||
}) => <Image alt={alt!} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||
|
||||
Poster.Loader = ({
|
||||
layout,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactElement;
|
||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||
|
||||
export const PosterBackground = ({
|
||||
alt,
|
||||
layout,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof ImageBackground>, "layout"> & { style?: ImageStyle } & {
|
||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<ImageBackground
|
||||
alt={alt!}
|
||||
layout={{ aspectRatio: 2 / 3, ...layout }}
|
||||
{...css({ borderRadius: imageBorderRadius }, props)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type ImageBackgroundProps = {
|
||||
children?: ReactNode;
|
||||
containerStyle?: YoshikiEnhanced<ViewStyle>;
|
||||
imageStyle?: YoshikiEnhanced<ImageStyle>;
|
||||
layout?: ImageLayout;
|
||||
contrast?: "light" | "dark" | "user";
|
||||
};
|
||||
|
||||
export const ImageBackground = <AsProps = ViewProps>({
|
||||
src,
|
||||
alt,
|
||||
quality,
|
||||
as,
|
||||
children,
|
||||
containerStyle,
|
||||
imageStyle,
|
||||
layout,
|
||||
contrast = "dark",
|
||||
imageSibling,
|
||||
...asProps
|
||||
}: {
|
||||
as?: ComponentType<AsProps>;
|
||||
imageSibling?: ReactElement;
|
||||
} & AsProps &
|
||||
ImageBackgroundProps &
|
||||
Props) => {
|
||||
const Container = as ?? View;
|
||||
|
||||
return (
|
||||
<ContrastArea contrastText mode={contrast}>
|
||||
{({ css }) => (
|
||||
<Container {...(css([layout, { overflow: "hidden" }], asProps) as AsProps)}>
|
||||
<View
|
||||
{...css([
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: -1,
|
||||
bg: (theme) => theme.background,
|
||||
},
|
||||
containerStyle,
|
||||
])}
|
||||
>
|
||||
{src && (
|
||||
<Image
|
||||
src={src}
|
||||
quality={quality}
|
||||
alt={alt!}
|
||||
layout={{ width: percent(100), height: percent(100) }}
|
||||
{...(css([{ borderWidth: 0, borderRadius: 0 }, imageStyle]) as {
|
||||
style: ImageStyle;
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{imageSibling}
|
||||
</View>
|
||||
{children}
|
||||
</Container>
|
||||
)}
|
||||
</ContrastArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const GradientImageBackground = <AsProps = ViewProps>({
|
||||
contrast = "dark",
|
||||
gradient,
|
||||
...props
|
||||
}: {
|
||||
as?: ComponentType<AsProps>;
|
||||
gradient?: Partial<LinearGradientProps>;
|
||||
} & AsProps &
|
||||
ImageBackgroundProps &
|
||||
Props) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
|
||||
return (
|
||||
<ImageBackground
|
||||
contrast={contrast}
|
||||
imageSibling={
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.25 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={["transparent", theme[contrast].darkOverlay]}
|
||||
{...css(
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
typeof gradient === "object" ? gradient : undefined,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import NextImage from "next/image";
|
||||
|
||||
export const Sprite = ({
|
||||
src,
|
||||
alt,
|
||||
style,
|
||||
x,
|
||||
y,
|
||||
...props
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
style?: object;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => {
|
||||
return (
|
||||
<NextImage
|
||||
src={src}
|
||||
priority={false}
|
||||
alt={alt!}
|
||||
// Don't use next's server to reprocess images, they are already optimized by kyoo.
|
||||
unoptimized={true}
|
||||
style={{
|
||||
objectFit: "none",
|
||||
objectPosition: `${-x}px ${-y}px`,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
@ -4,8 +4,9 @@ export * from "./theme";
|
||||
export * from "./icons";
|
||||
export * from "./links";
|
||||
export * from "./avatar";
|
||||
// export * from "./image";
|
||||
// export * from "./skeleton";
|
||||
export * from "./image";
|
||||
export * from "./image-background";
|
||||
export * from "./skeleton";
|
||||
export * from "./tooltip";
|
||||
// export * from "./container";
|
||||
export * from "./divider";
|
||||
@ -21,4 +22,3 @@ export * from "./button";
|
||||
// export * from "./chip";
|
||||
|
||||
export * from "./utils";
|
||||
export * from "./constants";
|
||||
|
@ -1,23 +1,3 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { LinearGradient as LG } from "expo-linear-gradient";
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||
@ -32,31 +12,11 @@ import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||
|
||||
const LinearGradient = Animated.createAnimatedComponent(LG);
|
||||
|
||||
export const SkeletonCss = () => (
|
||||
<style jsx global>{`
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
|
||||
export const Skeleton = ({
|
||||
children,
|
||||
show: forcedShow,
|
||||
lines = 1,
|
||||
variant = "text",
|
||||
...props
|
||||
}: Omit<ViewProps, "children"> & {
|
||||
children?: JSX.Element | JSX.Element[] | boolean | null;
|
||||
show?: boolean;
|
||||
lines?: number;
|
||||
variant?: "text" | "header" | "round" | "custom" | "fill" | "filltext";
|
||||
}) => {
|
||||
@ -75,8 +35,6 @@ export const Skeleton = ({
|
||||
mult.value = withRepeat(withDelay(800, withTiming(1, { duration: 800 })), 0);
|
||||
});
|
||||
|
||||
if (forcedShow === undefined && children && children !== true) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
@ -115,46 +73,44 @@ export const Skeleton = ({
|
||||
props,
|
||||
)}
|
||||
>
|
||||
{(forcedShow || !children || children === true) &&
|
||||
[...Array(lines)].map((_, i) => (
|
||||
<View
|
||||
key={`skeleton_${i}`}
|
||||
onLayout={(e) => {
|
||||
width.value = e.nativeEvent.layout.width;
|
||||
}}
|
||||
{...css([
|
||||
{
|
||||
bg: (theme) => theme.overlay0,
|
||||
},
|
||||
lines === 1 && {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
lines !== 1 && {
|
||||
width: i === lines - 1 ? percent(40) : percent(100),
|
||||
height: rem(1.2),
|
||||
marginBottom: rem(0.5),
|
||||
overflow: "hidden",
|
||||
borderRadius: px(6),
|
||||
},
|
||||
])}
|
||||
>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
colors={["transparent", theme.overlay1, "transparent"]}
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{ transform: [{ translateX: -width.value }] },
|
||||
animated,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{children}
|
||||
{[...Array(lines)].map((_, i) => (
|
||||
<View
|
||||
key={`skeleton_${i}`}
|
||||
onLayout={(e) => {
|
||||
if (i === 0) width.value = e.nativeEvent.layout.width;
|
||||
}}
|
||||
{...css([
|
||||
{
|
||||
bg: (theme) => theme.overlay0,
|
||||
},
|
||||
lines === 1 && {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
lines !== 1 && {
|
||||
width: i === lines - 1 ? percent(40) : percent(100),
|
||||
height: rem(1.2),
|
||||
marginBottom: rem(0.5),
|
||||
overflow: "hidden",
|
||||
borderRadius: px(6),
|
||||
},
|
||||
])}
|
||||
>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
colors={["transparent", theme.overlay1, "transparent"]}
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{ transform: [{ translateX: -width.value }] },
|
||||
animated,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||
|
||||
export const SkeletonCss = () => (
|
||||
<style jsx global>{`
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
|
||||
export const Skeleton = ({
|
||||
children,
|
||||
show: forcedShow,
|
||||
lines = 1,
|
||||
variant = "text",
|
||||
...props
|
||||
}: Omit<ViewProps, "children"> & {
|
||||
children?: JSX.Element | JSX.Element[] | boolean | null;
|
||||
show?: boolean;
|
||||
lines?: number;
|
||||
variant?: "text" | "header" | "round" | "custom" | "fill" | "filltext";
|
||||
}) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
|
||||
if (forcedShow === undefined && children && children !== true) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
[
|
||||
lines === 1 && { overflow: "hidden", borderRadius: px(6) },
|
||||
(variant === "text" || variant === "header") &&
|
||||
lines === 1 && [
|
||||
{
|
||||
width: percent(75),
|
||||
height: rem(1.2),
|
||||
margin: px(2),
|
||||
},
|
||||
variant === "text" && {
|
||||
margin: px(2),
|
||||
},
|
||||
variant === "header" && {
|
||||
marginBottom: rem(0.5),
|
||||
},
|
||||
],
|
||||
|
||||
variant === "round" && {
|
||||
borderRadius: 9999999,
|
||||
},
|
||||
variant === "fill" && {
|
||||
width: percent(100),
|
||||
height: percent(100),
|
||||
},
|
||||
variant === "filltext" && {
|
||||
width: percent(100),
|
||||
height: em(1),
|
||||
},
|
||||
],
|
||||
props,
|
||||
)}
|
||||
>
|
||||
{(forcedShow || !children || children === true) &&
|
||||
[...Array(lines)].map((_, i) => (
|
||||
<View
|
||||
key={`skeleton_${i}`}
|
||||
{...css([
|
||||
{
|
||||
bg: (theme) => theme.overlay0,
|
||||
},
|
||||
lines === 1 && {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
lines !== 1 && {
|
||||
width: i === lines - 1 ? percent(40) : percent(100),
|
||||
height: rem(1.2),
|
||||
marginBottom: rem(0.5),
|
||||
overflow: "hidden",
|
||||
borderRadius: px(6),
|
||||
},
|
||||
])}
|
||||
>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
colors={["transparent", theme.overlay1, "transparent"]}
|
||||
{...css([
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
// @ts-ignore Web only properties
|
||||
animation: "skeleton 1.6s linear 0.5s infinite",
|
||||
transform: "translateX(-100%)",
|
||||
},
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
57
front/src/primitives/types.d.ts
vendored
57
front/src/primitives/types.d.ts
vendored
@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import "react-native";
|
||||
|
||||
declare module "react-native" {
|
||||
interface PressableStateCallbackType {
|
||||
hovered?: boolean;
|
||||
focused?: boolean;
|
||||
}
|
||||
interface AccessibilityProps {
|
||||
tabIndex?: number;
|
||||
}
|
||||
interface ViewStyle {
|
||||
transitionProperty?: string;
|
||||
transitionDuration?: string;
|
||||
}
|
||||
interface TextProps {
|
||||
hrefAttrs?: {
|
||||
rel?: "noreferrer";
|
||||
target?: string;
|
||||
};
|
||||
}
|
||||
interface ViewProps {
|
||||
dataSet?: Record<string, string>;
|
||||
hrefAttrs?: {
|
||||
rel: "noreferrer";
|
||||
target?: "_blank";
|
||||
};
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "react" {
|
||||
interface StyleHTMLAttributes<T> extends HTMLAttributes<T> {
|
||||
jsx?: boolean;
|
||||
global?: boolean;
|
||||
}
|
||||
}
|
@ -9,6 +9,11 @@ export const AccountContext = createContext<{
|
||||
accounts: (Account & { select: () => void; remove: () => void })[];
|
||||
}>({ apiUrl: "api", authToken: null, selectedAccount: null, accounts: [] });
|
||||
|
||||
export const useToken = () => {
|
||||
const { apiUrl, authToken } = useContext(AccountContext);
|
||||
return { apiUrl, authToken };
|
||||
};
|
||||
|
||||
export const useAccount = () => {
|
||||
const { selectedAccount } = useContext(AccountContext);
|
||||
return selectedAccount;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type ReactNode, useEffect, useMemo, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { AccountP, UserP } from "~/models";
|
||||
import { useFetch } from "~/query";
|
||||
import { AccountContext } from "./account-context";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { type Account, AccountP } from "~/models";
|
||||
import { readValue, setCookie, storeValue } from "./settings";
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { MMKV, useMMKVString } from "react-native-mmkv";
|
||||
import type { ZodTypeAny, z } from "zod";
|
||||
import type { z, ZodType } from "zod/v4";
|
||||
import { getServerData } from "~/utils";
|
||||
|
||||
export const storage = new MMKV();
|
||||
@ -24,7 +24,7 @@ export const setCookie = (key: string, val?: unknown) => {
|
||||
document.cookie = `${key}=${value};${expires};path=/;samesite=strict`;
|
||||
};
|
||||
|
||||
export const readCookie = <T extends ZodTypeAny>(key: string, parser: T) => {
|
||||
export const readCookie = <T extends ZodType>(key: string, parser: T) => {
|
||||
const cookies = getServerData("cookies");
|
||||
console.log("cookies", cookies);
|
||||
const decodedCookie = decodeURIComponent(cookies);
|
||||
@ -37,7 +37,7 @@ export const readCookie = <T extends ZodTypeAny>(key: string, parser: T) => {
|
||||
return parser.parse(JSON.parse(str)) as z.infer<T>;
|
||||
};
|
||||
|
||||
export const useStoreValue = <T extends ZodTypeAny>(key: string, parser: T) => {
|
||||
export const useStoreValue = <T extends ZodType>(key: string, parser: T) => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined") {
|
||||
return readCookie(key, parser);
|
||||
}
|
||||
@ -50,7 +50,7 @@ export const storeValue = (key: string, value: unknown) => {
|
||||
storage.set(key, JSON.stringify(value));
|
||||
};
|
||||
|
||||
export const readValue = <T extends ZodTypeAny>(key: string, parser: T) => {
|
||||
export const readValue = <T extends ZodType>(key: string, parser: T) => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined") {
|
||||
return readCookie(key, parser);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
QueryClient,
|
||||
dehydrate,
|
||||
QueryClient,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
@ -8,12 +8,12 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { useContext } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { z } from "zod";
|
||||
import type { z } from "zod/v4";
|
||||
import { type KyooError, type Page, Paged } from "~/models";
|
||||
import { AccountContext } from "~/providers/account-context";
|
||||
import { setServerData } from "~/utils";
|
||||
|
||||
const ssrApiUrl = process.env.KYOO_URL ?? "http://back/api";
|
||||
const ssrApiUrl = process.env.KYOO_URL ?? "http://api:3567/api";
|
||||
|
||||
const cleanSlash = (str: string | null, keepFirst = false) => {
|
||||
if (!str) return null;
|
||||
@ -71,9 +71,9 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
||||
throw data as KyooError;
|
||||
}
|
||||
|
||||
if (resp.status === 204) return null;
|
||||
if (resp.status === 204) return null!;
|
||||
|
||||
if (context.plainText) return (await resp.text()) as unknown;
|
||||
if (context.plainText) return (await resp.text()) as any;
|
||||
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
@ -82,7 +82,7 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
||||
console.error("Invalid json from kyoo", e);
|
||||
throw { message: "Invalid response from kyoo", status: "json" } as KyooError;
|
||||
}
|
||||
if (!context.parser) return data;
|
||||
if (!context.parser) return data as any;
|
||||
const parsed = await context.parser.safeParseAsync(data);
|
||||
if (!parsed.success) {
|
||||
console.log("Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error);
|
||||
@ -108,11 +108,11 @@ export const createQueryClient = () =>
|
||||
},
|
||||
});
|
||||
|
||||
export type QueryIdentifier<T = unknown, Ret = T> = {
|
||||
parser: z.ZodType<T, z.ZodTypeDef, any>;
|
||||
export type QueryIdentifier<T = unknown> = {
|
||||
parser: z.ZodType<T>;
|
||||
path: (string | undefined)[];
|
||||
params?: { [query: string]: boolean | number | string | string[] | undefined };
|
||||
infinite?: boolean | { value: true; map?: (x: any[]) => Ret[] };
|
||||
infinite?: boolean;
|
||||
|
||||
placeholderData?: T | (() => T);
|
||||
enabled?: boolean;
|
||||
@ -143,7 +143,8 @@ export const keyToUrl = (key: ReturnType<typeof toQueryKey>) => {
|
||||
};
|
||||
|
||||
export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
|
||||
const { apiUrl, authToken } = useContext(AccountContext);
|
||||
let { apiUrl, authToken } = useContext(AccountContext);
|
||||
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
|
||||
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
|
||||
|
||||
return useQuery<Data, KyooError>({
|
||||
@ -155,17 +156,18 @@ export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
|
||||
signal: ctx.signal,
|
||||
authToken: authToken ?? null,
|
||||
...query.options,
|
||||
}),
|
||||
}) as Promise<Data>,
|
||||
placeholderData: query.placeholderData as any,
|
||||
enabled: query.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
|
||||
const { apiUrl, authToken } = useContext(AccountContext);
|
||||
export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
|
||||
let { apiUrl, authToken } = useContext(AccountContext);
|
||||
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
|
||||
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
|
||||
|
||||
const ret = useInfiniteQuery<Page<Data>, KyooError>({
|
||||
const res = useInfiniteQuery<Page<Data>, KyooError>({
|
||||
queryKey: key,
|
||||
queryFn: (ctx) =>
|
||||
queryFn({
|
||||
@ -174,20 +176,15 @@ export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) =
|
||||
signal: ctx.signal,
|
||||
authToken: authToken ?? null,
|
||||
...query.options,
|
||||
}),
|
||||
}) as Promise<Page<Data>>,
|
||||
getNextPageParam: (page: Page<Data>) => page?.next || undefined,
|
||||
initialPageParam: undefined,
|
||||
placeholderData: query.placeholderData as any,
|
||||
enabled: query.enabled,
|
||||
});
|
||||
const items = ret.data?.pages.flatMap((x) => x.items);
|
||||
return {
|
||||
...ret,
|
||||
items:
|
||||
items && typeof query.infinite === "object" && query.infinite.map
|
||||
? query.infinite.map(items)
|
||||
: (items as unknown as Ret[] | undefined),
|
||||
};
|
||||
const ret = res as typeof res & { items?: Data[] };
|
||||
ret.items = ret.data?.pages.flatMap((x) => x.items);
|
||||
return ret;
|
||||
};
|
||||
|
||||
export const prefetch = async (...queries: QueryIdentifier[]) => {
|
||||
@ -242,7 +239,7 @@ type MutationParams = {
|
||||
body?: object;
|
||||
};
|
||||
|
||||
export const useMutation = <T = void,>({
|
||||
export const useMutation = <T = void>({
|
||||
compute,
|
||||
invalidate,
|
||||
...queryParams
|
||||
|
Loading…
x
Reference in New Issue
Block a user