Player rewrite (#1020)

This commit is contained in:
Zoe Roux 2025-10-24 16:33:49 +02:00 committed by GitHub
commit a3f29c73ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
114 changed files with 3052 additions and 4877 deletions

View File

@ -67,5 +67,6 @@ PGPORT=5432
# v5 stuff, does absolutely nothing on master (aka: you can delete this) # v5 stuff, does absolutely nothing on master (aka: you can delete this)
EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}' 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}' 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" PROTECTED_CLAIMS="permissions,verified"

View File

@ -15,7 +15,7 @@
"sharp": "^0.34.2", "sharp": "^0.34.2",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.6", "@biomejs/biome": "2.1.1",
"@types/pg": "^8.15.2", "@types/pg": "^8.15.2",
"bun-types": "^1.2.14", "bun-types": "^1.2.14",
"node-addon-api": "^8.3.1", "node-addon-api": "^8.3.1",
@ -26,23 +26,23 @@
"drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch", "drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch",
}, },
"packages": { "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=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],

View File

@ -20,7 +20,7 @@
"sharp": "^0.34.2" "sharp": "^0.34.2"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.6", "@biomejs/biome": "2.1.1",
"@types/pg": "^8.15.2", "@types/pg": "^8.15.2",
"bun-types": "^1.2.14", "bun-types": "^1.2.14",
"node-addon-api": "^8.3.1" "node-addon-api": "^8.3.1"

View File

@ -54,7 +54,7 @@ export const entryProgressQ = db
}) })
.from(history) .from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk)) .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"))) .where(eq(profiles.id, sql.placeholder("userId")))
.orderBy(history.entryPk, desc(history.playedDate)) .orderBy(history.entryPk, desc(history.playedDate))
.as("progress"); .as("progress");

View File

@ -227,7 +227,6 @@ export const staffH = new Elysia({ tags: ["staff"] })
.from(watchlist) .from(watchlist)
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
.where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk))) .where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk)))
.limit(1)
.as("watchstatus"); .as("watchstatus");
const items = await db const items = await db

View File

