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)
EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}'
FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}'
GUEST_CLAIMS='{"permissions": ["core.read"]}'
GUEST_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}'
# GUEST_CLAIMS='{"permissions": ["core.read"]}'
PROTECTED_CLAIMS="permissions,verified"

View File

@ -15,7 +15,7 @@
"sharp": "^0.34.2",
},
"devDependencies": {
"@biomejs/biome": "2.2.6",
"@biomejs/biome": "2.1.1",
"@types/pg": "^8.15.2",
"bun-types": "^1.2.14",
"node-addon-api": "^8.3.1",
@ -26,23 +26,23 @@
"drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch",
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.2.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.6", "@biomejs/cli-darwin-x64": "2.2.6", "@biomejs/cli-linux-arm64": "2.2.6", "@biomejs/cli-linux-arm64-musl": "2.2.6", "@biomejs/cli-linux-x64": "2.2.6", "@biomejs/cli-linux-x64-musl": "2.2.6", "@biomejs/cli-win32-arm64": "2.2.6", "@biomejs/cli-win32-x64": "2.2.6" }, "bin": { "biome": "bin/biome" } }, "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw=="],
"@biomejs/biome": ["@biomejs/biome@2.1.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.1", "@biomejs/cli-darwin-x64": "2.1.1", "@biomejs/cli-linux-arm64": "2.1.1", "@biomejs/cli-linux-arm64-musl": "2.1.1", "@biomejs/cli-linux-x64": "2.1.1", "@biomejs/cli-linux-x64-musl": "2.1.1", "@biomejs/cli-win32-arm64": "2.1.1", "@biomejs/cli-win32-x64": "2.1.1" }, "bin": { "biome": "bin/biome" } }, "sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],

View File

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

View File

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

View File

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

View File

