mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-10-29 09:42:39 -04:00
Player rewrite (#1020)
This commit is contained in:
commit
a3f29c73ec
@ -67,5 +67,6 @@ PGPORT=5432
|
||||
# v5 stuff, does absolutely nothing on master (aka: you can delete this)
|
||||
EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}'
|
||||
FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}'
|
||||
GUEST_CLAIMS='{"permissions": ["core.read"]}'
|
||||
GUEST_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}'
|
||||
# GUEST_CLAIMS='{"permissions": ["core.read"]}'
|
||||
PROTECTED_CLAIMS="permissions,verified"
|
||||
|
||||
20
api/bun.lock
20
api/bun.lock
@ -15,7 +15,7 @@
|
||||
"sharp": "^0.34.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.6",
|
||||
"@biomejs/biome": "2.1.1",
|
||||
"@types/pg": "^8.15.2",
|
||||
"bun-types": "^1.2.14",
|
||||
"node-addon-api": "^8.3.1",
|
||||
@ -26,23 +26,23 @@
|
||||
"drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch",
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.2.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.6", "@biomejs/cli-darwin-x64": "2.2.6", "@biomejs/cli-linux-arm64": "2.2.6", "@biomejs/cli-linux-arm64-musl": "2.2.6", "@biomejs/cli-linux-x64": "2.2.6", "@biomejs/cli-linux-x64-musl": "2.2.6", "@biomejs/cli-win32-arm64": "2.2.6", "@biomejs/cli-win32-x64": "2.2.6" }, "bin": { "biome": "bin/biome" } }, "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw=="],
|
||||
"@biomejs/biome": ["@biomejs/biome@2.1.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.1", "@biomejs/cli-darwin-x64": "2.1.1", "@biomejs/cli-linux-arm64": "2.1.1", "@biomejs/cli-linux-arm64-musl": "2.1.1", "@biomejs/cli-linux-x64": "2.1.1", "@biomejs/cli-linux-x64-musl": "2.1.1", "@biomejs/cli-win32-arm64": "2.1.1", "@biomejs/cli-win32-x64": "2.1.1" }, "bin": { "biome": "bin/biome" } }, "sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA=="],
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ=="],
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g=="],
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ=="],
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA=="],
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog=="],
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw=="],
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="],
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"sharp": "^0.34.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.6",
|
||||
"@biomejs/biome": "2.1.1",
|
||||
"@types/pg": "^8.15.2",
|
||||
"bun-types": "^1.2.14",
|
||||
"node-addon-api": "^8.3.1"
|
||||
|
||||
@ -54,7 +54,7 @@ export const entryProgressQ = db
|
||||
})
|
||||
.from(history)
|
||||
.leftJoin(videos, eq(history.videoPk, videos.pk))
|
||||
.leftJoin(profiles, eq(history.profilePk, profiles.pk))
|
||||
.innerJoin(profiles, eq(history.profilePk, profiles.pk))
|
||||
.where(eq(profiles.id, sql.placeholder("userId")))
|
||||
.orderBy(history.entryPk, desc(history.playedDate))
|
||||
.as("progress");
|
||||
|
||||
@ -227,7 +227,6 @@ export const staffH = new Elysia({ tags: ["staff"] })
|
||||
.from(watchlist)
|
||||
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
|
||||
.where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk)))
|
||||
.limit(1)
|
||||
.as("watchstatus");
|
||||
|
||||
const items = await db
|
||||
|
||||
@ -15,7 +15,16 @@ import { alias } from "drizzle-orm/pg-core";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "~/auth";
|
||||
import { db, type Transaction } from "~/db";
|
||||
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
|
||||
import {
|
||||
entries,
|
||||
entryVideoJoin,
|
||||
history,
|
||||
profiles,
|
||||
shows,
|
||||
showTranslations,
|
||||
videos,
|
||||
} from "~/db/schema";
|
||||
import { watchlist } from "~/db/schema/watchlist";
|
||||
import {
|
||||
coalesce,
|
||||
conflictUpdateAllExcept,
|
||||
@ -30,10 +39,14 @@ import {
|
||||
import { Entry } from "~/models/entry";
|
||||
import { KError } from "~/models/error";
|
||||
import { bubbleVideo } from "~/models/examples";
|
||||
import { Progress } from "~/models/history";
|
||||
import { Movie, type MovieStatus } from "~/models/movie";
|
||||
import { Serie } from "~/models/serie";
|
||||
import {
|
||||
AcceptLanguage,
|
||||
buildRelations,
|
||||
createPage,
|
||||
type Image,
|
||||
isUuid,
|
||||
keysetPaginate,
|
||||
Page,
|
||||
@ -44,6 +57,7 @@ import {
|
||||
} from "~/models/utils";
|
||||
import { desc as description } from "~/models/utils/descriptions";
|
||||
import { Guess, Guesses, SeedVideo, Video } from "~/models/video";
|
||||
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
|
||||
import { comment } from "~/utils";
|
||||
import {
|
||||
entryProgressQ,
|
||||
@ -206,14 +220,44 @@ const videoRelations = {
|
||||
slugs: () => {
|
||||
return db
|
||||
.select({
|
||||
slugs: coalesce(jsonbAgg(entryVideoJoin.slug), sql`'[]'::jsonb`).as(
|
||||
"slugs",
|
||||
),
|
||||
slugs: coalesce<string[]>(
|
||||
jsonbAgg(entryVideoJoin.slug),
|
||||
sql`'[]'::jsonb`,
|
||||
).as("slugs"),
|
||||
})
|
||||
.from(entryVideoJoin)
|
||||
.where(eq(entryVideoJoin.videoPk, videos.pk))
|
||||
.as("slugs");
|
||||
},
|
||||
progress: () => {
|
||||
const query = db
|
||||
.select({
|
||||
json: jsonbBuildObject<Progress>({
|
||||
percent: history.percent,
|
||||
time: history.time,
|
||||
playedDate: sql`to_char(${history.playedDate}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
videoId: videos.id,
|
||||
}),
|
||||
})
|
||||
.from(history)
|
||||
.innerJoin(profiles, eq(history.profilePk, profiles.pk))
|
||||
.where(
|
||||
and(
|
||||
eq(profiles.id, sql.placeholder("userId")),
|
||||
eq(history.videoPk, videos.pk),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(history.playedDate))
|
||||
.limit(1);
|
||||
return sql`
|
||||
(
|
||||
select coalesce(
|
||||
${query},
|
||||
'{"percent": 0, "time": 0, "playedDate": null, "videoId": null}'::jsonb
|
||||
)
|
||||
as "progress"
|
||||
)` as any;
|
||||
},
|
||||
entries: ({ languages }: { languages: string[] }) => {
|
||||
const transQ = getEntryTransQ(languages);
|
||||
|
||||
@ -229,6 +273,7 @@ const videoRelations = {
|
||||
progress: mapProgress({ aliased: false }),
|
||||
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
availableSince: sql`to_char(${entries.availableSince}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
}),
|
||||
),
|
||||
sql`'[]'::jsonb`,
|
||||
@ -242,6 +287,74 @@ const videoRelations = {
|
||||
.where(eq(entryVideoJoin.videoPk, videos.pk))
|
||||
.as("entries");
|
||||
},
|
||||
show: ({
|
||||
languages,
|
||||
preferOriginal,
|
||||
}: {
|
||||
languages: string[];
|
||||
preferOriginal: boolean;
|
||||
}) => {
|
||||
const transQ = db
|
||||
.selectDistinctOn([showTranslations.pk])
|
||||
.from(showTranslations)
|
||||
.orderBy(
|
||||
showTranslations.pk,
|
||||
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
|
||||
)
|
||||
.as("t");
|
||||
|
||||
const watchStatusQ = db
|
||||
.select({
|
||||
watchStatus: jsonbBuildObject<MovieWatchStatus & SerieWatchStatus>({
|
||||
...getColumns(watchlist),
|
||||
percent: watchlist.seenCount,
|
||||
}).as("watchStatus"),
|
||||
})
|
||||
.from(watchlist)
|
||||
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
|
||||
.where(
|
||||
and(
|
||||
eq(profiles.id, sql.placeholder("userId")),
|
||||
eq(watchlist.showPk, shows.pk),
|
||||
),
|
||||
);
|
||||
|
||||
return db
|
||||
.select({
|
||||
json: jsonbBuildObject<Serie | Movie>({
|
||||
...getColumns(shows),
|
||||
...getColumns(transQ),
|
||||
// movie columns (status is only a typescript hint)
|
||||
status: sql<MovieStatus>`${shows.status}`,
|
||||
airDate: shows.startAir,
|
||||
kind: sql<any>`${shows.kind}`,
|
||||
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
|
||||
createdAt: sql`to_char(${shows.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
updatedAt: sql`to_char(${shows.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
|
||||
...(preferOriginal && {
|
||||
poster: sql<Image>`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`,
|
||||
thumbnail: sql<Image>`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`,
|
||||
banner: sql<Image>`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`,
|
||||
logo: sql<Image>`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`,
|
||||
}),
|
||||
watchStatus: sql`${watchStatusQ}`,
|
||||
}).as("json"),
|
||||
})
|
||||
.from(shows)
|
||||
.innerJoin(transQ, eq(shows.pk, transQ.pk))
|
||||
.where(
|
||||
eq(
|
||||
shows.pk,
|
||||
db
|
||||
.select({ pk: entries.showPk })
|
||||
.from(entries)
|
||||
.innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk))
|
||||
.where(eq(videos.pk, entryVideoJoin.videoPk)),
|
||||
),
|
||||
)
|
||||
.as("show");
|
||||
},
|
||||
previous: ({ languages }: { languages: string[] }) => {
|
||||
return getNextVideoEntry({ languages, prev: true });
|
||||
},
|
||||
@ -263,7 +376,7 @@ function getNextVideoEntry({
|
||||
const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`);
|
||||
return db
|
||||
.select({
|
||||
json: jsonbBuildObject<Entry>({
|
||||
json: jsonbBuildObject<{ video: string; entry: Entry }>({
|
||||
video: entryVideoJoin.slug,
|
||||
entry: {
|
||||
...getColumns(entries),
|
||||
@ -274,7 +387,7 @@ function getNextVideoEntry({
|
||||
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
},
|
||||
}),
|
||||
}).as("json"),
|
||||
})
|
||||
.from(entries)
|
||||
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
||||
@ -337,9 +450,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
":id",
|
||||
async ({
|
||||
params: { id },
|
||||
query: { with: relations },
|
||||
query: { with: relations, preferOriginal },
|
||||
headers: { "accept-language": langs },
|
||||
jwt: { sub },
|
||||
jwt: { sub, settings },
|
||||
status,
|
||||
}) => {
|
||||
const languages = processLanguages(langs);
|
||||
@ -351,10 +464,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
.select({
|
||||
...getColumns(videos),
|
||||
...buildRelations(
|
||||
["slugs", "entries", ...relations],
|
||||
["slugs", "progress", "entries", ...relations],
|
||||
videoRelations,
|
||||
{
|
||||
languages,
|
||||
preferOriginal: preferOriginal ?? settings.preferOriginal,
|
||||
},
|
||||
),
|
||||
})
|
||||
@ -382,10 +496,15 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
}),
|
||||
}),
|
||||
query: t.Object({
|
||||
with: t.Array(t.UnionEnum(["previous", "next"]), {
|
||||
with: t.Array(t.UnionEnum(["previous", "next", "show"]), {
|
||||
default: [],
|
||||
description: "Include related entries in the response.",
|
||||
}),
|
||||
preferOriginal: t.Optional(
|
||||
t.Boolean({
|
||||
description: description.preferOriginal,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
@ -400,6 +519,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
slugs: t.Array(
|
||||
t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }),
|
||||
),
|
||||
progress: Progress,
|
||||
entries: t.Array(Entry),
|
||||
previous: t.Optional(
|
||||
t.Nullable(
|
||||
@ -423,6 +543,12 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
}),
|
||||
),
|
||||
),
|
||||
show: t.Optional(
|
||||
t.Union([
|
||||
t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]),
|
||||
t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
]),
|
||||
404: {
|
||||
@ -433,6 +559,133 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/info",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/info`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get a video's metadata informations" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to retrieve.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/direct",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const filename = path.substring(path.lastIndexOf("/") + 1);
|
||||
return redirect(`/video/${path}/direct/${filename}`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/master.m3u8",
|
||||
async ({ params: { id }, request, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const query = request.url.substring(request.url.indexOf("?"));
|
||||
return redirect(`/video/${path}/master.m3u8${query}`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get redirected to the master.m3u8 of the video" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
|
||||
@ -18,7 +18,7 @@ export const history = schema.table(
|
||||
.references(() => entries.pk, { onDelete: "cascade" }),
|
||||
videoPk: integer().references(() => videos.pk, { onDelete: "set null" }),
|
||||
percent: integer().notNull().default(0),
|
||||
time: integer(),
|
||||
time: integer().notNull().default(0),
|
||||
playedDate: timestamp({ withTimezone: true, mode: "iso" })
|
||||
.notNull()
|
||||
.default(sql`now()`),
|
||||
|
||||
@ -107,7 +107,7 @@ export function values<K extends string>(
|
||||
};
|
||||
}
|
||||
|
||||
export const coalesce = <T>(val: SQL<T> | Column, def: SQL<T> | Column) => {
|
||||
export const coalesce = <T>(val: SQL<T> | SQLWrapper, def: SQL<T> | Column) => {
|
||||
return sql<T>`coalesce(${val}, ${def})`;
|
||||
};
|
||||
|
||||
|
||||
@ -3,15 +3,13 @@ import { comment } from "~/utils";
|
||||
|
||||
export const Progress = t.Object({
|
||||
percent: t.Integer({ minimum: 0, maximum: 100 }),
|
||||
time: t.Nullable(
|
||||
t.Integer({
|
||||
minimum: 0,
|
||||
description: comment`
|
||||
time: t.Integer({
|
||||
minimum: 0,
|
||||
description: comment`
|
||||
When this episode was stopped (in seconds since the start).
|
||||
This value is null if the entry was never watched or is finished.
|
||||
`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
playedDate: t.Nullable(t.String({ format: "date-time" })),
|
||||
videoId: t.Nullable(
|
||||
t.String({
|
||||
|
||||
@ -58,7 +58,7 @@ export const Sort = (
|
||||
const random = sort.find((x) => x.startsWith("random"));
|
||||
if (random) {
|
||||
const seed = random.includes(":")
|
||||
? Number.parseInt(random.substring("random:".length))
|
||||
? Number.parseInt(random.substring("random:".length), 10)
|
||||
: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
return { tablePk, random: { seed }, sort: [] };
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
"target": "ES2021",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"types": ["bun-types"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
**
|
||||
!/go.mod
|
||||
!/go.sum
|
||||
!/**.go
|
||||
!/**/*.go
|
||||
# generated via sqlc
|
||||
!/sql
|
||||
!/dbc
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": true,
|
||||
|
||||
@ -8,7 +8,7 @@ x-transcoder: &transcoder-base
|
||||
- transcoder
|
||||
ports:
|
||||
- "7666:7666"
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
cpus: 1
|
||||
environment:
|
||||
- JWKS_URL=http://auth:4568/.well-known/jwks.json
|
||||
@ -36,7 +36,7 @@ services:
|
||||
build:
|
||||
context: ./front
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
@ -56,7 +56,7 @@ services:
|
||||
build:
|
||||
context: ./auth
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@ -77,7 +77,7 @@ services:
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@ -106,7 +106,7 @@ services:
|
||||
|
||||
scanner:
|
||||
build: ./scanner
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
@ -176,7 +176,7 @@ services:
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.5
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
@ -189,7 +189,7 @@ services:
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
|
||||
@ -5,5 +5,5 @@
|
||||
!/metro.config.js
|
||||
!/app.config.ts
|
||||
!/src
|
||||
!/app
|
||||
!/public
|
||||
!/scripts
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
FROM oven/bun AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock .
|
||||
RUN bun install --production
|
||||
COPY package.json bun.lock scripts .
|
||||
COPY scripts scripts
|
||||
RUN bun install --production --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ FROM oven/bun
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock .
|
||||
COPY scripts scripts
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
@ -66,7 +66,12 @@ export const expo: ExpoConfig = {
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
enableNotificationControls: true,
|
||||
enableAndroidPictureInPicture: true,
|
||||
enableBackgroundAudio: true,
|
||||
androidExtensions: {
|
||||
useExoplayerDash: true,
|
||||
useExoplayerHls: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"build": "eas build --profile production --platform android --non-interactive --auto-submit",
|
||||
"build:apk": "eas build --profile preview --platform android --non-interactive --json",
|
||||
"build:dev": "eas build --profile development --platform android --non-interactive",
|
||||
"update": "eas update --auto --channel prod"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/poppins": "^0.2.3",
|
||||
"@formatjs/intl-displaynames": "^6.6.8",
|
||||
"@formatjs/intl-locale": "^4.0.0",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
"@kyoo/ui": "workspace:^",
|
||||
"@material-symbols/svg-400": "^0.22.0",
|
||||
"@react-native-community/netinfo": "11.3.2",
|
||||
"@shopify/flash-list": "1.7.1",
|
||||
"@tanstack/query-sync-storage-persister": "^5.51.21",
|
||||
"@tanstack/react-query": "^5.51.23",
|
||||
"@tanstack/react-query-persist-client": "^5.51.23",
|
||||
"array-shuffle": "^3.0.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
"expo": "^51.0.26",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.22",
|
||||
"expo-file-system": "~17.0.1",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-image-picker": "~15.0.7",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-localization": "~15.0.3",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "3.5.21",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-secure-store": "~13.0.2",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-updates": "~0.25.22",
|
||||
"i18next": "^23.12.2",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"moti": "^0.29.0",
|
||||
"react": "18.3.1",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-blurhash": "^2.0.3",
|
||||
"react-native-fast-image": "^8.6.3",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-reanimated": "~3.15.0",
|
||||
"react-native-safe-area-context": "4.10.8",
|
||||
"react-native-screens": "3.34.0",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.4.3",
|
||||
"yoshiki": "1.2.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"typescript": "~5.5.4"
|
||||
},
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": ["./src/polyfill.ts"],
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --check --ignore-path .gitignore '!src/utils/jotai-utils.tsx' .",
|
||||
"format:fix": "prettier --write --ignore-path .gitignore '!src/utils/jotai-utils.tsx' ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@kyoo/models": "workspace:^",
|
||||
"@kyoo/primitives": "workspace:^",
|
||||
"@kyoo/ui": "workspace:^",
|
||||
"@material-symbols/svg-400": "^0.22.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@tanstack/react-query": "^5.51.23",
|
||||
"@tanstack/react-query-devtools": "^5.51.23",
|
||||
"array-shuffle": "^3.0.0",
|
||||
"expo-image-picker": "~15.0.7",
|
||||
"expo-linear-gradient": "^13.0.2",
|
||||
"expo-modules-core": "^1.12.20",
|
||||
"hls.js": "^1.5.14",
|
||||
"i18next": "^23.12.2",
|
||||
"jassub": "1.7.15",
|
||||
"jotai": "^2.9.2",
|
||||
"moti": "^0.29.0",
|
||||
"next": "14.2.5",
|
||||
"next-translate": "^2.6.2",
|
||||
"raf": "^3.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-native-reanimated": "3.15.0",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-video": "^6.4.3",
|
||||
"react-native-web": "0.19.12",
|
||||
"react-tooltip": "^5.28.0",
|
||||
"solito": "^4.2.2",
|
||||
"srt-webvtt": "zoriya/srt-webvtt#build",
|
||||
"superjson": "^2.2.1",
|
||||
"sweetalert2": "^11.12.4",
|
||||
"yoshiki": "1.2.14",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/node": "22.2.0",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"react-native": "0.74.5",
|
||||
"typescript": "^5.5.4",
|
||||
"webpack": "^5.93.0"
|
||||
}
|
||||
}
|
||||
1344
front/bun.lock
1344
front/bun.lock
File diff suppressed because it is too large
Load Diff
2
front/bunfig.toml
Normal file
2
front/bunfig.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[install]
|
||||
linker = "hoisted"
|
||||
@ -4,6 +4,7 @@
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"postinstall": "bun ./scripts/postinstall.ts",
|
||||
"dev": "expo start",
|
||||
"apk": "eas build --profile preview --platform android --non-interactive --json",
|
||||
"apk:dev": "eas build --profile development --platform android --non-interactive",
|
||||
@ -13,55 +14,68 @@
|
||||
"format:fix": "biome format . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/html-elements": "^0.12.5",
|
||||
"@expo/html-elements": "^0.13.7",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@legendapp/list": "^1.0.20",
|
||||
"@material-symbols/svg-400": "^0.31.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"expo": "~53.0.10",
|
||||
"expo-build-properties": "^0.14.6",
|
||||
"expo-image": "^2.3.0",
|
||||
"expo-linear-gradient": "^14.1.5",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-localization": "^16.1.5",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-splash-screen": "^0.30.9",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-updates": "~0.28.14",
|
||||
"@legendapp/list": "^2.0.13",
|
||||
"@material-symbols/svg-400": "^0.38.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.4",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"expo": "54.0.17",
|
||||
"expo-build-properties": "^1.0.9",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-dev-client": "~6.0.16",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linear-gradient": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "^17.0.7",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-splash-screen": "^31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-updates": "~29.0.11",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jotai": "^2.12.5",
|
||||
"react": "19.0.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-video": "^6.15.0",
|
||||
"react-native-web": "^0.20.0",
|
||||
"jassub": "^1.8.6",
|
||||
"langmap": "^0.0.16",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^16.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-get-random-values": "^2.0.0",
|
||||
"react-native-mmkv": "^3.3.3",
|
||||
"react-native-nitro-modules": "^0.30.2",
|
||||
"react-native-reanimated": "~4.1.2",
|
||||
"react-native-safe-area-context": "5.6.1",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-video": "zoriya/react-native-video#build",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-tooltip": "^5.29.1",
|
||||
"sweetalert2": "^11.22.0",
|
||||
"sweetalert2": "^11.26.3",
|
||||
"uuid": "^13.0.0",
|
||||
"video.js": "^8.23.4",
|
||||
"yoshiki": "1.2.14",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0",
|
||||
"@tanstack/react-query-devtools": "^5.80.6",
|
||||
"@types/react": "~19.0.10",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"expo-dev-client": "^5.2.0",
|
||||
"@biomejs/biome": "2.2.6",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-dom": "~19.1.7",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"expo": {
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"listUnknownPackages": false
|
||||
"listUnknownPackages": false,
|
||||
"exclude": [
|
||||
"@gorhom/portal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@kyoo/models",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"sideEffects": false,
|
||||
"packageManager": "yarn@3.2.4",
|
||||
"devDependencies": {
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
@ -1,19 +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/>.
|
||||
*/
|
||||
@ -1,77 +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 { useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, getDefaultStore, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { z } from "zod";
|
||||
import { removeAccounts, setCookie, updateAccount } from "./account-internal";
|
||||
import type { KyooErrors } from "./kyoo-errors";
|
||||
import { useFetch } from "./query";
|
||||
import { ServerInfoP, type User, UserP } from "./resources";
|
||||
import { zdate } from "./utils";
|
||||
|
||||
const currentApiUrl = atom<string | null>(defaultApiUrl);
|
||||
export const getCurrentApiUrl = () => {
|
||||
const store = getDefaultStore();
|
||||
return store.get(currentApiUrl);
|
||||
};
|
||||
export const useCurrentApiUrl = () => {
|
||||
return useAtomValue(currentApiUrl);
|
||||
};
|
||||
export const setSsrApiUrl = () => {
|
||||
const store = getDefaultStore();
|
||||
store.set(currentApiUrl, process.env.KYOO_URL ?? "http://localhost:5000");
|
||||
};
|
||||
|
||||
|
||||
export const useAccount = () => {
|
||||
const acc = useContext(AccountContext);
|
||||
return acc.find((x) => x.selected) || null;
|
||||
};
|
||||
|
||||
export const useAccounts = () => {
|
||||
return useContext(AccountContext);
|
||||
};
|
||||
|
||||
export const useHasPermission = (perms?: string[]) => {
|
||||
const account = useAccount();
|
||||
const { data } = useFetch({
|
||||
path: ["info"],
|
||||
parser: ServerInfoP,
|
||||
});
|
||||
|
||||
if (!perms || !perms[0]) return true;
|
||||
|
||||
const available = account?.permissions ?? data?.guestPermissions;
|
||||
if (!available) return false;
|
||||
return perms.every((perm) => available.includes(perm));
|
||||
};
|
||||
|
||||
@ -1,26 +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/>.
|
||||
*/
|
||||
|
||||
export * from "./accounts";
|
||||
export { storage } from "./account-internal";
|
||||
export * from "./theme";
|
||||
export * from "./utils";
|
||||
export * from "./login";
|
||||
export * from "./issue";
|
||||
@ -1,48 +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 { z } from "zod";
|
||||
import { zdate } from "./utils";
|
||||
|
||||
export const IssueP = z.object({
|
||||
/**
|
||||
* The type of issue (for example, "Scanner" if this issue was created due to scanning error).
|
||||
*/
|
||||
domain: z.string(),
|
||||
/**
|
||||
* Why this issue was caused? An unique cause that can be used to identify this issue.
|
||||
* For the scanner, a cause should be a video path.
|
||||
*/
|
||||
cause: z.string(),
|
||||
/**
|
||||
* A human readable string explaining why this issue occured.
|
||||
*/
|
||||
reason: z.string(),
|
||||
/**
|
||||
* Some extra data that could store domain-specific info.
|
||||
*/
|
||||
extra: z.record(z.string(), z.any()),
|
||||
/**
|
||||
* The date the issue was reported.
|
||||
*/
|
||||
addedDate: zdate(),
|
||||
});
|
||||
|
||||
export type Issue = z.infer<typeof IssueP>;
|
||||
@ -1,179 +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 { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { addAccount, getCurrentAccount, removeAccounts, updateAccount } from "./account-internal";
|
||||
import { type Account, type Token, TokenP, getCurrentApiUrl } from "./accounts";
|
||||
import type { KyooErrors } from "./kyoo-errors";
|
||||
import { queryFn } from "./query";
|
||||
import { UserP } from "./resources";
|
||||
|
||||
type Result<A, B> =
|
||||
| { ok: true; value: A; error?: undefined }
|
||||
| { ok: false; value?: undefined; error: B };
|
||||
|
||||
export const login = async (
|
||||
action: "register" | "login",
|
||||
{ apiUrl, ...body }: { username: string; password: string; email?: string; apiUrl?: string },
|
||||
): Promise<Result<Account, string>> => {
|
||||
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 5_000);
|
||||
const token = await queryFn(
|
||||
{
|
||||
path: ["auth", action],
|
||||
method: "POST",
|
||||
body,
|
||||
authenticated: false,
|
||||
apiUrl,
|
||||
signal: controller.signal,
|
||||
},
|
||||
TokenP,
|
||||
);
|
||||
const user = await queryFn(
|
||||
{ path: ["auth", "me"], method: "GET", apiUrl },
|
||||
UserP,
|
||||
`Bearer ${token.access_token}`,
|
||||
);
|
||||
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
|
||||
addAccount(account);
|
||||
return { ok: true, value: account };
|
||||
} catch (e) {
|
||||
console.error(action, e);
|
||||
return { ok: false, error: (e as KyooErrors).errors[0] };
|
||||
}
|
||||
};
|
||||
|
||||
export const oidcLogin = async (provider: string, code: string, apiUrl?: string) => {
|
||||
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
|
||||
try {
|
||||
const token = await queryFn(
|
||||
{
|
||||
path: ["auth", "callback", provider, `?code=${code}`],
|
||||
method: "POST",
|
||||
authenticated: false,
|
||||
apiUrl,
|
||||
},
|
||||
TokenP,
|
||||
);
|
||||
const user = await queryFn(
|
||||
{ path: ["auth", "me"], method: "GET", apiUrl },
|
||||
UserP,
|
||||
`Bearer ${token.access_token}`,
|
||||
);
|
||||
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
|
||||
addAccount(account);
|
||||
return { ok: true, value: account };
|
||||
} catch (e) {
|
||||
console.error("oidcLogin", e);
|
||||
return { ok: false, error: (e as KyooErrors).errors[0] };
|
||||
}
|
||||
};
|
||||
|
||||
let running_id: string | null = null;
|
||||
let running: ReturnType<typeof getTokenWJ> | null = null;
|
||||
|
||||
export const getTokenWJ = async (
|
||||
acc?: Account | null,
|
||||
forceRefresh = false,
|
||||
): Promise<readonly [string, Token, null] | readonly [null, null, KyooErrors | null]> => {
|
||||
if (acc === undefined) acc = getCurrentAccount();
|
||||
if (!acc) return [null, null, null] as const;
|
||||
const account = acc;
|
||||
|
||||
async function run() {
|
||||
let token = account.token;
|
||||
|
||||
if (forceRefresh || account.token.expire_at <= new Date(new Date().getTime() + 10 * 1000)) {
|
||||
console.log("refreshing token for account", account.slug);
|
||||
try {
|
||||
token = await queryFn(
|
||||
{
|
||||
path: ["auth", "refresh", `?token=${account.token.refresh_token}`],
|
||||
method: "GET",
|
||||
authenticated: false,
|
||||
},
|
||||
TokenP,
|
||||
);
|
||||
if (Platform.OS !== "web" || typeof window !== "undefined")
|
||||
updateAccount(account.id, { ...account, token });
|
||||
} catch (e) {
|
||||
console.error("Error refreshing token durring ssr:", e);
|
||||
return [null, null, e as KyooErrors] as const;
|
||||
}
|
||||
}
|
||||
return [`${token.token_type} ${token.access_token}`, token, null] as const;
|
||||
}
|
||||
|
||||
// Do not cache promise durring ssr.
|
||||
if (Platform.OS === "web" && typeof window === "undefined") return await run();
|
||||
|
||||
if (running && running_id === account.id) return await running;
|
||||
running_id = account.id;
|
||||
running = run();
|
||||
const ret = await running;
|
||||
running_id = null;
|
||||
running = null;
|
||||
return ret;
|
||||
};
|
||||
|
||||
export const getToken = async (): Promise<string | null> => (await getTokenWJ())[0];
|
||||
|
||||
export const getCurrentToken = () => {
|
||||
const account = getCurrentAccount();
|
||||
return account ? `${account.token.token_type} ${account.token.access_token}` : null;
|
||||
};
|
||||
|
||||
export const useToken = () => {
|
||||
const account = getCurrentAccount();
|
||||
const refresher = useRef<NodeJS.Timeout | null>(null);
|
||||
const [token, setToken] = useState(
|
||||
account ? `${account.token.token_type} ${account.token.access_token}` : null,
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Refresh token when account change
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
const nToken = await getTokenWJ();
|
||||
setToken(nToken[0]);
|
||||
if (refresher.current) clearTimeout(refresher.current);
|
||||
if (nToken[1])
|
||||
refresher.current = setTimeout(run, nToken[1].expire_at.getTime() - Date.now());
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
if (refresher.current) clearTimeout(refresher.current);
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
if (!token) return null;
|
||||
return token;
|
||||
};
|
||||
|
||||
export const logout = () => {
|
||||
removeAccounts((x) => x.selected);
|
||||
};
|
||||
|
||||
export const deleteAccount = async () => {
|
||||
await queryFn({ path: ["auth", "me"], method: "DELETE" });
|
||||
logout();
|
||||
};
|
||||
@ -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 { Platform } from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { setCookie, storage } from "./account-internal";
|
||||
|
||||
export const useUserTheme = (ssrTheme?: "light" | "dark" | "auto") => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined" && ssrTheme) return ssrTheme;
|
||||
const [value] = useMMKVString("theme", storage);
|
||||
if (!value) return "auto";
|
||||
return value as "light" | "dark" | "auto";
|
||||
};
|
||||
|
||||
export const storeData = (key: string, value: string | number | boolean) => {
|
||||
storage.set(key, value);
|
||||
if (Platform.OS === "web") setCookie(key, value);
|
||||
};
|
||||
|
||||
export const deleteData = (key: string) => {
|
||||
storage.delete(key);
|
||||
if (Platform.OS === "web") setCookie(key, undefined);
|
||||
};
|
||||
|
||||
export const setUserTheme = (theme: "light" | "dark" | "auto") => {
|
||||
storeData("theme", theme);
|
||||
};
|
||||
@ -1,39 +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 { Platform } from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { z } from "zod";
|
||||
import { storage } from "./account-internal";
|
||||
import type { Movie, Show } from "./resources";
|
||||
|
||||
export const zdate = z.coerce.date;
|
||||
|
||||
export const useLocalSetting = (setting: string, def: string) => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined") return [def, null!] as const;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [val, setter] = useMMKVString(`settings.${setting}`, storage);
|
||||
return [val ?? def, setter] as const;
|
||||
};
|
||||
|
||||
export const getLocalSetting = (setting: string, def: string) => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined") return def;
|
||||
return storage.getString(`settings.${setting}`) ?? setting;
|
||||
};
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "@kyoo/ui",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"packageManager": "yarn@3.2.4",
|
||||
"dependencies": {
|
||||
"@kyoo/models": "workspace:^",
|
||||
"@kyoo/primitives": "workspace:^",
|
||||
"langmap": "^0.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@shopify/flash-list": "^1.7.1",
|
||||
"@types/langmap": "^0.0.3",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@gorhom/portal": "*",
|
||||
"@kesha-antonov/react-native-background-downloader": "*",
|
||||
"@material-symbols/svg-400": "*",
|
||||
"@shopify/flash-list": "^1.3.1",
|
||||
"@tanstack/react-query": "*",
|
||||
"expo-file-system": "*",
|
||||
"expo-image-picker": "~14.7.1",
|
||||
"expo-linear-gradient": "*",
|
||||
"expo-router": "*",
|
||||
"i18next": "*",
|
||||
"moti": "*",
|
||||
"react": "*",
|
||||
"react-i18next": "*",
|
||||
"react-native": "*",
|
||||
"react-native-reanimated": "*",
|
||||
"react-native-svg": "*",
|
||||
"yoshiki": "*"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
"expo-file-system": "^17.0.1",
|
||||
"expo-router": "^3.5.21"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@kesha-antonov/react-native-background-downloader": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-router": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,7 @@
|
||||
import { type WatchInfo, getCurrentApiUrl, queryFn, toQueryKey } from "@kyoo/models";
|
||||
import { getCurrentAccount } from "@kyoo/models/src/account-internal";
|
||||
import type { ReactNode } from "react";
|
||||
import { Player } from "../player";
|
||||
import { Player } from "../../../../src/ui/player../src/ui/player";
|
||||
|
||||
export const useDownloader = () => {
|
||||
return async (type: "episode" | "movie", slug: string) => {
|
||||
|
||||
@ -41,7 +41,7 @@ import { type PrimitiveAtom, atom, useSetAtom, useStore } from "jotai";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { ToastAndroid } from "react-native";
|
||||
import { z } from "zod";
|
||||
import { Player } from "../player";
|
||||
import { Player } from "../../../../src/ui/player";
|
||||
|
||||
type Router = ReturnType<typeof useRouter>;
|
||||
|
||||
|
||||
@ -1,78 +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 { Main } from "@kyoo/primitives";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import type { ReactElement } from "react";
|
||||
import { useYoshiki, vw } from "yoshiki/native";
|
||||
import { Navbar } from "../../../src/ui/navbar/src/ui/navbar";
|
||||
|
||||
export const DefaultLayout = ({
|
||||
page,
|
||||
transparent,
|
||||
}: {
|
||||
page: ReactElement;
|
||||
transparent?: boolean;
|
||||
}) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
return (
|
||||
<>
|
||||
<Navbar
|
||||
{...css(
|
||||
transparent && {
|
||||
bg: "transparent",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
)}
|
||||
background={
|
||||
transparent ? (
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.25 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={[theme.themeOverlay, "transparent"]}
|
||||
{...css({
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Main
|
||||
{...css({
|
||||
display: "flex",
|
||||
width: vw(100),
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
overflow: "hidden",
|
||||
})}
|
||||
>
|
||||
{page}
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,474 +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 { Audio, Chapter, KyooImage, Subtitle } from "@kyoo/models";
|
||||
import {
|
||||
CircularProgress,
|
||||
ContrastArea,
|
||||
H1,
|
||||
H2,
|
||||
IconButton,
|
||||
Poster,
|
||||
PressableFeedback,
|
||||
Skeleton,
|
||||
Slider,
|
||||
Tooltip,
|
||||
alpha,
|
||||
imageBorderRadius,
|
||||
tooltip,
|
||||
ts,
|
||||
useIsTouch,
|
||||
} from "@kyoo/primitives";
|
||||
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atom } from "jotai";
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ImageStyle, Platform, Pressable, View, type ViewProps } from "react-native";
|
||||
import { useRouter } from "solito/router";
|
||||
import { percent, rem, useYoshiki } from "yoshiki/native";
|
||||
import {
|
||||
bufferedAtom,
|
||||
durationAtom,
|
||||
fullscreenAtom,
|
||||
loadAtom,
|
||||
playAtom,
|
||||
progressAtom,
|
||||
} from "../state";
|
||||
import { LeftButtons, TouchControls } from "./left-buttons";
|
||||
import { RightButtons } from "./right-buttons";
|
||||
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
|
||||
|
||||
const hoverReasonAtom = atom({
|
||||
mouseMoved: false,
|
||||
mouseHover: false,
|
||||
menuOpened: false,
|
||||
});
|
||||
export const hoverAtom = atom((get) =>
|
||||
[!get(playAtom), ...Object.values(get(hoverReasonAtom))].includes(true),
|
||||
);
|
||||
export const seekingAtom = atom(false);
|
||||
export const seekProgressAtom = atom<number | null>(null);
|
||||
|
||||
export const Hover = ({
|
||||
isLoading,
|
||||
url,
|
||||
name,
|
||||
showName,
|
||||
poster,
|
||||
chapters,
|
||||
subtitles,
|
||||
audios,
|
||||
fonts,
|
||||
previousSlug,
|
||||
nextSlug,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
url: string;
|
||||
name?: string | null;
|
||||
showName?: string;
|
||||
poster?: KyooImage | null;
|
||||
chapters?: Chapter[];
|
||||
subtitles?: Subtitle[];
|
||||
audios?: Audio[];
|
||||
fonts?: string[];
|
||||
previousSlug?: string | null;
|
||||
nextSlug?: string | null;
|
||||
}) => {
|
||||
const show = useAtomValue(hoverAtom);
|
||||
const setHover = useSetAtom(hoverReasonAtom);
|
||||
const isSeeking = useAtomValue(seekingAtom);
|
||||
const isTouch = useIsTouch();
|
||||
|
||||
const showBottomSeeker = isSeeking && isTouch;
|
||||
|
||||
return (
|
||||
<ContrastArea mode="dark">
|
||||
{({ css }) => (
|
||||
<>
|
||||
<TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
|
||||
<View
|
||||
onPointerEnter={(e) => {
|
||||
if (e.nativeEvent.pointerType === "mouse")
|
||||
setHover((x) => ({ ...x, mouseHover: true }));
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.nativeEvent.pointerType === "mouse")
|
||||
setHover((x) => ({ ...x, mouseHover: false }));
|
||||
}}
|
||||
{...css({
|
||||
// TODO: animate show
|
||||
display: !show ? "none" : "flex",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
// box-none does not work on the web while none does not work on android
|
||||
pointerEvents: Platform.OS === "web" ? "none" : "box-none",
|
||||
})}
|
||||
>
|
||||
<Back
|
||||
isLoading={isLoading}
|
||||
name={showName}
|
||||
{...css({
|
||||
pointerEvents: "auto",
|
||||
})}
|
||||
/>
|
||||
<View
|
||||
{...css({
|
||||
// Fixed is used because firefox android make the hover disapear under the navigation bar in absolute
|
||||
position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bg: (theme) => theme.darkOverlay,
|
||||
flexDirection: "row",
|
||||
pointerEvents: "auto",
|
||||
padding: percent(1),
|
||||
})}
|
||||
>
|
||||
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} />
|
||||
<View
|
||||
{...css({
|
||||
marginLeft: { xs: ts(0.5), sm: ts(3) },
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
maxWidth: percent(100),
|
||||
})}
|
||||
>
|
||||
{!showBottomSeeker && (
|
||||
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
|
||||
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
|
||||
</H2>
|
||||
)}
|
||||
<ProgressBar chapters={chapters} url={url} />
|
||||
{showBottomSeeker ? (
|
||||
<BottomScrubber url={url} chapters={chapters} />
|
||||
) : (
|
||||
<View
|
||||
{...css({
|
||||
flexDirection: "row",
|
||||
flexGrow: 1,
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
})}
|
||||
>
|
||||
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
|
||||
<RightButtons
|
||||
subtitles={subtitles}
|
||||
audios={audios}
|
||||
fonts={fonts}
|
||||
onMenuOpen={() => setHover((x) => ({ ...x, menuOpened: true }))}
|
||||
onMenuClose={() => {
|
||||
// Disable hover since the menu overlay makes the mouseout unreliable.
|
||||
setHover((x) => ({ ...x, menuOpened: false, mouseHover: false }));
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ContrastArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
|
||||
const hover = useAtomValue(hoverAtom);
|
||||
const setHover = useSetAtom(hoverReasonAtom);
|
||||
const mouseCallback = useRef<NodeJS.Timeout | null>(null);
|
||||
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ count: 0 });
|
||||
const playerWidth = useRef<number | null>(null);
|
||||
const isTouch = useIsTouch();
|
||||
|
||||
const show = useCallback(() => {
|
||||
setHover((x) => ({ ...x, mouseMoved: true }));
|
||||
if (mouseCallback.current) clearTimeout(mouseCallback.current);
|
||||
mouseCallback.current = setTimeout(() => {
|
||||
setHover((x) => ({ ...x, mouseMoved: false }));
|
||||
}, 2500);
|
||||
}, [setHover]);
|
||||
|
||||
// On mouse move
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
const handler = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "mouse") return;
|
||||
show();
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", handler);
|
||||
return () => document.removeEventListener("pointermove", handler);
|
||||
}, [show]);
|
||||
|
||||
// When the controls hide, remove focus so space can be used to play/pause instead of triggering the button
|
||||
// It also serves to hide the tooltip.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (!hover && document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
}, [hover]);
|
||||
|
||||
const { css } = useYoshiki();
|
||||
|
||||
const duration = useAtomValue(durationAtom);
|
||||
const setPlay = useSetAtom(playAtom);
|
||||
const setProgress = useSetAtom(progressAtom);
|
||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||
|
||||
const onPress = (e: { pointerType: string; x: number }) => {
|
||||
if (Platform.OS === "web" && e.pointerType === "mouse") {
|
||||
setPlay((x) => !x);
|
||||
return;
|
||||
}
|
||||
if (hover) setHover((x) => ({ ...x, mouseMoved: false }));
|
||||
else show();
|
||||
};
|
||||
const onDoublePress = (e: { pointerType: string; x: number }) => {
|
||||
if (Platform.OS === "web" && e.pointerType === "mouse") {
|
||||
// Only reset touch count for the web, on mobile you can continue to seek by pressing again.
|
||||
touch.current.count = 0;
|
||||
setFullscreen((x) => !x);
|
||||
return;
|
||||
}
|
||||
|
||||
show();
|
||||
if (!duration || !playerWidth.current) return;
|
||||
|
||||
if (e.x < playerWidth.current * 0.33) {
|
||||
setProgress((x) => Math.max(x - 10, 0));
|
||||
}
|
||||
if (e.x > playerWidth.current * 0.66) {
|
||||
setProgress((x) => Math.min(x + 10, duration));
|
||||
}
|
||||
};
|
||||
|
||||
const onAnyPress = (e: { pointerType: string; x: number }) => {
|
||||
touch.current.count++;
|
||||
if (touch.current.count >= 2) {
|
||||
onDoublePress(e);
|
||||
clearTimeout(touch.current.timeout);
|
||||
} else {
|
||||
onPress(e);
|
||||
}
|
||||
|
||||
touch.current.timeout = setTimeout(() => {
|
||||
touch.current.count = 0;
|
||||
touch.current.timeout = undefined;
|
||||
}, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
tabIndex={-1}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.nativeEvent.pointerType === "mouse") setHover((x) => ({ ...x, mouseMoved: false }));
|
||||
}}
|
||||
onPress={(e) => {
|
||||
e.preventDefault();
|
||||
onAnyPress({
|
||||
pointerType: isTouch ? "touch" : "mouse",
|
||||
x: e.nativeEvent.locationX ?? e.nativeEvent.pageX,
|
||||
});
|
||||
}}
|
||||
onLayout={(e) => {
|
||||
playerWidth.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
{...css(
|
||||
// @ts-expect-error Web only property (cursor: unset)
|
||||
{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
cursor: hover ? "unset" : "none",
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressBar = ({ url, chapters }: { url: string; chapters?: Chapter[] }) => {
|
||||
const [progress, setProgress] = useAtom(progressAtom);
|
||||
const buffered = useAtomValue(bufferedAtom);
|
||||
const duration = useAtomValue(durationAtom);
|
||||
const setPlay = useSetAtom(playAtom);
|
||||
const [hoverProgress, setHoverProgress] = useState<number | null>(null);
|
||||
const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
||||
const [seekProgress, setSeekProgress] = useAtom(seekProgressAtom);
|
||||
const setSeeking = useSetAtom(seekingAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Slider
|
||||
progress={seekProgress ?? progress}
|
||||
startSeek={() => {
|
||||
setPlay(false);
|
||||
setSeeking(true);
|
||||
}}
|
||||
endSeek={() => {
|
||||
setSeeking(false);
|
||||
setProgress(seekProgress!);
|
||||
setSeekProgress(null);
|
||||
setTimeout(() => setPlay(true), 10);
|
||||
}}
|
||||
onHover={(progress, layout) => {
|
||||
setHoverProgress(progress);
|
||||
setLayout(layout);
|
||||
}}
|
||||
setProgress={(progress) => setSeekProgress(progress)}
|
||||
subtleProgress={buffered}
|
||||
max={duration}
|
||||
markers={chapters?.map((x) => x.startTime)}
|
||||
dataSet={{ tooltipId: "progress-scrubber" }}
|
||||
/>
|
||||
<Tooltip
|
||||
id={"progress-scrubber"}
|
||||
isOpen={hoverProgress !== null}
|
||||
place="top"
|
||||
position={{ x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), y: layout.y }}
|
||||
render={() =>
|
||||
hoverProgress ? (
|
||||
<ScrubberTooltip seconds={hoverProgress} chapters={chapters} url={url} />
|
||||
) : null
|
||||
}
|
||||
opacity={1}
|
||||
style={{ padding: 0, borderRadius: imageBorderRadius }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Back = ({
|
||||
isLoading,
|
||||
name,
|
||||
...props
|
||||
}: { isLoading: boolean; name?: string } & ViewProps) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bg: (theme) => theme.darkOverlay,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: percent(0.33),
|
||||
color: "white",
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
icon={ArrowBack}
|
||||
as={PressableFeedback}
|
||||
onPress={router.back}
|
||||
{...tooltip(t("player.back"))}
|
||||
/>
|
||||
<Skeleton>
|
||||
{isLoading ? (
|
||||
<Skeleton {...css({ width: rem(5) })} />
|
||||
) : (
|
||||
<H1
|
||||
{...css({
|
||||
alignSelf: "center",
|
||||
fontSize: rem(1.5),
|
||||
marginLeft: rem(1),
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</H1>
|
||||
)}
|
||||
</Skeleton>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPoster = ({
|
||||
poster,
|
||||
alt,
|
||||
isLoading,
|
||||
}: {
|
||||
poster?: KyooImage | null;
|
||||
alt?: string;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css({
|
||||
width: "15%",
|
||||
display: { xs: "none", sm: "flex" },
|
||||
position: "relative",
|
||||
})}
|
||||
>
|
||||
<Poster
|
||||
src={poster}
|
||||
quality="low"
|
||||
alt={alt}
|
||||
forcedLoading={isLoading}
|
||||
layout={{ width: percent(100) }}
|
||||
{...(css({ position: "absolute", bottom: 0 }) as { style: ImageStyle })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadingIndicator = () => {
|
||||
const isLoading = useAtomValue(loadAtom);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bg: (theme) => alpha(theme.colors.black, 0.3),
|
||||
justifyContent: "center",
|
||||
})}
|
||||
>
|
||||
<CircularProgress {...css({ alignSelf: "center" })} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@ -1,221 +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 { IconButton, Link, P, Slider, noTouch, tooltip, touchOnly, ts } from "@kyoo/primitives";
|
||||
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
|
||||
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
|
||||
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
|
||||
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
|
||||
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
|
||||
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
|
||||
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { type Stylable, px, useYoshiki } from "yoshiki/native";
|
||||
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
|
||||
import { HoverTouch, hoverAtom } from "./hover";
|
||||
|
||||
export const LeftButtons = ({
|
||||
previousSlug,
|
||||
nextSlug,
|
||||
}: {
|
||||
previousSlug?: string | null;
|
||||
nextSlug?: string | null;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
|
||||
const spacing = css({ marginHorizontal: ts(1) });
|
||||
|
||||
return (
|
||||
<View {...css({ flexDirection: "row" })}>
|
||||
<View {...css({ flexDirection: "row" }, noTouch)}>
|
||||
{previousSlug && (
|
||||
<IconButton
|
||||
icon={SkipPrevious}
|
||||
as={Link}
|
||||
href={previousSlug}
|
||||
replace
|
||||
{...tooltip(t("player.previous"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={isPlaying ? Pause : PlayArrow}
|
||||
onPress={() => setPlay(!isPlaying)}
|
||||
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
{nextSlug && (
|
||||
<IconButton
|
||||
icon={SkipNext}
|
||||
as={Link}
|
||||
href={nextSlug}
|
||||
replace
|
||||
{...tooltip(t("player.next"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
)}
|
||||
{Platform.OS === "web" && <VolumeSlider />}
|
||||
</View>
|
||||
<ProgressText {...css({ marginLeft: ts(1) })} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const TouchControls = ({
|
||||
previousSlug,
|
||||
nextSlug,
|
||||
...props
|
||||
}: {
|
||||
previousSlug?: string | null;
|
||||
nextSlug?: string | null;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
const hover = useAtomValue(hoverAtom);
|
||||
|
||||
const common = css(
|
||||
[
|
||||
{
|
||||
backgroundColor: (theme) => theme.darkOverlay,
|
||||
marginHorizontal: ts(3),
|
||||
},
|
||||
],
|
||||
touchOnly,
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverTouch
|
||||
{...css(
|
||||
{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
{hover && (
|
||||
<>
|
||||
<IconButton
|
||||
icon={SkipPrevious}
|
||||
as={Link}
|
||||
href={previousSlug!}
|
||||
replace
|
||||
size={ts(4)}
|
||||
{...css([!previousSlug && { opacity: 0, pointerEvents: "none" }], common)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={isPlaying ? Pause : PlayArrow}
|
||||
onPress={() => setPlay(!isPlaying)}
|
||||
size={ts(8)}
|
||||
{...common}
|
||||
/>
|
||||
<IconButton
|
||||
icon={SkipNext}
|
||||
as={Link}
|
||||
href={nextSlug!}
|
||||
replace
|
||||
size={ts(4)}
|
||||
{...css([!nextSlug && { opacity: 0, pointerEvents: "none" }], common)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</HoverTouch>
|
||||
);
|
||||
};
|
||||
|
||||
const VolumeSlider = () => {
|
||||
const [volume, setVolume] = useAtom(volumeAtom);
|
||||
const [isMuted, setMuted] = useAtom(mutedAtom);
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css({
|
||||
display: { xs: "none", sm: "flex" },
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
paddingRight: ts(1),
|
||||
})}
|
||||
>
|
||||
<IconButton
|
||||
icon={
|
||||
isMuted || volume === 0
|
||||
? VolumeOff
|
||||
: volume < 25
|
||||
? VolumeMute
|
||||
: volume < 65
|
||||
? VolumeDown
|
||||
: VolumeUp
|
||||
}
|
||||
onPress={() => setMuted(!isMuted)}
|
||||
{...tooltip(t("player.mute"), true)}
|
||||
/>
|
||||
<Slider
|
||||
progress={volume}
|
||||
setProgress={setVolume}
|
||||
size={4}
|
||||
{...css({ width: px(100) })}
|
||||
{...tooltip(t("player.volume"), true)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressText = (props: Stylable) => {
|
||||
const progress = useAtomValue(progressAtom);
|
||||
const duration = useAtomValue(durationAtom);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<P {...css({ alignSelf: "center" }, props)}>
|
||||
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
||||
</P>
|
||||
);
|
||||
};
|
||||
|
||||
export const toTimerString = (timer?: number, duration?: number) => {
|
||||
if (!duration) duration = timer;
|
||||
if (
|
||||
timer === undefined ||
|
||||
duration === undefined ||
|
||||
Number.isNaN(duration) ||
|
||||
Number.isNaN(timer)
|
||||
)
|
||||
return "??:??";
|
||||
const h = Math.floor(timer / 3600);
|
||||
const min = Math.floor((timer / 60) % 60);
|
||||
const sec = Math.floor(timer % 60);
|
||||
const fmt = (n: number) => n.toString().padStart(2, "0");
|
||||
|
||||
if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`;
|
||||
return `${fmt(min)}:${fmt(sec)}`;
|
||||
};
|
||||
@ -1,114 +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 { Audio, Subtitle } from "@kyoo/models";
|
||||
import { IconButton, Menu, tooltip, ts } from "@kyoo/primitives";
|
||||
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
|
||||
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
|
||||
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
|
||||
import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg";
|
||||
import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { type Stylable, useYoshiki } from "yoshiki/native";
|
||||
import { useSubtitleName } from "../../utils";
|
||||
import { fullscreenAtom, subtitleAtom } from "../state";
|
||||
import { AudiosMenu, QualitiesMenu } from "../video";
|
||||
|
||||
export const RightButtons = ({
|
||||
audios,
|
||||
subtitles,
|
||||
fonts,
|
||||
onMenuOpen,
|
||||
onMenuClose,
|
||||
...props
|
||||
}: {
|
||||
audios?: Audio[];
|
||||
subtitles?: Subtitle[];
|
||||
fonts?: string[];
|
||||
onMenuOpen: () => void;
|
||||
onMenuClose: () => void;
|
||||
} & Stylable) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const getSubtitleName = useSubtitleName();
|
||||
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
|
||||
const [selectedSubtitle, setSubtitle] = useAtom(subtitleAtom);
|
||||
|
||||
const spacing = css({ marginHorizontal: ts(1) });
|
||||
|
||||
return (
|
||||
<View {...css({ flexDirection: "row" }, props)}>
|
||||
{subtitles && subtitles.length > 0 && (
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={ClosedCaption}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
{...tooltip(t("player.subtitles"), true)}
|
||||
{...spacing}
|
||||
>
|
||||
<Menu.Item
|
||||
label={t("player.subtitle-none")}
|
||||
selected={!selectedSubtitle}
|
||||
onSelect={() => setSubtitle(null)}
|
||||
/>
|
||||
{subtitles
|
||||
.filter((x) => !!x.link)
|
||||
.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={x.index ?? i}
|
||||
label={x.link ? getSubtitleName(x) : `${getSubtitleName(x)} (${x.codec})`}
|
||||
selected={selectedSubtitle === x}
|
||||
disabled={!x.link}
|
||||
onSelect={() => setSubtitle(x)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
<AudiosMenu
|
||||
Trigger={IconButton}
|
||||
icon={MusicNote}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
audios={audios}
|
||||
{...tooltip(t("player.audios"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
<QualitiesMenu
|
||||
Trigger={IconButton}
|
||||
icon={SettingsIcon}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
{...tooltip(t("player.quality"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
{Platform.OS === "web" && (
|
||||
<IconButton
|
||||
icon={isFullscreen ? FullscreenExit : Fullscreen}
|
||||
onPress={() => setFullscreen(!isFullscreen)}
|
||||
{...tooltip(t("player.fullscreen"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@ -1,211 +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 Episode,
|
||||
EpisodeP,
|
||||
type Movie,
|
||||
MovieP,
|
||||
type QueryIdentifier,
|
||||
type WatchInfo,
|
||||
WatchInfoP,
|
||||
useFetch,
|
||||
} from "@kyoo/models";
|
||||
import { Head } from "@kyoo/primitives";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { type ComponentProps, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import { useRouter } from "solito/router";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { episodeDisplayNumber } from "../../../../src/ui/details/episode";
|
||||
import { ErrorView } from "../../../../src/ui/errors";
|
||||
import { Back, Hover, LoadingIndicator } from "./components/hover";
|
||||
import { useVideoKeyboard } from "./keyboard";
|
||||
import { Video, durationAtom, fullscreenAtom } from "./state";
|
||||
import { WatchStatusObserver } from "./watch-status-observer";
|
||||
|
||||
type Item = (Movie & { type: "movie" }) | (Episode & { type: "episode" });
|
||||
|
||||
const mapData = (
|
||||
data: Item | undefined,
|
||||
info: WatchInfo | undefined,
|
||||
previousSlug?: string,
|
||||
nextSlug?: string,
|
||||
): Partial<ComponentProps<typeof Hover>> & { isLoading: boolean } => {
|
||||
if (!data) return { isLoading: true };
|
||||
return {
|
||||
isLoading: false,
|
||||
name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data)} ${data.name}`,
|
||||
showName: data.type === "movie" ? data.name! : data.show!.name,
|
||||
poster: data.type === "movie" ? data.poster : data.show!.poster,
|
||||
subtitles: info?.subtitles,
|
||||
audios: info?.audios,
|
||||
chapters: info?.chapters,
|
||||
fonts: info?.fonts,
|
||||
previousSlug,
|
||||
nextSlug,
|
||||
};
|
||||
};
|
||||
|
||||
const formatTitleMetadata = (item: Item) => {
|
||||
if (item.type === "movie") {
|
||||
return item.name;
|
||||
}
|
||||
return `${item.name} (${episodeDisplayNumber({
|
||||
seasonNumber: item.seasonNumber,
|
||||
episodeNumber: item.episodeNumber,
|
||||
absoluteNumber: item.absoluteNumber,
|
||||
})})`;
|
||||
};
|
||||
|
||||
export const Player = ({
|
||||
slug,
|
||||
type,
|
||||
t: startTimeP,
|
||||
}: {
|
||||
slug: string;
|
||||
type: "episode" | "movie";
|
||||
t?: number;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [playbackError, setPlaybackError] = useState<string | undefined>(undefined);
|
||||
const { data, error } = useFetch(Player.query(type, slug));
|
||||
const { data: info, error: infoError } = useFetch(Player.infoQuery(type, slug));
|
||||
const image =
|
||||
data && data.type === "episode" ? (data.show?.poster ?? data?.poster) : data?.poster;
|
||||
const previous =
|
||||
data && data.type === "episode" && data.previousEpisode
|
||||
? `/watch/${data.previousEpisode.slug}?t=0`
|
||||
: undefined;
|
||||
const next =
|
||||
data && data.type === "episode" && data.nextEpisode
|
||||
? `/watch/${data.nextEpisode.slug}?t=0`
|
||||
: undefined;
|
||||
const title = data && formatTitleMetadata(data);
|
||||
const subtitle = data && data.type === "episode" ? data.show?.name : undefined;
|
||||
|
||||
useVideoKeyboard(info?.subtitles, info?.fonts, previous, next);
|
||||
|
||||
const startTime = startTimeP ?? data?.watchStatus?.watchedTime;
|
||||
|
||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (/Mobi/i.test(window.navigator.userAgent)) setFullscreen(true);
|
||||
return () => {
|
||||
if (!document.location.href.includes("/watch")) setFullscreen(false);
|
||||
};
|
||||
}, [setFullscreen]);
|
||||
|
||||
const setDuration = useSetAtom(durationAtom);
|
||||
useEffect(() => {
|
||||
setDuration(info?.durationSeconds);
|
||||
}, [info, setDuration]);
|
||||
|
||||
if (error || infoError || playbackError)
|
||||
return (
|
||||
<>
|
||||
<Back isLoading={false} {...css({ position: "relative", bg: (theme) => theme.accent })} />
|
||||
<ErrorView error={error ?? infoError ?? { errors: [playbackError!] }} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={title} description={data?.overview} />
|
||||
{data && info && (
|
||||
<WatchStatusObserver type={type} slug={data.slug} duration={info.durationSeconds} />
|
||||
)}
|
||||
<View
|
||||
{...css({
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
bg: "black",
|
||||
})}
|
||||
>
|
||||
<Video
|
||||
metadata={{
|
||||
title: title ?? t("show.episodeNoMetadata"),
|
||||
artist: subtitle ?? undefined,
|
||||
description: data?.overview ?? undefined,
|
||||
imageUri: image?.medium,
|
||||
next: next,
|
||||
previous: previous,
|
||||
}}
|
||||
links={data?.links}
|
||||
audios={info?.audios}
|
||||
subtitles={info?.subtitles}
|
||||
codec={info?.mimeCodec}
|
||||
setError={setPlaybackError}
|
||||
fonts={info?.fonts}
|
||||
startTime={startTime}
|
||||
onEnd={() => {
|
||||
if (!data) return;
|
||||
if (data.type === "movie")
|
||||
router.replace(`/movie/${data.slug}`, undefined, {
|
||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
|
||||
});
|
||||
else
|
||||
router.replace(next ?? `/show/${data.show!.slug}`, undefined, {
|
||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
|
||||
});
|
||||
}}
|
||||
{...css(StyleSheet.absoluteFillObject)}
|
||||
/>
|
||||
<LoadingIndicator />
|
||||
<Hover {...mapData(data, info, previous, next)} url={`${type}/${slug}`} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Player.query = (type: "episode" | "movie", slug: string): QueryIdentifier<Item> =>
|
||||
type === "episode"
|
||||
? {
|
||||
path: ["episode", slug],
|
||||
params: {
|
||||
fields: ["nextEpisode", "previousEpisode", "show", "watchStatus"],
|
||||
},
|
||||
parser: EpisodeP.transform((x) => ({ ...x, type: "episode" })),
|
||||
}
|
||||
: {
|
||||
path: ["movie", slug],
|
||||
params: {
|
||||
fields: ["watchStatus"],
|
||||
},
|
||||
parser: MovieP.transform((x) => ({ ...x, type: "movie" })),
|
||||
};
|
||||
|
||||
Player.infoQuery = (type: "episode" | "movie", slug: string): QueryIdentifier<WatchInfo> => ({
|
||||
path: [type, slug, "info"],
|
||||
parser: WatchInfoP,
|
||||
});
|
||||
|
||||
// if more queries are needed, dont forget to update download.tsx to cache those.
|
||||
Player.getFetchUrls = ({ slug, type }: { slug: string; type: "episode" | "movie" }) => [
|
||||
Player.query(type, slug),
|
||||
Player.infoQuery(type, slug),
|
||||
];
|
||||
|
||||
Player.requiredPermissions = ["overall.play"];
|
||||
@ -1,191 +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 { Subtitle } from "@kyoo/models";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useRouter } from "solito/router";
|
||||
import {
|
||||
durationAtom,
|
||||
fullscreenAtom,
|
||||
mutedAtom,
|
||||
playAtom,
|
||||
progressAtom,
|
||||
subtitleAtom,
|
||||
volumeAtom,
|
||||
} from "./state";
|
||||
|
||||
type Action =
|
||||
| { type: "play" }
|
||||
| { type: "mute" }
|
||||
| { type: "fullscreen" }
|
||||
| { type: "seek"; value: number }
|
||||
| { type: "seekTo"; value: number }
|
||||
| { type: "seekPercent"; value: number }
|
||||
| { type: "volume"; value: number }
|
||||
| { type: "subtitle"; subtitles: Subtitle[]; fonts: string[] };
|
||||
|
||||
export const reducerAtom = atom(null, (get, set, action: Action) => {
|
||||
const duration = get(durationAtom);
|
||||
switch (action.type) {
|
||||
case "play":
|
||||
set(playAtom, !get(playAtom));
|
||||
break;
|
||||
case "mute":
|
||||
set(mutedAtom, !get(mutedAtom));
|
||||
break;
|
||||
case "fullscreen":
|
||||
set(fullscreenAtom, !get(fullscreenAtom));
|
||||
break;
|
||||
case "seek":
|
||||
if (duration)
|
||||
set(progressAtom, Math.max(0, Math.min(get(progressAtom) + action.value, duration)));
|
||||
break;
|
||||
case "seekTo":
|
||||
set(progressAtom, action.value);
|
||||
break;
|
||||
case "seekPercent":
|
||||
if (duration) set(progressAtom, (duration * action.value) / 100);
|
||||
break;
|
||||
case "volume":
|
||||
set(volumeAtom, Math.max(0, Math.min(get(volumeAtom) + action.value, 100)));
|
||||
break;
|
||||
case "subtitle": {
|
||||
const subtitle = get(subtitleAtom);
|
||||
const index = subtitle ? action.subtitles.findIndex((x) => x.index === subtitle.index) : -1;
|
||||
set(
|
||||
subtitleAtom,
|
||||
index === -1 ? null : action.subtitles[(index + 1) % action.subtitles.length],
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const useVideoKeyboard = (
|
||||
subtitles?: Subtitle[],
|
||||
fonts?: string[],
|
||||
previousEpisode?: string,
|
||||
nextEpisode?: string,
|
||||
) => {
|
||||
const reducer = useSetAtom(reducerAtom);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
case "k":
|
||||
case "MediaPlay":
|
||||
case "MediaPause":
|
||||
case "MediaPlayPause":
|
||||
reducer({ type: "play" });
|
||||
break;
|
||||
|
||||
case "m":
|
||||
reducer({ type: "mute" });
|
||||
break;
|
||||
|
||||
case "ArrowLeft":
|
||||
reducer({ type: "seek", value: -5 });
|
||||
break;
|
||||
case "ArrowRight":
|
||||
reducer({ type: "seek", value: +5 });
|
||||
break;
|
||||
|
||||
case "j":
|
||||
reducer({ type: "seek", value: -10 });
|
||||
break;
|
||||
case "l":
|
||||
reducer({ type: "seek", value: +10 });
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
reducer({ type: "volume", value: +5 });
|
||||
break;
|
||||
case "ArrowDown":
|
||||
reducer({ type: "volume", value: -5 });
|
||||
break;
|
||||
|
||||
case "f":
|
||||
reducer({ type: "fullscreen" });
|
||||
break;
|
||||
|
||||
case "v":
|
||||
case "c":
|
||||
if (!subtitles || !fonts) return;
|
||||
reducer({ type: "subtitle", subtitles, fonts });
|
||||
break;
|
||||
|
||||
case "n":
|
||||
case "N":
|
||||
if (nextEpisode) router.push(nextEpisode);
|
||||
break;
|
||||
|
||||
case "p":
|
||||
case "P":
|
||||
if (previousEpisode) router.push(previousEpisode);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (event.code) {
|
||||
case "Digit0":
|
||||
reducer({ type: "seekPercent", value: 0 });
|
||||
break;
|
||||
case "Digit1":
|
||||
reducer({ type: "seekPercent", value: 10 });
|
||||
break;
|
||||
case "Digit2":
|
||||
reducer({ type: "seekPercent", value: 20 });
|
||||
break;
|
||||
case "Digit3":
|
||||
reducer({ type: "seekPercent", value: 30 });
|
||||
break;
|
||||
case "Digit4":
|
||||
reducer({ type: "seekPercent", value: 40 });
|
||||
break;
|
||||
case "Digit5":
|
||||
reducer({ type: "seekPercent", value: 50 });
|
||||
break;
|
||||
case "Digit6":
|
||||
reducer({ type: "seekPercent", value: 60 });
|
||||
break;
|
||||
case "Digit7":
|
||||
reducer({ type: "seekPercent", value: 70 });
|
||||
break;
|
||||
case "Digit8":
|
||||
reducer({ type: "seekPercent", value: 80 });
|
||||
break;
|
||||
case "Digit9":
|
||||
reducer({ type: "seekPercent", value: 90 });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keyup", handler);
|
||||
return () => document.removeEventListener("keyup", handler);
|
||||
}, [subtitles, fonts, nextEpisode, previousEpisode, router, reducer]);
|
||||
};
|
||||
@ -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 { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "solito/router";
|
||||
import { reducerAtom } from "./keyboard";
|
||||
import { durationAtom, playAtom, progressAtom } from "./state";
|
||||
|
||||
export const MediaSessionManager = ({
|
||||
title,
|
||||
subtitle,
|
||||
artist,
|
||||
imageUri,
|
||||
previous,
|
||||
next,
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
artist?: string;
|
||||
imageUri?: string | null;
|
||||
previous?: string;
|
||||
next?: string;
|
||||
}) => {
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
const progress = useAtomValue(progressAtom);
|
||||
const duration = useAtomValue(durationAtom);
|
||||
const reducer = useSetAtom(reducerAtom);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: title,
|
||||
album: subtitle,
|
||||
artist: artist,
|
||||
artwork: imageUri ? [{ src: imageUri }] : undefined,
|
||||
});
|
||||
}, [title, subtitle, artist, imageUri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const actions: [MediaSessionAction, MediaSessionActionHandler | null][] = [
|
||||
["play", () => setPlay(true)],
|
||||
["pause", () => setPlay(false)],
|
||||
["previoustrack", previous ? () => router.push(previous) : null],
|
||||
["nexttrack", next ? () => router.push(next) : null],
|
||||
[
|
||||
"seekbackward",
|
||||
(evt: MediaSessionActionDetails) =>
|
||||
reducer({ type: "seek", value: evt.seekOffset ? -evt.seekOffset : -10 }),
|
||||
],
|
||||
[
|
||||
"seekforward",
|
||||
(evt: MediaSessionActionDetails) =>
|
||||
reducer({ type: "seek", value: evt.seekOffset ? evt.seekOffset : 10 }),
|
||||
],
|
||||
[
|
||||
"seekto",
|
||||
(evt: MediaSessionActionDetails) => reducer({ type: "seekTo", value: evt.seekTime! }),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [action, handler] of actions) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, handler);
|
||||
} catch {}
|
||||
}
|
||||
}, [setPlay, reducer, router, previous, next]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
|
||||
}, [isPlaying]);
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator) || !duration) return;
|
||||
navigator.mediaSession.setPositionState({
|
||||
position: Math.min(progress, duration),
|
||||
duration,
|
||||
playbackRate: 1,
|
||||
});
|
||||
}, [progress, duration]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -1,265 +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 Audio, type Episode, type Subtitle, getLocalSetting, useAccount } from "@kyoo/models";
|
||||
import { useSnackbar } from "@kyoo/primitives";
|
||||
import { atom, getDefaultStore, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import {
|
||||
type ElementRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "./video";
|
||||
|
||||
export const playAtom = atom(true);
|
||||
export const loadAtom = atom(false);
|
||||
|
||||
export enum PlayMode {
|
||||
Direct,
|
||||
Hls,
|
||||
}
|
||||
export const playModeAtom = atom<PlayMode>(
|
||||
getLocalSetting("playmode", "direct") !== "auto" ? PlayMode.Direct : PlayMode.Hls,
|
||||
);
|
||||
|
||||
export const bufferedAtom = atom(0);
|
||||
export const durationAtom = atom<number | undefined>(undefined);
|
||||
|
||||
export const progressAtom = atom(
|
||||
(get) => get(privateProgressAtom),
|
||||
(get, set, update: number | ((value: number) => number)) => {
|
||||
const run = (value: number) => {
|
||||
set(privateProgressAtom, value);
|
||||
set(publicProgressAtom, value);
|
||||
};
|
||||
if (typeof update === "function") run(update(get(privateProgressAtom)));
|
||||
else run(update);
|
||||
},
|
||||
);
|
||||
const privateProgressAtom = atom(0);
|
||||
const publicProgressAtom = atom(0);
|
||||
|
||||
export const volumeAtom = atom(100);
|
||||
export const mutedAtom = atom(false);
|
||||
|
||||
export const fullscreenAtom = atom(
|
||||
(get) => get(privateFullscreen),
|
||||
(get, set, update: boolean | ((value: boolean) => boolean)) => {
|
||||
const run = async (value: boolean) => {
|
||||
try {
|
||||
if (value) {
|
||||
await document.body.requestFullscreen({
|
||||
navigationUI: "hide",
|
||||
});
|
||||
set(privateFullscreen, true);
|
||||
// @ts-expect-error Firefox does not support this so ts complains
|
||||
await screen.orientation.lock("landscape");
|
||||
} else {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
set(privateFullscreen, false);
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
if (typeof update === "function") run(update(get(privateFullscreen)));
|
||||
else run(update);
|
||||
},
|
||||
);
|
||||
const privateFullscreen = atom(false);
|
||||
|
||||
export const subtitleAtom = atom<Subtitle | null>(null);
|
||||
export const audioAtom = atom<Audio>({ index: 0 } as Audio);
|
||||
|
||||
export const Video = memo(function Video({
|
||||
links,
|
||||
subtitles,
|
||||
audios,
|
||||
codec,
|
||||
setError,
|
||||
fonts,
|
||||
startTime: startTimeP,
|
||||
metadata,
|
||||
...props
|
||||
}: {
|
||||
links?: Episode["links"];
|
||||
subtitles?: Subtitle[];
|
||||
audios?: Audio[];
|
||||
codec?: string;
|
||||
setError: (error: string | undefined) => void;
|
||||
fonts?: string[];
|
||||
startTime?: number | null;
|
||||
metadata: VideoMetadata & { next?: string; previous?: string };
|
||||
} & Partial<VideoProps>) {
|
||||
const ref = useRef<ElementRef<typeof NativeVideo> | null>(null);
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
const setLoad = useSetAtom(loadAtom);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [mode, setPlayMode] = useAtom(playModeAtom);
|
||||
|
||||
const startTime = useRef(startTimeP);
|
||||
useLayoutEffect(() => {
|
||||
startTime.current = startTimeP;
|
||||
}, [startTimeP]);
|
||||
|
||||
const publicProgress = useAtomValue(publicProgressAtom);
|
||||
const setPrivateProgress = useSetAtom(privateProgressAtom);
|
||||
const setPublicProgress = useSetAtom(publicProgressAtom);
|
||||
const setBuffered = useSetAtom(bufferedAtom);
|
||||
useEffect(() => {
|
||||
ref.current?.seek(publicProgress);
|
||||
}, [publicProgress]);
|
||||
|
||||
const getProgress = useAtomCallback(useCallback((get) => get(progressAtom), []));
|
||||
useEffect(() => {
|
||||
// Reset the state when a new video is loaded.
|
||||
|
||||
let newMode = getLocalSetting("playmode", "direct") !== "auto" ? PlayMode.Direct : PlayMode.Hls;
|
||||
// Only allow direct play if the device supports it
|
||||
if (newMode === PlayMode.Direct && codec && !canPlay(codec)) {
|
||||
console.log(`Browser can't natively play ${codec}, switching to hls stream.`);
|
||||
newMode = PlayMode.Hls;
|
||||
}
|
||||
setPlayMode(newMode);
|
||||
|
||||
setSource((newMode === PlayMode.Direct ? links?.direct : links?.hls) ?? null);
|
||||
setLoad(true);
|
||||
setPrivateProgress(startTime.current ?? 0);
|
||||
setPublicProgress(startTime.current ?? 0);
|
||||
setPlay(true);
|
||||
}, [links, codec, setLoad, setPrivateProgress, setPublicProgress, setPlay, setPlayMode]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: do not change source when links change, this is done above
|
||||
useEffect(() => {
|
||||
setSource((mode === PlayMode.Direct ? links?.direct : links?.hls) ?? null);
|
||||
// keep current time when changing between direct and hls.
|
||||
startTime.current = getProgress();
|
||||
setPlay(true);
|
||||
}, [mode, getProgress, setPlay]);
|
||||
|
||||
const account = useAccount();
|
||||
const defaultSubLanguage = account?.settings.subtitleLanguage;
|
||||
const setSubtitle = useSetAtom(subtitleAtom);
|
||||
|
||||
// When the video change, try to persist the subtitle language.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
|
||||
useEffect(() => {
|
||||
if (!subtitles) return;
|
||||
setSubtitle((subtitle) => {
|
||||
const subRet = subtitle
|
||||
? subtitles.find(
|
||||
(x) => x.language === subtitle.language && x.isForced === subtitle.isForced,
|
||||
)
|
||||
: null;
|
||||
if (subRet) return subRet;
|
||||
if (!defaultSubLanguage) return null;
|
||||
if (defaultSubLanguage === "default") return subtitles.find((x) => x.isDefault) ?? null;
|
||||
return subtitles.find((x) => x.language === defaultSubLanguage) ?? null;
|
||||
});
|
||||
}, [subtitles, setSubtitle, defaultSubLanguage, ref.current]);
|
||||
|
||||
const defaultAudioLanguage = account?.settings.audioLanguage ?? "default";
|
||||
const setAudio = useSetAtom(audioAtom);
|
||||
// When the video change, try to persist the subtitle language.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
|
||||
useEffect(() => {
|
||||
if (!audios) return;
|
||||
setAudio((audio) => {
|
||||
if (audio) {
|
||||
const ret = audios.find((x) => x.language === audio.language);
|
||||
if (ret) return ret;
|
||||
}
|
||||
if (defaultAudioLanguage !== "default") {
|
||||
const ret = audios.find((x) => x.language === defaultAudioLanguage);
|
||||
if (ret) return ret;
|
||||
}
|
||||
return audios.find((x) => x.isDefault) ?? audios[0];
|
||||
});
|
||||
}, [audios, setAudio, defaultAudioLanguage, ref.current]);
|
||||
|
||||
const volume = useAtomValue(volumeAtom);
|
||||
const isMuted = useAtomValue(mutedAtom);
|
||||
|
||||
const setFullscreen = useSetAtom(privateFullscreen);
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
const handler = () => {
|
||||
setFullscreen(document.fullscreenElement != null);
|
||||
};
|
||||
document.addEventListener("fullscreenchange", handler);
|
||||
return () => document.removeEventListener("fullscreenchange", handler);
|
||||
});
|
||||
|
||||
const createSnackbar = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!source || !links) return null;
|
||||
return (
|
||||
<NativeVideo
|
||||
ref={ref}
|
||||
{...props}
|
||||
source={{
|
||||
uri: source,
|
||||
startPosition: startTime.current ? startTime.current * 1000 : undefined,
|
||||
metadata: metadata,
|
||||
...links,
|
||||
}}
|
||||
showNotificationControls
|
||||
playInBackground
|
||||
playWhenInactive
|
||||
disableDisconnectError
|
||||
paused={!isPlaying}
|
||||
muted={isMuted}
|
||||
volume={volume}
|
||||
resizeMode="contain"
|
||||
onBuffer={({ isBuffering }) => setLoad(isBuffering)}
|
||||
onError={(status) => {
|
||||
console.error(status);
|
||||
setError(status.error.errorString);
|
||||
}}
|
||||
onProgress={(progress) => {
|
||||
setPrivateProgress(progress.currentTime);
|
||||
setBuffered(progress.playableDuration);
|
||||
}}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (state.isSeeking || getDefaultStore().get(loadAtom)) return;
|
||||
setPlay(state.isPlaying);
|
||||
}}
|
||||
fonts={fonts}
|
||||
subtitles={subtitles}
|
||||
onMediaUnsupported={() => {
|
||||
createSnackbar({
|
||||
key: "unsuported",
|
||||
label: t("player.unsupportedError"),
|
||||
duration: 3,
|
||||
});
|
||||
if (mode === PlayMode.Direct) setPlayMode(PlayMode.Hls);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -1,199 +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 "react-native-video";
|
||||
import type { ReactVideoSourceProperties } from "react-native-video";
|
||||
|
||||
declare module "react-native-video" {
|
||||
interface ReactVideoProps {
|
||||
fonts?: string[];
|
||||
subtitles?: Subtitle[];
|
||||
onMediaUnsupported?: () => void;
|
||||
}
|
||||
export type VideoProps = Omit<ReactVideoProps, "source"> & {
|
||||
source: ReactVideoSourceProperties & { hls: string | null };
|
||||
};
|
||||
}
|
||||
|
||||
export * from "react-native-video";
|
||||
|
||||
import { type Audio, type Subtitle, useToken } from "@kyoo/models";
|
||||
import { type IconButton, Menu } from "@kyoo/primitives";
|
||||
import "@kyoo/primitives/src/types.d.ts";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { type ComponentProps, forwardRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import uuid from "react-native-uuid";
|
||||
import NativeVideo, {
|
||||
type VideoRef,
|
||||
type OnLoadData,
|
||||
type VideoProps,
|
||||
SelectedTrackType,
|
||||
SelectedVideoTrackType,
|
||||
} from "react-native-video";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { useDisplayName } from "../utils";
|
||||
import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./state";
|
||||
|
||||
const MimeTypes: Map<string, string> = new Map([
|
||||
["subrip", "application/x-subrip"],
|
||||
["ass", "text/x-ssa"],
|
||||
["vtt", "text/vtt"],
|
||||
]);
|
||||
|
||||
const infoAtom = atom<OnLoadData | null>(null);
|
||||
const videoAtom = atom(0);
|
||||
|
||||
const clientId = uuid.v4() as string;
|
||||
|
||||
const Video = forwardRef<VideoRef, VideoProps>(function Video(
|
||||
{ onLoad, onBuffer, onError, onMediaUnsupported, source, subtitles, ...props },
|
||||
ref,
|
||||
) {
|
||||
const { css } = useYoshiki();
|
||||
const token = useToken();
|
||||
const setInfo = useSetAtom(infoAtom);
|
||||
const [video, setVideo] = useAtom(videoAtom);
|
||||
const audio = useAtomValue(audioAtom);
|
||||
const subtitle = useAtomValue(subtitleAtom);
|
||||
const mode = useAtomValue(playModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === PlayMode.Hls) setVideo(-1);
|
||||
}, [mode, setVideo]);
|
||||
|
||||
return (
|
||||
<View {...css({ flexGrow: 1, flexShrink: 1 })}>
|
||||
<NativeVideo
|
||||
ref={ref}
|
||||
source={{
|
||||
...source,
|
||||
headers: {
|
||||
...(token ? { Authorization: token } : {}),
|
||||
"X-CLIENT-ID": clientId,
|
||||
},
|
||||
}}
|
||||
onLoad={(info) => {
|
||||
onBuffer?.({ isBuffering: false });
|
||||
setInfo(info);
|
||||
onLoad?.(info);
|
||||
}}
|
||||
onBuffer={onBuffer}
|
||||
onError={(error) => {
|
||||
console.error(error);
|
||||
if (mode === PlayMode.Direct) onMediaUnsupported?.();
|
||||
else onError?.(error);
|
||||
}}
|
||||
selectedVideoTrack={
|
||||
video === -1
|
||||
? { type: SelectedVideoTrackType.AUTO }
|
||||
: { type: SelectedVideoTrackType.RESOLUTION, value: video }
|
||||
}
|
||||
// when video file is invalid, audio is undefined
|
||||
selectedAudioTrack={{ type: SelectedTrackType.INDEX, value: audio?.index ?? 0 }}
|
||||
textTracks={subtitles
|
||||
?.filter((x) => !!x.link)
|
||||
.map((x) => ({
|
||||
type: MimeTypes.get(x.codec) as any,
|
||||
uri: x.link!,
|
||||
title: x.title ?? "Unknown",
|
||||
language: x.language ?? ("Unknown" as any),
|
||||
}))}
|
||||
selectedTextTrack={
|
||||
subtitle
|
||||
? {
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: subtitles?.indexOf(subtitle),
|
||||
}
|
||||
: { type: SelectedTrackType.DISABLED, value: "" }
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default Video;
|
||||
|
||||
// mobile should be able to play everything
|
||||
export const canPlay = (_codec: string) => true;
|
||||
|
||||
type CustomMenu = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
|
||||
export const AudiosMenu = ({ audios, ...props }: CustomMenu & { audios?: Audio[] }) => {
|
||||
const info = useAtomValue(infoAtom);
|
||||
const [audio, setAudio] = useAtom(audioAtom);
|
||||
const getDisplayName = useDisplayName();
|
||||
|
||||
if (!info || info.audioTracks.length < 2) return null;
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
{info.audioTracks.map((x) => (
|
||||
<Menu.Item
|
||||
key={x.index}
|
||||
label={audios ? getDisplayName(audios[x.index]) : (x.title ?? x.language ?? "Unknown")}
|
||||
selected={audio!.index === x.index}
|
||||
onSelect={() => setAudio(x as any)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const QualitiesMenu = (props: CustomMenu) => {
|
||||
const { t } = useTranslation();
|
||||
const info = useAtomValue(infoAtom);
|
||||
const [mode, setPlayMode] = useAtom(playModeAtom);
|
||||
const [video, setVideo] = useAtom(videoAtom);
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Menu.Item
|
||||
label={t("player.direct")}
|
||||
selected={mode === PlayMode.Direct}
|
||||
onSelect={() => setPlayMode(PlayMode.Direct)}
|
||||
/>
|
||||
<Menu.Item
|
||||
// TODO: Display the currently selected quality (impossible with rn-video right now)
|
||||
label={t("player.auto")}
|
||||
selected={video === -1 && mode === PlayMode.Hls}
|
||||
onSelect={() => {
|
||||
setPlayMode(PlayMode.Hls);
|
||||
setVideo(-1);
|
||||
}}
|
||||
/>
|
||||
{/* TODO: Support video tracks when the play mode is not hls. */}
|
||||
{info?.videoTracks
|
||||
.sort((a: any, b: any) => b.height - a.height)
|
||||
.map((x: any, i: number) => (
|
||||
<Menu.Item
|
||||
key={i}
|
||||
label={`${x.height}p`}
|
||||
selected={video === x.height}
|
||||
onSelect={() => {
|
||||
setPlayMode(PlayMode.Hls);
|
||||
setVideo(x.height);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@ -1,452 +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 Audio, type Subtitle, getToken } from "@kyoo/models";
|
||||
import { Menu, tooltip } from "@kyoo/primitives";
|
||||
import Hls, { type Level, type LoadPolicy } from "hls.js";
|
||||
import Jassub from "jassub";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type RefObject,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { VideoProps } from "react-native-video";
|
||||
import toVttBlob from "srt-webvtt";
|
||||
import { useForceRerender, useYoshiki } from "yoshiki";
|
||||
import { useDisplayName } from "../utils";
|
||||
import { MediaSessionManager } from "./media-session";
|
||||
import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./state";
|
||||
|
||||
let hls: Hls | null = null;
|
||||
|
||||
function uuidv4(): string {
|
||||
// @ts-ignore I have no clue how this works, thanks https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
|
||||
);
|
||||
}
|
||||
|
||||
const client_id = typeof window === "undefined" ? "ssr" : uuidv4();
|
||||
|
||||
const initHls = (): Hls => {
|
||||
if (hls) hls.destroy();
|
||||
const loadPolicy: LoadPolicy = {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: Number.POSITIVE_INFINITY,
|
||||
maxLoadTimeMs: 60_000,
|
||||
timeoutRetry: {
|
||||
maxNumRetry: 2,
|
||||
retryDelayMs: 0,
|
||||
maxRetryDelayMs: 0,
|
||||
},
|
||||
errorRetry: {
|
||||
maxNumRetry: 1,
|
||||
retryDelayMs: 0,
|
||||
maxRetryDelayMs: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
hls = new Hls({
|
||||
xhrSetup: async (xhr) => {
|
||||
const token = await getToken();
|
||||
if (token) xhr.setRequestHeader("Authorization", token);
|
||||
xhr.setRequestHeader("X-CLIENT-ID", client_id);
|
||||
},
|
||||
autoStartLoad: false,
|
||||
startLevel: Number.POSITIVE_INFINITY,
|
||||
abrEwmaDefaultEstimate: 35_000_000,
|
||||
abrEwmaDefaultEstimateMax: 50_000_000,
|
||||
// debug: true,
|
||||
lowLatencyMode: false,
|
||||
fragLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: Number.POSITIVE_INFINITY,
|
||||
maxLoadTimeMs: 60_000,
|
||||
timeoutRetry: {
|
||||
maxNumRetry: 5,
|
||||
retryDelayMs: 100,
|
||||
maxRetryDelayMs: 0,
|
||||
},
|
||||
errorRetry: {
|
||||
maxNumRetry: 5,
|
||||
retryDelayMs: 0,
|
||||
maxRetryDelayMs: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
keyLoadPolicy: loadPolicy,
|
||||
certLoadPolicy: loadPolicy,
|
||||
playlistLoadPolicy: loadPolicy,
|
||||
manifestLoadPolicy: loadPolicy,
|
||||
steeringManifestLoadPolicy: loadPolicy,
|
||||
});
|
||||
return hls;
|
||||
};
|
||||
|
||||
const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function Video(
|
||||
{
|
||||
source,
|
||||
paused,
|
||||
muted,
|
||||
volume,
|
||||
onBuffer,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onError,
|
||||
onEnd,
|
||||
onPlaybackStateChanged,
|
||||
onMediaUnsupported,
|
||||
fonts,
|
||||
},
|
||||
forwaredRef,
|
||||
) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const oldHls = useRef<string | null>(null);
|
||||
const { css } = useYoshiki();
|
||||
const errorHandler = useRef<typeof onError>(onError);
|
||||
errorHandler.current = onError;
|
||||
|
||||
useImperativeHandle(
|
||||
forwaredRef,
|
||||
() => ({
|
||||
seek: (value: number) => {
|
||||
if (ref.current) ref.current.currentTime = value;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || paused === ref.current.paused) return;
|
||||
if (paused) ref.current?.pause();
|
||||
else ref.current?.play().catch(() => {});
|
||||
}, [paused]);
|
||||
useEffect(() => {
|
||||
if (!ref.current || !volume) return;
|
||||
ref.current.volume = Math.max(0, Math.min(volume, 100)) / 100;
|
||||
}, [volume]);
|
||||
|
||||
const subtitle = useAtomValue(subtitleAtom);
|
||||
useSubtitle(ref, subtitle, fonts);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: do not restart on startPosition change
|
||||
useLayoutEffect(() => {
|
||||
if (!ref?.current || !source.uri) return;
|
||||
if (!hls || oldHls.current !== source.hls) {
|
||||
// Reinit the hls player when we change track.
|
||||
hls = initHls();
|
||||
hls.loadSource(source.hls!);
|
||||
oldHls.current = source.hls;
|
||||
}
|
||||
if (!source.uri.endsWith(".m3u8")) {
|
||||
hls.detachMedia();
|
||||
ref.current.src = source.uri;
|
||||
} else {
|
||||
hls.attachMedia(ref.current);
|
||||
hls.startLoad(source.startPosition ? source.startPosition / 1000 : 0);
|
||||
hls.on(Hls.Events.ERROR, (_, d) => {
|
||||
if (!d.fatal || !hls?.media) return;
|
||||
console.warn("Hls error", d);
|
||||
errorHandler.current?.({
|
||||
error: { errorString: d.reason ?? d.error?.message ?? "Unknown hls error" },
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [source.uri, source.hls]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log("hls cleanup");
|
||||
if (hls) hls.destroy();
|
||||
hls = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mode = useAtomValue(playModeAtom);
|
||||
const audio = useAtomValue(audioAtom);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: also change when the mode change
|
||||
useEffect(() => {
|
||||
if (!hls) return;
|
||||
const update = () => {
|
||||
if (!hls) return;
|
||||
hls.audioTrack = audio?.index ?? 0;
|
||||
};
|
||||
update();
|
||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, update);
|
||||
return () => hls?.off(Hls.Events.AUDIO_TRACKS_UPDATED, update);
|
||||
}, [audio, mode]);
|
||||
|
||||
const setPlay = useSetAtom(playAtom);
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
// Set play state to the player's value (if autoplay is denied)
|
||||
setPlay(!ref.current.paused);
|
||||
}, [setPlay]);
|
||||
|
||||
const setProgress = useSetAtom(progressAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaSessionManager {...source.metadata} />
|
||||
<video
|
||||
ref={ref}
|
||||
src={source.uri}
|
||||
muted={muted}
|
||||
autoPlay={!paused}
|
||||
controls={false}
|
||||
playsInline
|
||||
onCanPlay={() => onBuffer?.call(null, { isBuffering: false })}
|
||||
onWaiting={() => onBuffer?.call(null, { isBuffering: true })}
|
||||
onDurationChange={() => {
|
||||
if (!ref.current) return;
|
||||
onLoad?.call(null, { duration: ref.current.duration } as any);
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!ref.current) return;
|
||||
onProgress?.call(null, {
|
||||
currentTime: ref.current.currentTime,
|
||||
playableDuration: ref.current.buffered.length
|
||||
? ref.current.buffered.end(ref.current.buffered.length - 1)
|
||||
: 0,
|
||||
seekableDuration: 0,
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
if (ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
|
||||
onMediaUnsupported?.call(undefined);
|
||||
else {
|
||||
onError?.call(null, {
|
||||
error: { errorString: ref.current?.error?.message ?? "Unknown error" },
|
||||
});
|
||||
}
|
||||
}}
|
||||
onLoadedMetadata={() => {
|
||||
if (source.startPosition) setProgress(source.startPosition / 1000);
|
||||
}}
|
||||
onPlay={() => onPlaybackStateChanged?.({ isPlaying: true, isSeeking: false })}
|
||||
onPause={() => onPlaybackStateChanged?.({ isPlaying: false, isSeeking: false })}
|
||||
onEnded={onEnd}
|
||||
{...css({ width: "100%", height: "100%", objectFit: "contain" })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Video;
|
||||
|
||||
export const canPlay = (codec: string) => {
|
||||
// most chrome based browser (and safari I think) supports matroska but reports they do not.
|
||||
// for those browsers, only check the codecs and not the container.
|
||||
if (navigator.userAgent.search("Firefox") === -1)
|
||||
codec = codec.replace("video/x-matroska", "video/mp4");
|
||||
const videos = document.getElementsByTagName("video");
|
||||
const video = videos.item(0) ?? document.createElement("video");
|
||||
return !!video.canPlayType(codec);
|
||||
};
|
||||
|
||||
const useSubtitle = (
|
||||
player: RefObject<HTMLVideoElement>,
|
||||
value: Subtitle | null,
|
||||
fonts?: string[],
|
||||
) => {
|
||||
const htmlTrack = useRef<HTMLTrackElement | null>();
|
||||
const subOcto = useRef<Jassub | null>();
|
||||
const mode = useAtom(playModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player.current) return;
|
||||
|
||||
const removeHtmlSubtitle = () => {
|
||||
if (htmlTrack.current) htmlTrack.current.remove();
|
||||
htmlTrack.current = null;
|
||||
};
|
||||
|
||||
const removeOctoSub = () => {
|
||||
if (subOcto.current) subOcto.current.destroy();
|
||||
subOcto.current = null;
|
||||
};
|
||||
|
||||
if (!value || !value.link) {
|
||||
removeHtmlSubtitle();
|
||||
removeOctoSub();
|
||||
} else if (value.codec === "vtt" || value.codec === "subrip") {
|
||||
removeOctoSub();
|
||||
if (player.current.textTracks.length > 0) player.current.textTracks[0].mode = "hidden";
|
||||
const addSubtitle = async () => {
|
||||
const track: HTMLTrackElement = htmlTrack.current ?? document.createElement("track");
|
||||
track.kind = "subtitles";
|
||||
track.label = value.title ?? value.language ?? "Subtitle";
|
||||
if (value.language) track.srclang = value.language;
|
||||
track.src = value.codec === "subrip" ? await toWebVtt(value.link!) : value.link!;
|
||||
track.className = "subtitle_container";
|
||||
track.default = true;
|
||||
track.onload = () => {
|
||||
if (player.current) player.current.textTracks[0].mode = "showing";
|
||||
};
|
||||
if (!htmlTrack.current) {
|
||||
htmlTrack.current = track;
|
||||
if (player.current) player.current.appendChild(track);
|
||||
}
|
||||
};
|
||||
addSubtitle();
|
||||
} else if (value.codec === "ass") {
|
||||
removeHtmlSubtitle();
|
||||
// Also recreate jassub when the player changes (this is not the most effective but
|
||||
// since it creates a div/canvas, it needs to be recreated when the UI rerender)
|
||||
// @ts-expect-error We are accessing the private _video field here.
|
||||
if (!subOcto.current || subOcto.current._video !== player.current) {
|
||||
removeOctoSub();
|
||||
subOcto.current = new Jassub({
|
||||
video: player.current,
|
||||
workerUrl: "/_next/static/chunks/jassub-worker.js",
|
||||
wasmUrl: "/_next/static/chunks/jassub-worker.wasm",
|
||||
legacyWasmUrl: "/_next/static/chunks/jassub-worker.wasm.js",
|
||||
// Disable offscreen renderer due to bugs on firefox and chrome android
|
||||
// (see https://github.com/ThaUnknown/jassub/issues/31)
|
||||
offscreenRender: false,
|
||||
subUrl: value.link,
|
||||
fonts: fonts,
|
||||
});
|
||||
} else {
|
||||
subOcto.current.freeTrack();
|
||||
subOcto.current.setTrackByUrl(value.link);
|
||||
}
|
||||
}
|
||||
// also include mode because srt get's disabled when the mode change (no idea why)
|
||||
mode;
|
||||
}, [player.current, value, fonts, mode]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (subOcto.current) subOcto.current.destroy();
|
||||
subOcto.current = null;
|
||||
if (htmlTrack.current) htmlTrack.current.remove();
|
||||
htmlTrack.current = null;
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
const toWebVtt = async (srtUrl: string) => {
|
||||
const token = await getToken();
|
||||
const query = await fetch(srtUrl, {
|
||||
headers: token
|
||||
? {
|
||||
Authorization: token,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const srt = await query.blob();
|
||||
return await toVttBlob(srt);
|
||||
};
|
||||
|
||||
export const AudiosMenu = ({
|
||||
audios,
|
||||
...props
|
||||
}: ComponentProps<typeof Menu<{ disabled?: boolean }>> & { audios?: Audio[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const rerender = useForceRerender();
|
||||
const [_, setAudio] = useAtom(audioAtom);
|
||||
const getDisplayName = useDisplayName();
|
||||
// force rerender when mode changes
|
||||
useAtomValue(playModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hls) return;
|
||||
hls.on(Hls.Events.AUDIO_TRACK_LOADED, rerender);
|
||||
return () => hls?.off(Hls.Events.AUDIO_TRACK_LOADED, rerender);
|
||||
});
|
||||
|
||||
if (!hls) return <Menu {...props} disabled {...tooltip(t("player.notInPristine"))} />;
|
||||
if (hls.audioTracks.length < 2) return null;
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
{hls.audioTracks.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={i.toString()}
|
||||
label={audios ? getDisplayName(audios[i]) : x.name}
|
||||
selected={hls!.audioTrack === i}
|
||||
onSelect={() => setAudio(audios?.[i] ?? ({ index: i } as any))}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const QualitiesMenu = (props: ComponentProps<typeof Menu>) => {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setPlayMode] = useAtom(playModeAtom);
|
||||
const rerender = useForceRerender();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Inculde hls in dependency array
|
||||
useEffect(() => {
|
||||
if (!hls) return;
|
||||
// Also rerender when hls instance changes
|
||||
rerender();
|
||||
hls.on(Hls.Events.LEVEL_SWITCHED, rerender);
|
||||
return () => hls?.off(Hls.Events.LEVEL_SWITCHED, rerender);
|
||||
}, [hls]);
|
||||
|
||||
const levelName = (label: Level, auto?: boolean): string => {
|
||||
const height = `${label.height}p`;
|
||||
if (auto) return height;
|
||||
return label.uri.includes("original") ? `${t("player.transmux")} (${height})` : height;
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Menu.Item
|
||||
label={t("player.direct")}
|
||||
selected={hls === null || mode === PlayMode.Direct}
|
||||
onSelect={() => setPlayMode(PlayMode.Direct)}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={
|
||||
hls?.autoLevelEnabled && hls.currentLevel >= 0
|
||||
? `${t("player.auto")} (${levelName(hls.levels[hls.currentLevel], true)})`
|
||||
: t("player.auto")
|
||||
}
|
||||
selected={hls?.autoLevelEnabled && mode === PlayMode.Hls}
|
||||
onSelect={() => {
|
||||
setPlayMode(PlayMode.Hls);
|
||||
if (hls) hls.currentLevel = -1;
|
||||
}}
|
||||
/>
|
||||
{hls?.levels
|
||||
.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={i.toString()}
|
||||
label={levelName(x)}
|
||||
selected={mode === PlayMode.Hls && hls!.currentLevel === i && !hls?.autoLevelEnabled}
|
||||
onSelect={() => {
|
||||
setPlayMode(PlayMode.Hls);
|
||||
hls!.currentLevel = i;
|
||||
}}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@ -1,43 +1,4 @@
|
||||
import type { Subtitle, Track } from "@kyoo/models";
|
||||
|
||||
import intl from "langmap";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const useLanguageName = () => {
|
||||
return (lang: string) => intl[lang]?.nativeName;
|
||||
};
|
||||
|
||||
export const useDisplayName = () => {
|
||||
const getLanguageName = useLanguageName();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (sub: Track) => {
|
||||
const lng = sub.language ? getLanguageName(sub.language) : null;
|
||||
|
||||
if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`;
|
||||
if (lng) return lng;
|
||||
if (sub.title) return sub.title;
|
||||
if (sub.index !== null) return `${t("mediainfo.unknown")} (${sub.index})`;
|
||||
return t("mediainfo.unknown");
|
||||
};
|
||||
};
|
||||
|
||||
export const useSubtitleName = () => {
|
||||
const getDisplayName = useDisplayName();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (sub: Subtitle) => {
|
||||
const name = getDisplayName(sub);
|
||||
const attributes = [name];
|
||||
|
||||
if (sub.isDefault) attributes.push(t("mediainfo.default"));
|
||||
if (sub.isForced) attributes.push(t("mediainfo.forced"));
|
||||
if (sub.isHearingImpaired) attributes.push(t("mediainfo.hearing-impaired"));
|
||||
if (sub.isExternal) attributes.push(t("mediainfo.external"));
|
||||
|
||||
return attributes.join(" - ");
|
||||
};
|
||||
};
|
||||
|
||||
const seenNativeNames = new Set();
|
||||
|
||||
|
||||
2
front/public/jassub/.gitignore
vendored
Normal file
2
front/public/jassub/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@ -198,6 +198,7 @@
|
||||
"volume": "Volume",
|
||||
"quality": "Quality",
|
||||
"audios": "Audio",
|
||||
"videos": "Video",
|
||||
"subtitles": "Subtitles",
|
||||
"subtitle-none": "None",
|
||||
"fullscreen": "Fullscreen",
|
||||
|
||||
12
front/scripts/postinstall.ts
Normal file
12
front/scripts/postinstall.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { readdir , mkdir } from 'node:fs/promises';
|
||||
|
||||
const srcDir = new URL("../node_modules/jassub/dist/", import.meta.url);
|
||||
const destDir = new URL("../public/jassub/", import.meta.url);
|
||||
|
||||
await mkdir(destDir, { recursive: true });
|
||||
|
||||
const files = await readdir(srcDir);
|
||||
for (const file of files) {
|
||||
const src = await Bun.file(new URL(file, srcDir)).arrayBuffer();
|
||||
await Bun.write(new URL(file, destDir), src);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg";
|
||||
import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg";
|
||||
// import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg";
|
||||
import Home from "@material-symbols/svg-400/rounded/home-fill.svg";
|
||||
import { Tabs } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -32,15 +32,15 @@ export default function TabsLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="downloads"
|
||||
options={{
|
||||
tabBarLabel: t("navbar.download"),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Icon icon={Downloading} color={color} size={size} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{/* <Tabs.Screen */}
|
||||
{/* name="downloads" */}
|
||||
{/* options={{ */}
|
||||
{/* tabBarLabel: t("navbar.download"), */}
|
||||
{/* tabBarIcon: ({ color, size }) => ( */}
|
||||
{/* <Icon icon={Downloading} color={color} size={size} /> */}
|
||||
{/* ), */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
3
front/src/app/(app)/watch/[slug].tsx
Normal file
3
front/src/app/(app)/watch/[slug].tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { Player } from "~/ui/player";
|
||||
|
||||
export default Player;
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
percent,
|
||||
rem,
|
||||
@ -8,14 +8,11 @@ import {
|
||||
type Theme,
|
||||
useYoshiki,
|
||||
} from "yoshiki/native";
|
||||
import { EntryContext } from "~/components/items/context-menus";
|
||||
import { ItemProgress } from "~/components/items/item-grid";
|
||||
import type { KImage, WatchStatusV } from "~/models";
|
||||
import type { KImage } from "~/models";
|
||||
import {
|
||||
focusReset,
|
||||
Image,
|
||||
ImageBackground,
|
||||
important,
|
||||
Link,
|
||||
P,
|
||||
Skeleton,
|
||||
@ -31,7 +28,7 @@ export const EntryBox = ({
|
||||
thumbnail,
|
||||
href,
|
||||
watchedPercent,
|
||||
watchedStatus,
|
||||
// watchedStatus,
|
||||
...props
|
||||
}: Stylable & {
|
||||
slug: string;
|
||||
@ -42,7 +39,7 @@ export const EntryBox = ({
|
||||
href: string;
|
||||
thumbnail: KImage | null;
|
||||
watchedPercent: number | null;
|
||||
watchedStatus: WatchStatusV | null;
|
||||
// watchedStatus: WatchStatusV | null;
|
||||
}) => {
|
||||
const [moreOpened, setMoreOpened] = useState(false);
|
||||
const { css } = useYoshiki("episodebox");
|
||||
@ -89,27 +86,27 @@ export const EntryBox = ({
|
||||
layout={{ width: percent(100), aspectRatio: 16 / 9 }}
|
||||
{...(css("poster") as any)}
|
||||
>
|
||||
{(watchedPercent || watchedStatus === "completed") && (
|
||||
<ItemProgress watchPercent={watchedPercent ?? 100} />
|
||||
)}
|
||||
<EntryContext
|
||||
slug={slug}
|
||||
serieSlug={serieSlug}
|
||||
status={watchedStatus}
|
||||
isOpen={moreOpened}
|
||||
setOpen={(v) => setMoreOpened(v)}
|
||||
{...css([
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bg: (theme) => theme.darkOverlay,
|
||||
},
|
||||
"more",
|
||||
Platform.OS === "web" &&
|
||||
moreOpened && { display: important("flex") },
|
||||
])}
|
||||
/>
|
||||
{/* {(watchedPercent || watchedStatus === "completed") && ( */}
|
||||
{/* <ItemProgress watchPercent={watchedPercent ?? 100} /> */}
|
||||
{/* )} */}
|
||||
{/* <EntryContext */}
|
||||
{/* slug={slug} */}
|
||||
{/* serieSlug={serieSlug} */}
|
||||
{/* status={watchedStatus} */}
|
||||
{/* isOpen={moreOpened} */}
|
||||
{/* setOpen={(v) => setMoreOpened(v)} */}
|
||||
{/* {...css([ */}
|
||||
{/* { */}
|
||||
{/* position: "absolute", */}
|
||||
{/* top: 0, */}
|
||||
{/* right: 0, */}
|
||||
{/* bg: (theme) => theme.darkOverlay, */}
|
||||
{/* }, */}
|
||||
{/* "more", */}
|
||||
{/* Platform.OS === "web" && */}
|
||||
{/* moreOpened && { display: important("flex") }, */}
|
||||
{/* ])} */}
|
||||
{/* /> */}
|
||||
</ImageBackground>
|
||||
<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
|
||||
{name ?? t("show.episodeNoMetadata")}
|
||||
|
||||
@ -12,7 +12,7 @@ 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";
|
||||
// import { useDownloader } from "../../packages/ui/src/downloads/ui/src/downloads";
|
||||
|
||||
export const EntryContext = ({
|
||||
slug,
|
||||
@ -27,32 +27,30 @@ export const EntryContext = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={MoreVert}
|
||||
{...tooltip(t("misc.more"))}
|
||||
{...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
|
||||
>
|
||||
{serieSlug && (
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.goToShow")}
|
||||
icon={Info}
|
||||
href={`/series/${serieSlug}`}
|
||||
/>
|
||||
)}
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.episodeMore.download")} */}
|
||||
{/* icon={Download} */}
|
||||
{/* onSelect={() => downloader(type, slug)} */}
|
||||
{/* /> */}
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={MoreVert}
|
||||
{...tooltip(t("misc.more"))}
|
||||
{...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
|
||||
>
|
||||
{serieSlug && (
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.mediainfo")}
|
||||
icon={MovieInfo}
|
||||
href={`/entries/${slug}/info`}
|
||||
label={t("home.episodeMore.goToShow")}
|
||||
icon={Info}
|
||||
href={`/series/${serieSlug}`}
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.episodeMore.download")} */}
|
||||
{/* icon={Download} */}
|
||||
{/* onSelect={() => downloader(type, slug)} */}
|
||||
{/* /> */}
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.mediainfo")}
|
||||
icon={MovieInfo}
|
||||
href={`/entries/${slug}/info`}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -87,60 +85,58 @@ export const ItemContext = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={MoreVert}
|
||||
{...tooltip(t("misc.more"))}
|
||||
{...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={MoreVert}
|
||||
{...tooltip(t("misc.more"))}
|
||||
{...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
|
||||
>
|
||||
<Menu.Sub
|
||||
label={account ? t("show.watchlistEdit") : t("show.watchlistLogin")}
|
||||
disabled={!account}
|
||||
icon={watchListIcon(status)}
|
||||
>
|
||||
<Menu.Sub
|
||||
label={account ? t("show.watchlistEdit") : t("show.watchlistLogin")}
|
||||
disabled={!account}
|
||||
icon={watchListIcon(status)}
|
||||
>
|
||||
{Object.values(WatchStatusV).map((x) => (
|
||||
<Menu.Item
|
||||
key={x}
|
||||
label={t(
|
||||
`show.watchlistMark.${x.toLowerCase() as Lowercase<WatchStatusV>}`,
|
||||
)}
|
||||
onSelect={() => mutation.mutate(x)}
|
||||
selected={x === status}
|
||||
/>
|
||||
))}
|
||||
{status !== null && (
|
||||
<Menu.Item
|
||||
label={t("show.watchlistMark.null")}
|
||||
onSelect={() => mutation.mutate(null)}
|
||||
/>
|
||||
)}
|
||||
</Menu.Sub>
|
||||
{kind === "movie" && (
|
||||
<>
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.episodeMore.download")} */}
|
||||
{/* icon={Download} */}
|
||||
{/* onSelect={() => downloader(type, slug)} */}
|
||||
{/* /> */}
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.mediainfo")}
|
||||
icon={MovieInfo}
|
||||
href={`/movies/${slug}/info`}
|
||||
/>
|
||||
</>
|
||||
{Object.values(WatchStatusV).map((x) => (
|
||||
<Menu.Item
|
||||
key={x}
|
||||
label={t(
|
||||
`show.watchlistMark.${x.toLowerCase() as Lowercase<WatchStatusV>}`,
|
||||
)}
|
||||
onSelect={() => mutation.mutate(x)}
|
||||
selected={x === status}
|
||||
/>
|
||||
))}
|
||||
{status !== null && (
|
||||
<Menu.Item
|
||||
label={t("show.watchlistMark.null")}
|
||||
onSelect={() => mutation.mutate(null)}
|
||||
/>
|
||||
)}
|
||||
{account?.isAdmin === true && (
|
||||
<>
|
||||
<HR />
|
||||
<Menu.Item
|
||||
label={t("home.refreshMetadata")}
|
||||
icon={Refresh}
|
||||
onSelect={() => metadataRefreshMutation.mutate()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
</Menu.Sub>
|
||||
{kind === "movie" && (
|
||||
<>
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.episodeMore.download")} */}
|
||||
{/* icon={Download} */}
|
||||
{/* onSelect={() => downloader(type, slug)} */}
|
||||
{/* /> */}
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.mediainfo")}
|
||||
icon={MovieInfo}
|
||||
href={`/movies/${slug}/info`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{account?.isAdmin === true && (
|
||||
<>
|
||||
<HR />
|
||||
<Menu.Item
|
||||
label={t("home.refreshMetadata")}
|
||||
icon={Refresh}
|
||||
onSelect={() => metadataRefreshMutation.mutate()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Done from "@material-symbols/svg-400/rounded/check-fill.svg";
|
||||
import { View } from "react-native";
|
||||
import { max, rem, useYoshiki } from "yoshiki/native";
|
||||
import { WatchStatusV } from "~/models";
|
||||
import type { WatchStatusV } from "~/models";
|
||||
import { Icon, P, ts } from "~/primitives";
|
||||
|
||||
export const ItemWatchStatus = ({
|
||||
@ -14,8 +14,7 @@ export const ItemWatchStatus = ({
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
if (watchStatus !== WatchStatusV.Completed && !unseenEpisodesCount)
|
||||
return null;
|
||||
if (watchStatus !== "completed" && !unseenEpisodesCount) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
@ -36,7 +35,7 @@ export const ItemWatchStatus = ({
|
||||
props,
|
||||
)}
|
||||
>
|
||||
{watchStatus === WatchStatusV.Completed ? (
|
||||
{watchStatus === "completed" ? (
|
||||
<Icon icon={Done} size={16} />
|
||||
) : (
|
||||
<P
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { User } from "./user";
|
||||
|
||||
// TODO: actually parse the token
|
||||
const TokenP = z.string();
|
||||
|
||||
export const AccountP = User.and(
|
||||
z.object({
|
||||
token: TokenP,
|
||||
|
||||
@ -29,7 +29,7 @@ const Base = z.object({
|
||||
),
|
||||
progress: z.object({
|
||||
percent: z.int().min(0).max(100),
|
||||
time: z.int().min(0).nullable(),
|
||||
time: z.int().min(0),
|
||||
playedDate: zdate().nullable(),
|
||||
videoId: z.string().nullable(),
|
||||
}),
|
||||
|
||||
@ -22,7 +22,7 @@ export const Extra = z.object({
|
||||
|
||||
progress: z.object({
|
||||
percent: z.int().min(0).max(100),
|
||||
time: z.int().min(0).nullable(),
|
||||
time: z.int().min(0),
|
||||
playedDate: zdate().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -12,3 +12,4 @@ export * from "./utils/genre";
|
||||
export * from "./utils/images";
|
||||
export * from "./utils/page";
|
||||
export * from "./video";
|
||||
export * from "./video-info";
|
||||
|
||||
@ -4,7 +4,6 @@ import { Genre } from "./utils/genre";
|
||||
import { KImage } from "./utils/images";
|
||||
import { Metadata } from "./utils/metadata";
|
||||
import { zdate } from "./utils/utils";
|
||||
import { EmbeddedVideo } from "./video";
|
||||
|
||||
export const Movie = z
|
||||
.object({
|
||||
@ -39,7 +38,18 @@ export const Movie = z
|
||||
updatedAt: zdate(),
|
||||
|
||||
studios: z.array(Studio).optional(),
|
||||
videos: z.array(EmbeddedVideo).optional(),
|
||||
videos: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
path: z.string(),
|
||||
rendering: z.string(),
|
||||
part: z.number().int().gt(0).nullable(),
|
||||
version: z.number().gt(0),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
watchStatus: z
|
||||
.object({
|
||||
status: z.enum([
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const QualityP = z
|
||||
.union([
|
||||
z.literal("original"),
|
||||
z.literal("8k"),
|
||||
z.literal("4k"),
|
||||
z.literal("1440p"),
|
||||
z.literal("1080p"),
|
||||
z.literal("720p"),
|
||||
z.literal("480p"),
|
||||
z.literal("360p"),
|
||||
z.literal("240p"),
|
||||
])
|
||||
.default("original");
|
||||
|
||||
/**
|
||||
* A Video Quality Enum.
|
||||
*/
|
||||
export type Quality = z.infer<typeof QualityP>;
|
||||
@ -1,182 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { QualityP } from "./quality";
|
||||
|
||||
/**
|
||||
* A audio or subtitle track.
|
||||
*/
|
||||
export const TrackP = z.object({
|
||||
/**
|
||||
* The index of this track on the episode.
|
||||
* NOTE: external subtitles can have a null index
|
||||
*/
|
||||
index: z.number().nullable(),
|
||||
/**
|
||||
* The title of the stream.
|
||||
*/
|
||||
title: z.string().nullable(),
|
||||
/**
|
||||
* The language of this stream (as a ISO-639-2 language code)
|
||||
*/
|
||||
language: z.string().nullable(),
|
||||
/**
|
||||
* The codec of this stream.
|
||||
*/
|
||||
codec: z.string(),
|
||||
/**
|
||||
* Is this stream the default one of it's type?
|
||||
*/
|
||||
isDefault: z.boolean(),
|
||||
/**
|
||||
* Is this stream tagged as forced?
|
||||
* NOTE: not available for videos
|
||||
*/
|
||||
isForced: z.boolean().optional(),
|
||||
});
|
||||
export type Track = z.infer<typeof TrackP>;
|
||||
|
||||
/**
|
||||
* A Video track
|
||||
*/
|
||||
export const VideoP = TrackP.extend({
|
||||
/**
|
||||
* The Quality of the Video
|
||||
* E.g. "1080p"
|
||||
*/
|
||||
quality: QualityP,
|
||||
/**
|
||||
* The Width of the Video Frame
|
||||
* E.g. 1424
|
||||
*/
|
||||
width: z.number(),
|
||||
/**
|
||||
* The Height of the Video Frame
|
||||
* E.g. 1072
|
||||
*/
|
||||
height: z.number(),
|
||||
/**
|
||||
* The Bitrate (in bits/seconds) of the video track
|
||||
* E.g. 2693245
|
||||
*/
|
||||
bitrate: z.number(),
|
||||
});
|
||||
|
||||
export type Video = z.infer<typeof VideoP>;
|
||||
|
||||
export const AudioP = TrackP;
|
||||
export type Audio = z.infer<typeof AudioP>;
|
||||
|
||||
export const SubtitleP = TrackP.extend({
|
||||
/*
|
||||
* The url of this track (only if this is a subtitle)..
|
||||
*/
|
||||
link: z.string().nullable(),
|
||||
/*
|
||||
* Is this an external subtitle (as in stored in a different file)
|
||||
*/
|
||||
isExternal: z.boolean(),
|
||||
/**
|
||||
* Is this a hearing impaired subtitle?
|
||||
*/
|
||||
isHearingImpaired: z.boolean(),
|
||||
});
|
||||
export type Subtitle = z.infer<typeof SubtitleP>;
|
||||
|
||||
export const ChapterP = z.object({
|
||||
/**
|
||||
* The start time of the chapter (in second from the start of the episode).
|
||||
*/
|
||||
startTime: z.number(),
|
||||
/**
|
||||
* The end time of the chapter (in second from the start of the episode).
|
||||
*/
|
||||
endTime: z.number(),
|
||||
/**
|
||||
* The name of this chapter. This should be a human-readable name that could be presented to the
|
||||
* user. There should be well-known chapters name for commonly used chapters. For example, use
|
||||
* "Opening" for the introduction-song and "Credits" for the end chapter with credits.
|
||||
*/
|
||||
name: z.string(),
|
||||
});
|
||||
export type Chapter = z.infer<typeof ChapterP>;
|
||||
|
||||
/**
|
||||
* The transcoder's info for this item. This include subtitles, fonts, chapters...
|
||||
*/
|
||||
export const WatchInfoP = z
|
||||
.object({
|
||||
/**
|
||||
* The sha1 of the video file.
|
||||
*/
|
||||
sha: z.string(),
|
||||
/**
|
||||
* The internal path of the video file.
|
||||
*/
|
||||
path: z.string(),
|
||||
/**
|
||||
* The extension used to store this video file.
|
||||
*/
|
||||
extension: z.string(),
|
||||
/**
|
||||
* The whole mimetype (defined as the RFC 6381).
|
||||
* ex: `video/mp4; codecs="avc1.640028, mp4a.40.2"`
|
||||
*/
|
||||
mimeCodec: z.string(),
|
||||
/**
|
||||
* The file size of the video file.
|
||||
*/
|
||||
size: z.number(),
|
||||
/**
|
||||
* The duration of the video (in seconds).
|
||||
*/
|
||||
duration: z.number(),
|
||||
/**
|
||||
* The container of the video file of this episode. Common containers are mp4, mkv, avi and so on.
|
||||
*/
|
||||
container: z.string().nullable(),
|
||||
/**
|
||||
* The video track.
|
||||
*/
|
||||
videos: z.array(VideoP),
|
||||
/**
|
||||
* The list of audio tracks.
|
||||
*/
|
||||
audios: z.array(AudioP),
|
||||
/**
|
||||
* The list of subtitles tracks.
|
||||
*/
|
||||
subtitles: z.array(SubtitleP),
|
||||
/**
|
||||
* The list of fonts that can be used to display subtitles.
|
||||
*/
|
||||
fonts: z.array(z.string()),
|
||||
/**
|
||||
* The list of chapters. See Chapter for more information.
|
||||
*/
|
||||
chapters: z.array(ChapterP),
|
||||
})
|
||||
.transform((x) => {
|
||||
const hour = Math.floor(x.duration / 3600);
|
||||
const minutes = Math.ceil((x.duration % 3600) / 60);
|
||||
|
||||
return {
|
||||
...x,
|
||||
duration: `${hour ? `${hour}h` : ""}${minutes}m`,
|
||||
durationSeconds: x.duration,
|
||||
size: humanFileSize(x.size),
|
||||
};
|
||||
});
|
||||
|
||||
// from https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
|
||||
const humanFileSize = (size: number): string => {
|
||||
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
return (
|
||||
// @ts-ignore I'm not gonna fix stackoverflow's working code.
|
||||
// biome-ignore lint/style/useTemplate: same as above
|
||||
(size / 1024 ** i).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A watch info for a video
|
||||
*/
|
||||
export type WatchInfo = z.infer<typeof WatchInfoP>;
|
||||
108
front/src/models/video-info.ts
Normal file
108
front/src/models/video-info.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const Quality = z
|
||||
.enum([
|
||||
"original",
|
||||
"8k",
|
||||
"4k",
|
||||
"1440p",
|
||||
"1080p",
|
||||
"720p",
|
||||
"480p",
|
||||
"360p",
|
||||
"240p",
|
||||
])
|
||||
.default("original");
|
||||
export type Quality = z.infer<typeof Quality>;
|
||||
|
||||
export const VideoTrack = z.object({
|
||||
index: z.number(),
|
||||
title: z.string().nullable(),
|
||||
language: z.string().nullable(),
|
||||
codec: z.string(),
|
||||
mimeCodec: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
bitrate: z.number(),
|
||||
isDefault: z.boolean(),
|
||||
});
|
||||
|
||||
export type VideoTrack = z.infer<typeof VideoTrack>;
|
||||
|
||||
export const AudioTrack = z.object({
|
||||
index: z.number(),
|
||||
title: z.string().nullable(),
|
||||
language: z.string().nullable(),
|
||||
codec: z.string(),
|
||||
mimeCodec: z.string(),
|
||||
bitrate: z.number(),
|
||||
isDefault: z.boolean(),
|
||||
});
|
||||
export type AudioTrack = z.infer<typeof AudioTrack>;
|
||||
|
||||
export const Subtitle = z.object({
|
||||
// external subtitles don't have indexes
|
||||
index: z.number().nullable(),
|
||||
title: z.string().nullable(),
|
||||
language: z.string().nullable(),
|
||||
codec: z.string(),
|
||||
mimeCodec: z.string().nullable(),
|
||||
extension: z.string(),
|
||||
isDefault: z.boolean(),
|
||||
isForced: z.boolean(),
|
||||
isHearingImpaired: z.boolean(),
|
||||
isExternal: z.boolean(),
|
||||
// only non-null when `isExternal` is true
|
||||
path: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
});
|
||||
export type Subtitle = z.infer<typeof Subtitle>;
|
||||
|
||||
export const Chapter = z.object({
|
||||
// in seconds
|
||||
startTime: z.number(),
|
||||
// in seconds
|
||||
endTime: z.number(),
|
||||
name: z.string(),
|
||||
type: z.enum(["content", "recap", "intro", "credits", "preview"]),
|
||||
});
|
||||
export type Chapter = z.infer<typeof Chapter>;
|
||||
|
||||
export const VideoInfo = z
|
||||
.object({
|
||||
sha: z.string(),
|
||||
path: z.string(),
|
||||
extension: z.string(),
|
||||
mimeCodec: z.string(),
|
||||
size: z.number(),
|
||||
// in seconds
|
||||
duration: z.number(),
|
||||
container: z.string().nullable(),
|
||||
videos: z.array(VideoTrack),
|
||||
audios: z.array(AudioTrack),
|
||||
subtitles: z.array(Subtitle),
|
||||
fonts: z.array(z.string()),
|
||||
chapters: z.array(Chapter),
|
||||
})
|
||||
.transform((x) => {
|
||||
const hour = Math.floor(x.duration / 3600);
|
||||
const minutes = Math.ceil((x.duration % 3600) / 60);
|
||||
|
||||
return {
|
||||
...x,
|
||||
duration: `${hour ? `${hour}h` : ""}${minutes}m`,
|
||||
durationSeconds: x.duration,
|
||||
size: humanFileSize(x.size),
|
||||
};
|
||||
});
|
||||
export type VideoInfo = z.infer<typeof VideoInfo>;
|
||||
|
||||
// from https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
|
||||
const humanFileSize = (size: number): string => {
|
||||
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
return (
|
||||
// @ts-expect-error I'm not gonna fix stackoverflow's working code.
|
||||
// biome-ignore lint/style/useTemplate: same as above
|
||||
(size / 1024 ** i).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,52 @@
|
||||
import { z } from "zod/v4";
|
||||
import { Entry } from "./entry";
|
||||
import { Extra } from "./extra";
|
||||
import { Show } from "./show";
|
||||
import { zdate } from "./utils/utils";
|
||||
|
||||
export const EmbeddedVideo = z.object({
|
||||
export const Video = z.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
path: z.string(),
|
||||
rendering: z.string(),
|
||||
part: z.number().int().gt(0).nullable(),
|
||||
version: z.number().gt(0),
|
||||
part: z.int().min(0).nullable(),
|
||||
version: z.int().min(0).default(1),
|
||||
guess: z.object({
|
||||
title: z.string(),
|
||||
kind: z.enum(["episode", "movie", "extra"]).nullable().optional(),
|
||||
extraKind: Extra.shape.kind.optional().nullable(),
|
||||
years: z.array(z.int()).default([]),
|
||||
episodes: z
|
||||
.array(
|
||||
z.object({
|
||||
season: z.int().nullable(),
|
||||
episode: z.int(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
externalId: z.record(z.string(), z.string()).default({}),
|
||||
|
||||
// Name of the tool that made the guess
|
||||
from: z.string(),
|
||||
// Adding that results in an infinite recursion
|
||||
// get history() {
|
||||
// return z.array(Video.shape.guess.omit({ history: true })).default([]);
|
||||
// },
|
||||
}),
|
||||
createdAt: zdate(),
|
||||
updatedAt: zdate(),
|
||||
});
|
||||
export type EmbeddedVideo = z.infer<typeof EmbeddedVideo>;
|
||||
|
||||
export const FullVideo = Video.extend({
|
||||
slugs: z.array(z.string()),
|
||||
progress: z.object({
|
||||
percent: z.int().min(0).max(100),
|
||||
time: z.int().min(0),
|
||||
playedDate: zdate().nullable(),
|
||||
videoId: z.string().nullable(),
|
||||
}),
|
||||
entries: z.array(Entry),
|
||||
previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
|
||||
next: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
|
||||
show: Show.optional(),
|
||||
});
|
||||
export type FullVideo = z.infer<typeof FullVideo>;
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ComponentType,
|
||||
type ForwardedRef,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import type { ComponentProps, ComponentType } from "react";
|
||||
import { Platform, type PressableProps } from "react-native";
|
||||
import type { SvgProps } from "react-native-svg";
|
||||
import type { YoshikiStyle } from "yoshiki";
|
||||
@ -13,12 +7,6 @@ import { PressableFeedback } from "./links";
|
||||
import { P } from "./text";
|
||||
import { type Breakpoint, focusReset, ts } from "./utils";
|
||||
|
||||
declare module "react" {
|
||||
function forwardRef<T, P = {}>(
|
||||
render: (props: P, ref: React.ForwardedRef<T>) => React.ReactElement | null,
|
||||
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
|
||||
}
|
||||
|
||||
export type Icon = ComponentType<SvgProps>;
|
||||
|
||||
type IconProps = {
|
||||
@ -54,27 +42,21 @@ export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef(function IconButton<
|
||||
AsProps = PressableProps,
|
||||
>(
|
||||
{
|
||||
icon,
|
||||
size,
|
||||
color,
|
||||
as,
|
||||
...asProps
|
||||
}: IconProps & {
|
||||
as?: ComponentType<AsProps>;
|
||||
} & AsProps,
|
||||
ref: ForwardedRef<unknown>,
|
||||
) {
|
||||
export const IconButton = <AsProps = PressableProps>({
|
||||
icon,
|
||||
size,
|
||||
color,
|
||||
as,
|
||||
...asProps
|
||||
}: IconProps & {
|
||||
as?: ComponentType<AsProps>;
|
||||
} & AsProps) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
|
||||
const Container = as ?? PressableFeedback;
|
||||
|
||||
return (
|
||||
<Container
|
||||
ref={ref as any}
|
||||
focusRipple
|
||||
{...(css(
|
||||
{
|
||||
@ -102,7 +84,7 @@ export const IconButton = forwardRef(function IconButton<
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const IconFab = <AsProps = PressableProps>(
|
||||
props: ComponentProps<typeof IconButton<AsProps>>,
|
||||
|
||||
@ -25,7 +25,7 @@ export const ImageBackground = ({
|
||||
layout: ImageLayout;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { css, theme } = useYoshiki();
|
||||
const { apiUrl, authToken } = useToken();
|
||||
|
||||
return (
|
||||
@ -42,7 +42,10 @@ export const ImageBackground = ({
|
||||
}}
|
||||
placeholder={{ blurhash: src?.blurhash }}
|
||||
accessibilityLabel={alt}
|
||||
{...(css([layout, { overflow: "hidden" }], props) as any)}
|
||||
{...(css(
|
||||
[layout, { overflow: "hidden", backgroundColor: theme.overlay0 }],
|
||||
props,
|
||||
) as any)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -34,7 +34,7 @@ export const Image = ({
|
||||
style?: ImageStyle;
|
||||
layout: ImageLayout;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { css, theme } = useYoshiki();
|
||||
const { apiUrl, authToken } = useToken();
|
||||
|
||||
return (
|
||||
@ -51,7 +51,10 @@ export const Image = ({
|
||||
}}
|
||||
placeholder={{ blurhash: src?.blurhash }}
|
||||
accessibilityLabel={alt}
|
||||
{...(css([layout, { borderRadius: 6 }], props) as any)}
|
||||
{...(css(
|
||||
[layout, { borderRadius: 6, backgroundColor: theme.overlay0 }],
|
||||
props,
|
||||
) as any)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,16 +7,16 @@ export * from "./divider";
|
||||
export * from "./icons";
|
||||
export * from "./image";
|
||||
export * from "./image-background";
|
||||
// export * from "./popup";
|
||||
// export * from "./select";
|
||||
export * from "./input";
|
||||
export * from "./links";
|
||||
// export * from "./progress";
|
||||
// export * from "./slider";
|
||||
// export * from "./snackbar";
|
||||
// export * from "./alert";
|
||||
export * from "./menu";
|
||||
export * from "./progress";
|
||||
// export * from "./popup";
|
||||
export * from "./select";
|
||||
export * from "./skeleton";
|
||||
export * from "./slider";
|
||||
export * from "./text";
|
||||
export * from "./theme";
|
||||
export * from "./tooltip";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { forwardRef, type ReactNode, useState } from "react";
|
||||
import { type ReactNode, type Ref, useState } from "react";
|
||||
import {
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
@ -6,20 +6,22 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { px, type Theme, useYoshiki } from "yoshiki/native";
|
||||
import type { YoshikiEnhanced } from "./image/base-image";
|
||||
import type { YoshikiEnhanced } from "./image";
|
||||
import { focusReset, ts } from "./utils";
|
||||
|
||||
export const Input = forwardRef<
|
||||
TextInput,
|
||||
{
|
||||
variant?: "small" | "big";
|
||||
right?: ReactNode;
|
||||
containerStyle?: YoshikiEnhanced<ViewStyle>;
|
||||
} & TextInputProps
|
||||
>(function Input(
|
||||
{ placeholderTextColor, variant = "small", right, containerStyle, ...props },
|
||||
export const Input = ({
|
||||
placeholderTextColor,
|
||||
variant = "small",
|
||||
right,
|
||||
containerStyle,
|
||||
ref,
|
||||
) {
|
||||
...props
|
||||
}: {
|
||||
variant?: "small" | "big";
|
||||
right?: ReactNode;
|
||||
containerStyle?: YoshikiEnhanced<ViewStyle>;
|
||||
ref?: Ref<TextInput>;
|
||||
} & TextInputProps) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const { css, theme } = useYoshiki();
|
||||
|
||||
@ -64,4 +66,4 @@ export const Input = forwardRef<
|
||||
{right}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,26 +1,5 @@
|
||||
/*
|
||||
* 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 { ActivityIndicator, Platform, View } from "react-native";
|
||||
import { Circle, Svg } from "react-native-svg";
|
||||
import { px, type Stylable, useYoshiki } from "yoshiki/native";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { type Stylable, useYoshiki } from "yoshiki/native";
|
||||
|
||||
export const CircularProgress = ({
|
||||
size = 48,
|
||||
@ -28,64 +7,9 @@ export const CircularProgress = ({
|
||||
color,
|
||||
...props
|
||||
}: { size?: number; tickness?: number; color?: string } & Stylable) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
|
||||
if (Platform.OS !== "web")
|
||||
return (
|
||||
<ActivityIndicator size={size} color={color ?? theme.accent} {...props} />
|
||||
);
|
||||
const { theme } = useYoshiki();
|
||||
|
||||
return (
|
||||
<View {...css({ width: size, height: size, overflow: "hidden" }, props)}>
|
||||
<style jsx global>{`
|
||||
@keyframes circularProgress-svg {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes circularProgress-circle {
|
||||
0% {
|
||||
stroke-dasharray: 1px, 200px;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 100px, 200px;
|
||||
stroke-dashoffset: -15px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 100px, 200px;
|
||||
stroke-dashoffset: -125px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<Svg
|
||||
viewBox={`${size / 2} ${size / 2} ${size} ${size}`}
|
||||
{...css(
|
||||
// @ts-ignore Web only
|
||||
Platform.OS === "web" && {
|
||||
animation: "circularProgress-svg 1.4s ease-in-out infinite",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Circle
|
||||
cx={size}
|
||||
cy={size}
|
||||
r={(size - tickness) / 2}
|
||||
strokeWidth={tickness}
|
||||
fill="none"
|
||||
stroke={color ?? theme.accent}
|
||||
strokeDasharray={[px(80), px(200)]}
|
||||
{...css(
|
||||
Platform.OS === "web" && {
|
||||
// @ts-ignore Web only
|
||||
animation: "circularProgress-circle 1.4s ease-in-out infinite",
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
<ActivityIndicator size={size} color={color ?? theme.accent} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
|
||||
import { Button } from "./button";
|
||||
import { Icon } from "./icons";
|
||||
|
||||
@ -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 Check from "@material-symbols/svg-400/rounded/check-fill.svg";
|
||||
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
|
||||
import ExpandLess from "@material-symbols/svg-400/rounded/keyboard_arrow_up-fill.svg";
|
||||
@ -30,7 +10,7 @@ import { Icon } from "./icons";
|
||||
import { PressableFeedback } from "./links";
|
||||
import { InternalTriger, YoshikiProvider } from "./menu.web";
|
||||
import { P } from "./text";
|
||||
import { ContrastArea, SwitchVariant } from "./themes";
|
||||
import { ContrastArea, SwitchVariant } from "./theme";
|
||||
import { focusReset, ts } from "./utils";
|
||||
|
||||
export const Select = ({
|
||||
@ -131,11 +111,11 @@ const Item = forwardRef<HTMLDivElement, { label: string; value: string }>(
|
||||
const { css: nCss } = useNativeYoshiki();
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
[data-highlighted] {
|
||||
background: ${theme.variant.accent};
|
||||
}
|
||||
`}</style>
|
||||
{/* <style jsx global>{` */}
|
||||
{/* [data-highlighted] { */}
|
||||
{/* background: ${theme.variant.accent}; */}
|
||||
{/* } */}
|
||||
{/* `}</style> */}
|
||||
<RSelect.Item
|
||||
ref={ref}
|
||||
value={value}
|
||||
|
||||
@ -106,11 +106,7 @@ export const Skeleton = ({
|
||||
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,
|
||||
]}
|
||||
style={[StyleSheet.absoluteFillObject, animated]}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@ -1,26 +1,10 @@
|
||||
/*
|
||||
* 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 { useRef, useState } from "react";
|
||||
import { type GestureResponderEvent, Platform, View } from "react-native";
|
||||
import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils";
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
Platform,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { percent, px, useYoshiki } from "yoshiki/native";
|
||||
import { focusReset } from "./utils";
|
||||
|
||||
@ -71,14 +55,14 @@ export const Slider = ({
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
// @ts-ignore Web only
|
||||
// @ts-expect-error Web only
|
||||
onMouseEnter={() => setHover(true)}
|
||||
// @ts-ignore Web only
|
||||
// @ts-expect-error Web only
|
||||
onMouseLeave={() => {
|
||||
setHover(false);
|
||||
onHover?.(null, layout);
|
||||
}}
|
||||
// @ts-ignore Web only
|
||||
// @ts-expect-error Web only
|
||||
onMouseMove={(e) =>
|
||||
onHover?.(
|
||||
Math.max(0, Math.min((e.clientX - layout.x) / layout.width, 1) * max),
|
||||
@ -123,7 +107,7 @@ export const Slider = ({
|
||||
{...css(
|
||||
{
|
||||
paddingVertical: ts(1),
|
||||
// @ts-ignore Web only
|
||||
// @ts-expect-error Web only
|
||||
cursor: "pointer",
|
||||
...focusReset,
|
||||
},
|
||||
|
||||
@ -39,7 +39,7 @@ export const useBreakpointMap = <T extends Record<string, unknown>>(
|
||||
value: T,
|
||||
): { [key in keyof T]: T[key] extends Breakpoint<infer V> ? V : T } => {
|
||||
const breakpoint = useBreakpoint();
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, val]) => [
|
||||
key,
|
||||
|
||||
@ -62,3 +62,16 @@ export const readValue = <T extends ZodType>(key: string, parser: T) => {
|
||||
if (val === undefined) return val;
|
||||
return parser.parse(JSON.parse(val)) as z.infer<T>;
|
||||
};
|
||||
|
||||
export const useLocalSetting = <T extends string>(setting: string, def: T) => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined")
|
||||
return [def as T, null!] as const;
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: ssr
|
||||
const [val, setter] = useMMKVString(`settings.${setting}`, storage);
|
||||
return [(val ?? def) as T, setter] as const;
|
||||
};
|
||||
|
||||
export const getLocalSetting = (setting: string, def: string) => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined") return def;
|
||||
return storage.getString(`settings.${setting}`) ?? setting;
|
||||
};
|
||||
|
||||
39
front/src/track-utils.ts
Normal file
39
front/src/track-utils.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import intl from "langmap";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Subtitle } from "./models";
|
||||
|
||||
export const useLanguageName = () => {
|
||||
return (lang: string) => intl[lang]?.nativeName;
|
||||
};
|
||||
|
||||
export const useDisplayName = () => {
|
||||
const getLanguageName = useLanguageName();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (sub: { language?: string; title?: string; index?: number }) => {
|
||||
const lng = sub.language ? getLanguageName(sub.language) : null;
|
||||
|
||||
if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`;
|
||||
if (lng) return lng;
|
||||
if (sub.title) return sub.title;
|
||||
if (sub.index !== null) return `${t("mediainfo.unknown")} (${sub.index})`;
|
||||
return t("mediainfo.unknown");
|
||||
};
|
||||
};
|
||||
|
||||
export const useSubtitleName = () => {
|
||||
const getDisplayName = useDisplayName();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (sub: Subtitle) => {
|
||||
const name = getDisplayName(sub);
|
||||
const attributes = [name];
|
||||
|
||||
if (sub.isDefault) attributes.push(t("mediainfo.default"));
|
||||
if (sub.isForced) attributes.push(t("mediainfo.forced"));
|
||||
if (sub.isHearingImpaired) attributes.push(t("mediainfo.hearing-impaired"));
|
||||
if (sub.isExternal) attributes.push(t("mediainfo.external"));
|
||||
|
||||
return attributes.join(" - ");
|
||||
};
|
||||
};
|
||||
@ -35,6 +35,7 @@ import {
|
||||
A,
|
||||
Chip,
|
||||
Container,
|
||||
ContrastArea,
|
||||
capitalize,
|
||||
DottedSeparator,
|
||||
GradientImageBackground,
|
||||
@ -714,30 +715,34 @@ export const Header = ({
|
||||
},
|
||||
}) as any)}
|
||||
/>
|
||||
<TitleLine
|
||||
kind={kind}
|
||||
slug={slug}
|
||||
name={data.name}
|
||||
tagline={data.tagline}
|
||||
date={getDisplayDate(data)}
|
||||
rating={data.rating}
|
||||
runtime={data.kind === "movie" ? data.runtime : null}
|
||||
poster={data.poster}
|
||||
studios={data.kind !== "collection" ? data.studios! : null}
|
||||
playHref={data.kind !== "collection" ? data.playHref : null}
|
||||
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
|
||||
watchStatus={
|
||||
data.kind !== "collection" ? data.watchStatus?.status! : null
|
||||
}
|
||||
{...css({
|
||||
marginTop: {
|
||||
xs: max(vh(20), px(200)),
|
||||
sm: vh(45),
|
||||
md: max(vh(30), px(150)),
|
||||
lg: max(vh(35), px(200)),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<ContrastArea>
|
||||
<TitleLine
|
||||
kind={kind}
|
||||
slug={slug}
|
||||
name={data.name}
|
||||
tagline={data.tagline}
|
||||
date={getDisplayDate(data)}
|
||||
rating={data.rating}
|
||||
runtime={data.kind === "movie" ? data.runtime : null}
|
||||
poster={data.poster}
|
||||
studios={data.kind !== "collection" ? data.studios! : null}
|
||||
playHref={data.kind !== "collection" ? data.playHref : null}
|
||||
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
|
||||
watchStatus={
|
||||
data.kind !== "collection"
|
||||
? (data.watchStatus?.status ?? null)
|
||||
: null
|
||||
}
|
||||
{...css({
|
||||
marginTop: {
|
||||
xs: max(vh(20), px(200)),
|
||||
sm: vh(45),
|
||||
md: max(vh(30), px(150)),
|
||||
lg: max(vh(35), px(200)),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</ContrastArea>
|
||||
<Description
|
||||
description={data?.description}
|
||||
genres={data?.genres}
|
||||
|
||||
9
front/src/ui/info/index.tsx
Normal file
9
front/src/ui/info/index.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { VideoInfo } from "~/models";
|
||||
import type { QueryIdentifier } from "~/query";
|
||||
|
||||
export const Info = () => {};
|
||||
|
||||
Info.infoQuery = (slug: string): QueryIdentifier<VideoInfo> => ({
|
||||
path: ["api", "videos", slug, "info"],
|
||||
parser: VideoInfo,
|
||||
});
|
||||
53
front/src/ui/player/controls/back.tsx
Normal file
53
front/src/ui/player/controls/back.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { percent, rem, useYoshiki } from "yoshiki/native";
|
||||
import {
|
||||
H1,
|
||||
IconButton,
|
||||
PressableFeedback,
|
||||
Skeleton,
|
||||
tooltip,
|
||||
} from "~/primitives";
|
||||
|
||||
export const Back = ({ name, ...props }: { name?: string } & ViewProps) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: percent(0.33),
|
||||
color: "white",
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
icon={ArrowBack}
|
||||
as={PressableFeedback}
|
||||
onPress={router.back}
|
||||
{...tooltip(t("player.back"))}
|
||||
/>
|
||||
{name ? (
|
||||
<H1
|
||||
{...css({
|
||||
alignSelf: "center",
|
||||
fontSize: rem(1.5),
|
||||
marginLeft: rem(1),
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</H1>
|
||||
) : (
|
||||
<Skeleton {...css({ width: rem(5) })} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
174
front/src/ui/player/controls/bottom-controls.tsx
Normal file
174
front/src/ui/player/controls/bottom-controls.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
|
||||
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
import { percent, rem, useYoshiki } from "yoshiki/native";
|
||||
import type { Chapter, KImage } from "~/models";
|
||||
import {
|
||||
H2,
|
||||
IconButton,
|
||||
Link,
|
||||
type Menu,
|
||||
Poster,
|
||||
Skeleton,
|
||||
tooltip,
|
||||
ts,
|
||||
useIsTouch,
|
||||
} from "~/primitives";
|
||||
import { FullscreenButton, PlayButton, VolumeSlider } from "./misc";
|
||||
import { ProgressBar, ProgressText } from "./progress";
|
||||
import { AudioMenu, QualityMenu, SubtitleMenu, VideoMenu } from "./tracks-menu";
|
||||
|
||||
export const BottomControls = ({
|
||||
player,
|
||||
poster,
|
||||
name,
|
||||
chapters,
|
||||
previous,
|
||||
next,
|
||||
setMenu,
|
||||
...props
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
poster?: KImage | null;
|
||||
name?: string;
|
||||
chapters: Chapter[];
|
||||
previous?: string | null;
|
||||
next?: string | null;
|
||||
setMenu: (isOpen: boolean) => void;
|
||||
} & ViewProps) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
{
|
||||
flexDirection: "row",
|
||||
padding: ts(1),
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<View
|
||||
{...css({
|
||||
width: "15%",
|
||||
display: { xs: "none", sm: "flex" },
|
||||
position: "relative",
|
||||
})}
|
||||
>
|
||||
{poster !== undefined ? (
|
||||
<Poster
|
||||
src={poster}
|
||||
quality="low"
|
||||
layout={{ width: percent(100) }}
|
||||
{...(css({ position: "absolute", bottom: 0 }) as any)}
|
||||
/>
|
||||
) : (
|
||||
<Poster.Loader
|
||||
layout={{ width: percent(100) }}
|
||||
{...(css({ position: "absolute", bottom: 0 }) as any)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
{...css({
|
||||
marginHorizontal: { xs: ts(0.5), sm: ts(3) },
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
{name ? (
|
||||
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
|
||||
{name}
|
||||
</H2>
|
||||
) : (
|
||||
<Skeleton {...css({ width: rem(15), height: rem(2) })} />
|
||||
)}
|
||||
<ProgressBar player={player} chapters={chapters} />
|
||||
<ControlButtons
|
||||
player={player}
|
||||
previous={previous}
|
||||
next={next}
|
||||
setMenu={setMenu}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ControlButtons = ({
|
||||
player,
|
||||
previous,
|
||||
next,
|
||||
setMenu,
|
||||
...props
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
previous?: string | null;
|
||||
next?: string | null;
|
||||
setMenu: (isOpen: boolean) => void;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const isTouch = useIsTouch();
|
||||
|
||||
const spacing = css({ marginHorizontal: ts(1) });
|
||||
const menuProps = {
|
||||
onMenuOpen: () => setMenu(true),
|
||||
onMenuClose: () => setMenu(false),
|
||||
...spacing,
|
||||
} satisfies Partial<ComponentProps<typeof Menu>>;
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
{
|
||||
flexDirection: "row",
|
||||
flex: 1,
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<View {...css({ flexDirection: "row" })}>
|
||||
{!isTouch && (
|
||||
<View {...css({ flexDirection: "row" })}>
|
||||
{previous && (
|
||||
<IconButton
|
||||
icon={SkipPrevious}
|
||||
as={Link}
|
||||
href={previous}
|
||||
replace
|
||||
{...tooltip(t("player.previous"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
)}
|
||||
<PlayButton player={player} {...spacing} />
|
||||
{next && (
|
||||
<IconButton
|
||||
icon={SkipNext}
|
||||
as={Link}
|
||||
href={next}
|
||||
replace
|
||||
{...tooltip(t("player.next"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
)}
|
||||
{Platform.OS === "web" && <VolumeSlider player={player} />}
|
||||
</View>
|
||||
)}
|
||||
<ProgressText player={player} {...spacing} />
|
||||
</View>
|
||||
<View {...css({ flexDirection: "row" })}>
|
||||
<SubtitleMenu player={player} {...menuProps} />
|
||||
<AudioMenu player={player} {...menuProps} />
|
||||
<VideoMenu player={player} {...menuProps} />
|
||||
<QualityMenu player={player} {...menuProps} />
|
||||
{Platform.OS === "web" && <FullscreenButton {...spacing} />}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
101
front/src/ui/player/controls/index.tsx
Normal file
101
front/src/ui/player/controls/index.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import type { Chapter, KImage } from "~/models";
|
||||
import { useIsTouch } from "~/primitives";
|
||||
import { Back } from "./back";
|
||||
import { BottomControls } from "./bottom-controls";
|
||||
import { MiddleControls } from "./middle-controls";
|
||||
import { TouchControls } from "./touch";
|
||||
|
||||
export const Controls = ({
|
||||
player,
|
||||
name,
|
||||
poster,
|
||||
subName,
|
||||
chapters,
|
||||
previous,
|
||||
next,
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
name?: string;
|
||||
poster?: KImage | null;
|
||||
subName?: string;
|
||||
chapters: Chapter[];
|
||||
previous?: string | null;
|
||||
next?: string | null;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isTouch = useIsTouch();
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
const [menuOpenned, setMenu] = useState(false);
|
||||
|
||||
const hoverControls = {
|
||||
onPointerEnter: (e) => {
|
||||
if (e.nativeEvent.pointerType === "mouse") setHover(true);
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
if (e.nativeEvent.pointerType === "mouse") setHover(false);
|
||||
},
|
||||
} satisfies ViewProps;
|
||||
|
||||
return (
|
||||
<View {...css(StyleSheet.absoluteFillObject)}>
|
||||
<TouchControls
|
||||
player={player}
|
||||
forceShow={hover || menuOpenned}
|
||||
{...css(StyleSheet.absoluteFillObject)}
|
||||
/>
|
||||
<Back
|
||||
name={name}
|
||||
{...css(
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bg: (theme) => theme.darkOverlay,
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
},
|
||||
hoverControls,
|
||||
)}
|
||||
/>
|
||||
{isTouch && (
|
||||
<MiddleControls player={player} previous={previous} next={next} />
|
||||
)}
|
||||
<BottomControls
|
||||
player={player}
|
||||
name={subName}
|
||||
poster={poster}
|
||||
chapters={chapters}
|
||||
previous={previous}
|
||||
next={next}
|
||||
setMenu={setMenu}
|
||||
{...css(
|
||||
{
|
||||
// Fixed is used because firefox android make the hover disappear under the navigation bar in absolute
|
||||
// position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bg: (theme) => theme.darkOverlay,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
hoverControls,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoadingIndicator } from "./misc";
|
||||
61
front/src/ui/player/controls/middle-controls.tsx
Normal file
61
front/src/ui/player/controls/middle-controls.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
|
||||
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
|
||||
import { View } from "react-native";
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { IconButton, Link, ts } from "~/primitives";
|
||||
import { PlayButton } from "./misc";
|
||||
|
||||
export const MiddleControls = ({
|
||||
player,
|
||||
previous,
|
||||
next,
|
||||
...props
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
previous?: string | null;
|
||||
next?: string | null;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
const common = css({
|
||||
backgroundColor: (theme) => theme.darkOverlay,
|
||||
marginHorizontal: ts(3),
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
icon={SkipPrevious}
|
||||
as={Link}
|
||||
href={previous ?? ""}
|
||||
replace
|
||||
size={ts(4)}
|
||||
{...css([!previous && { opacity: 0, pointerEvents: "none" }], common)}
|
||||
/>
|
||||
<PlayButton player={player} size={ts(8)} {...common} />
|
||||
<IconButton
|
||||
icon={SkipNext}
|
||||
as={Link}
|
||||
href={next ?? ""}
|
||||
replace
|
||||
size={ts(4)}
|
||||
{...css([!next && { opacity: 0, pointerEvents: "none" }], common)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
165
front/src/ui/player/controls/misc.tsx
Normal file
165
front/src/ui/player/controls/misc.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
|
||||
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
|
||||
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
|
||||
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
|
||||
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
|
||||
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
|
||||
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
|
||||
import { type ComponentProps, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type PressableProps, View } from "react-native";
|
||||
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||
import { px, useYoshiki } from "yoshiki/native";
|
||||
import {
|
||||
alpha,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Slider,
|
||||
tooltip,
|
||||
ts,
|
||||
} from "~/primitives";
|
||||
|
||||
export const PlayButton = ({
|
||||
player,
|
||||
...props
|
||||
}: { player: VideoPlayer } & Partial<
|
||||
ComponentProps<typeof IconButton<PressableProps>>
|
||||
>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [playing, setPlay] = useState(player.isPlaying);
|
||||
useEvent(player, "onPlaybackStateChange", (status) => {
|
||||
setPlay(status.isPlaying);
|
||||
});
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={playing ? Pause : PlayArrow}
|
||||
onPress={() => {
|
||||
if (playing) player.pause();
|
||||
else player.play();
|
||||
}}
|
||||
{...tooltip(playing ? t("player.pause") : t("player.play"), true)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const toggleFullscreen = async (set?: boolean) => {
|
||||
set ??= document.fullscreenElement === null;
|
||||
try {
|
||||
if (set) {
|
||||
await document.body.requestFullscreen({ navigationUI: "hide" });
|
||||
// @ts-expect-error Firefox does not support this so ts complains
|
||||
await screen.orientation.lock("landscape");
|
||||
} else {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const FullscreenButton = (
|
||||
props: Partial<ComponentProps<typeof IconButton<PressableProps>>>,
|
||||
) => {
|
||||
// this is a web only component
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
useEffect(() => {
|
||||
const update = () => setFullscreen(document.fullscreenElement !== null);
|
||||
document.addEventListener("fullscreenchange", update);
|
||||
return () => document.removeEventListener("fullscreenchange", update);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={fullscreen ? FullscreenExit : Fullscreen}
|
||||
onPress={() => toggleFullscreen()}
|
||||
{...tooltip(t("player.fullscreen"), true)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [volume, setVolume] = useState(player.volume);
|
||||
const [muted, setMuted] = useState(player.muted);
|
||||
useEvent(player, "onVolumeChange", (info) => {
|
||||
setVolume(info.volume);
|
||||
setMuted(info.muted);
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css(
|
||||
{
|
||||
display: { xs: "none", sm: "flex" },
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
paddingRight: ts(1),
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
icon={
|
||||
muted || volume === 0
|
||||
? VolumeOff
|
||||
: volume < 0.25
|
||||
? VolumeMute
|
||||
: volume < 0.65
|
||||
? VolumeDown
|
||||
: VolumeUp
|
||||
}
|
||||
onPress={() => {
|
||||
player.muted = !muted;
|
||||
}}
|
||||
{...tooltip(t("player.mute"), true)}
|
||||
/>
|
||||
<Slider
|
||||
progress={volume * 100}
|
||||
setProgress={(vol) => {
|
||||
player.volume = vol / 100;
|
||||
}}
|
||||
size={4}
|
||||
{...css({ width: px(100) })}
|
||||
{...tooltip(t("player.volume"), true)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => {
|
||||
const { css } = useYoshiki();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
useEvent(player, "onStatusChange", (status) => {
|
||||
setLoading(status === "loading");
|
||||
});
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bg: (theme) => alpha(theme.colors.black, 0.3),
|
||||
justifyContent: "center",
|
||||
})}
|
||||
>
|
||||
<CircularProgress {...css({ alignSelf: "center" })} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
111
front/src/ui/player/controls/progress.tsx
Normal file
111
front/src/ui/player/controls/progress.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import type { TextProps } from "react-native";
|
||||
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import type { Chapter } from "~/models";
|
||||
import { P, Slider } from "~/primitives";
|
||||
|
||||
export const ProgressBar = ({
|
||||
player,
|
||||
// url,
|
||||
chapters,
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
// url: string;
|
||||
chapters?: Chapter[];
|
||||
}) => {
|
||||
const [duration, setDuration] = useState(player.duration || 100);
|
||||
useEvent(player, "onLoad", (info) => {
|
||||
if (info.duration) setDuration(info.duration);
|
||||
});
|
||||
|
||||
const [progress, setProgress] = useState(player.currentTime || 0);
|
||||
const [buffer, setBuffer] = useState(0);
|
||||
useEvent(player, "onProgress", (progress) => {
|
||||
setProgress(progress.currentTime);
|
||||
setBuffer(progress.bufferDuration);
|
||||
});
|
||||
|
||||
const [seek, setSeek] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Slider
|
||||
progress={seek ?? progress}
|
||||
subtleProgress={buffer}
|
||||
max={duration}
|
||||
startSeek={() => {
|
||||
player.pause();
|
||||
}}
|
||||
setProgress={setSeek}
|
||||
endSeek={() => {
|
||||
player.seekTo(seek!);
|
||||
setTimeout(() => player.play(), 10);
|
||||
setSeek(null);
|
||||
}}
|
||||
// onHover={(progress, layout) => {
|
||||
// setHoverProgress(progress);
|
||||
// setLayout(layout);
|
||||
// }}
|
||||
markers={chapters?.map((x) => x.startTime)}
|
||||
// dataSet={{ tooltipId: "progress-scrubber" }}
|
||||
/>
|
||||
{/* <Tooltip */}
|
||||
{/* id={"progress-scrubber"} */}
|
||||
{/* isOpen={hoverProgress !== null} */}
|
||||
{/* place="top" */}
|
||||
{/* position={{ */}
|
||||
{/* x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), */}
|
||||
{/* y: layout.y, */}
|
||||
{/* }} */}
|
||||
{/* render={() => */}
|
||||
{/* hoverProgress ? ( */}
|
||||
{/* <ScrubberTooltip */}
|
||||
{/* seconds={hoverProgress} */}
|
||||
{/* chapters={chapters} */}
|
||||
{/* url={url} */}
|
||||
{/* /> */}
|
||||
{/* ) : null */}
|
||||
{/* } */}
|
||||
{/* opacity={1} */}
|
||||
{/* style={{ padding: 0, borderRadius: imageBorderRadius }} */}
|
||||
{/* /> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProgressText = ({
|
||||
player,
|
||||
...props
|
||||
}: { player: VideoPlayer } & TextProps) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
const [progress, setProgress] = useState(player.currentTime);
|
||||
useEvent(player, "onProgress", (progress) => {
|
||||
setProgress(progress.currentTime);
|
||||
});
|
||||
const [duration, setDuration] = useState(player.duration);
|
||||
useEvent(player, "onLoad", (info) => {
|
||||
if (info.duration) setDuration(info.duration);
|
||||
});
|
||||
|
||||
return (
|
||||
<P {...css({ alignSelf: "center" }, props)}>
|
||||
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
||||
</P>
|
||||
);
|
||||
};
|
||||
|
||||
const toTimerString = (timer?: number, duration?: number) => {
|
||||
if (!duration) duration = timer;
|
||||
if (timer === undefined || !Number.isFinite(timer)) return "??:??";
|
||||
|
||||
const h = Math.floor(timer / 3600);
|
||||
const min = Math.floor((timer / 60) % 60);
|
||||
const sec = Math.floor(timer % 60);
|
||||
const fmt = (n: number) => n.toString().padStart(2, "0");
|
||||
|
||||
return h !== 0 || (duration && duration >= 3600)
|
||||
? `${fmt(h)}:${fmt(min)}:${fmt(sec)}`
|
||||
: `${fmt(min)}:${fmt(sec)}`;
|
||||
};
|
||||
125
front/src/ui/player/controls/touch.tsx
Normal file
125
front/src/ui/player/controls/touch.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
Platform,
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
} from "react-native";
|
||||
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { useIsTouch } from "~/primitives";
|
||||
import { toggleFullscreen } from "./misc";
|
||||
|
||||
export const TouchControls = ({
|
||||
player,
|
||||
children,
|
||||
forceShow = false,
|
||||
...props
|
||||
}: { player: VideoPlayer; forceShow?: boolean } & PressableProps) => {
|
||||
const { css } = useYoshiki();
|
||||
const isTouch = useIsTouch();
|
||||
|
||||
const [playing, setPlay] = useState(player.isPlaying);
|
||||
useEvent(player, "onPlaybackStateChange", (status) => {
|
||||
setPlay(status.isPlaying);
|
||||
});
|
||||
|
||||
const [_show, setShow] = useState(false);
|
||||
const hideTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const shouldShow = forceShow || _show || !playing;
|
||||
const show = useCallback((val: boolean = true) => {
|
||||
setShow(val);
|
||||
if (hideTimeout.current) clearTimeout(hideTimeout.current);
|
||||
hideTimeout.current = setTimeout(() => {
|
||||
hideTimeout.current = null;
|
||||
setShow(false);
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
// On mouse move
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
const handler = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "mouse") return;
|
||||
show();
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", handler);
|
||||
return () => document.removeEventListener("pointermove", handler);
|
||||
}, [show]);
|
||||
|
||||
const playerWidth = useRef<number | null>(null);
|
||||
|
||||
return (
|
||||
<DoublePressable
|
||||
tabIndex={-1}
|
||||
onPress={() => {
|
||||
if (isTouch) {
|
||||
show(!shouldShow);
|
||||
return;
|
||||
}
|
||||
if (player.isPlaying) player.pause();
|
||||
else player.play();
|
||||
}}
|
||||
onDoublePress={(e) => {
|
||||
if (!isTouch) {
|
||||
toggleFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
show();
|
||||
if (Number.isNaN(player.duration) || !playerWidth.current) return;
|
||||
|
||||
const x = e.nativeEvent.locationX ?? e.nativeEvent.pageX;
|
||||
if (x < playerWidth.current * 0.33) player.seekBy(-10);
|
||||
if (x > playerWidth.current * 0.66) player.seekBy(10);
|
||||
// Do not reset press count, you can continue to seek by pressing again.
|
||||
return true;
|
||||
}}
|
||||
onLayout={(e) => {
|
||||
playerWidth.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
// instantly hide the controls when mouse leaves the view
|
||||
if (e.nativeEvent.pointerType === "mouse") show(false);
|
||||
}}
|
||||
{...css({ cursor: (shouldShow ? "unset" : "none") as any }, props)}
|
||||
>
|
||||
{shouldShow && children}
|
||||
</DoublePressable>
|
||||
);
|
||||
};
|
||||
|
||||
const DoublePressable = ({
|
||||
onPress,
|
||||
onDoublePress,
|
||||
...props
|
||||
}: {
|
||||
onDoublePress: (e: GestureResponderEvent) => boolean | undefined;
|
||||
} & PressableProps) => {
|
||||
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({
|
||||
count: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.preventDefault();
|
||||
touch.current.count++;
|
||||
if (touch.current.count >= 2) {
|
||||
const keepCount = onDoublePress(e);
|
||||
if (!keepCount) touch.current.count = 0;
|
||||
clearTimeout(touch.current.timeout);
|
||||
} else {
|
||||
onPress?.(e);
|
||||
}
|
||||
|
||||
touch.current.timeout = setTimeout(() => {
|
||||
touch.current.count = 0;
|
||||
touch.current.timeout = undefined;
|
||||
}, 400);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
191
front/src/ui/player/controls/tracks-menu.tsx
Normal file
191
front/src/ui/player/controls/tracks-menu.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
|
||||
import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg";
|
||||
import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg";
|
||||
import VideoSettings from "@material-symbols/svg-400/rounded/video_settings-fill.svg";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||
import { useForceRerender } from "yoshiki";
|
||||
import { IconButton, Menu, tooltip } from "~/primitives";
|
||||
import { useFetch } from "~/query";
|
||||
import { useDisplayName, useSubtitleName } from "~/track-utils";
|
||||
import { Info } from "~/ui/info";
|
||||
import { useQueryState } from "~/utils";
|
||||
|
||||
type MenuProps = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
|
||||
|
||||
export const SubtitleMenu = ({
|
||||
player,
|
||||
...props
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
} & Partial<MenuProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const getDisplayName = useSubtitleName();
|
||||
|
||||
const rerender = useForceRerender();
|
||||
useEvent(player, "onTrackChange", rerender);
|
||||
|
||||
const [slug] = useQueryState<string>("slug", undefined!);
|
||||
const { data } = useFetch(Info.infoQuery(slug));
|
||||
|
||||
if (data?.subtitles.length === 0) return null;
|
||||
|
||||
const selectedIdx = player
|
||||
.getAvailableTextTracks()
|
||||
.findIndex((x) => x.selected);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={ClosedCaption}
|
||||
{...tooltip(t("player.subtitles"), true)}
|
||||
{...props}
|
||||
>
|
||||
<Menu.Item
|
||||
label={t("player.subtitle-none")}
|
||||
selected={selectedIdx === -1}
|
||||
onSelect={() => player.selectTextTrack(null)}
|
||||
/>
|
||||
{data?.subtitles.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={x.index ?? x.link}
|
||||
label={getDisplayName(x)}
|
||||
selected={i === selectedIdx}
|
||||
onSelect={() =>
|
||||
player.selectTextTrack(player.getAvailableTextTracks()[i])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const AudioMenu = ({
|
||||
player,
|
||||
...props
|
||||
}: { player: VideoPlayer } & Partial<MenuProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const getDisplayName = useDisplayName();
|
||||
|
||||
const rerender = useForceRerender();
|
||||
useEvent(player, "onAudioTrackChange", rerender);
|
||||
|
||||
const tracks = player.getAvailableAudioTracks();
|
||||
if (tracks.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={MusicNote}
|
||||
{...tooltip(t("player.audios"), true)}
|
||||
{...props}
|
||||
>
|
||||
{tracks.map((x) => (
|
||||
<Menu.Item
|
||||
key={x.id}
|
||||
label={getDisplayName({ title: x.label, language: x.language })}
|
||||
selected={x.selected}
|
||||
onSelect={() => player.selectAudioTrack(x)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const VideoMenu = ({
|
||||
player,
|
||||
...props
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
} & Partial<MenuProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const getDisplayName = useDisplayName();
|
||||
|
||||
const rerender = useForceRerender();
|
||||
useEvent(player, "onVideoTrackChange", rerender);
|
||||
|
||||
const tracks = player.getAvailableVideoTracks();
|
||||
if (tracks.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={VideoSettings}
|
||||
{...tooltip(t("player.audios"), true)}
|
||||
{...props}
|
||||
>
|
||||
{tracks.map((x) => (
|
||||
<Menu.Item
|
||||
key={x.id}
|
||||
label={getDisplayName({ title: x.label, language: x.language })}
|
||||
selected={x.selected}
|
||||
onSelect={() => player.selectAudioTrack(x)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const PlayModeContext = createContext<
|
||||
["direct" | "hls", (val: "direct" | "hls") => void]
|
||||
>(null!);
|
||||
|
||||
export const QualityMenu = ({
|
||||
player,
|
||||
...props
|
||||
}: { player: VideoPlayer } & Partial<MenuProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [playMode, setPlayMode] = useContext(PlayModeContext);
|
||||
const rerender = useForceRerender();
|
||||
|
||||
useEvent(player, "onQualityChange", rerender);
|
||||
|
||||
const lvls = player.getAvailableQualities();
|
||||
const current = player.currentQuality;
|
||||
const auto = player.autoQualityEnabled;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={SettingsIcon}
|
||||
{...tooltip(t("player.quality"), true)}
|
||||
{...props}
|
||||
>
|
||||
<Menu.Item
|
||||
label={t("player.direct")}
|
||||
selected={playMode === "direct"}
|
||||
onSelect={() => setPlayMode("direct")}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={
|
||||
auto && current
|
||||
? `${t("player.auto")} (${current.id.includes("original") ? t("player.transmux") : `${current.height}p`})`
|
||||
: t("player.auto")
|
||||
}
|
||||
selected={auto && playMode === "hls"}
|
||||
onSelect={() => {
|
||||
setPlayMode("hls");
|
||||
player.selectQuality(null);
|
||||
}}
|
||||
/>
|
||||
{lvls
|
||||
.map((x) => (
|
||||
<Menu.Item
|
||||
key={x.id}
|
||||
label={
|
||||
x.id.includes("original")
|
||||
? `${t("player.transmux")} (${x.height}p)`
|
||||
: `${x.height}p`
|
||||
}
|
||||
selected={x.selected && !auto}
|
||||
onSelect={() => {
|
||||
setPlayMode("hls");
|
||||
player.selectQuality(x);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
224
front/src/ui/player/index.tsx
Normal file
224
front/src/ui/player/index.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import "react-native-get-random-values";
|
||||
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { entryDisplayNumber } from "~/components/entries";
|
||||
import { FullVideo, type KyooError } from "~/models";
|
||||
import { ContrastArea, Head } from "~/primitives";
|
||||
import { useToken } from "~/providers/account-context";
|
||||
import { useLocalSetting } from "~/providers/settings";
|
||||
import { type QueryIdentifier, useFetch } from "~/query";
|
||||
import { Info } from "~/ui/info";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { ErrorView } from "../errors";
|
||||
import { Controls, LoadingIndicator } from "./controls";
|
||||
import { Back } from "./controls/back";
|
||||
import { toggleFullscreen } from "./controls/misc";
|
||||
import { PlayModeContext } from "./controls/tracks-menu";
|
||||
import { useKeyboard } from "./keyboard";
|
||||
import { enhanceSubtitles } from "./subtitles";
|
||||
|
||||
const clientId = uuidv4();
|
||||
|
||||
export const Player = () => {
|
||||
const [slug, setSlug] = useQueryState<string>("slug", undefined!);
|
||||
const [start, setStart] = useQueryState<number | undefined>("t", undefined);
|
||||
|
||||
const { data, error } = useFetch(Player.query(slug));
|
||||
const { data: info, error: infoError } = useFetch(Info.infoQuery(slug));
|
||||
// TODO: map current entry using entries' duration & the current playtime
|
||||
const currentEntry = 0;
|
||||
const entry = data?.entries[currentEntry] ?? data?.entries[0];
|
||||
const title = entry
|
||||
? entry.kind === "movie"
|
||||
? entry.name
|
||||
: `${entry.name} (${entryDisplayNumber(entry)})`
|
||||
: null;
|
||||
|
||||
const { apiUrl, authToken } = useToken();
|
||||
const [defaultPlayMode] = useLocalSetting<"direct" | "hls">(
|
||||
"playMode",
|
||||
"direct",
|
||||
);
|
||||
const playModeState = useState(defaultPlayMode);
|
||||
const [playMode, setPlayMode] = playModeState;
|
||||
const player = useVideoPlayer(
|
||||
{
|
||||
uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`,
|
||||
// chrome based browsers support matroska but they tell they don't
|
||||
mimeType:
|
||||
playMode === "direct"
|
||||
? info?.mimeCodec?.replace("x-matroska", "mp4")
|
||||
: "application/vnd.apple.mpegurl",
|
||||
headers: authToken
|
||||
? {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
}
|
||||
: {},
|
||||
metadata: {
|
||||
title: title ?? undefined,
|
||||
artist: data?.show?.name ?? undefined,
|
||||
description: entry?.description ?? undefined,
|
||||
imageUri: data?.show?.thumbnail?.high ?? undefined,
|
||||
},
|
||||
externalSubtitles: info?.subtitles
|
||||
.filter(
|
||||
(x) => Platform.OS === "web" || playMode === "hls" || x.isExternal,
|
||||
)
|
||||
.map((x) => ({
|
||||
// we also add those without link to prevent the order from getting out of sync with `info.subtitles`.
|
||||
// since we never actually play those this is fine
|
||||
uri:
|
||||
x.codec === "subrip" && x.link && Platform.OS === "web"
|
||||
? `${x.link}?format=vtt`
|
||||
: x.link!,
|
||||
label: x.title ?? "Unknown",
|
||||
language: x.language ?? "und",
|
||||
type: x.codec,
|
||||
})),
|
||||
},
|
||||
(p) => {
|
||||
p.playWhenInactive = true;
|
||||
p.playInBackground = true;
|
||||
p.showNotificationControls = true;
|
||||
enhanceSubtitles(p);
|
||||
const seek = start ?? data?.progress.time;
|
||||
// TODO: fix console.error bellow
|
||||
if (seek) p.seekTo(seek);
|
||||
else console.error("Player got ready before progress info was loaded.");
|
||||
p.play();
|
||||
},
|
||||
);
|
||||
|
||||
// we'll also want to replace source here once https://github.com/TheWidlarzGroup/react-native-video/issues/4722 is ready
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") player.__ass.fonts = info?.fonts ?? [];
|
||||
}, [player, info?.fonts]);
|
||||
|
||||
const router = useRouter();
|
||||
const playPrev = useCallback(() => {
|
||||
if (!data?.previous) return false;
|
||||
setStart(0);
|
||||
setSlug(data.previous.video);
|
||||
return true;
|
||||
}, [data?.previous, setSlug, setStart]);
|
||||
const playNext = useCallback(() => {
|
||||
if (!data?.next) return false;
|
||||
setStart(0);
|
||||
setSlug(data.next.video);
|
||||
return true;
|
||||
}, [data?.next, setSlug, setStart]);
|
||||
|
||||
useEvent(player, "onEnd", () => {
|
||||
const hasNext = playNext();
|
||||
if (!hasNext && data?.show) router.navigate(data.show.href);
|
||||
});
|
||||
|
||||
// TODO: add the equivalent of this for android
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || typeof window === "undefined") return;
|
||||
window.navigator.mediaSession.setActionHandler(
|
||||
"previoustrack",
|
||||
data?.previous?.video ? playPrev : null,
|
||||
);
|
||||
window.navigator.mediaSession.setActionHandler(
|
||||
"nexttrack",
|
||||
data?.next?.video ? playNext : null,
|
||||
);
|
||||
}, [data?.next?.video, data?.previous?.video, playNext, playPrev]);
|
||||
|
||||
useKeyboard(player, playPrev, playNext);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (/Mobi/i.test(window.navigator.userAgent)) toggleFullscreen(true);
|
||||
return () => {
|
||||
if (!document.location.href.includes("/watch")) toggleFullscreen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [playbackError, setPlaybackError] = useState<KyooError | undefined>();
|
||||
useEvent(player, "onError", (error) => {
|
||||
if (
|
||||
error.code === "source/unsupported-content-type" &&
|
||||
playMode === "direct"
|
||||
)
|
||||
setPlayMode("hls");
|
||||
else setPlaybackError({ status: error.code, message: error.message });
|
||||
});
|
||||
const { css } = useYoshiki();
|
||||
if (error || infoError || playbackError) {
|
||||
return (
|
||||
<>
|
||||
<Back
|
||||
name={data?.show?.name ?? "Error"}
|
||||
{...css({ position: "relative", bg: (theme) => theme.accent })}
|
||||
/>
|
||||
<ErrorView error={error ?? infoError ?? playbackError!} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
>
|
||||
<Head
|
||||
title={title}
|
||||
description={entry?.description}
|
||||
image={data?.show?.thumbnail?.high}
|
||||
/>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
navigationBarHidden: true,
|
||||
statusBarHidden: true,
|
||||
orientation: "landscape",
|
||||
contentStyle: { paddingLeft: 0, paddingRight: 0 },
|
||||
}}
|
||||
/>
|
||||
<VideoView
|
||||
player={player}
|
||||
pictureInPicture
|
||||
autoEnterPictureInPicture
|
||||
resizeMode={"contain"}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<ContrastArea mode="dark">
|
||||
<LoadingIndicator player={player} />
|
||||
<PlayModeContext.Provider value={playModeState}>
|
||||
<Controls
|
||||
player={player}
|
||||
name={data?.show?.name}
|
||||
poster={data?.show?.poster}
|
||||
subName={
|
||||
entry
|
||||
? [entryDisplayNumber(entry), entry.name]
|
||||
.filter((x) => x)
|
||||
.join(" - ")
|
||||
: undefined
|
||||
}
|
||||
chapters={info?.chapters ?? []}
|
||||
previous={data?.previous?.video}
|
||||
next={data?.next?.video}
|
||||
/>
|
||||
</PlayModeContext.Provider>
|
||||
</ContrastArea>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
Player.query = (slug: string): QueryIdentifier<FullVideo> => ({
|
||||
path: ["api", "videos", slug],
|
||||
params: {
|
||||
with: ["next", "previous", "show"],
|
||||
},
|
||||
parser: FullVideo,
|
||||
});
|
||||
164
front/src/ui/player/keyboard.tsx
Normal file
164
front/src/ui/player/keyboard.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
import type { Subtitle } from "~/models";
|
||||
import { toggleFullscreen } from "./controls/misc";
|
||||
|
||||
type Action =
|
||||
| { type: "play" }
|
||||
| { type: "mute" }
|
||||
| { type: "fullscreen" }
|
||||
| { type: "seek"; value: number }
|
||||
| { type: "seekTo"; value: number }
|
||||
| { type: "seekPercent"; value: number }
|
||||
| { type: "volume"; value: number }
|
||||
| { type: "subtitle"; subtitles: Subtitle[]; fonts: string[] };
|
||||
|
||||
const reducer = (player: VideoPlayer, action: Action) => {
|
||||
switch (action.type) {
|
||||
case "play":
|
||||
if (player.isPlaying) player.pause();
|
||||
else player.play();
|
||||
break;
|
||||
case "mute":
|
||||
player.muted = !player.muted;
|
||||
break;
|
||||
case "fullscreen":
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "seek":
|
||||
player.seekBy(action.value);
|
||||
break;
|
||||
case "seekTo":
|
||||
player.seekTo(action.value);
|
||||
break;
|
||||
case "seekPercent":
|
||||
player.seekTo((player.duration * action.value) / 100);
|
||||
break;
|
||||
case "volume":
|
||||
player.volume = Math.max(0, Math.min(player.volume + action.value, 100));
|
||||
break;
|
||||
// case "subtitle": {
|
||||
// const subtitle = get(subtitleAtom);
|
||||
// const index = subtitle
|
||||
// ? action.subtitles.findIndex((x) => x.index === subtitle.index)
|
||||
// : -1;
|
||||
// set(
|
||||
// subtitleAtom,
|
||||
// index === -1
|
||||
// ? null
|
||||
// : action.subtitles[(index + 1) % action.subtitles.length],
|
||||
// );
|
||||
// break;
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
export const useKeyboard = (
|
||||
player: VideoPlayer,
|
||||
playPrev: () => void,
|
||||
playNext: () => void,
|
||||
// subtitles?: Subtitle[],
|
||||
// fonts?: string[],
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||
return;
|
||||
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
case "k":
|
||||
case "MediaPlay":
|
||||
case "MediaPause":
|
||||
case "MediaPlayPause":
|
||||
reducer(player, { type: "play" });
|
||||
break;
|
||||
|
||||
case "m":
|
||||
reducer(player, { type: "mute" });
|
||||
break;
|
||||
|
||||
case "ArrowLeft":
|
||||
reducer(player, { type: "seek", value: -5 });
|
||||
break;
|
||||
case "ArrowRight":
|
||||
reducer(player, { type: "seek", value: +5 });
|
||||
break;
|
||||
|
||||
case "j":
|
||||
reducer(player, { type: "seek", value: -10 });
|
||||
break;
|
||||
case "l":
|
||||
reducer(player, { type: "seek", value: +10 });
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
reducer(player, { type: "volume", value: +0.05 });
|
||||
break;
|
||||
case "ArrowDown":
|
||||
reducer(player, { type: "volume", value: -0.05 });
|
||||
break;
|
||||
|
||||
case "f":
|
||||
reducer(player, { type: "fullscreen" });
|
||||
break;
|
||||
|
||||
// case "v":
|
||||
// case "c":
|
||||
// if (!subtitles || !fonts) return;
|
||||
// reducer(player, { type: "subtitle", subtitles, fonts });
|
||||
// break;
|
||||
|
||||
case "n":
|
||||
case "N":
|
||||
playNext();
|
||||
break;
|
||||
|
||||
case "p":
|
||||
case "P":
|
||||
playPrev();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (event.code) {
|
||||
case "Digit0":
|
||||
reducer(player, { type: "seekPercent", value: 0 });
|
||||
break;
|
||||
case "Digit1":
|
||||
reducer(player, { type: "seekPercent", value: 10 });
|
||||
break;
|
||||
case "Digit2":
|
||||
reducer(player, { type: "seekPercent", value: 20 });
|
||||
break;
|
||||
case "Digit3":
|
||||
reducer(player, { type: "seekPercent", value: 30 });
|
||||
break;
|
||||
case "Digit4":
|
||||
reducer(player, { type: "seekPercent", value: 40 });
|
||||
break;
|
||||
case "Digit5":
|
||||
reducer(player, { type: "seekPercent", value: 50 });
|
||||
break;
|
||||
case "Digit6":
|
||||
reducer(player, { type: "seekPercent", value: 60 });
|
||||
break;
|
||||
case "Digit7":
|
||||
reducer(player, { type: "seekPercent", value: 70 });
|
||||
break;
|
||||
case "Digit8":
|
||||
reducer(player, { type: "seekPercent", value: 80 });
|
||||
break;
|
||||
case "Digit9":
|
||||
reducer(player, { type: "seekPercent", value: 90 });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keyup", handler);
|
||||
return () => document.removeEventListener("keyup", handler);
|
||||
}, [player, playPrev, playNext]);
|
||||
};
|
||||
@ -18,16 +18,27 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { type Chapter, type QueryIdentifier, imageFn, useFetch } from "@kyoo/models";
|
||||
import { P, Sprite, imageBorderRadius, ts } from "@kyoo/primitives";
|
||||
import {
|
||||
type Chapter,
|
||||
imageFn,
|
||||
type QueryIdentifier,
|
||||
useFetch,
|
||||
} from "@kyoo/models";
|
||||
import { imageBorderRadius, P, Sprite, ts } from "@kyoo/primitives";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native";
|
||||
import { ErrorView } from "../../../../../src/ui/errors";
|
||||
import { durationAtom } from "../state";
|
||||
import { seekProgressAtom } from "./hover";
|
||||
import { toTimerString } from "./left-buttons";
|
||||
import {
|
||||
percent,
|
||||
px,
|
||||
type Theme,
|
||||
useForceRerender,
|
||||
useYoshiki,
|
||||
} from "yoshiki/native";
|
||||
import { ErrorView } from "../../errors";
|
||||
import { seekProgressAtom } from "../controls";
|
||||
import { toTimerString } from "../controls/left-buttonsttons";
|
||||
import { durationAtom } from "./state";
|
||||
|
||||
type Thumb = {
|
||||
from: number;
|
||||
@ -42,8 +53,8 @@ type Thumb = {
|
||||
const parseTs = (time: string) => {
|
||||
const times = time.split(":");
|
||||
return (
|
||||
(Number.parseInt(times[0]) * 3600 +
|
||||
Number.parseInt(times[1]) * 60 +
|
||||
(Number.parseInt(times[0], 10) * 3600 +
|
||||
Number.parseInt(times[1], 10) * 60 +
|
||||
Number.parseFloat(times[2])) *
|
||||
1000
|
||||
);
|
||||
@ -69,7 +80,7 @@ export const useScrubber = (url: string) => {
|
||||
for (let i = 0; i < ret.length; i++) {
|
||||
const times = lines[i * 2].split(" --> ");
|
||||
const url = lines[i * 2 + 1].split("#xywh=");
|
||||
const xywh = url[1].split(",").map((x) => Number.parseInt(x));
|
||||
const xywh = url[1].split(",").map((x) => Number.parseInt(x, 10));
|
||||
ret[i] = {
|
||||
from: parseTs(times[0]),
|
||||
to: parseTs(times[1]),
|
||||
@ -123,7 +134,9 @@ export const ScrubberTooltip = ({
|
||||
const current =
|
||||
info.findLast((x) => x.from <= seconds * 1000 && seconds * 1000 < x.to) ??
|
||||
info.findLast(() => true);
|
||||
const chapter = chapters?.findLast((x) => x.startTime <= seconds && seconds < x.endTime);
|
||||
const chapter = chapters?.findLast(
|
||||
(x) => x.startTime <= seconds && seconds < x.endTime,
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@ -153,7 +166,13 @@ export const ScrubberTooltip = ({
|
||||
};
|
||||
let scrubberWidth = 0;
|
||||
|
||||
export const BottomScrubber = ({ url, chapters }: { url: string; chapters?: Chapter[] }) => {
|
||||
export const BottomScrubber = ({
|
||||
url,
|
||||
chapters,
|
||||
}: {
|
||||
url: string;
|
||||
chapters?: Chapter[];
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { info, error, stats } = useScrubber(url);
|
||||
const rerender = useForceRerender();
|
||||
@ -164,7 +183,9 @@ export const BottomScrubber = ({ url, chapters }: { url: string; chapters?: Chap
|
||||
if (error) return <ErrorView error={error} />;
|
||||
|
||||
const width = stats?.width ?? 1;
|
||||
const chapter = chapters?.findLast((x) => x.startTime <= progress && progress < x.endTime);
|
||||
const chapter = chapters?.findLast(
|
||||
(x) => x.startTime <= progress && progress < x.endTime,
|
||||
);
|
||||
return (
|
||||
<View {...css({ overflow: "hidden" })}>
|
||||
<View
|
||||
@ -18,12 +18,12 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { type MutationParam, WatchStatusV, useAccount } from "@kyoo/models";
|
||||
import { type MutationParam, useAccount, WatchStatusV } from "@kyoo/models";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { playAtom, progressAtom } from "./state";
|
||||
import { playAtom, progressAtom } from "./old/statee";
|
||||
|
||||
export const WatchStatusObserver = ({
|
||||
type,
|
||||
3
front/src/ui/player/subtitles.ts
Normal file
3
front/src/ui/player/subtitles.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
|
||||
export const enhanceSubtitles = (player: VideoPlayer) => player;
|
||||
62
front/src/ui/player/subtitles.web.ts
Normal file
62
front/src/ui/player/subtitles.web.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import Jassub from "jassub";
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
|
||||
declare module "react-native-video" {
|
||||
interface VideoPlayer {
|
||||
__getNativeRef(): HTMLVideoElement;
|
||||
__ass: {
|
||||
currentId?: string;
|
||||
jassub?: Jassub;
|
||||
fonts: string[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const enhanceSubtitles = (player: VideoPlayer) => {
|
||||
player.__ass = { fonts: [] };
|
||||
|
||||
const select = player.selectTextTrack.bind(player);
|
||||
player.selectTextTrack = (track) => {
|
||||
player.__ass.currentId = undefined;
|
||||
|
||||
// on the web, track.id is the url of the subtitle.
|
||||
if (!track || !track.id.endsWith(".ass")) {
|
||||
player.__ass.jassub?.destroy();
|
||||
player.__ass.jassub = undefined;
|
||||
select(track);
|
||||
return;
|
||||
}
|
||||
|
||||
// since we'll use a custom renderer for ass, disable the existing sub
|
||||
select(null);
|
||||
player.__ass.currentId = track.id;
|
||||
if (!player.__ass.jassub) {
|
||||
player.__ass.jassub = new Jassub({
|
||||
video: player.__getNativeRef(),
|
||||
workerUrl: "/jassub/jassub-worker.js",
|
||||
wasmUrl: "/jassub/jassub-worker.wasm",
|
||||
legacyWasmUrl: "/jassub/jassub-worker.wasm.js",
|
||||
modernWasmUrl: "/jassub/jassub-worker-modern.wasm",
|
||||
// Disable offscreen renderer due to bugs on firefox and chrome android
|
||||
// (see https://github.com/ThaUnknown/jassub/issues/31)
|
||||
// offscreenRender: false,
|
||||
subUrl: track.id,
|
||||
fonts: player.__ass.fonts,
|
||||
});
|
||||
} else {
|
||||
player.__ass.jassub.freeTrack();
|
||||
player.__ass.jassub.setTrackByUrl(track.id);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailable = player.getAvailableTextTracks.bind(player);
|
||||
player.getAvailableTextTracks = () => {
|
||||
const ret = getAvailable();
|
||||
if (player.__ass.currentId) {
|
||||
const current = ret.find((x) => x.id === player.__ass.currentId);
|
||||
if (current) current.selected = true;
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
return player;
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { NavigationContext, useRoute } from "@react-navigation/native";
|
||||
import { useContext } from "react";
|
||||
import { useCallback, useContext } from "react";
|
||||
import type { Movie, Show } from "~/models";
|
||||
|
||||
export function setServerData(_key: string, _val: any) {}
|
||||
@ -12,9 +12,12 @@ export const useQueryState = <S>(key: string, initial: S) => {
|
||||
const nav = useContext(NavigationContext);
|
||||
|
||||
const state = ((route.params as any)?.[key] as S) ?? initial;
|
||||
const update = (val: S | ((old: S) => S)) => {
|
||||
nav!.setParams({ [key]: val });
|
||||
};
|
||||
const update = useCallback(
|
||||
(val: S | ((old: S) => S)) => {
|
||||
nav!.setParams({ [key]: val });
|
||||
},
|
||||
[nav, key],
|
||||
);
|
||||
return [state, update] as const;
|
||||
};
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"rootDir": ".",
|
||||
@ -14,13 +16,25 @@
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node", "react"],
|
||||
"lib": ["dom", "esnext"]
|
||||
"types": [
|
||||
"node",
|
||||
"react"
|
||||
],
|
||||
"lib": [
|
||||
"dom",
|
||||
"esnext"
|
||||
]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".expo",
|
||||
"scripts",
|
||||
"**/test",
|
||||
"**/dist",
|
||||
"**/types",
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
"extends": ["config:recommended", ":disableRateLimiting"],
|
||||
"schedule": ["on monday"],
|
||||
"minimumReleaseAge": "5 days",
|
||||
"ignorePaths": ["**/front/**"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
**
|
||||
!/go.mod
|
||||
!/go.sum
|
||||
!/**.go
|
||||
!/**/*.go
|
||||
!/migrations
|
||||
# genereated via swag
|
||||
!/docs
|
||||
|
||||
@ -1,24 +1,4 @@
|
||||
# FROM golang:1.23 as build
|
||||
FROM debian:trixie-slim AS build
|
||||
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
|
||||
ENV GOTOOLCHAIN=local
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates openssl \
|
||||
golang\
|
||||
g++ \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
make \
|
||||
pkg-config
|
||||
|
||||
# https://github.com/golang/go/issues/54400
|
||||
ENV SSL_CERT_DIR=/etc/ssl/certs
|
||||
RUN update-ca-certificates
|
||||
|
||||
FROM golang:1.25 as build
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends --no-install-suggests -y \
|
||||
ffmpeg libavformat-dev libavutil-dev libswscale-dev \
|
||||
@ -32,10 +12,8 @@ RUN go mod download
|
||||
COPY . .
|
||||
RUN GOOS=linux go build -o ./transcoder
|
||||
|
||||
# debian is required for nvidia hardware acceleration
|
||||
# we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6)
|
||||
# https://packages.debian.org/bookworm/ffmpeg for version tracking
|
||||
FROM debian:trixie-slim
|
||||
# https://packages.debian.org/trixie/ffmpeg for version tracking
|
||||
FROM debian:trixie
|
||||
|
||||
# read target arch from buildx or default to amd64 if using legacy builder.
|
||||
ARG TARGETARCH
|
||||
|
||||
@ -1,27 +1,4 @@
|
||||
# we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6)
|
||||
# https://packages.debian.org/bookworm/ffmpeg for version tracking
|
||||
# FROM golang:1.21
|
||||
# trixie's golang is also 1.21
|
||||
FROM debian:trixie-slim
|
||||
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
|
||||
ENV GOTOOLCHAIN=local
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates openssl \
|
||||
golang\
|
||||
g++ \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
make \
|
||||
pkg-config
|
||||
|
||||
# https://github.com/golang/go/issues/54400
|
||||
ENV SSL_CERT_DIR=/etc/ssl/certs
|
||||
RUN update-ca-certificates
|
||||
|
||||
FROM golang:1.25
|
||||
# read target arch from buildx or default to amd64 if using legacy builder.
|
||||
ARG TARGETARCH
|
||||
ENV TARGETARCH=${TARGETARCH:-amd64}
|
||||
|
||||
@ -3,6 +3,7 @@ module github.com/zoriya/kyoo/transcoder
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astisub v0.35.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.3
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
@ -19,6 +20,8 @@ require (
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/asticode/go-astikit v0.20.0 // indirect
|
||||
github.com/asticode/go-astits v1.8.0 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
|
||||
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astisub v0.35.0 h1:wnELGJMeJbavW//X7nLTy97L3iblub7tO1VSeHnZBdA=
|
||||
github.com/asticode/go-astisub v0.35.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
|
||||
github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=
|
||||
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY=
|
||||
@ -40,35 +42,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkE
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o=
|
||||
github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
|
||||
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
@ -79,25 +58,16 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE=
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00=
|
||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||
@ -124,29 +94,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
|
||||
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
|
||||
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
||||
@ -155,25 +104,12 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
gitlab.com/opennota/screengen v1.0.2 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw=
|
||||
gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
|
||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
@ -182,23 +118,17 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.1 h1:sFV08OT1eZ1yroLCZVClIVd9YySgCh9eGjBWO0oRayI=
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user