@ -15,7 +15,16 @@ import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { auth } from "~/auth"; import { auth } from "~/auth";
import { db, type Transaction } from "~/db"; 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 { import {
coalesce, coalesce,
conflictUpdateAllExcept, conflictUpdateAllExcept,
@ -30,10 +39,14 @@ import {
import { Entry } from "~/models/entry"; import { Entry } from "~/models/entry";
import { KError } from "~/models/error"; import { KError } from "~/models/error";
import { bubbleVideo } from "~/models/examples"; import { bubbleVideo } from "~/models/examples";
import { Progress } from "~/models/history";
import { Movie, type MovieStatus } from "~/models/movie";
import { Serie } from "~/models/serie";
import { import {
AcceptLanguage, AcceptLanguage,
buildRelations, buildRelations,
createPage, createPage,
type Image,
isUuid, isUuid,
keysetPaginate, keysetPaginate,
Page, Page,
@ -44,6 +57,7 @@ import {
} from "~/models/utils"; } from "~/models/utils";
import { desc as description } from "~/models/utils/descriptions"; import { desc as description } from "~/models/utils/descriptions";
import { Guess, Guesses, SeedVideo, Video } from "~/models/video"; import { Guess, Guesses, SeedVideo, Video } from "~/models/video";
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import { comment } from "~/utils"; import { comment } from "~/utils";
import { import {
entryProgressQ, entryProgressQ,
@ -206,14 +220,44 @@ const videoRelations = {
slugs: () => { slugs: () => {
return db return db
.select({ .select({
slugs: coalesce(jsonbAgg(entryVideoJoin.slug), sql`'[]'::jsonb`).as( slugs: coalesce<string[]>(
"slugs", jsonbAgg(entryVideoJoin.slug),
), sql`'[]'::jsonb`,
).as("slugs"),
}) })
.from(entryVideoJoin) .from(entryVideoJoin)
.where(eq(entryVideoJoin.videoPk, videos.pk)) .where(eq(entryVideoJoin.videoPk, videos.pk))
.as("slugs"); .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[] }) => { entries: ({ languages }: { languages: string[] }) => {
const transQ = getEntryTransQ(languages); const transQ = getEntryTransQ(languages);
@ -229,6 +273,7 @@ const videoRelations = {
progress: mapProgress({ aliased: false }), progress: mapProgress({ aliased: false }),
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, 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"')`, 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`, sql`'[]'::jsonb`,
@ -242,6 +287,74 @@ const videoRelations = {
.where(eq(entryVideoJoin.videoPk, videos.pk)) .where(eq(entryVideoJoin.videoPk, videos.pk))
.as("entries"); .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[] }) => { previous: ({ languages }: { languages: string[] }) => {
return getNextVideoEntry({ languages, prev: true }); return getNextVideoEntry({ languages, prev: true });
}, },
@ -263,7 +376,7 @@ function getNextVideoEntry({
const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`); const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`);
return db return db
.select({ .select({
json: jsonbBuildObject<Entry>({ json: jsonbBuildObject<{ video: string; entry: Entry }>({
video: entryVideoJoin.slug, video: entryVideoJoin.slug,
entry: { entry: {
...getColumns(entries), ...getColumns(entries),
@ -274,7 +387,7 @@ function getNextVideoEntry({
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, 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"')`, updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
}, },
}), }).as("json"),
}) })
.from(entries) .from(entries)
.innerJoin(transQ, eq(entries.pk, transQ.pk)) .innerJoin(transQ, eq(entries.pk, transQ.pk))
@ -337,9 +450,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
":id", ":id",
async ({ async ({
params: { id }, params: { id },
query: { with: relations }, query: { with: relations, preferOriginal },
headers: { "accept-language": langs }, headers: { "accept-language": langs },
jwt: { sub }, jwt: { sub, settings },
status, status,
}) => { }) => {
const languages = processLanguages(langs); const languages = processLanguages(langs);
@ -351,10 +464,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.select({ .select({
...getColumns(videos), ...getColumns(videos),
...buildRelations( ...buildRelations(
["slugs", "entries", ...relations], ["slugs", "progress", "entries", ...relations],
videoRelations, videoRelations,
{ {
languages, languages,
preferOriginal: preferOriginal ?? settings.preferOriginal,
}, },
), ),
}) })
@ -382,10 +496,15 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
}), }),
}), }),
query: t.Object({ query: t.Object({
with: t.Array(t.UnionEnum(["previous", "next"]), { with: t.Array(t.UnionEnum(["previous", "next", "show"]), {
default: [], default: [],
description: "Include related entries in the response.", description: "Include related entries in the response.",
}), }),
preferOriginal: t.Optional(
t.Boolean({
description: description.preferOriginal,
}),
),
}), }),
headers: t.Object( headers: t.Object(
{ {
@ -400,6 +519,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
slugs: t.Array( slugs: t.Array(
t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }), t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }),
), ),
progress: Progress,
entries: t.Array(Entry), entries: t.Array(Entry),
previous: t.Optional( previous: t.Optional(
t.Nullable( 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: { 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( .get(
"", "",
async () => { async () => {

View File

@ -18,7 +18,7 @@ export const history = schema.table(
.references(() => entries.pk, { onDelete: "cascade" }), .references(() => entries.pk, { onDelete: "cascade" }),
videoPk: integer().references(() => videos.pk, { onDelete: "set null" }), videoPk: integer().references(() => videos.pk, { onDelete: "set null" }),
percent: integer().notNull().default(0), percent: integer().notNull().default(0),
time: integer(), time: integer().notNull().default(0),
playedDate: timestamp({ withTimezone: true, mode: "iso" }) playedDate: timestamp({ withTimezone: true, mode: "iso" })
.notNull() .notNull()
.default(sql`now()`), .default(sql`now()`),

View File

@ -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})`; return sql<T>`coalesce(${val}, ${def})`;
}; };

View File

@ -3,15 +3,13 @@ import { comment } from "~/utils";
export const Progress = t.Object({ export const Progress = t.Object({
percent: t.Integer({ minimum: 0, maximum: 100 }), percent: t.Integer({ minimum: 0, maximum: 100 }),
time: t.Nullable( time: t.Integer({
t.Integer({ minimum: 0,
minimum: 0, description: comment`
description: comment`
When this episode was stopped (in seconds since the start). When this episode was stopped (in seconds since the start).
This value is null if the entry was never watched or is finished. This value is null if the entry was never watched or is finished.
`, `,
}), }),
),
playedDate: t.Nullable(t.String({ format: "date-time" })), playedDate: t.Nullable(t.String({ format: "date-time" })),
videoId: t.Nullable( videoId: t.Nullable(
t.String({ t.String({

View File

@ -58,7 +58,7 @@ export const Sort = (
const random = sort.find((x) => x.startsWith("random")); const random = sort.find((x) => x.startsWith("random"));
if (random) { if (random) {
const seed = random.includes(":") 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); : Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
return { tablePk, random: { seed }, sort: [] }; return { tablePk, random: { seed }, sort: [] };
} }

View File

@ -3,7 +3,6 @@
"target": "ES2021", "target": "ES2021",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "node", "moduleResolution": "node",
"types": ["bun-types"],
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,

View File

@ -1,7 +1,7 @@
** **
!/go.mod !/go.mod
!/go.sum !/go.sum
!/**.go !/**/*.go
# generated via sqlc # generated via sqlc
!/sql !/sql
!/dbc !/dbc

View File

@ -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": { "formatter": {
"enabled": true, "enabled": true,
"formatWithErrors": true, "formatWithErrors": true,

View File

@ -8,7 +8,7 @@ x-transcoder: &transcoder-base
- transcoder - transcoder
ports: ports:
- "7666:7666" - "7666:7666"
restart: unless-stopped restart: on-failure
cpus: 1 cpus: 1
environment: environment:
- JWKS_URL=http://auth:4568/.well-known/jwks.json - JWKS_URL=http://auth:4568/.well-known/jwks.json
@ -36,7 +36,7 @@ services:
build: build:
context: ./front context: ./front
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
restart: unless-stopped restart: on-failure
ports: ports:
- "8081:8081" - "8081:8081"
environment: environment:
@ -56,7 +56,7 @@ services:
build: build:
context: ./auth context: ./auth
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
restart: unless-stopped restart: on-failure
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -77,7 +77,7 @@ services:
build: build:
context: ./api context: ./api
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
restart: unless-stopped restart: on-failure
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -106,7 +106,7 @@ services:
scanner: scanner:
build: ./scanner build: ./scanner
restart: unless-stopped restart: on-failure
depends_on: depends_on:
api: api:
condition: service_started condition: service_started
@ -176,7 +176,7 @@ services:
traefik: traefik:
image: traefik:v3.5 image: traefik:v3.5
restart: unless-stopped restart: on-failure
command: command:
- "--providers.docker=true" - "--providers.docker=true"
- "--providers.docker.exposedbydefault=false" - "--providers.docker.exposedbydefault=false"
@ -189,7 +189,7 @@ services:
postgres: postgres:
image: postgres:15 image: postgres:15
restart: unless-stopped restart: on-failure
env_file: env_file:
- ./.env - ./.env
volumes: volumes:

View File

@ -5,5 +5,5 @@
!/metro.config.js !/metro.config.js
!/app.config.ts !/app.config.ts
!/src !/src
!/app
!/public !/public
!/scripts

View File

@ -1,8 +1,9 @@
FROM oven/bun AS builder FROM oven/bun AS builder
WORKDIR /app WORKDIR /app
COPY package.json bun.lock . COPY package.json bun.lock scripts .
RUN bun install --production COPY scripts scripts
RUN bun install --production --frozen-lockfile
COPY . . COPY . .

View File

@ -2,6 +2,7 @@ FROM oven/bun
WORKDIR /app WORKDIR /app
COPY package.json bun.lock . COPY package.json bun.lock .
COPY scripts scripts
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY . . COPY . .

View File

@ -66,7 +66,12 @@ export const expo: ExpoConfig = {
[ [
"react-native-video", "react-native-video",
{ {
enableNotificationControls: true, enableAndroidPictureInPicture: true,
enableBackgroundAudio: true,
androidExtensions: {
useExoplayerDash: true,
useExoplayerHls: true,
},
}, },
], ],
], ],

View File

@ -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
}

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

2
front/bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[install]
linker = "hoisted"

View File

@ -4,6 +4,7 @@
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"postinstall": "bun ./scripts/postinstall.ts",
"dev": "expo start", "dev": "expo start",
"apk": "eas build --profile preview --platform android --non-interactive --json", "apk": "eas build --profile preview --platform android --non-interactive --json",
"apk:dev": "eas build --profile development --platform android --non-interactive", "apk:dev": "eas build --profile development --platform android --non-interactive",
@ -13,55 +14,68 @@
"format:fix": "biome format . --write" "format:fix": "biome format . --write"
}, },
"dependencies": { "dependencies": {
"@expo/html-elements": "^0.12.5", "@expo/html-elements": "^0.13.7",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@legendapp/list": "^1.0.20", "@legendapp/list": "^2.0.13",
"@material-symbols/svg-400": "^0.31.6", "@material-symbols/svg-400": "^0.38.0",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@react-navigation/bottom-tabs": "^7.3.10", "@radix-ui/react-select": "^2.2.6",
"@react-navigation/elements": "^2.3.8", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/native": "^7.1.6", "@react-navigation/elements": "^2.6.4",
"@tanstack/react-query": "^5.80.6", "@react-navigation/native": "^7.1.8",
"expo": "~53.0.10", "@tanstack/react-query": "^5.90.5",
"expo-build-properties": "^0.14.6", "expo": "54.0.17",
"expo-image": "^2.3.0", "expo-build-properties": "^1.0.9",
"expo-linear-gradient": "^14.1.5", "expo-constants": "~18.0.10",
"expo-linking": "~7.1.5", "expo-dev-client": "~6.0.16",
"expo-localization": "^16.1.5", "expo-image": "~3.0.10",
"expo-router": "~5.1.0", "expo-linear-gradient": "^15.0.7",
"expo-splash-screen": "^0.30.9", "expo-linking": "~8.0.8",
"expo-status-bar": "~2.2.3", "expo-localization": "^17.0.7",
"expo-updates": "~0.28.14", "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", "i18next-http-backend": "^3.0.2",
"jotai": "^2.12.5", "jassub": "^1.8.6",
"react": "19.0.0", "langmap": "^0.0.16",
"react-i18next": "^15.5.2", "react": "19.1.0",
"react-native": "0.79.3", "react-dom": "19.1.0",
"react-native-mmkv": "^3.2.0", "react-i18next": "^16.1.0",
"react-native-reanimated": "~3.17.4", "react-native": "0.81.5",
"react-native-safe-area-context": "5.4.0", "react-native-get-random-values": "^2.0.0",
"react-native-screens": "~4.11.1", "react-native-mmkv": "^3.3.3",
"react-native-svg": "15.11.2", "react-native-nitro-modules": "^0.30.2",
"react-native-video": "^6.15.0", "react-native-reanimated": "~4.1.2",
"react-native-web": "^0.20.0", "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", "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", "yoshiki": "1.2.14",
"zod": "^3.25.56" "zod": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0", "@biomejs/biome": "2.2.6",
"@tanstack/react-query-devtools": "^5.80.6", "@tanstack/react-query-devtools": "^5.90.2",
"@types/react": "~19.0.10", "@types/bun": "^1.3.0",
"@types/react-dom": "^19.1.6", "@types/react": "~19.1.10",
"expo-dev-client": "^5.2.0", "@types/react-dom": "~19.1.7",
"react-native-svg-transformer": "^1.5.1", "react-native-svg-transformer": "^1.5.1",
"typescript": "5.8.3" "typescript": "5.9.3"
}, },
"expo": { "expo": {
"doctor": { "doctor": {
"reactNativeDirectoryCheck": { "reactNativeDirectoryCheck": {
"listUnknownPackages": false "listUnknownPackages": false,
"exclude": [
"@gorhom/portal"
]
} }
} }
} }

View File

@ -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"
}
}

View File

@ -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/>.
*/

View File

@ -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));
};

View File

@ -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";

View File

@ -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>;

View File

@ -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();
};

View File

@ -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);
};

View File

@ -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;
};

View File

@ -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"]
}

View File

@ -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
}
}
}

View File

@ -21,7 +21,7 @@
import { type WatchInfo, getCurrentApiUrl, queryFn, toQueryKey } from "@kyoo/models"; import { type WatchInfo, getCurrentApiUrl, queryFn, toQueryKey } from "@kyoo/models";
import { getCurrentAccount } from "@kyoo/models/src/account-internal"; import { getCurrentAccount } from "@kyoo/models/src/account-internal";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Player } from "../player"; import { Player } from "../../../../src/ui/player../src/ui/player";
export const useDownloader = () => { export const useDownloader = () => {
return async (type: "episode" | "movie", slug: string) => { return async (type: "episode" | "movie", slug: string) => {

View File

@ -41,7 +41,7 @@ import { type PrimitiveAtom, atom, useSetAtom, useStore } from "jotai";
import { type ReactNode, useEffect } from "react"; import { type ReactNode, useEffect } from "react";
import { ToastAndroid } from "react-native"; import { ToastAndroid } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { Player } from "../player"; import { Player } from "../../../../src/ui/player";
type Router = ReturnType<typeof useRouter>; type Router = ReturnType<typeof useRouter>;

View File

@ -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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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)}`;
};

View File

@ -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>
);
};

View File

@ -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"];

View File

@ -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]);
};

View File

@ -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;
};

View File

@ -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);
}}
/>
);
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -1,43 +1,4 @@
import type { Subtitle, Track } from "@kyoo/models";
import intl from "langmap"; 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(); const seenNativeNames = new Set();

2
front/public/jassub/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -198,6 +198,7 @@
"volume": "Volume", "volume": "Volume",
"quality": "Quality", "quality": "Quality",
"audios": "Audio", "audios": "Audio",
"videos": "Video",
"subtitles": "Subtitles", "subtitles": "Subtitles",
"subtitle-none": "None", "subtitle-none": "None",
"fullscreen": "Fullscreen", "fullscreen": "Fullscreen",

View 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);
}