@ -15,7 +15,16 @@ import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia";
import { auth } from "~/auth";
import { db, type Transaction } from "~/db";
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
import {
entries,
entryVideoJoin,
history,
profiles,
shows,
showTranslations,
videos,
} from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import {
coalesce,
conflictUpdateAllExcept,
@ -30,10 +39,14 @@ import {
import { Entry } from "~/models/entry";
import { KError } from "~/models/error";
import { bubbleVideo } from "~/models/examples";
import { Progress } from "~/models/history";
import { Movie, type MovieStatus } from "~/models/movie";
import { Serie } from "~/models/serie";
import {
AcceptLanguage,
buildRelations,
createPage,
type Image,
isUuid,
keysetPaginate,
Page,
@ -44,6 +57,7 @@ import {
} from "~/models/utils";
import { desc as description } from "~/models/utils/descriptions";
import { Guess, Guesses, SeedVideo, Video } from "~/models/video";
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import { comment } from "~/utils";
import {
entryProgressQ,
@ -206,14 +220,44 @@ const videoRelations = {
slugs: () => {
return db
.select({
slugs: coalesce(jsonbAgg(entryVideoJoin.slug), sql`'[]'::jsonb`).as(
"slugs",
),
slugs: coalesce<string[]>(
jsonbAgg(entryVideoJoin.slug),
sql`'[]'::jsonb`,
).as("slugs"),
})
.from(entryVideoJoin)
.where(eq(entryVideoJoin.videoPk, videos.pk))
.as("slugs");
},
progress: () => {
const query = db
.select({
json: jsonbBuildObject<Progress>({
percent: history.percent,
time: history.time,
playedDate: sql`to_char(${history.playedDate}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
videoId: videos.id,
}),
})
.from(history)
.innerJoin(profiles, eq(history.profilePk, profiles.pk))
.where(
and(
eq(profiles.id, sql.placeholder("userId")),
eq(history.videoPk, videos.pk),
),
)
.orderBy(desc(history.playedDate))
.limit(1);
return sql`
(
select coalesce(
${query},
'{"percent": 0, "time": 0, "playedDate": null, "videoId": null}'::jsonb
)
as "progress"
)` as any;
},
entries: ({ languages }: { languages: string[] }) => {
const transQ = getEntryTransQ(languages);
@ -229,6 +273,7 @@ const videoRelations = {
progress: mapProgress({ aliased: false }),
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
availableSince: sql`to_char(${entries.availableSince}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
}),
),
sql`'[]'::jsonb`,
@ -242,6 +287,74 @@ const videoRelations = {
.where(eq(entryVideoJoin.videoPk, videos.pk))
.as("entries");
},
show: ({
languages,
preferOriginal,
}: {
languages: string[];
preferOriginal: boolean;
}) => {
const transQ = db
.selectDistinctOn([showTranslations.pk])
.from(showTranslations)
.orderBy(
showTranslations.pk,
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
)
.as("t");
const watchStatusQ = db
.select({
watchStatus: jsonbBuildObject<MovieWatchStatus & SerieWatchStatus>({
...getColumns(watchlist),
percent: watchlist.seenCount,
}).as("watchStatus"),
})
.from(watchlist)
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
.where(
and(
eq(profiles.id, sql.placeholder("userId")),
eq(watchlist.showPk, shows.pk),
),
);
return db
.select({
json: jsonbBuildObject<Serie | Movie>({
...getColumns(shows),
...getColumns(transQ),
// movie columns (status is only a typescript hint)
status: sql<MovieStatus>`${shows.status}`,
airDate: shows.startAir,
kind: sql<any>`${shows.kind}`,
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
createdAt: sql`to_char(${shows.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
updatedAt: sql`to_char(${shows.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
...(preferOriginal && {
poster: sql<Image>`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`,
thumbnail: sql<Image>`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`,
banner: sql<Image>`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`,
logo: sql<Image>`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`,
}),
watchStatus: sql`${watchStatusQ}`,
}).as("json"),
})
.from(shows)
.innerJoin(transQ, eq(shows.pk, transQ.pk))
.where(
eq(
shows.pk,
db
.select({ pk: entries.showPk })
.from(entries)
.innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk))
.where(eq(videos.pk, entryVideoJoin.videoPk)),
),
)
.as("show");
},
previous: ({ languages }: { languages: string[] }) => {
return getNextVideoEntry({ languages, prev: true });
},
@ -263,7 +376,7 @@ function getNextVideoEntry({
const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`);
return db
.select({
json: jsonbBuildObject<Entry>({
json: jsonbBuildObject<{ video: string; entry: Entry }>({
video: entryVideoJoin.slug,
entry: {
...getColumns(entries),
@ -274,7 +387,7 @@ function getNextVideoEntry({
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
},
}),
}).as("json"),
})
.from(entries)
.innerJoin(transQ, eq(entries.pk, transQ.pk))
@ -337,9 +450,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
":id",
async ({
params: { id },
query: { with: relations },
query: { with: relations, preferOriginal },
headers: { "accept-language": langs },
jwt: { sub },
jwt: { sub, settings },
status,
}) => {
const languages = processLanguages(langs);
@ -351,10 +464,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.select({
...getColumns(videos),
...buildRelations(
["slugs", "entries", ...relations],
["slugs", "progress", "entries", ...relations],
videoRelations,
{
languages,
preferOriginal: preferOriginal ?? settings.preferOriginal,
},
),
})
@ -382,10 +496,15 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
}),
}),
query: t.Object({
with: t.Array(t.UnionEnum(["previous", "next"]), {
with: t.Array(t.UnionEnum(["previous", "next", "show"]), {
default: [],
description: "Include related entries in the response.",
}),
preferOriginal: t.Optional(
t.Boolean({
description: description.preferOriginal,
}),
),
}),
headers: t.Object(
{
@ -400,6 +519,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
slugs: t.Array(
t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }),
),
progress: Progress,
entries: t.Array(Entry),
previous: t.Optional(
t.Nullable(
@ -423,6 +543,12 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
}),
),
),
show: t.Optional(
t.Union([
t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]),
t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]),
]),
),
}),
]),
404: {
@ -433,6 +559,133 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
},
},
)
.get(
":id/info",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
return redirect(`/video/${path}/info`);
},
{
detail: { description: "Get a video's metadata informations" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to retrieve.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/direct",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const filename = path.substring(path.lastIndexOf("/") + 1);
return redirect(`/video/${path}/direct/${filename}`);
},
{
detail: {
description: "Get redirected to the direct stream of the video",
},
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/master.m3u8",
async ({ params: { id }, request, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const query = request.url.substring(request.url.indexOf("?"));
return redirect(`/video/${path}/master.m3u8${query}`);
},
{
detail: { description: "Get redirected to the master.m3u8 of the video" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
"",
async () => {

View File

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

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

View File

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

View File

@ -58,7 +58,7 @@ export const Sort = (
const random = sort.find((x) => x.startsWith("random"));
if (random) {
const seed = random.includes(":")
? Number.parseInt(random.substring("random:".length))
? Number.parseInt(random.substring("random:".length), 10)
: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
return { tablePk, random: { seed }, sort: [] };
}

View File

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

View File

@ -1,7 +1,7 @@
**
!/go.mod
!/go.sum
!/**.go
!/**/*.go
# generated via sqlc
!/sql
!/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": {
"enabled": true,
"formatWithErrors": true,

View File

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

View File

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

View File

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

View File

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

View File

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

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 { getCurrentAccount } from "@kyoo/models/src/account-internal";
import type { ReactNode } from "react";
import { Player } from "../player";
import { Player } from "../../../../src/ui/player../src/ui/player";
export const useDownloader = () => {
return async (type: "episode" | "movie", slug: string) => {

View File

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

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 { useTranslation } from "react-i18next";
export const useLanguageName = () => {
return (lang: string) => intl[lang]?.nativeName;
};
export const useDisplayName = () => {
const getLanguageName = useLanguageName();
const { t } = useTranslation();
return (sub: Track) => {
const lng = sub.language ? getLanguageName(sub.language) : null;
if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`;
if (lng) return lng;
if (sub.title) return sub.title;
if (sub.index !== null) return `${t("mediainfo.unknown")} (${sub.index})`;
return t("mediainfo.unknown");
};
};
export const useSubtitleName = () => {
const getDisplayName = useDisplayName();
const { t } = useTranslation();
return (sub: Subtitle) => {
const name = getDisplayName(sub);
const attributes = [name];
if (sub.isDefault) attributes.push(t("mediainfo.default"));
if (sub.isForced) attributes.push(t("mediainfo.forced"));
if (sub.isHearingImpaired) attributes.push(t("mediainfo.hearing-impaired"));
if (sub.isExternal) attributes.push(t("mediainfo.external"));
return attributes.join(" - ");
};
};
const seenNativeNames = new Set();

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

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

View File

@ -198,6 +198,7 @@
"volume": "Volume",
"quality": "Quality",
"audios": "Audio",
"videos": "Video",
"subtitles": "Subtitles",
"subtitle-none": "None",
"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 Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg";
// import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg";
import Home from "@material-symbols/svg-400/rounded/home-fill.svg";
import { Tabs } from "expo-router";
import { useTranslation } from "react-i18next";
@ -32,15 +32,15 @@ export default function TabsLayout() {
),
}}
/>
<Tabs.Screen
name="downloads"
options={{
tabBarLabel: t("navbar.download"),
tabBarIcon: ({ color, size }) => (
<Icon icon={Downloading} color={color} size={size} />
),
}}
/>
{/* <Tabs.Screen */}
{/* name="downloads" */}
{/* options={{ */}
{/* tabBarLabel: t("navbar.download"), */}
{/* tabBarIcon: ({ color, size }) => ( */}
{/* <Icon icon={Downloading} color={color} size={size} /> */}
{/* ), */}
{/* }} */}
{/* /> */}
</Tabs>
);
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import { Genre } from "./utils/genre";
import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
import { EmbeddedVideo } from "./video";
export const Movie = z
.object({
@ -39,7 +38,18 @@ export const Movie = z
updatedAt: zdate(),
studios: z.array(Studio).optional(),
videos: z.array(EmbeddedVideo).optional(),
videos: z
.array(
z.object({
id: z.string(),
slug: z.string(),
path: z.string(),
rendering: z.string(),
part: z.number().int().gt(0).nullable(),
version: z.number().gt(0),
}),
)
.optional(),
watchStatus: z
.object({
status: z.enum([

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 { Entry } from "./entry";
import { Extra } from "./extra";
import { Show } from "./show";
import { zdate } from "./utils/utils";
export const EmbeddedVideo = z.object({
export const Video = z.object({
id: z.string(),
slug: z.string(),
path: z.string(),
rendering: z.string(),
part: z.number().int().gt(0).nullable(),
version: z.number().gt(0),
part: z.int().min(0).nullable(),
version: z.int().min(0).default(1),
guess: z.object({
title: z.string(),
kind: z.enum(["episode", "movie", "extra"]).nullable().optional(),
extraKind: Extra.shape.kind.optional().nullable(),
years: z.array(z.int()).default([]),
episodes: z
.array(
z.object({
season: z.int().nullable(),
episode: z.int(),
}),
)
.default([]),
externalId: z.record(z.string(), z.string()).default({}),
// Name of the tool that made the guess
from: z.string(),
// Adding that results in an infinite recursion
// get history() {
// return z.array(Video.shape.guess.omit({ history: true })).default([]);
// },
}),
createdAt: zdate(),
updatedAt: zdate(),
});
export type EmbeddedVideo = z.infer<typeof EmbeddedVideo>;
export const FullVideo = Video.extend({
slugs: z.array(z.string()),
progress: z.object({
percent: z.int().min(0).max(100),
time: z.int().min(0),
playedDate: zdate().nullable(),
videoId: z.string().nullable(),
}),
entries: z.array(Entry),
previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
next: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
show: Show.optional(),
});
export type FullVideo = z.infer<typeof FullVideo>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +1,5 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ActivityIndicator, Platform, View } from "react-native";
import { Circle, Svg } from "react-native-svg";
import { px, type Stylable, useYoshiki } from "yoshiki/native";
import { ActivityIndicator } from "react-native";
import { type Stylable, useYoshiki } from "yoshiki/native";
export const CircularProgress = ({
size = 48,
@ -28,64 +7,9 @@ export const CircularProgress = ({
color,
...props
}: { size?: number; tickness?: number; color?: string } & Stylable) => {
const { css, theme } = useYoshiki();
if (Platform.OS !== "web")
return (
<ActivityIndicator size={size} color={color ?? theme.accent} {...props} />
);
const { theme } = useYoshiki();
return (
<View {...css({ width: size, height: size, overflow: "hidden" }, props)}>
<style jsx global>{`
@keyframes circularProgress-svg {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes circularProgress-circle {
0% {
stroke-dasharray: 1px, 200px;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -15px;
}
100% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -125px;
}
}
`}</style>
<Svg
viewBox={`${size / 2} ${size / 2} ${size} ${size}`}
{...css(
// @ts-ignore Web only
Platform.OS === "web" && {
animation: "circularProgress-svg 1.4s ease-in-out infinite",
},
)}
>
<Circle
cx={size}
cy={size}
r={(size - tickness) / 2}
strokeWidth={tickness}
fill="none"
stroke={color ?? theme.accent}
strokeDasharray={[px(80), px(200)]}
{...css(
Platform.OS === "web" && {
// @ts-ignore Web only
animation: "circularProgress-circle 1.4s ease-in-out infinite",
},
)}
/>
</Svg>
</View>
<ActivityIndicator size={size} color={color ?? theme.accent} {...props} />
);
};

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

View File

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

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

View File

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

View File

@ -62,3 +62,16 @@ export const readValue = <T extends ZodType>(key: string, parser: T) => {
if (val === undefined) return val;
return parser.parse(JSON.parse(val)) as z.infer<T>;
};
export const useLocalSetting = <T extends string>(setting: string, def: T) => {
if (Platform.OS === "web" && typeof window === "undefined")
return [def as T, null!] as const;
// biome-ignore lint/correctness/useHookAtTopLevel: ssr
const [val, setter] = useMMKVString(`settings.${setting}`, storage);
return [(val ?? def) as T, setter] as const;
};
export const getLocalSetting = (setting: string, def: string) => {
if (Platform.OS === "web" && typeof window === "undefined") return def;
return storage.getString(`settings.${setting}`) ?? setting;
};

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

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

View File

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

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

View File

@ -2,7 +2,9 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
"~/*": [
"./src/*"
]
},
"strict": true,
"rootDir": ".",
@ -14,13 +16,25 @@
"skipLibCheck": true,
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
"types": ["node", "react"],
"lib": ["dom", "esnext"]
"types": [
"node",
"react"
],
"lib": [
"dom",
"esnext"
]
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"node_modules",
".expo",
"scripts",
"**/test",
"**/dist",
"**/types",

View File

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

View File

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

View File

@ -1,24 +1,4 @@
# FROM golang:1.23 as build
FROM debian:trixie-slim AS build
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
ENV GOTOOLCHAIN=local
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
ca-certificates openssl \
golang\
g++ \
gcc \
libc6-dev \
make \
pkg-config
# https://github.com/golang/go/issues/54400
ENV SSL_CERT_DIR=/etc/ssl/certs
RUN update-ca-certificates
FROM golang:1.25 as build
RUN apt-get update \
&& apt-get install --no-install-recommends --no-install-suggests -y \
ffmpeg libavformat-dev libavutil-dev libswscale-dev \
@ -32,10 +12,8 @@ RUN go mod download
COPY . .
RUN GOOS=linux go build -o ./transcoder
# debian is required for nvidia hardware acceleration
# we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6)
# https://packages.debian.org/bookworm/ffmpeg for version tracking
FROM debian:trixie-slim
# https://packages.debian.org/trixie/ffmpeg for version tracking
FROM debian:trixie
# read target arch from buildx or default to amd64 if using legacy builder.
ARG TARGETARCH

View File

@ -1,27 +1,4 @@
# we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6)
# https://packages.debian.org/bookworm/ffmpeg for version tracking
# FROM golang:1.21
# trixie's golang is also 1.21
FROM debian:trixie-slim
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
ENV GOTOOLCHAIN=local
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
ca-certificates openssl \
golang\
g++ \
gcc \
libc6-dev \
make \
pkg-config
# https://github.com/golang/go/issues/54400
ENV SSL_CERT_DIR=/etc/ssl/certs
RUN update-ca-certificates
FROM golang:1.25
# read target arch from buildx or default to amd64 if using legacy builder.
ARG TARGETARCH
ENV TARGETARCH=${TARGETARCH:-amd64}

View File

@ -3,6 +3,7 @@ module github.com/zoriya/kyoo/transcoder
go 1.24.2
require (
github.com/asticode/go-astisub v0.35.0
github.com/aws/aws-sdk-go-v2 v1.39.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5
github.com/disintegration/imaging v1.6.2
@ -19,6 +20,8 @@ require (
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect

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/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astisub v0.35.0 h1:wnELGJMeJbavW//X7nLTy97L3iblub7tO1VSeHnZBdA=
github.com/asticode/go-astisub v0.35.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM=
github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY=
@ -40,35 +42,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkE
github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o=
github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
@ -79,25 +58,16 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE=
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
@ -124,29 +94,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
@ -155,25 +104,12 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
gitlab.com/opennota/screengen v1.0.2 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw=
gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
@ -182,23 +118,17 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/vansante/go-ffprobe.v2 v2.2.1 h1:sFV08OT1eZ1yroLCZVClIVd9YySgCh9eGjBWO0oRayI=
gopkg.in/vansante/go-ffprobe.v2 v2.2.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

Some files were not shown because too many files have changed in this diff Show More