View File

@ -1,5 +1,5 @@
import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg"; 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 Home from "@material-symbols/svg-400/rounded/home-fill.svg";
import { Tabs } from "expo-router"; import { Tabs } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -32,15 +32,15 @@ export default function TabsLayout() {
), ),
}} }}
/> />
<Tabs.Screen {/* <Tabs.Screen */}
name="downloads" {/* name="downloads" */}
options={{ {/* options={{ */}
tabBarLabel: t("navbar.download"), {/* tabBarLabel: t("navbar.download"), */}
tabBarIcon: ({ color, size }) => ( {/* tabBarIcon: ({ color, size }) => ( */}
<Icon icon={Downloading} color={color} size={size} /> {/* <Icon icon={Downloading} color={color} size={size} /> */}
), {/* ), */}
}} {/* }} */}
/> {/* /> */}
</Tabs> </Tabs>
); );
} }

View File

@ -0,0 +1,3 @@
import { Player } from "~/ui/player";
export default Player;

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { View } from "react-native";
import { import {
percent, percent,
rem, rem,
@ -8,14 +8,11 @@ import {
type Theme, type Theme,
useYoshiki, useYoshiki,
} from "yoshiki/native"; } from "yoshiki/native";
import { EntryContext } from "~/components/items/context-menus"; import type { KImage } from "~/models";
import { ItemProgress } from "~/components/items/item-grid";
import type { KImage, WatchStatusV } from "~/models";
import { import {
focusReset, focusReset,
Image, Image,
ImageBackground, ImageBackground,
important,
Link, Link,
P, P,
Skeleton, Skeleton,
@ -31,7 +28,7 @@ export const EntryBox = ({
thumbnail, thumbnail,
href, href,
watchedPercent, watchedPercent,
watchedStatus, // watchedStatus,
...props ...props
}: Stylable & { }: Stylable & {
slug: string; slug: string;
@ -42,7 +39,7 @@ export const EntryBox = ({
href: string; href: string;
thumbnail: KImage | null; thumbnail: KImage | null;
watchedPercent: number | null; watchedPercent: number | null;
watchedStatus: WatchStatusV | null; // watchedStatus: WatchStatusV | null;
}) => { }) => {
const [moreOpened, setMoreOpened] = useState(false); const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("episodebox"); const { css } = useYoshiki("episodebox");
@ -89,27 +86,27 @@ export const EntryBox = ({
layout={{ width: percent(100), aspectRatio: 16 / 9 }} layout={{ width: percent(100), aspectRatio: 16 / 9 }}
{...(css("poster") as any)} {...(css("poster") as any)}
> >
{(watchedPercent || watchedStatus === "completed") && ( {/* {(watchedPercent || watchedStatus === "completed") && ( */}
<ItemProgress watchPercent={watchedPercent ?? 100} /> {/* <ItemProgress watchPercent={watchedPercent ?? 100} /> */}
)} {/* )} */}
<EntryContext {/* <EntryContext */}
slug={slug} {/* slug={slug} */}
serieSlug={serieSlug} {/* serieSlug={serieSlug} */}
status={watchedStatus} {/* status={watchedStatus} */}
isOpen={moreOpened} {/* isOpen={moreOpened} */}
setOpen={(v) => setMoreOpened(v)} {/* setOpen={(v) => setMoreOpened(v)} */}
{...css([ {/* {...css([ */}
{ {/* { */}
position: "absolute", {/* position: "absolute", */}
top: 0, {/* top: 0, */}
right: 0, {/* right: 0, */}
bg: (theme) => theme.darkOverlay, {/* bg: (theme) => theme.darkOverlay, */}
}, {/* }, */}
"more", {/* "more", */}
Platform.OS === "web" && {/* Platform.OS === "web" && */}
moreOpened && { display: important("flex") }, {/* moreOpened && { display: important("flex") }, */}
])} {/* ])} */}
/> {/* /> */}
</ImageBackground> </ImageBackground>
<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}> <P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
{name ?? t("show.episodeNoMetadata")} {name ?? t("show.episodeNoMetadata")}

View File

@ -12,7 +12,7 @@ import { HR, IconButton, Menu, tooltip } from "~/primitives";
import { useAccount } from "~/providers/account-context"; import { useAccount } from "~/providers/account-context";
import { useMutation } from "~/query"; import { useMutation } from "~/query";
import { watchListIcon } from "./watchlist-info"; 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 = ({ export const EntryContext = ({
slug, slug,
@ -27,32 +27,30 @@ export const EntryContext = ({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <Menu
<Menu Trigger={IconButton}
Trigger={IconButton} icon={MoreVert}
icon={MoreVert} {...tooltip(t("misc.more"))}
{...tooltip(t("misc.more"))} {...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
{...(css([Platform.OS !== "web" && { display: "none" }], props) as any)} >
> {serieSlug && (
{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.Item <Menu.Item
label={t("home.episodeMore.mediainfo")} label={t("home.episodeMore.goToShow")}
icon={MovieInfo} icon={Info}
href={`/entries/${slug}/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 ( return (
<> <Menu
<Menu Trigger={IconButton}
Trigger={IconButton} icon={MoreVert}
icon={MoreVert} {...tooltip(t("misc.more"))}
{...tooltip(t("misc.more"))} {...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
{...(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 {Object.values(WatchStatusV).map((x) => (
label={account ? t("show.watchlistEdit") : t("show.watchlistLogin")} <Menu.Item
disabled={!account} key={x}
icon={watchListIcon(status)} label={t(
> `show.watchlistMark.${x.toLowerCase() as Lowercase<WatchStatusV>}`,
{Object.values(WatchStatusV).map((x) => ( )}
<Menu.Item onSelect={() => mutation.mutate(x)}
key={x} selected={x === status}
label={t( />
`show.watchlistMark.${x.toLowerCase() as Lowercase<WatchStatusV>}`, ))}
)} {status !== null && (
onSelect={() => mutation.mutate(x)} <Menu.Item
selected={x === status} label={t("show.watchlistMark.null")}
/> onSelect={() => mutation.mutate(null)}
))} />
{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`}
/>
</>
)} )}
{account?.isAdmin === true && ( </Menu.Sub>
<> {kind === "movie" && (
<HR /> <>
<Menu.Item {/* <Menu.Item */}
label={t("home.refreshMetadata")} {/* label={t("home.episodeMore.download")} */}
icon={Refresh} {/* icon={Download} */}
onSelect={() => metadataRefreshMutation.mutate()} {/* onSelect={() => downloader(type, slug)} */}
/> {/* /> */}
</> <Menu.Item
)} label={t("home.episodeMore.mediainfo")}
</Menu> icon={MovieInfo}
</> href={`/movies/${slug}/info`}
/>
</>
)}
{account?.isAdmin === true && (
<>
<HR />
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
</Menu>
); );
}; };

View File

@ -1,7 +1,7 @@
import Done from "@material-symbols/svg-400/rounded/check-fill.svg"; import Done from "@material-symbols/svg-400/rounded/check-fill.svg";
import { View } from "react-native"; import { View } from "react-native";
import { max, rem, useYoshiki } from "yoshiki/native"; import { max, rem, useYoshiki } from "yoshiki/native";
import { WatchStatusV } from "~/models"; import type { WatchStatusV } from "~/models";
import { Icon, P, ts } from "~/primitives"; import { Icon, P, ts } from "~/primitives";
export const ItemWatchStatus = ({ export const ItemWatchStatus = ({
@ -14,8 +14,7 @@ export const ItemWatchStatus = ({
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
if (watchStatus !== WatchStatusV.Completed && !unseenEpisodesCount) if (watchStatus !== "completed" && !unseenEpisodesCount) return null;
return null;
return ( return (
<View <View
@ -36,7 +35,7 @@ export const ItemWatchStatus = ({
props, props,
)} )}
> >
{watchStatus === WatchStatusV.Completed ? ( {watchStatus === "completed" ? (
<Icon icon={Done} size={16} /> <Icon icon={Done} size={16} />
) : ( ) : (
<P <P

View File

@ -1,6 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { User } from "./user"; import { User } from "./user";
// TODO: actually parse the token
const TokenP = z.string();
export const AccountP = User.and( export const AccountP = User.and(
z.object({ z.object({
token: TokenP, token: TokenP,

View File

@ -29,7 +29,7 @@ const Base = z.object({
), ),
progress: z.object({ progress: z.object({
percent: z.int().min(0).max(100), percent: z.int().min(0).max(100),
time: z.int().min(0).nullable(), time: z.int().min(0),
playedDate: zdate().nullable(), playedDate: zdate().nullable(),
videoId: z.string().nullable(), videoId: z.string().nullable(),
}), }),

View File

@ -22,7 +22,7 @@ export const Extra = z.object({
progress: z.object({ progress: z.object({
percent: z.int().min(0).max(100), percent: z.int().min(0).max(100),
time: z.int().min(0).nullable(), time: z.int().min(0),
playedDate: zdate().nullable(), playedDate: zdate().nullable(),
}), }),
}); });

View File

@ -12,3 +12,4 @@ export * from "./utils/genre";
export * from "./utils/images"; export * from "./utils/images";
export * from "./utils/page"; export * from "./utils/page";
export * from "./video"; export * from "./video";
export * from "./video-info";

View File

@ -4,7 +4,6 @@ import { Genre } from "./utils/genre";
import { KImage } from "./utils/images"; import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata"; import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils"; import { zdate } from "./utils/utils";
import { EmbeddedVideo } from "./video";
export const Movie = z export const Movie = z
.object({ .object({
@ -39,7 +38,18 @@ export const Movie = z
updatedAt: zdate(), updatedAt: zdate(),
studios: z.array(Studio).optional(), 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 watchStatus: z
.object({ .object({
status: z.enum([ status: z.enum([

View File

@ -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>;

View File

@ -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>;

View 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]
);
};

View File

@ -1,11 +1,52 @@
import { z } from "zod/v4"; 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(), id: z.string(),
slug: z.string(),
path: z.string(), path: z.string(),
rendering: z.string(), rendering: z.string(),
part: z.number().int().gt(0).nullable(), part: z.int().min(0).nullable(),
version: z.number().gt(0), 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>;

View File

@ -1,10 +1,4 @@
import type React from "react"; import type { ComponentProps, ComponentType } from "react";
import {
type ComponentProps,
type ComponentType,
type ForwardedRef,
forwardRef,
} from "react";
import { Platform, type PressableProps } from "react-native"; import { Platform, type PressableProps } from "react-native";
import type { SvgProps } from "react-native-svg"; import type { SvgProps } from "react-native-svg";
import type { YoshikiStyle } from "yoshiki"; import type { YoshikiStyle } from "yoshiki";
@ -13,12 +7,6 @@ import { PressableFeedback } from "./links";
import { P } from "./text"; import { P } from "./text";
import { type Breakpoint, focusReset, ts } from "./utils"; 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>; export type Icon = ComponentType<SvgProps>;
type IconProps = { type IconProps = {
@ -54,27 +42,21 @@ export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
); );
}; };
export const IconButton = forwardRef(function IconButton< export const IconButton = <AsProps = PressableProps>({
AsProps = PressableProps, icon,
>( size,
{ color,
icon, as,
size, ...asProps
color, }: IconProps & {
as, as?: ComponentType<AsProps>;
...asProps } & AsProps) => {
}: IconProps & {
as?: ComponentType<AsProps>;
} & AsProps,
ref: ForwardedRef<unknown>,
) {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const Container = as ?? PressableFeedback; const Container = as ?? PressableFeedback;
return ( return (
<Container <Container
ref={ref as any}
focusRipple focusRipple
{...(css( {...(css(
{ {
@ -102,7 +84,7 @@ export const IconButton = forwardRef(function IconButton<
/> />
</Container> </Container>
); );
}); };
export const IconFab = <AsProps = PressableProps>( export const IconFab = <AsProps = PressableProps>(
props: ComponentProps<typeof IconButton<AsProps>>, props: ComponentProps<typeof IconButton<AsProps>>,

View File

@ -25,7 +25,7 @@ export const ImageBackground = ({
layout: ImageLayout; layout: ImageLayout;
children: ReactNode; children: ReactNode;
}) => { }) => {
const { css } = useYoshiki(); const { css, theme } = useYoshiki();
const { apiUrl, authToken } = useToken(); const { apiUrl, authToken } = useToken();
return ( return (
@ -42,7 +42,10 @@ export const ImageBackground = ({
}} }}
placeholder={{ blurhash: src?.blurhash }} placeholder={{ blurhash: src?.blurhash }}
accessibilityLabel={alt} accessibilityLabel={alt}
{...(css([layout, { overflow: "hidden" }], props) as any)} {...(css(
[layout, { overflow: "hidden", backgroundColor: theme.overlay0 }],
props,
) as any)}
/> />
); );
}; };

View File

@ -34,7 +34,7 @@ export const Image = ({
style?: ImageStyle; style?: ImageStyle;
layout: ImageLayout; layout: ImageLayout;
}) => { }) => {
const { css } = useYoshiki(); const { css, theme } = useYoshiki();
const { apiUrl, authToken } = useToken(); const { apiUrl, authToken } = useToken();
return ( return (
@ -51,7 +51,10 @@ export const Image = ({
}} }}
placeholder={{ blurhash: src?.blurhash }} placeholder={{ blurhash: src?.blurhash }}
accessibilityLabel={alt} accessibilityLabel={alt}
{...(css([layout, { borderRadius: 6 }], props) as any)} {...(css(
[layout, { borderRadius: 6, backgroundColor: theme.overlay0 }],
props,
) as any)}
/> />
); );
}; };

View File

@ -7,16 +7,16 @@ export * from "./divider";
export * from "./icons"; export * from "./icons";
export * from "./image"; export * from "./image";
export * from "./image-background"; export * from "./image-background";
// export * from "./popup";
// export * from "./select";
export * from "./input"; export * from "./input";
export * from "./links"; export * from "./links";
// export * from "./progress";
// export * from "./slider";
// export * from "./snackbar"; // export * from "./snackbar";
// export * from "./alert"; // export * from "./alert";
export * from "./menu"; export * from "./menu";
export * from "./progress";
// export * from "./popup";
export * from "./select";
export * from "./skeleton"; export * from "./skeleton";
export * from "./slider";
export * from "./text"; export * from "./text";
export * from "./theme"; export * from "./theme";
export * from "./tooltip"; export * from "./tooltip";

View File

@ -1,4 +1,4 @@
import { forwardRef, type ReactNode, useState } from "react"; import { type ReactNode, type Ref, useState } from "react";
import { import {
TextInput, TextInput,
type TextInputProps, type TextInputProps,
@ -6,20 +6,22 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { px, type Theme, useYoshiki } from "yoshiki/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"; import { focusReset, ts } from "./utils";
export const Input = forwardRef< export const Input = ({
TextInput, placeholderTextColor,
{ variant = "small",
variant?: "small" | "big"; right,
right?: ReactNode; containerStyle,
containerStyle?: YoshikiEnhanced<ViewStyle>;
} & TextInputProps
>(function Input(
{ placeholderTextColor, variant = "small", right, containerStyle, ...props },
ref, ref,
) { ...props
}: {
variant?: "small" | "big";
right?: ReactNode;
containerStyle?: YoshikiEnhanced<ViewStyle>;
ref?: Ref<TextInput>;
} & TextInputProps) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
@ -64,4 +66,4 @@ export const Input = forwardRef<
{right} {right}
</View> </View>
); );
}); };

View File

@ -1,26 +1,5 @@
/* import { ActivityIndicator } from "react-native";
* Kyoo - A portable and vast media library solution. import { type Stylable, useYoshiki } from "yoshiki/native";
* 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";
export const CircularProgress = ({ export const CircularProgress = ({
size = 48, size = 48,
@ -28,64 +7,9 @@ export const CircularProgress = ({
color, color,
...props ...props
}: { size?: number; tickness?: number; color?: string } & Stylable) => { }: { size?: number; tickness?: number; color?: string } & Stylable) => {
const { css, theme } = useYoshiki(); const { theme } = useYoshiki();
if (Platform.OS !== "web")
return (
<ActivityIndicator size={size} color={color ?? theme.accent} {...props} />
);
return ( return (
<View {...css({ width: size, height: size, overflow: "hidden" }, props)}> <ActivityIndicator size={size} color={color ?? theme.accent} {...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>
); );
}; };

View File

@ -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 ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import { Button } from "./button"; import { Button } from "./button";
import { Icon } from "./icons"; import { Icon } from "./icons";

View File

@ -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 Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-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"; 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 { PressableFeedback } from "./links";
import { InternalTriger, YoshikiProvider } from "./menu.web"; import { InternalTriger, YoshikiProvider } from "./menu.web";
import { P } from "./text"; import { P } from "./text";
import { ContrastArea, SwitchVariant } from "./themes"; import { ContrastArea, SwitchVariant } from "./theme";
import { focusReset, ts } from "./utils"; import { focusReset, ts } from "./utils";
export const Select = ({ export const Select = ({
@ -131,11 +111,11 @@ const Item = forwardRef<HTMLDivElement, { label: string; value: string }>(
const { css: nCss } = useNativeYoshiki(); const { css: nCss } = useNativeYoshiki();
return ( return (
<> <>
<style jsx global>{` {/* <style jsx global>{` */}
[data-highlighted] { {/* [data-highlighted] { */}
background: ${theme.variant.accent}; {/* background: ${theme.variant.accent}; */}
} {/* } */}
`}</style> {/* `}</style> */}
<RSelect.Item <RSelect.Item
ref={ref} ref={ref}
value={value} value={value}

View File

@ -106,11 +106,7 @@ export const Skeleton = ({
start={{ x: 0, y: 0.5 }} start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }} end={{ x: 1, y: 0.5 }}
colors={["transparent", theme.overlay1, "transparent"]} colors={["transparent", theme.overlay1, "transparent"]}
style={[ style={[StyleSheet.absoluteFillObject, animated]}
StyleSheet.absoluteFillObject,
{ transform: [{ translateX: -width.value }] },
animated,
]}
/> />
</View> </View>
))} ))}

View File

@ -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 { useRef, useState } from "react";
import { type GestureResponderEvent, Platform, View } from "react-native"; import {
import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils"; type GestureResponderEvent,
Platform,
View,
type ViewProps,
} from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native"; import { percent, px, useYoshiki } from "yoshiki/native";
import { focusReset } from "./utils"; import { focusReset } from "./utils";
@ -71,14 +55,14 @@ export const Slider = ({
return ( return (
<View <View
ref={ref} ref={ref}
// @ts-ignore Web only // @ts-expect-error Web only
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
// @ts-ignore Web only // @ts-expect-error Web only
onMouseLeave={() => { onMouseLeave={() => {
setHover(false); setHover(false);
onHover?.(null, layout); onHover?.(null, layout);
}} }}
// @ts-ignore Web only // @ts-expect-error Web only
onMouseMove={(e) => onMouseMove={(e) =>
onHover?.( onHover?.(
Math.max(0, Math.min((e.clientX - layout.x) / layout.width, 1) * max), Math.max(0, Math.min((e.clientX - layout.x) / layout.width, 1) * max),
@ -123,7 +107,7 @@ export const Slider = ({
{...css( {...css(
{ {
paddingVertical: ts(1), paddingVertical: ts(1),
// @ts-ignore Web only // @ts-expect-error Web only
cursor: "pointer", cursor: "pointer",
...focusReset, ...focusReset,
}, },

View File

@ -39,7 +39,7 @@ export const useBreakpointMap = <T extends Record<string, unknown>>(
value: T, value: T,
): { [key in keyof T]: T[key] extends Breakpoint<infer V> ? V : T } => { ): { [key in keyof T]: T[key] extends Breakpoint<infer V> ? V : T } => {
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
// @ts-ignore // @ts-expect-error
return Object.fromEntries( return Object.fromEntries(
Object.entries(value).map(([key, val]) => [ Object.entries(value).map(([key, val]) => [
key, key,

View File

@ -62,3 +62,16 @@ export const readValue = <T extends ZodType>(key: string, parser: T) => {
if (val === undefined) return val; if (val === undefined) return val;
return parser.parse(JSON.parse(val)) as z.infer<T>; 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
View 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(" - ");
};
};

View File

@ -35,6 +35,7 @@ import {
A, A,
Chip, Chip,
Container, Container,
ContrastArea,
capitalize, capitalize,
DottedSeparator, DottedSeparator,
GradientImageBackground, GradientImageBackground,
@ -714,30 +715,34 @@ export const Header = ({
}, },
}) as any)} }) as any)}
/> />
<TitleLine <ContrastArea>
kind={kind} <TitleLine
slug={slug} kind={kind}
name={data.name} slug={slug}
tagline={data.tagline} name={data.name}
date={getDisplayDate(data)} tagline={data.tagline}
rating={data.rating} date={getDisplayDate(data)}
runtime={data.kind === "movie" ? data.runtime : null} rating={data.rating}
poster={data.poster} runtime={data.kind === "movie" ? data.runtime : null}
studios={data.kind !== "collection" ? data.studios! : null} poster={data.poster}
playHref={data.kind !== "collection" ? data.playHref : null} studios={data.kind !== "collection" ? data.studios! : null}
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null} playHref={data.kind !== "collection" ? data.playHref : null}
watchStatus={ trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
data.kind !== "collection" ? data.watchStatus?.status! : null watchStatus={
} data.kind !== "collection"
{...css({ ? (data.watchStatus?.status ?? null)
marginTop: { : null
xs: max(vh(20), px(200)), }
sm: vh(45), {...css({
md: max(vh(30), px(150)), marginTop: {
lg: max(vh(35), px(200)), xs: max(vh(20), px(200)),
}, sm: vh(45),
})} md: max(vh(30), px(150)),
/> lg: max(vh(35), px(200)),
},
})}
/>
</ContrastArea>
<Description <Description
description={data?.description} description={data?.description}
genres={data?.genres} genres={data?.genres}

View 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,
});

View 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>
);
};

View 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>
);
};

View 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";

View 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>
);
};

View 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>
);
};

View 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)}`;
};

View 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}
/>
);
};

View 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>
);
};

View 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,
});

View 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]);
};

View File

@ -18,16 +18,27 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { type Chapter, type QueryIdentifier, imageFn, useFetch } from "@kyoo/models"; import {
import { P, Sprite, imageBorderRadius, ts } from "@kyoo/primitives"; type Chapter,
imageFn,
type QueryIdentifier,
useFetch,
} from "@kyoo/models";
import { imageBorderRadius, P, Sprite, ts } from "@kyoo/primitives";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native"; import {
import { ErrorView } from "../../../../../src/ui/errors"; percent,
import { durationAtom } from "../state"; px,
import { seekProgressAtom } from "./hover"; type Theme,
import { toTimerString } from "./left-buttons"; 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 = { type Thumb = {
from: number; from: number;
@ -42,8 +53,8 @@ type Thumb = {
const parseTs = (time: string) => { const parseTs = (time: string) => {
const times = time.split(":"); const times = time.split(":");
return ( return (
(Number.parseInt(times[0]) * 3600 + (Number.parseInt(times[0], 10) * 3600 +
Number.parseInt(times[1]) * 60 + Number.parseInt(times[1], 10) * 60 +
Number.parseFloat(times[2])) * Number.parseFloat(times[2])) *
1000 1000
); );
@ -69,7 +80,7 @@ export const useScrubber = (url: string) => {
for (let i = 0; i < ret.length; i++) { for (let i = 0; i < ret.length; i++) {
const times = lines[i * 2].split(" --> "); const times = lines[i * 2].split(" --> ");
const url = lines[i * 2 + 1].split("#xywh="); 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] = { ret[i] = {
from: parseTs(times[0]), from: parseTs(times[0]),
to: parseTs(times[1]), to: parseTs(times[1]),
@ -123,7 +134,9 @@ export const ScrubberTooltip = ({
const current = const current =
info.findLast((x) => x.from <= seconds * 1000 && seconds * 1000 < x.to) ?? info.findLast((x) => x.from <= seconds * 1000 && seconds * 1000 < x.to) ??
info.findLast(() => true); 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 ( return (
<View <View
@ -153,7 +166,13 @@ export const ScrubberTooltip = ({
}; };
let scrubberWidth = 0; 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 { css } = useYoshiki();
const { info, error, stats } = useScrubber(url); const { info, error, stats } = useScrubber(url);
const rerender = useForceRerender(); const rerender = useForceRerender();
@ -164,7 +183,9 @@ export const BottomScrubber = ({ url, chapters }: { url: string; chapters?: Chap
if (error) return <ErrorView error={error} />; if (error) return <ErrorView error={error} />;
const width = stats?.width ?? 1; 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 ( return (
<View {...css({ overflow: "hidden" })}> <View {...css({ overflow: "hidden" })}>
<View <View

View File

@ -18,12 +18,12 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * 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 { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useAtomCallback } from "jotai/utils"; import { useAtomCallback } from "jotai/utils";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { playAtom, progressAtom } from "./state"; import { playAtom, progressAtom } from "./old/statee";
export const WatchStatusObserver = ({ export const WatchStatusObserver = ({
type, type,

View File

@ -0,0 +1,3 @@
import type { VideoPlayer } from "react-native-video";
export const enhanceSubtitles = (player: VideoPlayer) => player;

View 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;
};

View File

@ -1,5 +1,5 @@
import { NavigationContext, useRoute } from "@react-navigation/native"; import { NavigationContext, useRoute } from "@react-navigation/native";
import { useContext } from "react"; import { useCallback, useContext } from "react";
import type { Movie, Show } from "~/models"; import type { Movie, Show } from "~/models";
export function setServerData(_key: string, _val: any) {} export function setServerData(_key: string, _val: any) {}
@ -12,9 +12,12 @@ export const useQueryState = <S>(key: string, initial: S) => {
const nav = useContext(NavigationContext); const nav = useContext(NavigationContext);
const state = ((route.params as any)?.[key] as S) ?? initial; const state = ((route.params as any)?.[key] as S) ?? initial;
const update = (val: S | ((old: S) => S)) => { const update = useCallback(
nav!.setParams({ [key]: val }); (val: S | ((old: S) => S)) => {
}; nav!.setParams({ [key]: val });
},
[nav, key],
);
return [state, update] as const; return [state, update] as const;
}; };

View File

@ -2,7 +2,9 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": [
"./src/*"
]
}, },
"strict": true, "strict": true,
"rootDir": ".", "rootDir": ".",
@ -14,13 +16,25 @@
"skipLibCheck": true, "skipLibCheck": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"types": ["node", "react"], "types": [
"lib": ["dom", "esnext"] "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": [ "exclude": [
"node_modules", "node_modules",
".expo", ".expo",
"scripts",
"**/test", "**/test",
"**/dist", "**/dist",
"**/types", "**/types",

View File

@ -3,7 +3,6 @@
"extends": ["config:recommended", ":disableRateLimiting"], "extends": ["config:recommended", ":disableRateLimiting"],
"schedule": ["on monday"], "schedule": ["on monday"],
"minimumReleaseAge": "5 days", "minimumReleaseAge": "5 days",
"ignorePaths": ["**/front/**"],
"packageRules": [ "packageRules": [
{ {
"matchDatasources": ["docker"], "matchDatasources": ["docker"],

View File

@ -1,7 +1,7 @@
** **
!/go.mod !/go.mod
!/go.sum !/go.sum
!/**.go !/**/*.go
!/migrations !/migrations
# genereated via swag # genereated via swag
!/docs !/docs

View File

@ -1,24 +1,4 @@
# FROM golang:1.23 as build FROM golang:1.25 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
RUN apt-get update \ RUN apt-get update \
&& apt-get install --no-install-recommends --no-install-suggests -y \ && apt-get install --no-install-recommends --no-install-suggests -y \
ffmpeg libavformat-dev libavutil-dev libswscale-dev \ ffmpeg libavformat-dev libavutil-dev libswscale-dev \
@ -32,10 +12,8 @@ RUN go mod download
COPY . . COPY . .
RUN GOOS=linux go build -o ./transcoder RUN GOOS=linux go build -o ./transcoder
# debian is required for nvidia hardware acceleration # https://packages.debian.org/trixie/ffmpeg for version tracking
# we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6) FROM debian:trixie
# https://packages.debian.org/bookworm/ffmpeg for version tracking
FROM debian:trixie-slim
# read target arch from buildx or default to amd64 if using legacy builder. # read target arch from buildx or default to amd64 if using legacy builder.
ARG TARGETARCH ARG TARGETARCH

View File

@ -1,27 +1,4 @@
# we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6) FROM golang:1.25
# 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
# read target arch from buildx or default to amd64 if using legacy builder. # read target arch from buildx or default to amd64 if using legacy builder.
ARG TARGETARCH ARG TARGETARCH
ENV TARGETARCH=${TARGETARCH:-amd64} ENV TARGETARCH=${TARGETARCH:-amd64}

View File

@ -3,6 +3,7 @@ module github.com/zoriya/kyoo/transcoder
go 1.24.2 go 1.24.2
require ( 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 v1.39.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
@ -19,6 +20,8 @@ require (
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect 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/ghodss/yaml v1.0.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect

View File

@ -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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 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/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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 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 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= 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/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 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 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 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 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 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 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/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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= 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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 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 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE=
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= 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= 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-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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 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 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= 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= 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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 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 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw=
gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU= 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 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= 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 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= 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/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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= 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 h1:sFV08OT1eZ1yroLCZVClIVd9YySgCh9eGjBWO0oRayI=
gopkg.in/vansante/go-ffprobe.v2 v2.2.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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