mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-10-30 18:22:41 -04:00 
			
		
		
		
	Player rewrite (#1020)
This commit is contained in:
		
						commit
						a3f29c73ec
					
				| @ -67,5 +67,6 @@ PGPORT=5432 | |||||||
| # v5 stuff, does absolutely nothing on master (aka: you can delete this) | # v5 stuff, does absolutely nothing on master (aka: you can delete this) | ||||||
| EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}' | EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}' | ||||||
| FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}' | FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}' | ||||||
| GUEST_CLAIMS='{"permissions": ["core.read"]}' | GUEST_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}' | ||||||
|  | # GUEST_CLAIMS='{"permissions": ["core.read"]}' | ||||||
| PROTECTED_CLAIMS="permissions,verified" | PROTECTED_CLAIMS="permissions,verified" | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								api/bun.lock
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								api/bun.lock
									
									
									
									
									
								
							| @ -15,7 +15,7 @@ | |||||||
|         "sharp": "^0.34.2", |         "sharp": "^0.34.2", | ||||||
|       }, |       }, | ||||||
|       "devDependencies": { |       "devDependencies": { | ||||||
|         "@biomejs/biome": "2.2.6", |         "@biomejs/biome": "2.1.1", | ||||||
|         "@types/pg": "^8.15.2", |         "@types/pg": "^8.15.2", | ||||||
|         "bun-types": "^1.2.14", |         "bun-types": "^1.2.14", | ||||||
|         "node-addon-api": "^8.3.1", |         "node-addon-api": "^8.3.1", | ||||||
| @ -26,23 +26,23 @@ | |||||||
|     "drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch", |     "drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch", | ||||||
|   }, |   }, | ||||||
|   "packages": { |   "packages": { | ||||||
|     "@biomejs/biome": ["@biomejs/biome@2.2.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.6", "@biomejs/cli-darwin-x64": "2.2.6", "@biomejs/cli-linux-arm64": "2.2.6", "@biomejs/cli-linux-arm64-musl": "2.2.6", "@biomejs/cli-linux-x64": "2.2.6", "@biomejs/cli-linux-x64-musl": "2.2.6", "@biomejs/cli-win32-arm64": "2.2.6", "@biomejs/cli-win32-x64": "2.2.6" }, "bin": { "biome": "bin/biome" } }, "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw=="], |     "@biomejs/biome": ["@biomejs/biome@2.1.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.1", "@biomejs/cli-darwin-x64": "2.1.1", "@biomejs/cli-linux-arm64": "2.1.1", "@biomejs/cli-linux-arm64-musl": "2.1.1", "@biomejs/cli-linux-x64": "2.1.1", "@biomejs/cli-linux-x64-musl": "2.1.1", "@biomejs/cli-win32-arm64": "2.1.1", "@biomejs/cli-win32-x64": "2.1.1" }, "bin": { "biome": "bin/biome" } }, "sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA=="], |     "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ=="], |     "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g=="], |     "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ=="], |     "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA=="], |     "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog=="], |     "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw=="], |     "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q=="], | ||||||
| 
 | 
 | ||||||
|     "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="], |     "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg=="], | ||||||
| 
 | 
 | ||||||
|     "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], |     "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -20,7 +20,7 @@ | |||||||
| 		"sharp": "^0.34.2" | 		"sharp": "^0.34.2" | ||||||
| 	}, | 	}, | ||||||
| 	"devDependencies": { | 	"devDependencies": { | ||||||
| 		"@biomejs/biome": "2.2.6", | 		"@biomejs/biome": "2.1.1", | ||||||
| 		"@types/pg": "^8.15.2", | 		"@types/pg": "^8.15.2", | ||||||
| 		"bun-types": "^1.2.14", | 		"bun-types": "^1.2.14", | ||||||
| 		"node-addon-api": "^8.3.1" | 		"node-addon-api": "^8.3.1" | ||||||
|  | |||||||
| @ -54,7 +54,7 @@ export const entryProgressQ = db | |||||||
| 	}) | 	}) | ||||||
| 	.from(history) | 	.from(history) | ||||||
| 	.leftJoin(videos, eq(history.videoPk, videos.pk)) | 	.leftJoin(videos, eq(history.videoPk, videos.pk)) | ||||||
| 	.leftJoin(profiles, eq(history.profilePk, profiles.pk)) | 	.innerJoin(profiles, eq(history.profilePk, profiles.pk)) | ||||||
| 	.where(eq(profiles.id, sql.placeholder("userId"))) | 	.where(eq(profiles.id, sql.placeholder("userId"))) | ||||||
| 	.orderBy(history.entryPk, desc(history.playedDate)) | 	.orderBy(history.entryPk, desc(history.playedDate)) | ||||||
| 	.as("progress"); | 	.as("progress"); | ||||||
|  | |||||||
| @ -227,7 +227,6 @@ export const staffH = new Elysia({ tags: ["staff"] }) | |||||||
| 				.from(watchlist) | 				.from(watchlist) | ||||||
| 				.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) | 				.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) | ||||||
| 				.where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk))) | 				.where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk))) | ||||||
| 				.limit(1) |  | ||||||
| 				.as("watchstatus"); | 				.as("watchstatus"); | ||||||
| 
 | 
 | ||||||
| 			const items = await db | 			const items = await db | ||||||
|  | |||||||
| @ -15,7 +15,16 @@ import { alias } from "drizzle-orm/pg-core"; | |||||||
| import { Elysia, t } from "elysia"; | import { Elysia, t } from "elysia"; | ||||||
| import { auth } from "~/auth"; | import { auth } from "~/auth"; | ||||||
| import { db, type Transaction } from "~/db"; | import { db, type Transaction } from "~/db"; | ||||||
| import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; | import { | ||||||
|  | 	entries, | ||||||
|  | 	entryVideoJoin, | ||||||
|  | 	history, | ||||||
|  | 	profiles, | ||||||
|  | 	shows, | ||||||
|  | 	showTranslations, | ||||||
|  | 	videos, | ||||||
|  | } from "~/db/schema"; | ||||||
|  | import { watchlist } from "~/db/schema/watchlist"; | ||||||
| import { | import { | ||||||
| 	coalesce, | 	coalesce, | ||||||
| 	conflictUpdateAllExcept, | 	conflictUpdateAllExcept, | ||||||
| @ -30,10 +39,14 @@ import { | |||||||
| import { Entry } from "~/models/entry"; | import { Entry } from "~/models/entry"; | ||||||
| import { KError } from "~/models/error"; | import { KError } from "~/models/error"; | ||||||
| import { bubbleVideo } from "~/models/examples"; | import { bubbleVideo } from "~/models/examples"; | ||||||
|  | import { Progress } from "~/models/history"; | ||||||
|  | import { Movie, type MovieStatus } from "~/models/movie"; | ||||||
|  | import { Serie } from "~/models/serie"; | ||||||
| import { | import { | ||||||
| 	AcceptLanguage, | 	AcceptLanguage, | ||||||
| 	buildRelations, | 	buildRelations, | ||||||
| 	createPage, | 	createPage, | ||||||
|  | 	type Image, | ||||||
| 	isUuid, | 	isUuid, | ||||||
| 	keysetPaginate, | 	keysetPaginate, | ||||||
| 	Page, | 	Page, | ||||||
| @ -44,6 +57,7 @@ import { | |||||||
| } from "~/models/utils"; | } from "~/models/utils"; | ||||||
| import { desc as description } from "~/models/utils/descriptions"; | import { desc as description } from "~/models/utils/descriptions"; | ||||||
| import { Guess, Guesses, SeedVideo, Video } from "~/models/video"; | import { Guess, Guesses, SeedVideo, Video } from "~/models/video"; | ||||||
|  | import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist"; | ||||||
| import { comment } from "~/utils"; | import { comment } from "~/utils"; | ||||||
| import { | import { | ||||||
| 	entryProgressQ, | 	entryProgressQ, | ||||||
| @ -206,14 +220,44 @@ const videoRelations = { | |||||||
| 	slugs: () => { | 	slugs: () => { | ||||||
| 		return db | 		return db | ||||||
| 			.select({ | 			.select({ | ||||||
| 				slugs: coalesce(jsonbAgg(entryVideoJoin.slug), sql`'[]'::jsonb`).as( | 				slugs: coalesce<string[]>( | ||||||
| 					"slugs", | 					jsonbAgg(entryVideoJoin.slug), | ||||||
| 				), | 					sql`'[]'::jsonb`, | ||||||
|  | 				).as("slugs"), | ||||||
| 			}) | 			}) | ||||||
| 			.from(entryVideoJoin) | 			.from(entryVideoJoin) | ||||||
| 			.where(eq(entryVideoJoin.videoPk, videos.pk)) | 			.where(eq(entryVideoJoin.videoPk, videos.pk)) | ||||||
| 			.as("slugs"); | 			.as("slugs"); | ||||||
| 	}, | 	}, | ||||||
|  | 	progress: () => { | ||||||
|  | 		const query = db | ||||||
|  | 			.select({ | ||||||
|  | 				json: jsonbBuildObject<Progress>({ | ||||||
|  | 					percent: history.percent, | ||||||
|  | 					time: history.time, | ||||||
|  | 					playedDate: sql`to_char(${history.playedDate}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
|  | 					videoId: videos.id, | ||||||
|  | 				}), | ||||||
|  | 			}) | ||||||
|  | 			.from(history) | ||||||
|  | 			.innerJoin(profiles, eq(history.profilePk, profiles.pk)) | ||||||
|  | 			.where( | ||||||
|  | 				and( | ||||||
|  | 					eq(profiles.id, sql.placeholder("userId")), | ||||||
|  | 					eq(history.videoPk, videos.pk), | ||||||
|  | 				), | ||||||
|  | 			) | ||||||
|  | 			.orderBy(desc(history.playedDate)) | ||||||
|  | 			.limit(1); | ||||||
|  | 		return sql` | ||||||
|  | 			( | ||||||
|  | 				select coalesce( | ||||||
|  | 					${query}, | ||||||
|  | 					'{"percent": 0, "time": 0, "playedDate": null, "videoId": null}'::jsonb | ||||||
|  | 				) | ||||||
|  | 				as "progress" | ||||||
|  | 			)` as any;
 | ||||||
|  | 	}, | ||||||
| 	entries: ({ languages }: { languages: string[] }) => { | 	entries: ({ languages }: { languages: string[] }) => { | ||||||
| 		const transQ = getEntryTransQ(languages); | 		const transQ = getEntryTransQ(languages); | ||||||
| 
 | 
 | ||||||
| @ -229,6 +273,7 @@ const videoRelations = { | |||||||
| 							progress: mapProgress({ aliased: false }), | 							progress: mapProgress({ aliased: false }), | ||||||
| 							createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | 							createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
| 							updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | 							updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
|  | 							availableSince: sql`to_char(${entries.availableSince}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
| 						}), | 						}), | ||||||
| 					), | 					), | ||||||
| 					sql`'[]'::jsonb`, | 					sql`'[]'::jsonb`, | ||||||
| @ -242,6 +287,74 @@ const videoRelations = { | |||||||
| 			.where(eq(entryVideoJoin.videoPk, videos.pk)) | 			.where(eq(entryVideoJoin.videoPk, videos.pk)) | ||||||
| 			.as("entries"); | 			.as("entries"); | ||||||
| 	}, | 	}, | ||||||
|  | 	show: ({ | ||||||
|  | 		languages, | ||||||
|  | 		preferOriginal, | ||||||
|  | 	}: { | ||||||
|  | 		languages: string[]; | ||||||
|  | 		preferOriginal: boolean; | ||||||
|  | 	}) => { | ||||||
|  | 		const transQ = db | ||||||
|  | 			.selectDistinctOn([showTranslations.pk]) | ||||||
|  | 			.from(showTranslations) | ||||||
|  | 			.orderBy( | ||||||
|  | 				showTranslations.pk, | ||||||
|  | 				sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, | ||||||
|  | 			) | ||||||
|  | 			.as("t"); | ||||||
|  | 
 | ||||||
|  | 		const watchStatusQ = db | ||||||
|  | 			.select({ | ||||||
|  | 				watchStatus: jsonbBuildObject<MovieWatchStatus & SerieWatchStatus>({ | ||||||
|  | 					...getColumns(watchlist), | ||||||
|  | 					percent: watchlist.seenCount, | ||||||
|  | 				}).as("watchStatus"), | ||||||
|  | 			}) | ||||||
|  | 			.from(watchlist) | ||||||
|  | 			.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) | ||||||
|  | 			.where( | ||||||
|  | 				and( | ||||||
|  | 					eq(profiles.id, sql.placeholder("userId")), | ||||||
|  | 					eq(watchlist.showPk, shows.pk), | ||||||
|  | 				), | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 		return db | ||||||
|  | 			.select({ | ||||||
|  | 				json: jsonbBuildObject<Serie | Movie>({ | ||||||
|  | 					...getColumns(shows), | ||||||
|  | 					...getColumns(transQ), | ||||||
|  | 					// movie columns (status is only a typescript hint)
 | ||||||
|  | 					status: sql<MovieStatus>`${shows.status}`, | ||||||
|  | 					airDate: shows.startAir, | ||||||
|  | 					kind: sql<any>`${shows.kind}`, | ||||||
|  | 					isAvailable: sql<boolean>`${shows.availableCount} != 0`, | ||||||
|  | 					createdAt: sql`to_char(${shows.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
|  | 					updatedAt: sql`to_char(${shows.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
|  | 
 | ||||||
|  | 					...(preferOriginal && { | ||||||
|  | 						poster: sql<Image>`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`, | ||||||
|  | 						thumbnail: sql<Image>`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`, | ||||||
|  | 						banner: sql<Image>`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`, | ||||||
|  | 						logo: sql<Image>`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, | ||||||
|  | 					}), | ||||||
|  | 					watchStatus: sql`${watchStatusQ}`, | ||||||
|  | 				}).as("json"), | ||||||
|  | 			}) | ||||||
|  | 			.from(shows) | ||||||
|  | 			.innerJoin(transQ, eq(shows.pk, transQ.pk)) | ||||||
|  | 			.where( | ||||||
|  | 				eq( | ||||||
|  | 					shows.pk, | ||||||
|  | 					db | ||||||
|  | 						.select({ pk: entries.showPk }) | ||||||
|  | 						.from(entries) | ||||||
|  | 						.innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk)) | ||||||
|  | 						.where(eq(videos.pk, entryVideoJoin.videoPk)), | ||||||
|  | 				), | ||||||
|  | 			) | ||||||
|  | 			.as("show"); | ||||||
|  | 	}, | ||||||
| 	previous: ({ languages }: { languages: string[] }) => { | 	previous: ({ languages }: { languages: string[] }) => { | ||||||
| 		return getNextVideoEntry({ languages, prev: true }); | 		return getNextVideoEntry({ languages, prev: true }); | ||||||
| 	}, | 	}, | ||||||
| @ -263,7 +376,7 @@ function getNextVideoEntry({ | |||||||
| 	const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`); | 	const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`); | ||||||
| 	return db | 	return db | ||||||
| 		.select({ | 		.select({ | ||||||
| 			json: jsonbBuildObject<Entry>({ | 			json: jsonbBuildObject<{ video: string; entry: Entry }>({ | ||||||
| 				video: entryVideoJoin.slug, | 				video: entryVideoJoin.slug, | ||||||
| 				entry: { | 				entry: { | ||||||
| 					...getColumns(entries), | 					...getColumns(entries), | ||||||
| @ -274,7 +387,7 @@ function getNextVideoEntry({ | |||||||
| 					createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | 					createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
| 					updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | 					updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, | ||||||
| 				}, | 				}, | ||||||
| 			}), | 			}).as("json"), | ||||||
| 		}) | 		}) | ||||||
| 		.from(entries) | 		.from(entries) | ||||||
| 		.innerJoin(transQ, eq(entries.pk, transQ.pk)) | 		.innerJoin(transQ, eq(entries.pk, transQ.pk)) | ||||||
| @ -337,9 +450,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) | |||||||
| 		":id", | 		":id", | ||||||
| 		async ({ | 		async ({ | ||||||
| 			params: { id }, | 			params: { id }, | ||||||
| 			query: { with: relations }, | 			query: { with: relations, preferOriginal }, | ||||||
| 			headers: { "accept-language": langs }, | 			headers: { "accept-language": langs }, | ||||||
| 			jwt: { sub }, | 			jwt: { sub, settings }, | ||||||
| 			status, | 			status, | ||||||
| 		}) => { | 		}) => { | ||||||
| 			const languages = processLanguages(langs); | 			const languages = processLanguages(langs); | ||||||
| @ -351,10 +464,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) | |||||||
| 				.select({ | 				.select({ | ||||||
| 					...getColumns(videos), | 					...getColumns(videos), | ||||||
| 					...buildRelations( | 					...buildRelations( | ||||||
| 						["slugs", "entries", ...relations], | 						["slugs", "progress", "entries", ...relations], | ||||||
| 						videoRelations, | 						videoRelations, | ||||||
| 						{ | 						{ | ||||||
| 							languages, | 							languages, | ||||||
|  | 							preferOriginal: preferOriginal ?? settings.preferOriginal, | ||||||
| 						}, | 						}, | ||||||
| 					), | 					), | ||||||
| 				}) | 				}) | ||||||
| @ -382,10 +496,15 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) | |||||||
| 				}), | 				}), | ||||||
| 			}), | 			}), | ||||||
| 			query: t.Object({ | 			query: t.Object({ | ||||||
| 				with: t.Array(t.UnionEnum(["previous", "next"]), { | 				with: t.Array(t.UnionEnum(["previous", "next", "show"]), { | ||||||
| 					default: [], | 					default: [], | ||||||
| 					description: "Include related entries in the response.", | 					description: "Include related entries in the response.", | ||||||
| 				}), | 				}), | ||||||
|  | 				preferOriginal: t.Optional( | ||||||
|  | 					t.Boolean({ | ||||||
|  | 						description: description.preferOriginal, | ||||||
|  | 					}), | ||||||
|  | 				), | ||||||
| 			}), | 			}), | ||||||
| 			headers: t.Object( | 			headers: t.Object( | ||||||
| 				{ | 				{ | ||||||
| @ -400,6 +519,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) | |||||||
| 						slugs: t.Array( | 						slugs: t.Array( | ||||||
| 							t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }), | 							t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }), | ||||||
| 						), | 						), | ||||||
|  | 						progress: Progress, | ||||||
| 						entries: t.Array(Entry), | 						entries: t.Array(Entry), | ||||||
| 						previous: t.Optional( | 						previous: t.Optional( | ||||||
| 							t.Nullable( | 							t.Nullable( | ||||||
| @ -423,6 +543,12 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) | |||||||
| 								}), | 								}), | ||||||
| 							), | 							), | ||||||
| 						), | 						), | ||||||
|  | 						show: t.Optional( | ||||||
|  | 							t.Union([ | ||||||
|  | 								t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]), | ||||||
|  | 								t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]), | ||||||
|  | 							]), | ||||||
|  | 						), | ||||||
| 					}), | 					}), | ||||||
| 				]), | 				]), | ||||||
| 				404: { | 				404: { | ||||||
| @ -433,6 +559,133 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) | |||||||
| 			}, | 			}, | ||||||
| 		}, | 		}, | ||||||
| 	) | 	) | ||||||
|  | 	.get( | ||||||
|  | 		":id/info", | ||||||
|  | 		async ({ params: { id }, status, redirect }) => { | ||||||
|  | 			const [video] = await db | ||||||
|  | 				.select({ | ||||||
|  | 					path: videos.path, | ||||||
|  | 				}) | ||||||
|  | 				.from(videos) | ||||||
|  | 				.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) | ||||||
|  | 				.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id)) | ||||||
|  | 				.limit(1); | ||||||
|  | 
 | ||||||
|  | 			if (!video) { | ||||||
|  | 				return status(404, { | ||||||
|  | 					status: 404, | ||||||
|  | 					message: `No video found with id or slug '${id}'`, | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 			const path = Buffer.from(video.path, "utf8").toString("base64url"); | ||||||
|  | 			return redirect(`/video/${path}/info`); | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			detail: { description: "Get a video's metadata informations" }, | ||||||
|  | 			params: t.Object({ | ||||||
|  | 				id: t.String({ | ||||||
|  | 					description: "The id or slug of the video to retrieve.", | ||||||
|  | 					example: "made-in-abyss-s1e13", | ||||||
|  | 				}), | ||||||
|  | 			}), | ||||||
|  | 			response: { | ||||||
|  | 				302: t.Void({ | ||||||
|  | 					description: | ||||||
|  | 						"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)", | ||||||
|  | 				}), | ||||||
|  | 				404: { | ||||||
|  | 					...KError, | ||||||
|  | 					description: "No video found with the given id or slug.", | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	) | ||||||
|  | 	.get( | ||||||
|  | 		":id/direct", | ||||||
|  | 		async ({ params: { id }, status, redirect }) => { | ||||||
|  | 			const [video] = await db | ||||||
|  | 				.select({ | ||||||
|  | 					path: videos.path, | ||||||
|  | 				}) | ||||||
|  | 				.from(videos) | ||||||
|  | 				.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) | ||||||
|  | 				.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id)) | ||||||
|  | 				.limit(1); | ||||||
|  | 
 | ||||||
|  | 			if (!video) { | ||||||
|  | 				return status(404, { | ||||||
|  | 					status: 404, | ||||||
|  | 					message: `No video found with id or slug '${id}'`, | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 			const path = Buffer.from(video.path, "utf8").toString("base64url"); | ||||||
|  | 			const filename = path.substring(path.lastIndexOf("/") + 1); | ||||||
|  | 			return redirect(`/video/${path}/direct/${filename}`); | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			detail: { | ||||||
|  | 				description: "Get redirected to the direct stream of the video", | ||||||
|  | 			}, | ||||||
|  | 			params: t.Object({ | ||||||
|  | 				id: t.String({ | ||||||
|  | 					description: "The id or slug of the video to watch.", | ||||||
|  | 					example: "made-in-abyss-s1e13", | ||||||
|  | 				}), | ||||||
|  | 			}), | ||||||
|  | 			response: { | ||||||
|  | 				302: t.Void({ | ||||||
|  | 					description: | ||||||
|  | 						"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)", | ||||||
|  | 				}), | ||||||
|  | 				404: { | ||||||
|  | 					...KError, | ||||||
|  | 					description: "No video found with the given id or slug.", | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	) | ||||||
|  | 	.get( | ||||||
|  | 		":id/master.m3u8", | ||||||
|  | 		async ({ params: { id }, request, status, redirect }) => { | ||||||
|  | 			const [video] = await db | ||||||
|  | 				.select({ | ||||||
|  | 					path: videos.path, | ||||||
|  | 				}) | ||||||
|  | 				.from(videos) | ||||||
|  | 				.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) | ||||||
|  | 				.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id)) | ||||||
|  | 				.limit(1); | ||||||
|  | 
 | ||||||
|  | 			if (!video) { | ||||||
|  | 				return status(404, { | ||||||
|  | 					status: 404, | ||||||
|  | 					message: `No video found with id or slug '${id}'`, | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 			const path = Buffer.from(video.path, "utf8").toString("base64url"); | ||||||
|  | 			const query = request.url.substring(request.url.indexOf("?")); | ||||||
|  | 			return redirect(`/video/${path}/master.m3u8${query}`); | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			detail: { description: "Get redirected to the master.m3u8 of the video" }, | ||||||
|  | 			params: t.Object({ | ||||||
|  | 				id: t.String({ | ||||||
|  | 					description: "The id or slug of the video to watch.", | ||||||
|  | 					example: "made-in-abyss-s1e13", | ||||||
|  | 				}), | ||||||
|  | 			}), | ||||||
|  | 			response: { | ||||||
|  | 				302: t.Void({ | ||||||
|  | 					description: | ||||||
|  | 						"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)", | ||||||
|  | 				}), | ||||||
|  | 				404: { | ||||||
|  | 					...KError, | ||||||
|  | 					description: "No video found with the given id or slug.", | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	) | ||||||
| 	.get( | 	.get( | ||||||
| 		"", | 		"", | ||||||
| 		async () => { | 		async () => { | ||||||
|  | |||||||
| @ -18,7 +18,7 @@ export const history = schema.table( | |||||||
| 			.references(() => entries.pk, { onDelete: "cascade" }), | 			.references(() => entries.pk, { onDelete: "cascade" }), | ||||||
| 		videoPk: integer().references(() => videos.pk, { onDelete: "set null" }), | 		videoPk: integer().references(() => videos.pk, { onDelete: "set null" }), | ||||||
| 		percent: integer().notNull().default(0), | 		percent: integer().notNull().default(0), | ||||||
| 		time: integer(), | 		time: integer().notNull().default(0), | ||||||
| 		playedDate: timestamp({ withTimezone: true, mode: "iso" }) | 		playedDate: timestamp({ withTimezone: true, mode: "iso" }) | ||||||
| 			.notNull() | 			.notNull() | ||||||
| 			.default(sql`now()`), | 			.default(sql`now()`), | ||||||
|  | |||||||
| @ -107,7 +107,7 @@ export function values<K extends string>( | |||||||
| 	}; | 	}; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const coalesce = <T>(val: SQL<T> | Column, def: SQL<T> | Column) => { | export const coalesce = <T>(val: SQL<T> | SQLWrapper, def: SQL<T> | Column) => { | ||||||
| 	return sql<T>`coalesce(${val}, ${def})`; | 	return sql<T>`coalesce(${val}, ${def})`; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -3,15 +3,13 @@ import { comment } from "~/utils"; | |||||||
| 
 | 
 | ||||||
| export const Progress = t.Object({ | export const Progress = t.Object({ | ||||||
| 	percent: t.Integer({ minimum: 0, maximum: 100 }), | 	percent: t.Integer({ minimum: 0, maximum: 100 }), | ||||||
| 	time: t.Nullable( | 	time: t.Integer({ | ||||||
| 		t.Integer({ |  | ||||||
| 		minimum: 0, | 		minimum: 0, | ||||||
| 		description: comment` | 		description: comment` | ||||||
| 				When this episode was stopped (in seconds since the start). | 				When this episode was stopped (in seconds since the start). | ||||||
| 				This value is null if the entry was never watched or is finished. | 				This value is null if the entry was never watched or is finished. | ||||||
| 			`,
 | 			`,
 | ||||||
| 	}), | 	}), | ||||||
| 	), |  | ||||||
| 	playedDate: t.Nullable(t.String({ format: "date-time" })), | 	playedDate: t.Nullable(t.String({ format: "date-time" })), | ||||||
| 	videoId: t.Nullable( | 	videoId: t.Nullable( | ||||||
| 		t.String({ | 		t.String({ | ||||||
|  | |||||||
| @ -58,7 +58,7 @@ export const Sort = ( | |||||||
| 			const random = sort.find((x) => x.startsWith("random")); | 			const random = sort.find((x) => x.startsWith("random")); | ||||||
| 			if (random) { | 			if (random) { | ||||||
| 				const seed = random.includes(":") | 				const seed = random.includes(":") | ||||||
| 					? Number.parseInt(random.substring("random:".length)) | 					? Number.parseInt(random.substring("random:".length), 10) | ||||||
| 					: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); | 					: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); | ||||||
| 				return { tablePk, random: { seed }, sort: [] }; | 				return { tablePk, random: { seed }, sort: [] }; | ||||||
| 			} | 			} | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ | |||||||
| 		"target": "ES2021", | 		"target": "ES2021", | ||||||
| 		"module": "ES2022", | 		"module": "ES2022", | ||||||
| 		"moduleResolution": "node", | 		"moduleResolution": "node", | ||||||
| 		"types": ["bun-types"], |  | ||||||
| 		"esModuleInterop": true, | 		"esModuleInterop": true, | ||||||
| 		"forceConsistentCasingInFileNames": true, | 		"forceConsistentCasingInFileNames": true, | ||||||
| 		"strict": true, | 		"strict": true, | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| ** | ** | ||||||
| !/go.mod | !/go.mod | ||||||
| !/go.sum | !/go.sum | ||||||
| !/**.go | !/**/*.go | ||||||
| # generated via sqlc | # generated via sqlc | ||||||
| !/sql | !/sql | ||||||
| !/dbc | !/dbc | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", | 	"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", | ||||||
| 	"formatter": { | 	"formatter": { | ||||||
| 		"enabled": true, | 		"enabled": true, | ||||||
| 		"formatWithErrors": true, | 		"formatWithErrors": true, | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ x-transcoder: &transcoder-base | |||||||
|         - transcoder |         - transcoder | ||||||
|   ports: |   ports: | ||||||
|     - "7666:7666" |     - "7666:7666" | ||||||
|   restart: unless-stopped |   restart: on-failure | ||||||
|   cpus: 1 |   cpus: 1 | ||||||
|   environment: |   environment: | ||||||
|     - JWKS_URL=http://auth:4568/.well-known/jwks.json |     - JWKS_URL=http://auth:4568/.well-known/jwks.json | ||||||
| @ -36,7 +36,7 @@ services: | |||||||
|     build: |     build: | ||||||
|       context: ./front |       context: ./front | ||||||
|       dockerfile: Dockerfile.dev |       dockerfile: Dockerfile.dev | ||||||
|     restart: unless-stopped |     restart: on-failure | ||||||
|     ports: |     ports: | ||||||
|       - "8081:8081" |       - "8081:8081" | ||||||
|     environment: |     environment: | ||||||
| @ -56,7 +56,7 @@ services: | |||||||
|     build: |     build: | ||||||
|       context: ./auth |       context: ./auth | ||||||
|       dockerfile: Dockerfile.dev |       dockerfile: Dockerfile.dev | ||||||
|     restart: unless-stopped |     restart: on-failure | ||||||
|     depends_on: |     depends_on: | ||||||
|       postgres: |       postgres: | ||||||
|         condition: service_healthy |         condition: service_healthy | ||||||
| @ -77,7 +77,7 @@ services: | |||||||
|     build: |     build: | ||||||
|       context: ./api |       context: ./api | ||||||
|       dockerfile: Dockerfile.dev |       dockerfile: Dockerfile.dev | ||||||
|     restart: unless-stopped |     restart: on-failure | ||||||
|     depends_on: |     depends_on: | ||||||
|       postgres: |       postgres: | ||||||
|         condition: service_healthy |         condition: service_healthy | ||||||
| @ -106,7 +106,7 @@ services: | |||||||
| 
 | 
 | ||||||
|   scanner: |   scanner: | ||||||
|     build: ./scanner |     build: ./scanner | ||||||
|     restart: unless-stopped |     restart: on-failure | ||||||
|     depends_on: |     depends_on: | ||||||
|       api: |       api: | ||||||
|         condition: service_started |         condition: service_started | ||||||
| @ -176,7 +176,7 @@ services: | |||||||
| 
 | 
 | ||||||
|   traefik: |   traefik: | ||||||
|     image: traefik:v3.5 |     image: traefik:v3.5 | ||||||
|     restart: unless-stopped |     restart: on-failure | ||||||
|     command: |     command: | ||||||
|       - "--providers.docker=true" |       - "--providers.docker=true" | ||||||
|       - "--providers.docker.exposedbydefault=false" |       - "--providers.docker.exposedbydefault=false" | ||||||
| @ -189,7 +189,7 @@ services: | |||||||
| 
 | 
 | ||||||
|   postgres: |   postgres: | ||||||
|     image: postgres:15 |     image: postgres:15 | ||||||
|     restart: unless-stopped |     restart: on-failure | ||||||
|     env_file: |     env_file: | ||||||
|       - ./.env |       - ./.env | ||||||
|     volumes: |     volumes: | ||||||
|  | |||||||
| @ -5,5 +5,5 @@ | |||||||
| !/metro.config.js | !/metro.config.js | ||||||
| !/app.config.ts | !/app.config.ts | ||||||
| !/src | !/src | ||||||
| !/app |  | ||||||
| !/public | !/public | ||||||
|  | !/scripts | ||||||
|  | |||||||
| @ -1,8 +1,9 @@ | |||||||
| FROM oven/bun AS builder | FROM oven/bun AS builder | ||||||
| WORKDIR /app | WORKDIR /app | ||||||
| 
 | 
 | ||||||
| COPY package.json bun.lock . | COPY package.json bun.lock scripts . | ||||||
| RUN bun install --production | COPY scripts scripts | ||||||
|  | RUN bun install --production --frozen-lockfile | ||||||
| 
 | 
 | ||||||
| COPY . . | COPY . . | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ FROM oven/bun | |||||||
| WORKDIR /app | WORKDIR /app | ||||||
| 
 | 
 | ||||||
| COPY package.json bun.lock . | COPY package.json bun.lock . | ||||||
|  | COPY scripts scripts | ||||||
| RUN bun install --frozen-lockfile | RUN bun install --frozen-lockfile | ||||||
| 
 | 
 | ||||||
| COPY . . | COPY . . | ||||||
|  | |||||||
| @ -66,7 +66,12 @@ export const expo: ExpoConfig = { | |||||||
| 		[ | 		[ | ||||||
| 			"react-native-video", | 			"react-native-video", | ||||||
| 			{ | 			{ | ||||||
| 				enableNotificationControls: true, | 				enableAndroidPictureInPicture: true, | ||||||
|  | 				enableBackgroundAudio: true, | ||||||
|  | 				androidExtensions: { | ||||||
|  | 					useExoplayerDash: true, | ||||||
|  | 					useExoplayerHls: true, | ||||||
|  | 				}, | ||||||
| 			}, | 			}, | ||||||
| 		], | 		], | ||||||
| 	], | 	], | ||||||
|  | |||||||
| @ -1,73 +0,0 @@ | |||||||
| { |  | ||||||
| 	"name": "mobile", |  | ||||||
| 	"version": "1.0.0", |  | ||||||
| 	"main": "expo-router/entry", |  | ||||||
| 	"sideEffects": false, |  | ||||||
| 	"scripts": { |  | ||||||
| 		"dev": "expo start", |  | ||||||
| 		"android": "expo run:android", |  | ||||||
| 		"ios": "expo run:ios", |  | ||||||
| 		"web": "expo start --web", |  | ||||||
| 		"build": "eas build --profile production --platform android --non-interactive --auto-submit", |  | ||||||
| 		"build:apk": "eas build --profile preview --platform android --non-interactive --json", |  | ||||||
| 		"build:dev": "eas build --profile development --platform android --non-interactive", |  | ||||||
| 		"update": "eas update --auto --channel prod" |  | ||||||
| 	}, |  | ||||||
| 	"dependencies": { |  | ||||||
| 		"@expo-google-fonts/poppins": "^0.2.3", |  | ||||||
| 		"@formatjs/intl-displaynames": "^6.6.8", |  | ||||||
| 		"@formatjs/intl-locale": "^4.0.0", |  | ||||||
| 		"@gorhom/portal": "^1.0.14", |  | ||||||
| 		"@kesha-antonov/react-native-background-downloader": "^3.2.0", |  | ||||||
| 		"@kyoo/ui": "workspace:^", |  | ||||||
| 		"@material-symbols/svg-400": "^0.22.0", |  | ||||||
| 		"@react-native-community/netinfo": "11.3.2", |  | ||||||
| 		"@shopify/flash-list": "1.7.1", |  | ||||||
| 		"@tanstack/query-sync-storage-persister": "^5.51.21", |  | ||||||
| 		"@tanstack/react-query": "^5.51.23", |  | ||||||
| 		"@tanstack/react-query-persist-client": "^5.51.23", |  | ||||||
| 		"array-shuffle": "^3.0.0", |  | ||||||
| 		"babel-plugin-transform-inline-environment-variables": "^0.4.4", |  | ||||||
| 		"expo": "^51.0.26", |  | ||||||
| 		"expo-build-properties": "~0.12.5", |  | ||||||
| 		"expo-constants": "~16.0.2", |  | ||||||
| 		"expo-dev-client": "~4.0.22", |  | ||||||
| 		"expo-file-system": "~17.0.1", |  | ||||||
| 		"expo-font": "~12.0.9", |  | ||||||
| 		"expo-image-picker": "~15.0.7", |  | ||||||
| 		"expo-linear-gradient": "~13.0.2", |  | ||||||
| 		"expo-linking": "~6.3.1", |  | ||||||
| 		"expo-localization": "~15.0.3", |  | ||||||
| 		"expo-navigation-bar": "~3.0.7", |  | ||||||
| 		"expo-router": "3.5.21", |  | ||||||
| 		"expo-screen-orientation": "~7.0.5", |  | ||||||
| 		"expo-secure-store": "~13.0.2", |  | ||||||
| 		"expo-status-bar": "~1.12.1", |  | ||||||
| 		"expo-updates": "~0.25.22", |  | ||||||
| 		"i18next": "^23.12.2", |  | ||||||
| 		"intl-pluralrules": "^2.0.1", |  | ||||||
| 		"moti": "^0.29.0", |  | ||||||
| 		"react": "18.3.1", |  | ||||||
| 		"react-i18next": "^15.0.1", |  | ||||||
| 		"react-native": "0.74.5", |  | ||||||
| 		"react-native-blurhash": "^2.0.3", |  | ||||||
| 		"react-native-fast-image": "^8.6.3", |  | ||||||
| 		"react-native-mmkv": "^2.12.2", |  | ||||||
| 		"react-native-reanimated": "~3.15.0", |  | ||||||
| 		"react-native-safe-area-context": "4.10.8", |  | ||||||
| 		"react-native-screens": "3.34.0", |  | ||||||
| 		"react-native-svg": "15.2.0", |  | ||||||
| 		"react-native-uuid": "^2.0.2", |  | ||||||
| 		"react-native-video": "^6.4.3", |  | ||||||
| 		"yoshiki": "1.2.14" |  | ||||||
| 	}, |  | ||||||
| 	"devDependencies": { |  | ||||||
| 		"@babel/core": "^7.25.2", |  | ||||||
| 		"react-native-svg-transformer": "^1.5.0", |  | ||||||
| 		"typescript": "~5.5.4" |  | ||||||
| 	}, |  | ||||||
| 	"installConfig": { |  | ||||||
| 		"hoistingLimits": "workspaces" |  | ||||||
| 	}, |  | ||||||
| 	"private": true |  | ||||||
| } |  | ||||||
| @ -1,60 +0,0 @@ | |||||||
| { |  | ||||||
| 	"name": "web", |  | ||||||
| 	"version": "0.1.0", |  | ||||||
| 	"private": true, |  | ||||||
| 	"sideEffects": ["./src/polyfill.ts"], |  | ||||||
| 	"scripts": { |  | ||||||
| 		"dev": "next dev", |  | ||||||
| 		"build": "next build", |  | ||||||
| 		"start": "next start", |  | ||||||
| 		"lint": "next lint", |  | ||||||
| 		"format": "prettier --check --ignore-path .gitignore '!src/utils/jotai-utils.tsx' .", |  | ||||||
| 		"format:fix": "prettier --write --ignore-path .gitignore '!src/utils/jotai-utils.tsx' ." |  | ||||||
| 	}, |  | ||||||
| 	"dependencies": { |  | ||||||
| 		"@gorhom/portal": "^1.0.14", |  | ||||||
| 		"@kyoo/models": "workspace:^", |  | ||||||
| 		"@kyoo/primitives": "workspace:^", |  | ||||||
| 		"@kyoo/ui": "workspace:^", |  | ||||||
| 		"@material-symbols/svg-400": "^0.22.0", |  | ||||||
| 		"@radix-ui/react-dropdown-menu": "^2.1.1", |  | ||||||
| 		"@radix-ui/react-select": "^2.1.1", |  | ||||||
| 		"@tanstack/react-query": "^5.51.23", |  | ||||||
| 		"@tanstack/react-query-devtools": "^5.51.23", |  | ||||||
| 		"array-shuffle": "^3.0.0", |  | ||||||
| 		"expo-image-picker": "~15.0.7", |  | ||||||
| 		"expo-linear-gradient": "^13.0.2", |  | ||||||
| 		"expo-modules-core": "^1.12.20", |  | ||||||
| 		"hls.js": "^1.5.14", |  | ||||||
| 		"i18next": "^23.12.2", |  | ||||||
| 		"jassub": "1.7.15", |  | ||||||
| 		"jotai": "^2.9.2", |  | ||||||
| 		"moti": "^0.29.0", |  | ||||||
| 		"next": "14.2.5", |  | ||||||
| 		"next-translate": "^2.6.2", |  | ||||||
| 		"raf": "^3.4.1", |  | ||||||
| 		"react": "18.3.1", |  | ||||||
| 		"react-dom": "18.3.1", |  | ||||||
| 		"react-i18next": "^15.0.1", |  | ||||||
| 		"react-native-reanimated": "3.15.0", |  | ||||||
| 		"react-native-svg": "15.2.0", |  | ||||||
| 		"react-native-video": "^6.4.3", |  | ||||||
| 		"react-native-web": "0.19.12", |  | ||||||
| 		"react-tooltip": "^5.28.0", |  | ||||||
| 		"solito": "^4.2.2", |  | ||||||
| 		"srt-webvtt": "zoriya/srt-webvtt#build", |  | ||||||
| 		"superjson": "^2.2.1", |  | ||||||
| 		"sweetalert2": "^11.12.4", |  | ||||||
| 		"yoshiki": "1.2.14", |  | ||||||
| 		"zod": "^3.23.8" |  | ||||||
| 	}, |  | ||||||
| 	"devDependencies": { |  | ||||||
| 		"@svgr/webpack": "^8.1.0", |  | ||||||
| 		"@types/node": "22.2.0", |  | ||||||
| 		"@types/react-dom": "18.3.0", |  | ||||||
| 		"copy-webpack-plugin": "^12.0.2", |  | ||||||
| 		"react-native": "0.74.5", |  | ||||||
| 		"typescript": "^5.5.4", |  | ||||||
| 		"webpack": "^5.93.0" |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
							
								
								
									
										1344
									
								
								front/bun.lock
									
									
									
									
									
								
							
							
						
						
									
										1344
									
								
								front/bun.lock
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										2
									
								
								front/bunfig.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								front/bunfig.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | |||||||
|  | [install] | ||||||
|  | linker = "hoisted" | ||||||
| @ -4,6 +4,7 @@ | |||||||
| 	"main": "expo-router/entry", | 	"main": "expo-router/entry", | ||||||
| 	"version": "1.0.0", | 	"version": "1.0.0", | ||||||
| 	"scripts": { | 	"scripts": { | ||||||
|  | 		"postinstall": "bun ./scripts/postinstall.ts", | ||||||
| 		"dev": "expo start", | 		"dev": "expo start", | ||||||
| 		"apk": "eas build --profile preview --platform android --non-interactive --json", | 		"apk": "eas build --profile preview --platform android --non-interactive --json", | ||||||
| 		"apk:dev": "eas build --profile development --platform android --non-interactive", | 		"apk:dev": "eas build --profile development --platform android --non-interactive", | ||||||
| @ -13,55 +14,68 @@ | |||||||
| 		"format:fix": "biome format . --write" | 		"format:fix": "biome format . --write" | ||||||
| 	}, | 	}, | ||||||
| 	"dependencies": { | 	"dependencies": { | ||||||
| 		"@expo/html-elements": "^0.12.5", | 		"@expo/html-elements": "^0.13.7", | ||||||
| 		"@gorhom/portal": "^1.0.14", | 		"@gorhom/portal": "^1.0.14", | ||||||
| 		"@legendapp/list": "^1.0.20", | 		"@legendapp/list": "^2.0.13", | ||||||
| 		"@material-symbols/svg-400": "^0.31.6", | 		"@material-symbols/svg-400": "^0.38.0", | ||||||
| 		"@radix-ui/react-dropdown-menu": "^2.1.15", | 		"@radix-ui/react-dropdown-menu": "^2.1.16", | ||||||
| 		"@react-navigation/bottom-tabs": "^7.3.10", | 		"@radix-ui/react-select": "^2.2.6", | ||||||
| 		"@react-navigation/elements": "^2.3.8", | 		"@react-navigation/bottom-tabs": "^7.4.0", | ||||||
| 		"@react-navigation/native": "^7.1.6", | 		"@react-navigation/elements": "^2.6.4", | ||||||
| 		"@tanstack/react-query": "^5.80.6", | 		"@react-navigation/native": "^7.1.8", | ||||||
| 		"expo": "~53.0.10", | 		"@tanstack/react-query": "^5.90.5", | ||||||
| 		"expo-build-properties": "^0.14.6", | 		"expo": "54.0.17", | ||||||
| 		"expo-image": "^2.3.0", | 		"expo-build-properties": "^1.0.9", | ||||||
| 		"expo-linear-gradient": "^14.1.5", | 		"expo-constants": "~18.0.10", | ||||||
| 		"expo-linking": "~7.1.5", | 		"expo-dev-client": "~6.0.16", | ||||||
| 		"expo-localization": "^16.1.5", | 		"expo-image": "~3.0.10", | ||||||
| 		"expo-router": "~5.1.0", | 		"expo-linear-gradient": "^15.0.7", | ||||||
| 		"expo-splash-screen": "^0.30.9", | 		"expo-linking": "~8.0.8", | ||||||
| 		"expo-status-bar": "~2.2.3", | 		"expo-localization": "^17.0.7", | ||||||
| 		"expo-updates": "~0.28.14", | 		"expo-router": "~6.0.13", | ||||||
|  | 		"expo-splash-screen": "^31.0.10", | ||||||
|  | 		"expo-status-bar": "~3.0.8", | ||||||
|  | 		"expo-updates": "~29.0.11", | ||||||
| 		"i18next-http-backend": "^3.0.2", | 		"i18next-http-backend": "^3.0.2", | ||||||
| 		"jotai": "^2.12.5", | 		"jassub": "^1.8.6", | ||||||
| 		"react": "19.0.0", | 		"langmap": "^0.0.16", | ||||||
| 		"react-i18next": "^15.5.2", | 		"react": "19.1.0", | ||||||
| 		"react-native": "0.79.3", | 		"react-dom": "19.1.0", | ||||||
| 		"react-native-mmkv": "^3.2.0", | 		"react-i18next": "^16.1.0", | ||||||
| 		"react-native-reanimated": "~3.17.4", | 		"react-native": "0.81.5", | ||||||
| 		"react-native-safe-area-context": "5.4.0", | 		"react-native-get-random-values": "^2.0.0", | ||||||
| 		"react-native-screens": "~4.11.1", | 		"react-native-mmkv": "^3.3.3", | ||||||
| 		"react-native-svg": "15.11.2", | 		"react-native-nitro-modules": "^0.30.2", | ||||||
| 		"react-native-video": "^6.15.0", | 		"react-native-reanimated": "~4.1.2", | ||||||
| 		"react-native-web": "^0.20.0", | 		"react-native-safe-area-context": "5.6.1", | ||||||
|  | 		"react-native-screens": "~4.16.0", | ||||||
|  | 		"react-native-svg": "15.12.1", | ||||||
|  | 		"react-native-video": "zoriya/react-native-video#build", | ||||||
|  | 		"react-native-web": "^0.21.2", | ||||||
|  | 		"react-native-worklets": "0.5.1", | ||||||
| 		"react-tooltip": "^5.29.1", | 		"react-tooltip": "^5.29.1", | ||||||
| 		"sweetalert2": "^11.22.0", | 		"sweetalert2": "^11.26.3", | ||||||
|  | 		"uuid": "^13.0.0", | ||||||
|  | 		"video.js": "^8.23.4", | ||||||
| 		"yoshiki": "1.2.14", | 		"yoshiki": "1.2.14", | ||||||
| 		"zod": "^3.25.56" | 		"zod": "^4.1.11" | ||||||
| 	}, | 	}, | ||||||
| 	"devDependencies": { | 	"devDependencies": { | ||||||
| 		"@biomejs/biome": "2.0.0", | 		"@biomejs/biome": "2.2.6", | ||||||
| 		"@tanstack/react-query-devtools": "^5.80.6", | 		"@tanstack/react-query-devtools": "^5.90.2", | ||||||
| 		"@types/react": "~19.0.10", | 		"@types/bun": "^1.3.0", | ||||||
| 		"@types/react-dom": "^19.1.6", | 		"@types/react": "~19.1.10", | ||||||
| 		"expo-dev-client": "^5.2.0", | 		"@types/react-dom": "~19.1.7", | ||||||
| 		"react-native-svg-transformer": "^1.5.1", | 		"react-native-svg-transformer": "^1.5.1", | ||||||
| 		"typescript": "5.8.3" | 		"typescript": "5.9.3" | ||||||
| 	}, | 	}, | ||||||
| 	"expo": { | 	"expo": { | ||||||
| 		"doctor": { | 		"doctor": { | ||||||
| 			"reactNativeDirectoryCheck": { | 			"reactNativeDirectoryCheck": { | ||||||
| 				"listUnknownPackages": false | 				"listUnknownPackages": false, | ||||||
|  | 				"exclude": [ | ||||||
|  | 					"@gorhom/portal" | ||||||
|  | 				] | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | |||||||
| @ -1,24 +0,0 @@ | |||||||
| { |  | ||||||
| 	"name": "@kyoo/models", |  | ||||||
| 	"main": "src/index.ts", |  | ||||||
| 	"types": "src/index.ts", |  | ||||||
| 	"sideEffects": false, |  | ||||||
| 	"packageManager": "yarn@3.2.4", |  | ||||||
| 	"devDependencies": { |  | ||||||
| 		"react-native-mmkv": "^2.12.2", |  | ||||||
| 		"typescript": "^5.5.4" |  | ||||||
| 	}, |  | ||||||
| 	"peerDependencies": { |  | ||||||
| 		"@tanstack/react-query": "*", |  | ||||||
| 		"react": "*", |  | ||||||
| 		"react-native": "*" |  | ||||||
| 	}, |  | ||||||
| 	"peerDependenciesMeta": { |  | ||||||
| 		"react-native-web": { |  | ||||||
| 			"optional": true |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 	"dependencies": { |  | ||||||
| 		"zod": "^3.23.8" |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @ -1,19 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| @ -1,77 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { useQueryClient } from "@tanstack/react-query"; |  | ||||||
| import { atom, getDefaultStore, useAtomValue, useSetAtom } from "jotai"; |  | ||||||
| import { |  | ||||||
| 	type ReactNode, |  | ||||||
| 	createContext, |  | ||||||
| 	useContext, |  | ||||||
| 	useEffect, |  | ||||||
| 	useMemo, |  | ||||||
| 	useRef, |  | ||||||
| 	useState, |  | ||||||
| } from "react"; |  | ||||||
| import { Platform } from "react-native"; |  | ||||||
| import { useMMKVString } from "react-native-mmkv"; |  | ||||||
| import { z } from "zod"; |  | ||||||
| import { removeAccounts, setCookie, updateAccount } from "./account-internal"; |  | ||||||
| import type { KyooErrors } from "./kyoo-errors"; |  | ||||||
| import { useFetch } from "./query"; |  | ||||||
| import { ServerInfoP, type User, UserP } from "./resources"; |  | ||||||
| import { zdate } from "./utils"; |  | ||||||
| 
 |  | ||||||
| const currentApiUrl = atom<string | null>(defaultApiUrl); |  | ||||||
| export const getCurrentApiUrl = () => { |  | ||||||
| 	const store = getDefaultStore(); |  | ||||||
| 	return store.get(currentApiUrl); |  | ||||||
| }; |  | ||||||
| export const useCurrentApiUrl = () => { |  | ||||||
| 	return useAtomValue(currentApiUrl); |  | ||||||
| }; |  | ||||||
| export const setSsrApiUrl = () => { |  | ||||||
| 	const store = getDefaultStore(); |  | ||||||
| 	store.set(currentApiUrl, process.env.KYOO_URL ?? "http://localhost:5000"); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| export const useAccount = () => { |  | ||||||
| 	const acc = useContext(AccountContext); |  | ||||||
| 	return acc.find((x) => x.selected) || null; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const useAccounts = () => { |  | ||||||
| 	return useContext(AccountContext); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const useHasPermission = (perms?: string[]) => { |  | ||||||
| 	const account = useAccount(); |  | ||||||
| 	const { data } = useFetch({ |  | ||||||
| 		path: ["info"], |  | ||||||
| 		parser: ServerInfoP, |  | ||||||
| 	}); |  | ||||||
| 
 |  | ||||||
| 	if (!perms || !perms[0]) return true; |  | ||||||
| 
 |  | ||||||
| 	const available = account?.permissions ?? data?.guestPermissions; |  | ||||||
| 	if (!available) return false; |  | ||||||
| 	return perms.every((perm) => available.includes(perm)); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| @ -1,26 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| export * from "./accounts"; |  | ||||||
| export { storage } from "./account-internal"; |  | ||||||
| export * from "./theme"; |  | ||||||
| export * from "./utils"; |  | ||||||
| export * from "./login"; |  | ||||||
| export * from "./issue"; |  | ||||||
| @ -1,48 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { z } from "zod"; |  | ||||||
| import { zdate } from "./utils"; |  | ||||||
| 
 |  | ||||||
| export const IssueP = z.object({ |  | ||||||
| 	/** |  | ||||||
| 	 * The type of issue (for example, "Scanner" if this issue was created due to scanning error). |  | ||||||
| 	 */ |  | ||||||
| 	domain: z.string(), |  | ||||||
| 	/** |  | ||||||
| 	 * Why this issue was caused? An unique cause that can be used to identify this issue. |  | ||||||
| 	 * For the scanner, a cause should be a video path. |  | ||||||
| 	 */ |  | ||||||
| 	cause: z.string(), |  | ||||||
| 	/** |  | ||||||
| 	 * A human readable string explaining why this issue occured. |  | ||||||
| 	 */ |  | ||||||
| 	reason: z.string(), |  | ||||||
| 	/** |  | ||||||
| 	 * Some extra data that could store domain-specific info. |  | ||||||
| 	 */ |  | ||||||
| 	extra: z.record(z.string(), z.any()), |  | ||||||
| 	/** |  | ||||||
| 	 * The date the issue was reported. |  | ||||||
| 	 */ |  | ||||||
| 	addedDate: zdate(), |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export type Issue = z.infer<typeof IssueP>; |  | ||||||
| @ -1,179 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { useEffect, useRef, useState } from "react"; |  | ||||||
| import { Platform } from "react-native"; |  | ||||||
| import { addAccount, getCurrentAccount, removeAccounts, updateAccount } from "./account-internal"; |  | ||||||
| import { type Account, type Token, TokenP, getCurrentApiUrl } from "./accounts"; |  | ||||||
| import type { KyooErrors } from "./kyoo-errors"; |  | ||||||
| import { queryFn } from "./query"; |  | ||||||
| import { UserP } from "./resources"; |  | ||||||
| 
 |  | ||||||
| type Result<A, B> = |  | ||||||
| 	| { ok: true; value: A; error?: undefined } |  | ||||||
| 	| { ok: false; value?: undefined; error: B }; |  | ||||||
| 
 |  | ||||||
| export const login = async ( |  | ||||||
| 	action: "register" | "login", |  | ||||||
| 	{ apiUrl, ...body }: { username: string; password: string; email?: string; apiUrl?: string }, |  | ||||||
| ): Promise<Result<Account, string>> => { |  | ||||||
| 	if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!; |  | ||||||
| 	try { |  | ||||||
| 		const controller = new AbortController(); |  | ||||||
| 		setTimeout(() => controller.abort(), 5_000); |  | ||||||
| 		const token = await queryFn( |  | ||||||
| 			{ |  | ||||||
| 				path: ["auth", action], |  | ||||||
| 				method: "POST", |  | ||||||
| 				body, |  | ||||||
| 				authenticated: false, |  | ||||||
| 				apiUrl, |  | ||||||
| 				signal: controller.signal, |  | ||||||
| 			}, |  | ||||||
| 			TokenP, |  | ||||||
| 		); |  | ||||||
| 		const user = await queryFn( |  | ||||||
| 			{ path: ["auth", "me"], method: "GET", apiUrl }, |  | ||||||
| 			UserP, |  | ||||||
| 			`Bearer ${token.access_token}`, |  | ||||||
| 		); |  | ||||||
| 		const account: Account = { ...user, apiUrl: apiUrl, token, selected: true }; |  | ||||||
| 		addAccount(account); |  | ||||||
| 		return { ok: true, value: account }; |  | ||||||
| 	} catch (e) { |  | ||||||
| 		console.error(action, e); |  | ||||||
| 		return { ok: false, error: (e as KyooErrors).errors[0] }; |  | ||||||
| 	} |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const oidcLogin = async (provider: string, code: string, apiUrl?: string) => { |  | ||||||
| 	if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!; |  | ||||||
| 	try { |  | ||||||
| 		const token = await queryFn( |  | ||||||
| 			{ |  | ||||||
| 				path: ["auth", "callback", provider, `?code=${code}`], |  | ||||||
| 				method: "POST", |  | ||||||
| 				authenticated: false, |  | ||||||
| 				apiUrl, |  | ||||||
| 			}, |  | ||||||
| 			TokenP, |  | ||||||
| 		); |  | ||||||
| 		const user = await queryFn( |  | ||||||
| 			{ path: ["auth", "me"], method: "GET", apiUrl }, |  | ||||||
| 			UserP, |  | ||||||
| 			`Bearer ${token.access_token}`, |  | ||||||
| 		); |  | ||||||
| 		const account: Account = { ...user, apiUrl: apiUrl, token, selected: true }; |  | ||||||
| 		addAccount(account); |  | ||||||
| 		return { ok: true, value: account }; |  | ||||||
| 	} catch (e) { |  | ||||||
| 		console.error("oidcLogin", e); |  | ||||||
| 		return { ok: false, error: (e as KyooErrors).errors[0] }; |  | ||||||
| 	} |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| let running_id: string | null = null; |  | ||||||
| let running: ReturnType<typeof getTokenWJ> | null = null; |  | ||||||
| 
 |  | ||||||
| export const getTokenWJ = async ( |  | ||||||
| 	acc?: Account | null, |  | ||||||
| 	forceRefresh = false, |  | ||||||
| ): Promise<readonly [string, Token, null] | readonly [null, null, KyooErrors | null]> => { |  | ||||||
| 	if (acc === undefined) acc = getCurrentAccount(); |  | ||||||
| 	if (!acc) return [null, null, null] as const; |  | ||||||
| 	const account = acc; |  | ||||||
| 
 |  | ||||||
| 	async function run() { |  | ||||||
| 		let token = account.token; |  | ||||||
| 
 |  | ||||||
| 		if (forceRefresh || account.token.expire_at <= new Date(new Date().getTime() + 10 * 1000)) { |  | ||||||
| 			console.log("refreshing token for account", account.slug); |  | ||||||
| 			try { |  | ||||||
| 				token = await queryFn( |  | ||||||
| 					{ |  | ||||||
| 						path: ["auth", "refresh", `?token=${account.token.refresh_token}`], |  | ||||||
| 						method: "GET", |  | ||||||
| 						authenticated: false, |  | ||||||
| 					}, |  | ||||||
| 					TokenP, |  | ||||||
| 				); |  | ||||||
| 				if (Platform.OS !== "web" || typeof window !== "undefined") |  | ||||||
| 					updateAccount(account.id, { ...account, token }); |  | ||||||
| 			} catch (e) { |  | ||||||
| 				console.error("Error refreshing token durring ssr:", e); |  | ||||||
| 				return [null, null, e as KyooErrors] as const; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return [`${token.token_type} ${token.access_token}`, token, null] as const; |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Do not cache promise durring ssr.
 |  | ||||||
| 	if (Platform.OS === "web" && typeof window === "undefined") return await run(); |  | ||||||
| 
 |  | ||||||
| 	if (running && running_id === account.id) return await running; |  | ||||||
| 	running_id = account.id; |  | ||||||
| 	running = run(); |  | ||||||
| 	const ret = await running; |  | ||||||
| 	running_id = null; |  | ||||||
| 	running = null; |  | ||||||
| 	return ret; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const getToken = async (): Promise<string | null> => (await getTokenWJ())[0]; |  | ||||||
| 
 |  | ||||||
| export const getCurrentToken = () => { |  | ||||||
| 	const account = getCurrentAccount(); |  | ||||||
| 	return account ? `${account.token.token_type} ${account.token.access_token}` : null; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const useToken = () => { |  | ||||||
| 	const account = getCurrentAccount(); |  | ||||||
| 	const refresher = useRef<NodeJS.Timeout | null>(null); |  | ||||||
| 	const [token, setToken] = useState( |  | ||||||
| 		account ? `${account.token.token_type} ${account.token.access_token}` : null, |  | ||||||
| 	); |  | ||||||
| 
 |  | ||||||
| 	// biome-ignore lint/correctness/useExhaustiveDependencies: Refresh token when account change
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		async function run() { |  | ||||||
| 			const nToken = await getTokenWJ(); |  | ||||||
| 			setToken(nToken[0]); |  | ||||||
| 			if (refresher.current) clearTimeout(refresher.current); |  | ||||||
| 			if (nToken[1]) |  | ||||||
| 				refresher.current = setTimeout(run, nToken[1].expire_at.getTime() - Date.now()); |  | ||||||
| 		} |  | ||||||
| 		run(); |  | ||||||
| 		return () => { |  | ||||||
| 			if (refresher.current) clearTimeout(refresher.current); |  | ||||||
| 		}; |  | ||||||
| 	}, [account]); |  | ||||||
| 
 |  | ||||||
| 	if (!token) return null; |  | ||||||
| 	return token; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const logout = () => { |  | ||||||
| 	removeAccounts((x) => x.selected); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const deleteAccount = async () => { |  | ||||||
| 	await queryFn({ path: ["auth", "me"], method: "DELETE" }); |  | ||||||
| 	logout(); |  | ||||||
| }; |  | ||||||
| @ -1,44 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { Platform } from "react-native"; |  | ||||||
| import { useMMKVString } from "react-native-mmkv"; |  | ||||||
| import { setCookie, storage } from "./account-internal"; |  | ||||||
| 
 |  | ||||||
| export const useUserTheme = (ssrTheme?: "light" | "dark" | "auto") => { |  | ||||||
| 	if (Platform.OS === "web" && typeof window === "undefined" && ssrTheme) return ssrTheme; |  | ||||||
| 	const [value] = useMMKVString("theme", storage); |  | ||||||
| 	if (!value) return "auto"; |  | ||||||
| 	return value as "light" | "dark" | "auto"; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const storeData = (key: string, value: string | number | boolean) => { |  | ||||||
| 	storage.set(key, value); |  | ||||||
| 	if (Platform.OS === "web") setCookie(key, value); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const deleteData = (key: string) => { |  | ||||||
| 	storage.delete(key); |  | ||||||
| 	if (Platform.OS === "web") setCookie(key, undefined); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const setUserTheme = (theme: "light" | "dark" | "auto") => { |  | ||||||
| 	storeData("theme", theme); |  | ||||||
| }; |  | ||||||
| @ -1,39 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { Platform } from "react-native"; |  | ||||||
| import { useMMKVString } from "react-native-mmkv"; |  | ||||||
| import { z } from "zod"; |  | ||||||
| import { storage } from "./account-internal"; |  | ||||||
| import type { Movie, Show } from "./resources"; |  | ||||||
| 
 |  | ||||||
| export const zdate = z.coerce.date; |  | ||||||
| 
 |  | ||||||
| export const useLocalSetting = (setting: string, def: string) => { |  | ||||||
| 	if (Platform.OS === "web" && typeof window === "undefined") return [def, null!] as const; |  | ||||||
| 	// eslint-disable-next-line react-hooks/rules-of-hooks
 |  | ||||||
| 	const [val, setter] = useMMKVString(`settings.${setting}`, storage); |  | ||||||
| 	return [val ?? def, setter] as const; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const getLocalSetting = (setting: string, def: string) => { |  | ||||||
| 	if (Platform.OS === "web" && typeof window === "undefined") return def; |  | ||||||
| 	return storage.getString(`settings.${setting}`) ?? setting; |  | ||||||
| }; |  | ||||||
| @ -1,26 +0,0 @@ | |||||||
| { |  | ||||||
| 	"compilerOptions": { |  | ||||||
| 		"target": "es6", |  | ||||||
| 		"lib": ["dom", "dom.iterable", "esnext"], |  | ||||||
| 		"declaration": true, |  | ||||||
| 		"sourceMap": true, |  | ||||||
| 		"noEmit": true, |  | ||||||
| 		"allowJs": true, |  | ||||||
| 		"skipLibCheck": true, |  | ||||||
| 		"strict": true, |  | ||||||
| 		"forceConsistentCasingInFileNames": true, |  | ||||||
| 		"esModuleInterop": true, |  | ||||||
| 		"module": "esnext", |  | ||||||
| 		"moduleResolution": "node", |  | ||||||
| 		"resolveJsonModule": true, |  | ||||||
| 		"isolatedModules": true, |  | ||||||
| 		"jsx": "react-jsx", |  | ||||||
| 		"incremental": true, |  | ||||||
| 		"baseUrl": ".", |  | ||||||
| 		"paths": { |  | ||||||
| 			"~/*": ["src/*"] |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 	"include": ["**/*.ts", "**/*.tsx"], |  | ||||||
| 	"exclude": ["node_modules"] |  | ||||||
| } |  | ||||||
| @ -1,50 +0,0 @@ | |||||||
| { |  | ||||||
| 	"name": "@kyoo/ui", |  | ||||||
| 	"main": "src/index.ts", |  | ||||||
| 	"types": "src/index.ts", |  | ||||||
| 	"packageManager": "yarn@3.2.4", |  | ||||||
| 	"dependencies": { |  | ||||||
| 		"@kyoo/models": "workspace:^", |  | ||||||
| 		"@kyoo/primitives": "workspace:^", |  | ||||||
| 		"langmap": "^0.0.16" |  | ||||||
| 	}, |  | ||||||
| 	"devDependencies": { |  | ||||||
| 		"@gorhom/portal": "^1.0.14", |  | ||||||
| 		"@shopify/flash-list": "^1.7.1", |  | ||||||
| 		"@types/langmap": "^0.0.3", |  | ||||||
| 		"react-native-uuid": "^2.0.2", |  | ||||||
| 		"typescript": "^5.5.4" |  | ||||||
| 	}, |  | ||||||
| 	"peerDependencies": { |  | ||||||
| 		"@gorhom/portal": "*", |  | ||||||
| 		"@kesha-antonov/react-native-background-downloader": "*", |  | ||||||
| 		"@material-symbols/svg-400": "*", |  | ||||||
| 		"@shopify/flash-list": "^1.3.1", |  | ||||||
| 		"@tanstack/react-query": "*", |  | ||||||
| 		"expo-file-system": "*", |  | ||||||
| 		"expo-image-picker": "~14.7.1", |  | ||||||
| 		"expo-linear-gradient": "*", |  | ||||||
| 		"expo-router": "*", |  | ||||||
| 		"i18next": "*", |  | ||||||
| 		"moti": "*", |  | ||||||
| 		"react": "*", |  | ||||||
| 		"react-i18next": "*", |  | ||||||
| 		"react-native": "*", |  | ||||||
| 		"react-native-reanimated": "*", |  | ||||||
| 		"react-native-svg": "*", |  | ||||||
| 		"yoshiki": "*" |  | ||||||
| 	}, |  | ||||||
| 	"optionalDependencies": { |  | ||||||
| 		"@kesha-antonov/react-native-background-downloader": "^3.2.0", |  | ||||||
| 		"expo-file-system": "^17.0.1", |  | ||||||
| 		"expo-router": "^3.5.21" |  | ||||||
| 	}, |  | ||||||
| 	"peerDependenciesMeta": { |  | ||||||
| 		"@kesha-antonov/react-native-background-downloader": { |  | ||||||
| 			"optional": true |  | ||||||
| 		}, |  | ||||||
| 		"expo-router": { |  | ||||||
| 			"optional": true |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @ -21,7 +21,7 @@ | |||||||
| import { type WatchInfo, getCurrentApiUrl, queryFn, toQueryKey } from "@kyoo/models"; | import { type WatchInfo, getCurrentApiUrl, queryFn, toQueryKey } from "@kyoo/models"; | ||||||
| import { getCurrentAccount } from "@kyoo/models/src/account-internal"; | import { getCurrentAccount } from "@kyoo/models/src/account-internal"; | ||||||
| import type { ReactNode } from "react"; | import type { ReactNode } from "react"; | ||||||
| import { Player } from "../player"; | import { Player } from "../../../../src/ui/player../src/ui/player"; | ||||||
| 
 | 
 | ||||||
| export const useDownloader = () => { | export const useDownloader = () => { | ||||||
| 	return async (type: "episode" | "movie", slug: string) => { | 	return async (type: "episode" | "movie", slug: string) => { | ||||||
|  | |||||||
| @ -41,7 +41,7 @@ import { type PrimitiveAtom, atom, useSetAtom, useStore } from "jotai"; | |||||||
| import { type ReactNode, useEffect } from "react"; | import { type ReactNode, useEffect } from "react"; | ||||||
| import { ToastAndroid } from "react-native"; | import { ToastAndroid } from "react-native"; | ||||||
| import { z } from "zod"; | import { z } from "zod"; | ||||||
| import { Player } from "../player"; | import { Player } from "../../../../src/ui/player"; | ||||||
| 
 | 
 | ||||||
| type Router = ReturnType<typeof useRouter>; | type Router = ReturnType<typeof useRouter>; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,78 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { Main } from "@kyoo/primitives"; |  | ||||||
| import { LinearGradient } from "expo-linear-gradient"; |  | ||||||
| import type { ReactElement } from "react"; |  | ||||||
| import { useYoshiki, vw } from "yoshiki/native"; |  | ||||||
| import { Navbar } from "../../../src/ui/navbar/src/ui/navbar"; |  | ||||||
| 
 |  | ||||||
| export const DefaultLayout = ({ |  | ||||||
| 	page, |  | ||||||
| 	transparent, |  | ||||||
| }: { |  | ||||||
| 	page: ReactElement; |  | ||||||
| 	transparent?: boolean; |  | ||||||
| }) => { |  | ||||||
| 	const { css, theme } = useYoshiki(); |  | ||||||
| 	return ( |  | ||||||
| 		<> |  | ||||||
| 			<Navbar |  | ||||||
| 				{...css( |  | ||||||
| 					transparent && { |  | ||||||
| 						bg: "transparent", |  | ||||||
| 						position: "absolute", |  | ||||||
| 						top: 0, |  | ||||||
| 						left: 0, |  | ||||||
| 						right: 0, |  | ||||||
| 						shadowOpacity: 0, |  | ||||||
| 					}, |  | ||||||
| 				)} |  | ||||||
| 				background={ |  | ||||||
| 					transparent ? ( |  | ||||||
| 						<LinearGradient |  | ||||||
| 							start={{ x: 0, y: 0.25 }} |  | ||||||
| 							end={{ x: 0, y: 1 }} |  | ||||||
| 							colors={[theme.themeOverlay, "transparent"]} |  | ||||||
| 							{...css({ |  | ||||||
| 								height: "100%", |  | ||||||
| 								position: "absolute", |  | ||||||
| 								top: 0, |  | ||||||
| 								left: 0, |  | ||||||
| 								right: 0, |  | ||||||
| 							})} |  | ||||||
| 						/> |  | ||||||
| 					) : undefined |  | ||||||
| 				} |  | ||||||
| 			/> |  | ||||||
| 			<Main |  | ||||||
| 				{...css({ |  | ||||||
| 					display: "flex", |  | ||||||
| 					width: vw(100), |  | ||||||
| 					flexGrow: 1, |  | ||||||
| 					flexShrink: 1, |  | ||||||
| 					overflow: "hidden", |  | ||||||
| 				})} |  | ||||||
| 			> |  | ||||||
| 				{page} |  | ||||||
| 			</Main> |  | ||||||
| 		</> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| @ -1,474 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import type { Audio, Chapter, KyooImage, Subtitle } from "@kyoo/models"; |  | ||||||
| import { |  | ||||||
| 	CircularProgress, |  | ||||||
| 	ContrastArea, |  | ||||||
| 	H1, |  | ||||||
| 	H2, |  | ||||||
| 	IconButton, |  | ||||||
| 	Poster, |  | ||||||
| 	PressableFeedback, |  | ||||||
| 	Skeleton, |  | ||||||
| 	Slider, |  | ||||||
| 	Tooltip, |  | ||||||
| 	alpha, |  | ||||||
| 	imageBorderRadius, |  | ||||||
| 	tooltip, |  | ||||||
| 	ts, |  | ||||||
| 	useIsTouch, |  | ||||||
| } from "@kyoo/primitives"; |  | ||||||
| import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; |  | ||||||
| import { useAtom, useAtomValue, useSetAtom } from "jotai"; |  | ||||||
| import { atom } from "jotai"; |  | ||||||
| import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| import { type ImageStyle, Platform, Pressable, View, type ViewProps } from "react-native"; |  | ||||||
| import { useRouter } from "solito/router"; |  | ||||||
| import { percent, rem, useYoshiki } from "yoshiki/native"; |  | ||||||
| import { |  | ||||||
| 	bufferedAtom, |  | ||||||
| 	durationAtom, |  | ||||||
| 	fullscreenAtom, |  | ||||||
| 	loadAtom, |  | ||||||
| 	playAtom, |  | ||||||
| 	progressAtom, |  | ||||||
| } from "../state"; |  | ||||||
| import { LeftButtons, TouchControls } from "./left-buttons"; |  | ||||||
| import { RightButtons } from "./right-buttons"; |  | ||||||
| import { BottomScrubber, ScrubberTooltip } from "./scrubber"; |  | ||||||
| 
 |  | ||||||
| const hoverReasonAtom = atom({ |  | ||||||
| 	mouseMoved: false, |  | ||||||
| 	mouseHover: false, |  | ||||||
| 	menuOpened: false, |  | ||||||
| }); |  | ||||||
| export const hoverAtom = atom((get) => |  | ||||||
| 	[!get(playAtom), ...Object.values(get(hoverReasonAtom))].includes(true), |  | ||||||
| ); |  | ||||||
| export const seekingAtom = atom(false); |  | ||||||
| export const seekProgressAtom = atom<number | null>(null); |  | ||||||
| 
 |  | ||||||
| export const Hover = ({ |  | ||||||
| 	isLoading, |  | ||||||
| 	url, |  | ||||||
| 	name, |  | ||||||
| 	showName, |  | ||||||
| 	poster, |  | ||||||
| 	chapters, |  | ||||||
| 	subtitles, |  | ||||||
| 	audios, |  | ||||||
| 	fonts, |  | ||||||
| 	previousSlug, |  | ||||||
| 	nextSlug, |  | ||||||
| }: { |  | ||||||
| 	isLoading: boolean; |  | ||||||
| 	url: string; |  | ||||||
| 	name?: string | null; |  | ||||||
| 	showName?: string; |  | ||||||
| 	poster?: KyooImage | null; |  | ||||||
| 	chapters?: Chapter[]; |  | ||||||
| 	subtitles?: Subtitle[]; |  | ||||||
| 	audios?: Audio[]; |  | ||||||
| 	fonts?: string[]; |  | ||||||
| 	previousSlug?: string | null; |  | ||||||
| 	nextSlug?: string | null; |  | ||||||
| }) => { |  | ||||||
| 	const show = useAtomValue(hoverAtom); |  | ||||||
| 	const setHover = useSetAtom(hoverReasonAtom); |  | ||||||
| 	const isSeeking = useAtomValue(seekingAtom); |  | ||||||
| 	const isTouch = useIsTouch(); |  | ||||||
| 
 |  | ||||||
| 	const showBottomSeeker = isSeeking && isTouch; |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<ContrastArea mode="dark"> |  | ||||||
| 			{({ css }) => ( |  | ||||||
| 				<> |  | ||||||
| 					<TouchControls previousSlug={previousSlug} nextSlug={nextSlug} /> |  | ||||||
| 					<View |  | ||||||
| 						onPointerEnter={(e) => { |  | ||||||
| 							if (e.nativeEvent.pointerType === "mouse") |  | ||||||
| 								setHover((x) => ({ ...x, mouseHover: true })); |  | ||||||
| 						}} |  | ||||||
| 						onPointerLeave={(e) => { |  | ||||||
| 							if (e.nativeEvent.pointerType === "mouse") |  | ||||||
| 								setHover((x) => ({ ...x, mouseHover: false })); |  | ||||||
| 						}} |  | ||||||
| 						{...css({ |  | ||||||
| 							// TODO: animate show
 |  | ||||||
| 							display: !show ? "none" : "flex", |  | ||||||
| 							position: "absolute", |  | ||||||
| 							top: 0, |  | ||||||
| 							left: 0, |  | ||||||
| 							bottom: 0, |  | ||||||
| 							right: 0, |  | ||||||
| 							// box-none does not work on the web while none does not work on android
 |  | ||||||
| 							pointerEvents: Platform.OS === "web" ? "none" : "box-none", |  | ||||||
| 						})} |  | ||||||
| 					> |  | ||||||
| 						<Back |  | ||||||
| 							isLoading={isLoading} |  | ||||||
| 							name={showName} |  | ||||||
| 							{...css({ |  | ||||||
| 								pointerEvents: "auto", |  | ||||||
| 							})} |  | ||||||
| 						/> |  | ||||||
| 						<View |  | ||||||
| 							{...css({ |  | ||||||
| 								// Fixed is used because firefox android make the hover disapear under the navigation bar in absolute
 |  | ||||||
| 								position: Platform.OS === "web" ? ("fixed" as any) : "absolute", |  | ||||||
| 								bottom: 0, |  | ||||||
| 								left: 0, |  | ||||||
| 								right: 0, |  | ||||||
| 								bg: (theme) => theme.darkOverlay, |  | ||||||
| 								flexDirection: "row", |  | ||||||
| 								pointerEvents: "auto", |  | ||||||
| 								padding: percent(1), |  | ||||||
| 							})} |  | ||||||
| 						> |  | ||||||
| 							<VideoPoster poster={poster} alt={showName} isLoading={isLoading} /> |  | ||||||
| 							<View |  | ||||||
| 								{...css({ |  | ||||||
| 									marginLeft: { xs: ts(0.5), sm: ts(3) }, |  | ||||||
| 									flexDirection: "column", |  | ||||||
| 									flexGrow: 1, |  | ||||||
| 									flexShrink: 1, |  | ||||||
| 									maxWidth: percent(100), |  | ||||||
| 								})} |  | ||||||
| 							> |  | ||||||
| 								{!showBottomSeeker && ( |  | ||||||
| 									<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}> |  | ||||||
| 										{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name} |  | ||||||
| 									</H2> |  | ||||||
| 								)} |  | ||||||
| 								<ProgressBar chapters={chapters} url={url} /> |  | ||||||
| 								{showBottomSeeker ? ( |  | ||||||
| 									<BottomScrubber url={url} chapters={chapters} /> |  | ||||||
| 								) : ( |  | ||||||
| 									<View |  | ||||||
| 										{...css({ |  | ||||||
| 											flexDirection: "row", |  | ||||||
| 											flexGrow: 1, |  | ||||||
| 											justifyContent: "space-between", |  | ||||||
| 											flexWrap: "wrap", |  | ||||||
| 										})} |  | ||||||
| 									> |  | ||||||
| 										<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} /> |  | ||||||
| 										<RightButtons |  | ||||||
| 											subtitles={subtitles} |  | ||||||
| 											audios={audios} |  | ||||||
| 											fonts={fonts} |  | ||||||
| 											onMenuOpen={() => setHover((x) => ({ ...x, menuOpened: true }))} |  | ||||||
| 											onMenuClose={() => { |  | ||||||
| 												// Disable hover since the menu overlay makes the mouseout unreliable.
 |  | ||||||
| 												setHover((x) => ({ ...x, menuOpened: false, mouseHover: false })); |  | ||||||
| 											}} |  | ||||||
| 										/> |  | ||||||
| 									</View> |  | ||||||
| 								)} |  | ||||||
| 							</View> |  | ||||||
| 						</View> |  | ||||||
| 					</View> |  | ||||||
| 				</> |  | ||||||
| 			)} |  | ||||||
| 		</ContrastArea> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => { |  | ||||||
| 	const hover = useAtomValue(hoverAtom); |  | ||||||
| 	const setHover = useSetAtom(hoverReasonAtom); |  | ||||||
| 	const mouseCallback = useRef<NodeJS.Timeout | null>(null); |  | ||||||
| 	const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ count: 0 }); |  | ||||||
| 	const playerWidth = useRef<number | null>(null); |  | ||||||
| 	const isTouch = useIsTouch(); |  | ||||||
| 
 |  | ||||||
| 	const show = useCallback(() => { |  | ||||||
| 		setHover((x) => ({ ...x, mouseMoved: true })); |  | ||||||
| 		if (mouseCallback.current) clearTimeout(mouseCallback.current); |  | ||||||
| 		mouseCallback.current = setTimeout(() => { |  | ||||||
| 			setHover((x) => ({ ...x, mouseMoved: false })); |  | ||||||
| 		}, 2500); |  | ||||||
| 	}, [setHover]); |  | ||||||
| 
 |  | ||||||
| 	// On mouse move
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (Platform.OS !== "web") return; |  | ||||||
| 		const handler = (e: PointerEvent) => { |  | ||||||
| 			if (e.pointerType !== "mouse") return; |  | ||||||
| 			show(); |  | ||||||
| 		}; |  | ||||||
| 
 |  | ||||||
| 		document.addEventListener("pointermove", handler); |  | ||||||
| 		return () => document.removeEventListener("pointermove", handler); |  | ||||||
| 	}, [show]); |  | ||||||
| 
 |  | ||||||
| 	// When the controls hide, remove focus so space can be used to play/pause instead of triggering the button
 |  | ||||||
| 	// It also serves to hide the tooltip.
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (Platform.OS !== "web") return; |  | ||||||
| 		if (!hover && document.activeElement instanceof HTMLElement) document.activeElement.blur(); |  | ||||||
| 	}, [hover]); |  | ||||||
| 
 |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 
 |  | ||||||
| 	const duration = useAtomValue(durationAtom); |  | ||||||
| 	const setPlay = useSetAtom(playAtom); |  | ||||||
| 	const setProgress = useSetAtom(progressAtom); |  | ||||||
| 	const setFullscreen = useSetAtom(fullscreenAtom); |  | ||||||
| 
 |  | ||||||
| 	const onPress = (e: { pointerType: string; x: number }) => { |  | ||||||
| 		if (Platform.OS === "web" && e.pointerType === "mouse") { |  | ||||||
| 			setPlay((x) => !x); |  | ||||||
| 			return; |  | ||||||
| 		} |  | ||||||
| 		if (hover) setHover((x) => ({ ...x, mouseMoved: false })); |  | ||||||
| 		else show(); |  | ||||||
| 	}; |  | ||||||
| 	const onDoublePress = (e: { pointerType: string; x: number }) => { |  | ||||||
| 		if (Platform.OS === "web" && e.pointerType === "mouse") { |  | ||||||
| 			// Only reset touch count for the web, on mobile you can continue to seek by pressing again.
 |  | ||||||
| 			touch.current.count = 0; |  | ||||||
| 			setFullscreen((x) => !x); |  | ||||||
| 			return; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		show(); |  | ||||||
| 		if (!duration || !playerWidth.current) return; |  | ||||||
| 
 |  | ||||||
| 		if (e.x < playerWidth.current * 0.33) { |  | ||||||
| 			setProgress((x) => Math.max(x - 10, 0)); |  | ||||||
| 		} |  | ||||||
| 		if (e.x > playerWidth.current * 0.66) { |  | ||||||
| 			setProgress((x) => Math.min(x + 10, duration)); |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
| 
 |  | ||||||
| 	const onAnyPress = (e: { pointerType: string; x: number }) => { |  | ||||||
| 		touch.current.count++; |  | ||||||
| 		if (touch.current.count >= 2) { |  | ||||||
| 			onDoublePress(e); |  | ||||||
| 			clearTimeout(touch.current.timeout); |  | ||||||
| 		} else { |  | ||||||
| 			onPress(e); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		touch.current.timeout = setTimeout(() => { |  | ||||||
| 			touch.current.count = 0; |  | ||||||
| 			touch.current.timeout = undefined; |  | ||||||
| 		}, 400); |  | ||||||
| 	}; |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<Pressable |  | ||||||
| 			tabIndex={-1} |  | ||||||
| 			onPointerLeave={(e) => { |  | ||||||
| 				if (e.nativeEvent.pointerType === "mouse") setHover((x) => ({ ...x, mouseMoved: false })); |  | ||||||
| 			}} |  | ||||||
| 			onPress={(e) => { |  | ||||||
| 				e.preventDefault(); |  | ||||||
| 				onAnyPress({ |  | ||||||
| 					pointerType: isTouch ? "touch" : "mouse", |  | ||||||
| 					x: e.nativeEvent.locationX ?? e.nativeEvent.pageX, |  | ||||||
| 				}); |  | ||||||
| 			}} |  | ||||||
| 			onLayout={(e) => { |  | ||||||
| 				playerWidth.current = e.nativeEvent.layout.width; |  | ||||||
| 			}} |  | ||||||
| 			{...css( |  | ||||||
| 				// @ts-expect-error Web only property (cursor: unset)
 |  | ||||||
| 				{ |  | ||||||
| 					flexDirection: "row", |  | ||||||
| 					justifyContent: "center", |  | ||||||
| 					alignItems: "center", |  | ||||||
| 					position: "absolute", |  | ||||||
| 					top: 0, |  | ||||||
| 					left: 0, |  | ||||||
| 					right: 0, |  | ||||||
| 					bottom: 0, |  | ||||||
| 					cursor: hover ? "unset" : "none", |  | ||||||
| 				}, |  | ||||||
| 				props, |  | ||||||
| 			)} |  | ||||||
| 		> |  | ||||||
| 			{children} |  | ||||||
| 		</Pressable> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const ProgressBar = ({ url, chapters }: { url: string; chapters?: Chapter[] }) => { |  | ||||||
| 	const [progress, setProgress] = useAtom(progressAtom); |  | ||||||
| 	const buffered = useAtomValue(bufferedAtom); |  | ||||||
| 	const duration = useAtomValue(durationAtom); |  | ||||||
| 	const setPlay = useSetAtom(playAtom); |  | ||||||
| 	const [hoverProgress, setHoverProgress] = useState<number | null>(null); |  | ||||||
| 	const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 }); |  | ||||||
| 	const [seekProgress, setSeekProgress] = useAtom(seekProgressAtom); |  | ||||||
| 	const setSeeking = useSetAtom(seekingAtom); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<> |  | ||||||
| 			<Slider |  | ||||||
| 				progress={seekProgress ?? progress} |  | ||||||
| 				startSeek={() => { |  | ||||||
| 					setPlay(false); |  | ||||||
| 					setSeeking(true); |  | ||||||
| 				}} |  | ||||||
| 				endSeek={() => { |  | ||||||
| 					setSeeking(false); |  | ||||||
| 					setProgress(seekProgress!); |  | ||||||
| 					setSeekProgress(null); |  | ||||||
| 					setTimeout(() => setPlay(true), 10); |  | ||||||
| 				}} |  | ||||||
| 				onHover={(progress, layout) => { |  | ||||||
| 					setHoverProgress(progress); |  | ||||||
| 					setLayout(layout); |  | ||||||
| 				}} |  | ||||||
| 				setProgress={(progress) => setSeekProgress(progress)} |  | ||||||
| 				subtleProgress={buffered} |  | ||||||
| 				max={duration} |  | ||||||
| 				markers={chapters?.map((x) => x.startTime)} |  | ||||||
| 				dataSet={{ tooltipId: "progress-scrubber" }} |  | ||||||
| 			/> |  | ||||||
| 			<Tooltip |  | ||||||
| 				id={"progress-scrubber"} |  | ||||||
| 				isOpen={hoverProgress !== null} |  | ||||||
| 				place="top" |  | ||||||
| 				position={{ x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), y: layout.y }} |  | ||||||
| 				render={() => |  | ||||||
| 					hoverProgress ? ( |  | ||||||
| 						<ScrubberTooltip seconds={hoverProgress} chapters={chapters} url={url} /> |  | ||||||
| 					) : null |  | ||||||
| 				} |  | ||||||
| 				opacity={1} |  | ||||||
| 				style={{ padding: 0, borderRadius: imageBorderRadius }} |  | ||||||
| 			/> |  | ||||||
| 		</> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const Back = ({ |  | ||||||
| 	isLoading, |  | ||||||
| 	name, |  | ||||||
| 	...props |  | ||||||
| }: { isLoading: boolean; name?: string } & ViewProps) => { |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 	const router = useRouter(); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<View |  | ||||||
| 			{...css( |  | ||||||
| 				{ |  | ||||||
| 					position: "absolute", |  | ||||||
| 					top: 0, |  | ||||||
| 					left: 0, |  | ||||||
| 					right: 0, |  | ||||||
| 					bg: (theme) => theme.darkOverlay, |  | ||||||
| 					display: "flex", |  | ||||||
| 					flexDirection: "row", |  | ||||||
| 					alignItems: "center", |  | ||||||
| 					padding: percent(0.33), |  | ||||||
| 					color: "white", |  | ||||||
| 				}, |  | ||||||
| 				props, |  | ||||||
| 			)} |  | ||||||
| 		> |  | ||||||
| 			<IconButton |  | ||||||
| 				icon={ArrowBack} |  | ||||||
| 				as={PressableFeedback} |  | ||||||
| 				onPress={router.back} |  | ||||||
| 				{...tooltip(t("player.back"))} |  | ||||||
| 			/> |  | ||||||
| 			<Skeleton> |  | ||||||
| 				{isLoading ? ( |  | ||||||
| 					<Skeleton {...css({ width: rem(5) })} /> |  | ||||||
| 				) : ( |  | ||||||
| 					<H1 |  | ||||||
| 						{...css({ |  | ||||||
| 							alignSelf: "center", |  | ||||||
| 							fontSize: rem(1.5), |  | ||||||
| 							marginLeft: rem(1), |  | ||||||
| 						})} |  | ||||||
| 					> |  | ||||||
| 						{name} |  | ||||||
| 					</H1> |  | ||||||
| 				)} |  | ||||||
| 			</Skeleton> |  | ||||||
| 		</View> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const VideoPoster = ({ |  | ||||||
| 	poster, |  | ||||||
| 	alt, |  | ||||||
| 	isLoading, |  | ||||||
| }: { |  | ||||||
| 	poster?: KyooImage | null; |  | ||||||
| 	alt?: string; |  | ||||||
| 	isLoading: boolean; |  | ||||||
| }) => { |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<View |  | ||||||
| 			{...css({ |  | ||||||
| 				width: "15%", |  | ||||||
| 				display: { xs: "none", sm: "flex" }, |  | ||||||
| 				position: "relative", |  | ||||||
| 			})} |  | ||||||
| 		> |  | ||||||
| 			<Poster |  | ||||||
| 				src={poster} |  | ||||||
| 				quality="low" |  | ||||||
| 				alt={alt} |  | ||||||
| 				forcedLoading={isLoading} |  | ||||||
| 				layout={{ width: percent(100) }} |  | ||||||
| 				{...(css({ position: "absolute", bottom: 0 }) as { style: ImageStyle })} |  | ||||||
| 			/> |  | ||||||
| 		</View> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const LoadingIndicator = () => { |  | ||||||
| 	const isLoading = useAtomValue(loadAtom); |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 
 |  | ||||||
| 	if (!isLoading) return null; |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<View |  | ||||||
| 			{...css({ |  | ||||||
| 				position: "absolute", |  | ||||||
| 				pointerEvents: "none", |  | ||||||
| 				top: 0, |  | ||||||
| 				bottom: 0, |  | ||||||
| 				left: 0, |  | ||||||
| 				right: 0, |  | ||||||
| 				bg: (theme) => alpha(theme.colors.black, 0.3), |  | ||||||
| 				justifyContent: "center", |  | ||||||
| 			})} |  | ||||||
| 		> |  | ||||||
| 			<CircularProgress {...css({ alignSelf: "center" })} /> |  | ||||||
| 		</View> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| @ -1,221 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { IconButton, Link, P, Slider, noTouch, tooltip, touchOnly, ts } from "@kyoo/primitives"; |  | ||||||
| import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg"; |  | ||||||
| import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; |  | ||||||
| import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg"; |  | ||||||
| import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg"; |  | ||||||
| import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg"; |  | ||||||
| import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg"; |  | ||||||
| import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg"; |  | ||||||
| import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg"; |  | ||||||
| import { useAtom, useAtomValue } from "jotai"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| import { Platform, View } from "react-native"; |  | ||||||
| import { type Stylable, px, useYoshiki } from "yoshiki/native"; |  | ||||||
| import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; |  | ||||||
| import { HoverTouch, hoverAtom } from "./hover"; |  | ||||||
| 
 |  | ||||||
| export const LeftButtons = ({ |  | ||||||
| 	previousSlug, |  | ||||||
| 	nextSlug, |  | ||||||
| }: { |  | ||||||
| 	previousSlug?: string | null; |  | ||||||
| 	nextSlug?: string | null; |  | ||||||
| }) => { |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 	const [isPlaying, setPlay] = useAtom(playAtom); |  | ||||||
| 
 |  | ||||||
| 	const spacing = css({ marginHorizontal: ts(1) }); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<View {...css({ flexDirection: "row" })}> |  | ||||||
| 			<View {...css({ flexDirection: "row" }, noTouch)}> |  | ||||||
| 				{previousSlug && ( |  | ||||||
| 					<IconButton |  | ||||||
| 						icon={SkipPrevious} |  | ||||||
| 						as={Link} |  | ||||||
| 						href={previousSlug} |  | ||||||
| 						replace |  | ||||||
| 						{...tooltip(t("player.previous"), true)} |  | ||||||
| 						{...spacing} |  | ||||||
| 					/> |  | ||||||
| 				)} |  | ||||||
| 				<IconButton |  | ||||||
| 					icon={isPlaying ? Pause : PlayArrow} |  | ||||||
| 					onPress={() => setPlay(!isPlaying)} |  | ||||||
| 					{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} |  | ||||||
| 					{...spacing} |  | ||||||
| 				/> |  | ||||||
| 				{nextSlug && ( |  | ||||||
| 					<IconButton |  | ||||||
| 						icon={SkipNext} |  | ||||||
| 						as={Link} |  | ||||||
| 						href={nextSlug} |  | ||||||
| 						replace |  | ||||||
| 						{...tooltip(t("player.next"), true)} |  | ||||||
| 						{...spacing} |  | ||||||
| 					/> |  | ||||||
| 				)} |  | ||||||
| 				{Platform.OS === "web" && <VolumeSlider />} |  | ||||||
| 			</View> |  | ||||||
| 			<ProgressText {...css({ marginLeft: ts(1) })} /> |  | ||||||
| 		</View> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const TouchControls = ({ |  | ||||||
| 	previousSlug, |  | ||||||
| 	nextSlug, |  | ||||||
| 	...props |  | ||||||
| }: { |  | ||||||
| 	previousSlug?: string | null; |  | ||||||
| 	nextSlug?: string | null; |  | ||||||
| }) => { |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const [isPlaying, setPlay] = useAtom(playAtom); |  | ||||||
| 	const hover = useAtomValue(hoverAtom); |  | ||||||
| 
 |  | ||||||
| 	const common = css( |  | ||||||
| 		[ |  | ||||||
| 			{ |  | ||||||
| 				backgroundColor: (theme) => theme.darkOverlay, |  | ||||||
| 				marginHorizontal: ts(3), |  | ||||||
| 			}, |  | ||||||
| 		], |  | ||||||
| 		touchOnly, |  | ||||||
| 	); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<HoverTouch |  | ||||||
| 			{...css( |  | ||||||
| 				{ |  | ||||||
| 					flexDirection: "row", |  | ||||||
| 					justifyContent: "center", |  | ||||||
| 					alignItems: "center", |  | ||||||
| 					position: "absolute", |  | ||||||
| 					top: 0, |  | ||||||
| 					left: 0, |  | ||||||
| 					right: 0, |  | ||||||
| 					bottom: 0, |  | ||||||
| 				}, |  | ||||||
| 				props, |  | ||||||
| 			)} |  | ||||||
| 		> |  | ||||||
| 			{hover && ( |  | ||||||
| 				<> |  | ||||||
| 					<IconButton |  | ||||||
| 						icon={SkipPrevious} |  | ||||||
| 						as={Link} |  | ||||||
| 						href={previousSlug!} |  | ||||||
| 						replace |  | ||||||
| 						size={ts(4)} |  | ||||||
| 						{...css([!previousSlug && { opacity: 0, pointerEvents: "none" }], common)} |  | ||||||
| 					/> |  | ||||||
| 					<IconButton |  | ||||||
| 						icon={isPlaying ? Pause : PlayArrow} |  | ||||||
| 						onPress={() => setPlay(!isPlaying)} |  | ||||||
| 						size={ts(8)} |  | ||||||
| 						{...common} |  | ||||||
| 					/> |  | ||||||
| 					<IconButton |  | ||||||
| 						icon={SkipNext} |  | ||||||
| 						as={Link} |  | ||||||
| 						href={nextSlug!} |  | ||||||
| 						replace |  | ||||||
| 						size={ts(4)} |  | ||||||
| 						{...css([!nextSlug && { opacity: 0, pointerEvents: "none" }], common)} |  | ||||||
| 					/> |  | ||||||
| 				</> |  | ||||||
| 			)} |  | ||||||
| 		</HoverTouch> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const VolumeSlider = () => { |  | ||||||
| 	const [volume, setVolume] = useAtom(volumeAtom); |  | ||||||
| 	const [isMuted, setMuted] = useAtom(mutedAtom); |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<View |  | ||||||
| 			{...css({ |  | ||||||
| 				display: { xs: "none", sm: "flex" }, |  | ||||||
| 				alignItems: "center", |  | ||||||
| 				flexDirection: "row", |  | ||||||
| 				paddingRight: ts(1), |  | ||||||
| 			})} |  | ||||||
| 		> |  | ||||||
| 			<IconButton |  | ||||||
| 				icon={ |  | ||||||
| 					isMuted || volume === 0 |  | ||||||
| 						? VolumeOff |  | ||||||
| 						: volume < 25 |  | ||||||
| 							? VolumeMute |  | ||||||
| 							: volume < 65 |  | ||||||
| 								? VolumeDown |  | ||||||
| 								: VolumeUp |  | ||||||
| 				} |  | ||||||
| 				onPress={() => setMuted(!isMuted)} |  | ||||||
| 				{...tooltip(t("player.mute"), true)} |  | ||||||
| 			/> |  | ||||||
| 			<Slider |  | ||||||
| 				progress={volume} |  | ||||||
| 				setProgress={setVolume} |  | ||||||
| 				size={4} |  | ||||||
| 				{...css({ width: px(100) })} |  | ||||||
| 				{...tooltip(t("player.volume"), true)} |  | ||||||
| 			/> |  | ||||||
| 		</View> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const ProgressText = (props: Stylable) => { |  | ||||||
| 	const progress = useAtomValue(progressAtom); |  | ||||||
| 	const duration = useAtomValue(durationAtom); |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<P {...css({ alignSelf: "center" }, props)}> |  | ||||||
| 			{toTimerString(progress, duration)} : {toTimerString(duration)} |  | ||||||
| 		</P> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const toTimerString = (timer?: number, duration?: number) => { |  | ||||||
| 	if (!duration) duration = timer; |  | ||||||
| 	if ( |  | ||||||
| 		timer === undefined || |  | ||||||
| 		duration === undefined || |  | ||||||
| 		Number.isNaN(duration) || |  | ||||||
| 		Number.isNaN(timer) |  | ||||||
| 	) |  | ||||||
| 		return "??:??"; |  | ||||||
| 	const h = Math.floor(timer / 3600); |  | ||||||
| 	const min = Math.floor((timer / 60) % 60); |  | ||||||
| 	const sec = Math.floor(timer % 60); |  | ||||||
| 	const fmt = (n: number) => n.toString().padStart(2, "0"); |  | ||||||
| 
 |  | ||||||
| 	if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`; |  | ||||||
| 	return `${fmt(min)}:${fmt(sec)}`; |  | ||||||
| }; |  | ||||||
| @ -1,114 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import type { Audio, Subtitle } from "@kyoo/models"; |  | ||||||
| import { IconButton, Menu, tooltip, ts } from "@kyoo/primitives"; |  | ||||||
| import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg"; |  | ||||||
| import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg"; |  | ||||||
| import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg"; |  | ||||||
| import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg"; |  | ||||||
| import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg"; |  | ||||||
| import { useAtom } from "jotai"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| import { Platform, View } from "react-native"; |  | ||||||
| import { type Stylable, useYoshiki } from "yoshiki/native"; |  | ||||||
| import { useSubtitleName } from "../../utils"; |  | ||||||
| import { fullscreenAtom, subtitleAtom } from "../state"; |  | ||||||
| import { AudiosMenu, QualitiesMenu } from "../video"; |  | ||||||
| 
 |  | ||||||
| export const RightButtons = ({ |  | ||||||
| 	audios, |  | ||||||
| 	subtitles, |  | ||||||
| 	fonts, |  | ||||||
| 	onMenuOpen, |  | ||||||
| 	onMenuClose, |  | ||||||
| 	...props |  | ||||||
| }: { |  | ||||||
| 	audios?: Audio[]; |  | ||||||
| 	subtitles?: Subtitle[]; |  | ||||||
| 	fonts?: string[]; |  | ||||||
| 	onMenuOpen: () => void; |  | ||||||
| 	onMenuClose: () => void; |  | ||||||
| } & Stylable) => { |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 	const getSubtitleName = useSubtitleName(); |  | ||||||
| 	const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); |  | ||||||
| 	const [selectedSubtitle, setSubtitle] = useAtom(subtitleAtom); |  | ||||||
| 
 |  | ||||||
| 	const spacing = css({ marginHorizontal: ts(1) }); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<View {...css({ flexDirection: "row" }, props)}> |  | ||||||
| 			{subtitles && subtitles.length > 0 && ( |  | ||||||
| 				<Menu |  | ||||||
| 					Trigger={IconButton} |  | ||||||
| 					icon={ClosedCaption} |  | ||||||
| 					onMenuOpen={onMenuOpen} |  | ||||||
| 					onMenuClose={onMenuClose} |  | ||||||
| 					{...tooltip(t("player.subtitles"), true)} |  | ||||||
| 					{...spacing} |  | ||||||
| 				> |  | ||||||
| 					<Menu.Item |  | ||||||
| 						label={t("player.subtitle-none")} |  | ||||||
| 						selected={!selectedSubtitle} |  | ||||||
| 						onSelect={() => setSubtitle(null)} |  | ||||||
| 					/> |  | ||||||
| 					{subtitles |  | ||||||
| 						.filter((x) => !!x.link) |  | ||||||
| 						.map((x, i) => ( |  | ||||||
| 							<Menu.Item |  | ||||||
| 								key={x.index ?? i} |  | ||||||
| 								label={x.link ? getSubtitleName(x) : `${getSubtitleName(x)} (${x.codec})`} |  | ||||||
| 								selected={selectedSubtitle === x} |  | ||||||
| 								disabled={!x.link} |  | ||||||
| 								onSelect={() => setSubtitle(x)} |  | ||||||
| 							/> |  | ||||||
| 						))} |  | ||||||
| 				</Menu> |  | ||||||
| 			)} |  | ||||||
| 			<AudiosMenu |  | ||||||
| 				Trigger={IconButton} |  | ||||||
| 				icon={MusicNote} |  | ||||||
| 				onMenuOpen={onMenuOpen} |  | ||||||
| 				onMenuClose={onMenuClose} |  | ||||||
| 				audios={audios} |  | ||||||
| 				{...tooltip(t("player.audios"), true)} |  | ||||||
| 				{...spacing} |  | ||||||
| 			/> |  | ||||||
| 			<QualitiesMenu |  | ||||||
| 				Trigger={IconButton} |  | ||||||
| 				icon={SettingsIcon} |  | ||||||
| 				onMenuOpen={onMenuOpen} |  | ||||||
| 				onMenuClose={onMenuClose} |  | ||||||
| 				{...tooltip(t("player.quality"), true)} |  | ||||||
| 				{...spacing} |  | ||||||
| 			/> |  | ||||||
| 			{Platform.OS === "web" && ( |  | ||||||
| 				<IconButton |  | ||||||
| 					icon={isFullscreen ? FullscreenExit : Fullscreen} |  | ||||||
| 					onPress={() => setFullscreen(!isFullscreen)} |  | ||||||
| 					{...tooltip(t("player.fullscreen"), true)} |  | ||||||
| 					{...spacing} |  | ||||||
| 				/> |  | ||||||
| 			)} |  | ||||||
| 		</View> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| @ -1,211 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { |  | ||||||
| 	type Episode, |  | ||||||
| 	EpisodeP, |  | ||||||
| 	type Movie, |  | ||||||
| 	MovieP, |  | ||||||
| 	type QueryIdentifier, |  | ||||||
| 	type WatchInfo, |  | ||||||
| 	WatchInfoP, |  | ||||||
| 	useFetch, |  | ||||||
| } from "@kyoo/models"; |  | ||||||
| import { Head } from "@kyoo/primitives"; |  | ||||||
| import { useSetAtom } from "jotai"; |  | ||||||
| import { type ComponentProps, useEffect, useState } from "react"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| import { Platform, StyleSheet, View } from "react-native"; |  | ||||||
| import { useRouter } from "solito/router"; |  | ||||||
| import { useYoshiki } from "yoshiki/native"; |  | ||||||
| import { episodeDisplayNumber } from "../../../../src/ui/details/episode"; |  | ||||||
| import { ErrorView } from "../../../../src/ui/errors"; |  | ||||||
| import { Back, Hover, LoadingIndicator } from "./components/hover"; |  | ||||||
| import { useVideoKeyboard } from "./keyboard"; |  | ||||||
| import { Video, durationAtom, fullscreenAtom } from "./state"; |  | ||||||
| import { WatchStatusObserver } from "./watch-status-observer"; |  | ||||||
| 
 |  | ||||||
| type Item = (Movie & { type: "movie" }) | (Episode & { type: "episode" }); |  | ||||||
| 
 |  | ||||||
| const mapData = ( |  | ||||||
| 	data: Item | undefined, |  | ||||||
| 	info: WatchInfo | undefined, |  | ||||||
| 	previousSlug?: string, |  | ||||||
| 	nextSlug?: string, |  | ||||||
| ): Partial<ComponentProps<typeof Hover>> & { isLoading: boolean } => { |  | ||||||
| 	if (!data) return { isLoading: true }; |  | ||||||
| 	return { |  | ||||||
| 		isLoading: false, |  | ||||||
| 		name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data)} ${data.name}`, |  | ||||||
| 		showName: data.type === "movie" ? data.name! : data.show!.name, |  | ||||||
| 		poster: data.type === "movie" ? data.poster : data.show!.poster, |  | ||||||
| 		subtitles: info?.subtitles, |  | ||||||
| 		audios: info?.audios, |  | ||||||
| 		chapters: info?.chapters, |  | ||||||
| 		fonts: info?.fonts, |  | ||||||
| 		previousSlug, |  | ||||||
| 		nextSlug, |  | ||||||
| 	}; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const formatTitleMetadata = (item: Item) => { |  | ||||||
| 	if (item.type === "movie") { |  | ||||||
| 		return item.name; |  | ||||||
| 	} |  | ||||||
| 	return `${item.name} (${episodeDisplayNumber({ |  | ||||||
| 		seasonNumber: item.seasonNumber, |  | ||||||
| 		episodeNumber: item.episodeNumber, |  | ||||||
| 		absoluteNumber: item.absoluteNumber, |  | ||||||
| 	})})`;
 |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const Player = ({ |  | ||||||
| 	slug, |  | ||||||
| 	type, |  | ||||||
| 	t: startTimeP, |  | ||||||
| }: { |  | ||||||
| 	slug: string; |  | ||||||
| 	type: "episode" | "movie"; |  | ||||||
| 	t?: number; |  | ||||||
| }) => { |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 	const router = useRouter(); |  | ||||||
| 
 |  | ||||||
| 	const [playbackError, setPlaybackError] = useState<string | undefined>(undefined); |  | ||||||
| 	const { data, error } = useFetch(Player.query(type, slug)); |  | ||||||
| 	const { data: info, error: infoError } = useFetch(Player.infoQuery(type, slug)); |  | ||||||
| 	const image = |  | ||||||
| 		data && data.type === "episode" ? (data.show?.poster ?? data?.poster) : data?.poster; |  | ||||||
| 	const previous = |  | ||||||
| 		data && data.type === "episode" && data.previousEpisode |  | ||||||
| 			? `/watch/${data.previousEpisode.slug}?t=0` |  | ||||||
| 			: undefined; |  | ||||||
| 	const next = |  | ||||||
| 		data && data.type === "episode" && data.nextEpisode |  | ||||||
| 			? `/watch/${data.nextEpisode.slug}?t=0` |  | ||||||
| 			: undefined; |  | ||||||
| 	const title = data && formatTitleMetadata(data); |  | ||||||
| 	const subtitle = data && data.type === "episode" ? data.show?.name : undefined; |  | ||||||
| 
 |  | ||||||
| 	useVideoKeyboard(info?.subtitles, info?.fonts, previous, next); |  | ||||||
| 
 |  | ||||||
| 	const startTime = startTimeP ?? data?.watchStatus?.watchedTime; |  | ||||||
| 
 |  | ||||||
| 	const setFullscreen = useSetAtom(fullscreenAtom); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (Platform.OS !== "web") return; |  | ||||||
| 		if (/Mobi/i.test(window.navigator.userAgent)) setFullscreen(true); |  | ||||||
| 		return () => { |  | ||||||
| 			if (!document.location.href.includes("/watch")) setFullscreen(false); |  | ||||||
| 		}; |  | ||||||
| 	}, [setFullscreen]); |  | ||||||
| 
 |  | ||||||
| 	const setDuration = useSetAtom(durationAtom); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		setDuration(info?.durationSeconds); |  | ||||||
| 	}, [info, setDuration]); |  | ||||||
| 
 |  | ||||||
| 	if (error || infoError || playbackError) |  | ||||||
| 		return ( |  | ||||||
| 			<> |  | ||||||
| 				<Back isLoading={false} {...css({ position: "relative", bg: (theme) => theme.accent })} /> |  | ||||||
| 				<ErrorView error={error ?? infoError ?? { errors: [playbackError!] }} /> |  | ||||||
| 			</> |  | ||||||
| 		); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<> |  | ||||||
| 			<Head title={title} description={data?.overview} /> |  | ||||||
| 			{data && info && ( |  | ||||||
| 				<WatchStatusObserver type={type} slug={data.slug} duration={info.durationSeconds} /> |  | ||||||
| 			)} |  | ||||||
| 			<View |  | ||||||
| 				{...css({ |  | ||||||
| 					flexGrow: 1, |  | ||||||
| 					flexShrink: 1, |  | ||||||
| 					bg: "black", |  | ||||||
| 				})} |  | ||||||
| 			> |  | ||||||
| 				<Video |  | ||||||
| 					metadata={{ |  | ||||||
| 						title: title ?? t("show.episodeNoMetadata"), |  | ||||||
| 						artist: subtitle ?? undefined, |  | ||||||
| 						description: data?.overview ?? undefined, |  | ||||||
| 						imageUri: image?.medium, |  | ||||||
| 						next: next, |  | ||||||
| 						previous: previous, |  | ||||||
| 					}} |  | ||||||
| 					links={data?.links} |  | ||||||
| 					audios={info?.audios} |  | ||||||
| 					subtitles={info?.subtitles} |  | ||||||
| 					codec={info?.mimeCodec} |  | ||||||
| 					setError={setPlaybackError} |  | ||||||
| 					fonts={info?.fonts} |  | ||||||
| 					startTime={startTime} |  | ||||||
| 					onEnd={() => { |  | ||||||
| 						if (!data) return; |  | ||||||
| 						if (data.type === "movie") |  | ||||||
| 							router.replace(`/movie/${data.slug}`, undefined, { |  | ||||||
| 								experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true }, |  | ||||||
| 							}); |  | ||||||
| 						else |  | ||||||
| 							router.replace(next ?? `/show/${data.show!.slug}`, undefined, { |  | ||||||
| 								experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true }, |  | ||||||
| 							}); |  | ||||||
| 					}} |  | ||||||
| 					{...css(StyleSheet.absoluteFillObject)} |  | ||||||
| 				/> |  | ||||||
| 				<LoadingIndicator /> |  | ||||||
| 				<Hover {...mapData(data, info, previous, next)} url={`${type}/${slug}`} /> |  | ||||||
| 			</View> |  | ||||||
| 		</> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| Player.query = (type: "episode" | "movie", slug: string): QueryIdentifier<Item> => |  | ||||||
| 	type === "episode" |  | ||||||
| 		? { |  | ||||||
| 				path: ["episode", slug], |  | ||||||
| 				params: { |  | ||||||
| 					fields: ["nextEpisode", "previousEpisode", "show", "watchStatus"], |  | ||||||
| 				}, |  | ||||||
| 				parser: EpisodeP.transform((x) => ({ ...x, type: "episode" })), |  | ||||||
| 			} |  | ||||||
| 		: { |  | ||||||
| 				path: ["movie", slug], |  | ||||||
| 				params: { |  | ||||||
| 					fields: ["watchStatus"], |  | ||||||
| 				}, |  | ||||||
| 				parser: MovieP.transform((x) => ({ ...x, type: "movie" })), |  | ||||||
| 			}; |  | ||||||
| 
 |  | ||||||
| Player.infoQuery = (type: "episode" | "movie", slug: string): QueryIdentifier<WatchInfo> => ({ |  | ||||||
| 	path: [type, slug, "info"], |  | ||||||
| 	parser: WatchInfoP, |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| // if more queries are needed, dont forget to update download.tsx to cache those.
 |  | ||||||
| Player.getFetchUrls = ({ slug, type }: { slug: string; type: "episode" | "movie" }) => [ |  | ||||||
| 	Player.query(type, slug), |  | ||||||
| 	Player.infoQuery(type, slug), |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| Player.requiredPermissions = ["overall.play"]; |  | ||||||
| @ -1,191 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import type { Subtitle } from "@kyoo/models"; |  | ||||||
| import { atom, useSetAtom } from "jotai"; |  | ||||||
| import { useEffect } from "react"; |  | ||||||
| import { Platform } from "react-native"; |  | ||||||
| import { useRouter } from "solito/router"; |  | ||||||
| import { |  | ||||||
| 	durationAtom, |  | ||||||
| 	fullscreenAtom, |  | ||||||
| 	mutedAtom, |  | ||||||
| 	playAtom, |  | ||||||
| 	progressAtom, |  | ||||||
| 	subtitleAtom, |  | ||||||
| 	volumeAtom, |  | ||||||
| } from "./state"; |  | ||||||
| 
 |  | ||||||
| type Action = |  | ||||||
| 	| { type: "play" } |  | ||||||
| 	| { type: "mute" } |  | ||||||
| 	| { type: "fullscreen" } |  | ||||||
| 	| { type: "seek"; value: number } |  | ||||||
| 	| { type: "seekTo"; value: number } |  | ||||||
| 	| { type: "seekPercent"; value: number } |  | ||||||
| 	| { type: "volume"; value: number } |  | ||||||
| 	| { type: "subtitle"; subtitles: Subtitle[]; fonts: string[] }; |  | ||||||
| 
 |  | ||||||
| export const reducerAtom = atom(null, (get, set, action: Action) => { |  | ||||||
| 	const duration = get(durationAtom); |  | ||||||
| 	switch (action.type) { |  | ||||||
| 		case "play": |  | ||||||
| 			set(playAtom, !get(playAtom)); |  | ||||||
| 			break; |  | ||||||
| 		case "mute": |  | ||||||
| 			set(mutedAtom, !get(mutedAtom)); |  | ||||||
| 			break; |  | ||||||
| 		case "fullscreen": |  | ||||||
| 			set(fullscreenAtom, !get(fullscreenAtom)); |  | ||||||
| 			break; |  | ||||||
| 		case "seek": |  | ||||||
| 			if (duration) |  | ||||||
| 				set(progressAtom, Math.max(0, Math.min(get(progressAtom) + action.value, duration))); |  | ||||||
| 			break; |  | ||||||
| 		case "seekTo": |  | ||||||
| 			set(progressAtom, action.value); |  | ||||||
| 			break; |  | ||||||
| 		case "seekPercent": |  | ||||||
| 			if (duration) set(progressAtom, (duration * action.value) / 100); |  | ||||||
| 			break; |  | ||||||
| 		case "volume": |  | ||||||
| 			set(volumeAtom, Math.max(0, Math.min(get(volumeAtom) + action.value, 100))); |  | ||||||
| 			break; |  | ||||||
| 		case "subtitle": { |  | ||||||
| 			const subtitle = get(subtitleAtom); |  | ||||||
| 			const index = subtitle ? action.subtitles.findIndex((x) => x.index === subtitle.index) : -1; |  | ||||||
| 			set( |  | ||||||
| 				subtitleAtom, |  | ||||||
| 				index === -1 ? null : action.subtitles[(index + 1) % action.subtitles.length], |  | ||||||
| 			); |  | ||||||
| 			break; |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export const useVideoKeyboard = ( |  | ||||||
| 	subtitles?: Subtitle[], |  | ||||||
| 	fonts?: string[], |  | ||||||
| 	previousEpisode?: string, |  | ||||||
| 	nextEpisode?: string, |  | ||||||
| ) => { |  | ||||||
| 	const reducer = useSetAtom(reducerAtom); |  | ||||||
| 	const router = useRouter(); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (Platform.OS !== "web") return; |  | ||||||
| 		const handler = (event: KeyboardEvent) => { |  | ||||||
| 			if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; |  | ||||||
| 
 |  | ||||||
| 			switch (event.key) { |  | ||||||
| 				case " ": |  | ||||||
| 				case "k": |  | ||||||
| 				case "MediaPlay": |  | ||||||
| 				case "MediaPause": |  | ||||||
| 				case "MediaPlayPause": |  | ||||||
| 					reducer({ type: "play" }); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "m": |  | ||||||
| 					reducer({ type: "mute" }); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "ArrowLeft": |  | ||||||
| 					reducer({ type: "seek", value: -5 }); |  | ||||||
| 					break; |  | ||||||
| 				case "ArrowRight": |  | ||||||
| 					reducer({ type: "seek", value: +5 }); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "j": |  | ||||||
| 					reducer({ type: "seek", value: -10 }); |  | ||||||
| 					break; |  | ||||||
| 				case "l": |  | ||||||
| 					reducer({ type: "seek", value: +10 }); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "ArrowUp": |  | ||||||
| 					reducer({ type: "volume", value: +5 }); |  | ||||||
| 					break; |  | ||||||
| 				case "ArrowDown": |  | ||||||
| 					reducer({ type: "volume", value: -5 }); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "f": |  | ||||||
| 					reducer({ type: "fullscreen" }); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "v": |  | ||||||
| 				case "c": |  | ||||||
| 					if (!subtitles || !fonts) return; |  | ||||||
| 					reducer({ type: "subtitle", subtitles, fonts }); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "n": |  | ||||||
| 				case "N": |  | ||||||
| 					if (nextEpisode) router.push(nextEpisode); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				case "p": |  | ||||||
| 				case "P": |  | ||||||
| 					if (previousEpisode) router.push(previousEpisode); |  | ||||||
| 					break; |  | ||||||
| 
 |  | ||||||
| 				default: |  | ||||||
| 					break; |  | ||||||
| 			} |  | ||||||
| 			switch (event.code) { |  | ||||||
| 				case "Digit0": |  | ||||||
| 					reducer({ type: "seekPercent", value: 0 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit1": |  | ||||||
| 					reducer({ type: "seekPercent", value: 10 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit2": |  | ||||||
| 					reducer({ type: "seekPercent", value: 20 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit3": |  | ||||||
| 					reducer({ type: "seekPercent", value: 30 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit4": |  | ||||||
| 					reducer({ type: "seekPercent", value: 40 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit5": |  | ||||||
| 					reducer({ type: "seekPercent", value: 50 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit6": |  | ||||||
| 					reducer({ type: "seekPercent", value: 60 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit7": |  | ||||||
| 					reducer({ type: "seekPercent", value: 70 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit8": |  | ||||||
| 					reducer({ type: "seekPercent", value: 80 }); |  | ||||||
| 					break; |  | ||||||
| 				case "Digit9": |  | ||||||
| 					reducer({ type: "seekPercent", value: 90 }); |  | ||||||
| 					break; |  | ||||||
| 			} |  | ||||||
| 		}; |  | ||||||
| 
 |  | ||||||
| 		document.addEventListener("keyup", handler); |  | ||||||
| 		return () => document.removeEventListener("keyup", handler); |  | ||||||
| 	}, [subtitles, fonts, nextEpisode, previousEpisode, router, reducer]); |  | ||||||
| }; |  | ||||||
| @ -1,102 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { useAtom, useAtomValue, useSetAtom } from "jotai"; |  | ||||||
| import { useEffect } from "react"; |  | ||||||
| import { useRouter } from "solito/router"; |  | ||||||
| import { reducerAtom } from "./keyboard"; |  | ||||||
| import { durationAtom, playAtom, progressAtom } from "./state"; |  | ||||||
| 
 |  | ||||||
| export const MediaSessionManager = ({ |  | ||||||
| 	title, |  | ||||||
| 	subtitle, |  | ||||||
| 	artist, |  | ||||||
| 	imageUri, |  | ||||||
| 	previous, |  | ||||||
| 	next, |  | ||||||
| }: { |  | ||||||
| 	title?: string; |  | ||||||
| 	subtitle?: string; |  | ||||||
| 	artist?: string; |  | ||||||
| 	imageUri?: string | null; |  | ||||||
| 	previous?: string; |  | ||||||
| 	next?: string; |  | ||||||
| }) => { |  | ||||||
| 	const [isPlaying, setPlay] = useAtom(playAtom); |  | ||||||
| 	const progress = useAtomValue(progressAtom); |  | ||||||
| 	const duration = useAtomValue(durationAtom); |  | ||||||
| 	const reducer = useSetAtom(reducerAtom); |  | ||||||
| 	const router = useRouter(); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!("mediaSession" in navigator)) return; |  | ||||||
| 		navigator.mediaSession.metadata = new MediaMetadata({ |  | ||||||
| 			title: title, |  | ||||||
| 			album: subtitle, |  | ||||||
| 			artist: artist, |  | ||||||
| 			artwork: imageUri ? [{ src: imageUri }] : undefined, |  | ||||||
| 		}); |  | ||||||
| 	}, [title, subtitle, artist, imageUri]); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!("mediaSession" in navigator)) return; |  | ||||||
| 		const actions: [MediaSessionAction, MediaSessionActionHandler | null][] = [ |  | ||||||
| 			["play", () => setPlay(true)], |  | ||||||
| 			["pause", () => setPlay(false)], |  | ||||||
| 			["previoustrack", previous ? () => router.push(previous) : null], |  | ||||||
| 			["nexttrack", next ? () => router.push(next) : null], |  | ||||||
| 			[ |  | ||||||
| 				"seekbackward", |  | ||||||
| 				(evt: MediaSessionActionDetails) => |  | ||||||
| 					reducer({ type: "seek", value: evt.seekOffset ? -evt.seekOffset : -10 }), |  | ||||||
| 			], |  | ||||||
| 			[ |  | ||||||
| 				"seekforward", |  | ||||||
| 				(evt: MediaSessionActionDetails) => |  | ||||||
| 					reducer({ type: "seek", value: evt.seekOffset ? evt.seekOffset : 10 }), |  | ||||||
| 			], |  | ||||||
| 			[ |  | ||||||
| 				"seekto", |  | ||||||
| 				(evt: MediaSessionActionDetails) => reducer({ type: "seekTo", value: evt.seekTime! }), |  | ||||||
| 			], |  | ||||||
| 		]; |  | ||||||
| 
 |  | ||||||
| 		for (const [action, handler] of actions) { |  | ||||||
| 			try { |  | ||||||
| 				navigator.mediaSession.setActionHandler(action, handler); |  | ||||||
| 			} catch {} |  | ||||||
| 		} |  | ||||||
| 	}, [setPlay, reducer, router, previous, next]); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!("mediaSession" in navigator)) return; |  | ||||||
| 		navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused"; |  | ||||||
| 	}, [isPlaying]); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!("mediaSession" in navigator) || !duration) return; |  | ||||||
| 		navigator.mediaSession.setPositionState({ |  | ||||||
| 			position: Math.min(progress, duration), |  | ||||||
| 			duration, |  | ||||||
| 			playbackRate: 1, |  | ||||||
| 		}); |  | ||||||
| 	}, [progress, duration]); |  | ||||||
| 
 |  | ||||||
| 	return null; |  | ||||||
| }; |  | ||||||
| @ -1,265 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { type Audio, type Episode, type Subtitle, getLocalSetting, useAccount } from "@kyoo/models"; |  | ||||||
| import { useSnackbar } from "@kyoo/primitives"; |  | ||||||
| import { atom, getDefaultStore, useAtom, useAtomValue, useSetAtom } from "jotai"; |  | ||||||
| import { useAtomCallback } from "jotai/utils"; |  | ||||||
| import { |  | ||||||
| 	type ElementRef, |  | ||||||
| 	memo, |  | ||||||
| 	useCallback, |  | ||||||
| 	useEffect, |  | ||||||
| 	useLayoutEffect, |  | ||||||
| 	useRef, |  | ||||||
| 	useState, |  | ||||||
| } from "react"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| import { Platform } from "react-native"; |  | ||||||
| import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "./video"; |  | ||||||
| 
 |  | ||||||
| export const playAtom = atom(true); |  | ||||||
| export const loadAtom = atom(false); |  | ||||||
| 
 |  | ||||||
| export enum PlayMode { |  | ||||||
| 	Direct, |  | ||||||
| 	Hls, |  | ||||||
| } |  | ||||||
| export const playModeAtom = atom<PlayMode>( |  | ||||||
| 	getLocalSetting("playmode", "direct") !== "auto" ? PlayMode.Direct : PlayMode.Hls, |  | ||||||
| ); |  | ||||||
| 
 |  | ||||||
| export const bufferedAtom = atom(0); |  | ||||||
| export const durationAtom = atom<number | undefined>(undefined); |  | ||||||
| 
 |  | ||||||
| export const progressAtom = atom( |  | ||||||
| 	(get) => get(privateProgressAtom), |  | ||||||
| 	(get, set, update: number | ((value: number) => number)) => { |  | ||||||
| 		const run = (value: number) => { |  | ||||||
| 			set(privateProgressAtom, value); |  | ||||||
| 			set(publicProgressAtom, value); |  | ||||||
| 		}; |  | ||||||
| 		if (typeof update === "function") run(update(get(privateProgressAtom))); |  | ||||||
| 		else run(update); |  | ||||||
| 	}, |  | ||||||
| ); |  | ||||||
| const privateProgressAtom = atom(0); |  | ||||||
| const publicProgressAtom = atom(0); |  | ||||||
| 
 |  | ||||||
| export const volumeAtom = atom(100); |  | ||||||
| export const mutedAtom = atom(false); |  | ||||||
| 
 |  | ||||||
| export const fullscreenAtom = atom( |  | ||||||
| 	(get) => get(privateFullscreen), |  | ||||||
| 	(get, set, update: boolean | ((value: boolean) => boolean)) => { |  | ||||||
| 		const run = async (value: boolean) => { |  | ||||||
| 			try { |  | ||||||
| 				if (value) { |  | ||||||
| 					await document.body.requestFullscreen({ |  | ||||||
| 						navigationUI: "hide", |  | ||||||
| 					}); |  | ||||||
| 					set(privateFullscreen, true); |  | ||||||
| 					// @ts-expect-error Firefox does not support this so ts complains
 |  | ||||||
| 					await screen.orientation.lock("landscape"); |  | ||||||
| 				} else { |  | ||||||
| 					if (document.fullscreenElement) await document.exitFullscreen(); |  | ||||||
| 					set(privateFullscreen, false); |  | ||||||
| 					screen.orientation.unlock(); |  | ||||||
| 				} |  | ||||||
| 			} catch (e) { |  | ||||||
| 				console.error(e); |  | ||||||
| 			} |  | ||||||
| 		}; |  | ||||||
| 		if (typeof update === "function") run(update(get(privateFullscreen))); |  | ||||||
| 		else run(update); |  | ||||||
| 	}, |  | ||||||
| ); |  | ||||||
| const privateFullscreen = atom(false); |  | ||||||
| 
 |  | ||||||
| export const subtitleAtom = atom<Subtitle | null>(null); |  | ||||||
| export const audioAtom = atom<Audio>({ index: 0 } as Audio); |  | ||||||
| 
 |  | ||||||
| export const Video = memo(function Video({ |  | ||||||
| 	links, |  | ||||||
| 	subtitles, |  | ||||||
| 	audios, |  | ||||||
| 	codec, |  | ||||||
| 	setError, |  | ||||||
| 	fonts, |  | ||||||
| 	startTime: startTimeP, |  | ||||||
| 	metadata, |  | ||||||
| 	...props |  | ||||||
| }: { |  | ||||||
| 	links?: Episode["links"]; |  | ||||||
| 	subtitles?: Subtitle[]; |  | ||||||
| 	audios?: Audio[]; |  | ||||||
| 	codec?: string; |  | ||||||
| 	setError: (error: string | undefined) => void; |  | ||||||
| 	fonts?: string[]; |  | ||||||
| 	startTime?: number | null; |  | ||||||
| 	metadata: VideoMetadata & { next?: string; previous?: string }; |  | ||||||
| } & Partial<VideoProps>) { |  | ||||||
| 	const ref = useRef<ElementRef<typeof NativeVideo> | null>(null); |  | ||||||
| 	const [isPlaying, setPlay] = useAtom(playAtom); |  | ||||||
| 	const setLoad = useSetAtom(loadAtom); |  | ||||||
| 	const [source, setSource] = useState<string | null>(null); |  | ||||||
| 	const [mode, setPlayMode] = useAtom(playModeAtom); |  | ||||||
| 
 |  | ||||||
| 	const startTime = useRef(startTimeP); |  | ||||||
| 	useLayoutEffect(() => { |  | ||||||
| 		startTime.current = startTimeP; |  | ||||||
| 	}, [startTimeP]); |  | ||||||
| 
 |  | ||||||
| 	const publicProgress = useAtomValue(publicProgressAtom); |  | ||||||
| 	const setPrivateProgress = useSetAtom(privateProgressAtom); |  | ||||||
| 	const setPublicProgress = useSetAtom(publicProgressAtom); |  | ||||||
| 	const setBuffered = useSetAtom(bufferedAtom); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		ref.current?.seek(publicProgress); |  | ||||||
| 	}, [publicProgress]); |  | ||||||
| 
 |  | ||||||
| 	const getProgress = useAtomCallback(useCallback((get) => get(progressAtom), [])); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		// Reset the state when a new video is loaded.
 |  | ||||||
| 
 |  | ||||||
| 		let newMode = getLocalSetting("playmode", "direct") !== "auto" ? PlayMode.Direct : PlayMode.Hls; |  | ||||||
| 		// Only allow direct play if the device supports it
 |  | ||||||
| 		if (newMode === PlayMode.Direct && codec && !canPlay(codec)) { |  | ||||||
| 			console.log(`Browser can't natively play ${codec}, switching to hls stream.`); |  | ||||||
| 			newMode = PlayMode.Hls; |  | ||||||
| 		} |  | ||||||
| 		setPlayMode(newMode); |  | ||||||
| 
 |  | ||||||
| 		setSource((newMode === PlayMode.Direct ? links?.direct : links?.hls) ?? null); |  | ||||||
| 		setLoad(true); |  | ||||||
| 		setPrivateProgress(startTime.current ?? 0); |  | ||||||
| 		setPublicProgress(startTime.current ?? 0); |  | ||||||
| 		setPlay(true); |  | ||||||
| 	}, [links, codec, setLoad, setPrivateProgress, setPublicProgress, setPlay, setPlayMode]); |  | ||||||
| 
 |  | ||||||
| 	// biome-ignore lint/correctness/useExhaustiveDependencies: do not change source when links change, this is done above
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		setSource((mode === PlayMode.Direct ? links?.direct : links?.hls) ?? null); |  | ||||||
| 		// keep current time when changing between direct and hls.
 |  | ||||||
| 		startTime.current = getProgress(); |  | ||||||
| 		setPlay(true); |  | ||||||
| 	}, [mode, getProgress, setPlay]); |  | ||||||
| 
 |  | ||||||
| 	const account = useAccount(); |  | ||||||
| 	const defaultSubLanguage = account?.settings.subtitleLanguage; |  | ||||||
| 	const setSubtitle = useSetAtom(subtitleAtom); |  | ||||||
| 
 |  | ||||||
| 	// When the video change, try to persist the subtitle language.
 |  | ||||||
| 	// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!subtitles) return; |  | ||||||
| 		setSubtitle((subtitle) => { |  | ||||||
| 			const subRet = subtitle |  | ||||||
| 				? subtitles.find( |  | ||||||
| 						(x) => x.language === subtitle.language && x.isForced === subtitle.isForced, |  | ||||||
| 					) |  | ||||||
| 				: null; |  | ||||||
| 			if (subRet) return subRet; |  | ||||||
| 			if (!defaultSubLanguage) return null; |  | ||||||
| 			if (defaultSubLanguage === "default") return subtitles.find((x) => x.isDefault) ?? null; |  | ||||||
| 			return subtitles.find((x) => x.language === defaultSubLanguage) ?? null; |  | ||||||
| 		}); |  | ||||||
| 	}, [subtitles, setSubtitle, defaultSubLanguage, ref.current]); |  | ||||||
| 
 |  | ||||||
| 	const defaultAudioLanguage = account?.settings.audioLanguage ?? "default"; |  | ||||||
| 	const setAudio = useSetAtom(audioAtom); |  | ||||||
| 	// When the video change, try to persist the subtitle language.
 |  | ||||||
| 	// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!audios) return; |  | ||||||
| 		setAudio((audio) => { |  | ||||||
| 			if (audio) { |  | ||||||
| 				const ret = audios.find((x) => x.language === audio.language); |  | ||||||
| 				if (ret) return ret; |  | ||||||
| 			} |  | ||||||
| 			if (defaultAudioLanguage !== "default") { |  | ||||||
| 				const ret = audios.find((x) => x.language === defaultAudioLanguage); |  | ||||||
| 				if (ret) return ret; |  | ||||||
| 			} |  | ||||||
| 			return audios.find((x) => x.isDefault) ?? audios[0]; |  | ||||||
| 		}); |  | ||||||
| 	}, [audios, setAudio, defaultAudioLanguage, ref.current]); |  | ||||||
| 
 |  | ||||||
| 	const volume = useAtomValue(volumeAtom); |  | ||||||
| 	const isMuted = useAtomValue(mutedAtom); |  | ||||||
| 
 |  | ||||||
| 	const setFullscreen = useSetAtom(privateFullscreen); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (Platform.OS !== "web") return; |  | ||||||
| 		const handler = () => { |  | ||||||
| 			setFullscreen(document.fullscreenElement != null); |  | ||||||
| 		}; |  | ||||||
| 		document.addEventListener("fullscreenchange", handler); |  | ||||||
| 		return () => document.removeEventListener("fullscreenchange", handler); |  | ||||||
| 	}); |  | ||||||
| 
 |  | ||||||
| 	const createSnackbar = useSnackbar(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 
 |  | ||||||
| 	if (!source || !links) return null; |  | ||||||
| 	return ( |  | ||||||
| 		<NativeVideo |  | ||||||
| 			ref={ref} |  | ||||||
| 			{...props} |  | ||||||
| 			source={{ |  | ||||||
| 				uri: source, |  | ||||||
| 				startPosition: startTime.current ? startTime.current * 1000 : undefined, |  | ||||||
| 				metadata: metadata, |  | ||||||
| 				...links, |  | ||||||
| 			}} |  | ||||||
| 			showNotificationControls |  | ||||||
| 			playInBackground |  | ||||||
| 			playWhenInactive |  | ||||||
| 			disableDisconnectError |  | ||||||
| 			paused={!isPlaying} |  | ||||||
| 			muted={isMuted} |  | ||||||
| 			volume={volume} |  | ||||||
| 			resizeMode="contain" |  | ||||||
| 			onBuffer={({ isBuffering }) => setLoad(isBuffering)} |  | ||||||
| 			onError={(status) => { |  | ||||||
| 				console.error(status); |  | ||||||
| 				setError(status.error.errorString); |  | ||||||
| 			}} |  | ||||||
| 			onProgress={(progress) => { |  | ||||||
| 				setPrivateProgress(progress.currentTime); |  | ||||||
| 				setBuffered(progress.playableDuration); |  | ||||||
| 			}} |  | ||||||
| 			onPlaybackStateChanged={(state) => { |  | ||||||
| 				if (state.isSeeking || getDefaultStore().get(loadAtom)) return; |  | ||||||
| 				setPlay(state.isPlaying); |  | ||||||
| 			}} |  | ||||||
| 			fonts={fonts} |  | ||||||
| 			subtitles={subtitles} |  | ||||||
| 			onMediaUnsupported={() => { |  | ||||||
| 				createSnackbar({ |  | ||||||
| 					key: "unsuported", |  | ||||||
| 					label: t("player.unsupportedError"), |  | ||||||
| 					duration: 3, |  | ||||||
| 				}); |  | ||||||
| 				if (mode === PlayMode.Direct) setPlayMode(PlayMode.Hls); |  | ||||||
| 			}} |  | ||||||
| 		/> |  | ||||||
| 	); |  | ||||||
| }); |  | ||||||
| @ -1,199 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import "react-native-video"; |  | ||||||
| import type { ReactVideoSourceProperties } from "react-native-video"; |  | ||||||
| 
 |  | ||||||
| declare module "react-native-video" { |  | ||||||
| 	interface ReactVideoProps { |  | ||||||
| 		fonts?: string[]; |  | ||||||
| 		subtitles?: Subtitle[]; |  | ||||||
| 		onMediaUnsupported?: () => void; |  | ||||||
| 	} |  | ||||||
| 	export type VideoProps = Omit<ReactVideoProps, "source"> & { |  | ||||||
| 		source: ReactVideoSourceProperties & { hls: string | null }; |  | ||||||
| 	}; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export * from "react-native-video"; |  | ||||||
| 
 |  | ||||||
| import { type Audio, type Subtitle, useToken } from "@kyoo/models"; |  | ||||||
| import { type IconButton, Menu } from "@kyoo/primitives"; |  | ||||||
| import "@kyoo/primitives/src/types.d.ts"; |  | ||||||
| import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; |  | ||||||
| import { type ComponentProps, forwardRef, useEffect } from "react"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| import { View } from "react-native"; |  | ||||||
| import uuid from "react-native-uuid"; |  | ||||||
| import NativeVideo, { |  | ||||||
| 	type VideoRef, |  | ||||||
| 	type OnLoadData, |  | ||||||
| 	type VideoProps, |  | ||||||
| 	SelectedTrackType, |  | ||||||
| 	SelectedVideoTrackType, |  | ||||||
| } from "react-native-video"; |  | ||||||
| import { useYoshiki } from "yoshiki/native"; |  | ||||||
| import { useDisplayName } from "../utils"; |  | ||||||
| import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./state"; |  | ||||||
| 
 |  | ||||||
| const MimeTypes: Map<string, string> = new Map([ |  | ||||||
| 	["subrip", "application/x-subrip"], |  | ||||||
| 	["ass", "text/x-ssa"], |  | ||||||
| 	["vtt", "text/vtt"], |  | ||||||
| ]); |  | ||||||
| 
 |  | ||||||
| const infoAtom = atom<OnLoadData | null>(null); |  | ||||||
| const videoAtom = atom(0); |  | ||||||
| 
 |  | ||||||
| const clientId = uuid.v4() as string; |  | ||||||
| 
 |  | ||||||
| const Video = forwardRef<VideoRef, VideoProps>(function Video( |  | ||||||
| 	{ onLoad, onBuffer, onError, onMediaUnsupported, source, subtitles, ...props }, |  | ||||||
| 	ref, |  | ||||||
| ) { |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const token = useToken(); |  | ||||||
| 	const setInfo = useSetAtom(infoAtom); |  | ||||||
| 	const [video, setVideo] = useAtom(videoAtom); |  | ||||||
| 	const audio = useAtomValue(audioAtom); |  | ||||||
| 	const subtitle = useAtomValue(subtitleAtom); |  | ||||||
| 	const mode = useAtomValue(playModeAtom); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (mode === PlayMode.Hls) setVideo(-1); |  | ||||||
| 	}, [mode, setVideo]); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<View {...css({ flexGrow: 1, flexShrink: 1 })}> |  | ||||||
| 			<NativeVideo |  | ||||||
| 				ref={ref} |  | ||||||
| 				source={{ |  | ||||||
| 					...source, |  | ||||||
| 					headers: { |  | ||||||
| 						...(token ? { Authorization: token } : {}), |  | ||||||
| 						"X-CLIENT-ID": clientId, |  | ||||||
| 					}, |  | ||||||
| 				}} |  | ||||||
| 				onLoad={(info) => { |  | ||||||
| 					onBuffer?.({ isBuffering: false }); |  | ||||||
| 					setInfo(info); |  | ||||||
| 					onLoad?.(info); |  | ||||||
| 				}} |  | ||||||
| 				onBuffer={onBuffer} |  | ||||||
| 				onError={(error) => { |  | ||||||
| 					console.error(error); |  | ||||||
| 					if (mode === PlayMode.Direct) onMediaUnsupported?.(); |  | ||||||
| 					else onError?.(error); |  | ||||||
| 				}} |  | ||||||
| 				selectedVideoTrack={ |  | ||||||
| 					video === -1 |  | ||||||
| 						? { type: SelectedVideoTrackType.AUTO } |  | ||||||
| 						: { type: SelectedVideoTrackType.RESOLUTION, value: video } |  | ||||||
| 				} |  | ||||||
| 				// when video file is invalid, audio is undefined
 |  | ||||||
| 				selectedAudioTrack={{ type: SelectedTrackType.INDEX, value: audio?.index ?? 0 }} |  | ||||||
| 				textTracks={subtitles |  | ||||||
| 					?.filter((x) => !!x.link) |  | ||||||
| 					.map((x) => ({ |  | ||||||
| 						type: MimeTypes.get(x.codec) as any, |  | ||||||
| 						uri: x.link!, |  | ||||||
| 						title: x.title ?? "Unknown", |  | ||||||
| 						language: x.language ?? ("Unknown" as any), |  | ||||||
| 					}))} |  | ||||||
| 				selectedTextTrack={ |  | ||||||
| 					subtitle |  | ||||||
| 						? { |  | ||||||
| 								type: SelectedTrackType.INDEX, |  | ||||||
| 								value: subtitles?.indexOf(subtitle), |  | ||||||
| 							} |  | ||||||
| 						: { type: SelectedTrackType.DISABLED, value: "" } |  | ||||||
| 				} |  | ||||||
| 				{...props} |  | ||||||
| 			/> |  | ||||||
| 		</View> |  | ||||||
| 	); |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export default Video; |  | ||||||
| 
 |  | ||||||
| // mobile should be able to play everything
 |  | ||||||
| export const canPlay = (_codec: string) => true; |  | ||||||
| 
 |  | ||||||
| type CustomMenu = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>; |  | ||||||
| export const AudiosMenu = ({ audios, ...props }: CustomMenu & { audios?: Audio[] }) => { |  | ||||||
| 	const info = useAtomValue(infoAtom); |  | ||||||
| 	const [audio, setAudio] = useAtom(audioAtom); |  | ||||||
| 	const getDisplayName = useDisplayName(); |  | ||||||
| 
 |  | ||||||
| 	if (!info || info.audioTracks.length < 2) return null; |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<Menu {...props}> |  | ||||||
| 			{info.audioTracks.map((x) => ( |  | ||||||
| 				<Menu.Item |  | ||||||
| 					key={x.index} |  | ||||||
| 					label={audios ? getDisplayName(audios[x.index]) : (x.title ?? x.language ?? "Unknown")} |  | ||||||
| 					selected={audio!.index === x.index} |  | ||||||
| 					onSelect={() => setAudio(x as any)} |  | ||||||
| 				/> |  | ||||||
| 			))} |  | ||||||
| 		</Menu> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const QualitiesMenu = (props: CustomMenu) => { |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 	const info = useAtomValue(infoAtom); |  | ||||||
| 	const [mode, setPlayMode] = useAtom(playModeAtom); |  | ||||||
| 	const [video, setVideo] = useAtom(videoAtom); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<Menu {...props}> |  | ||||||
| 			<Menu.Item |  | ||||||
| 				label={t("player.direct")} |  | ||||||
| 				selected={mode === PlayMode.Direct} |  | ||||||
| 				onSelect={() => setPlayMode(PlayMode.Direct)} |  | ||||||
| 			/> |  | ||||||
| 			<Menu.Item |  | ||||||
| 				// TODO: Display the currently selected quality (impossible with rn-video right now)
 |  | ||||||
| 				label={t("player.auto")} |  | ||||||
| 				selected={video === -1 && mode === PlayMode.Hls} |  | ||||||
| 				onSelect={() => { |  | ||||||
| 					setPlayMode(PlayMode.Hls); |  | ||||||
| 					setVideo(-1); |  | ||||||
| 				}} |  | ||||||
| 			/> |  | ||||||
| 			{/* TODO: Support video tracks when the play mode is not hls. */} |  | ||||||
| 			{info?.videoTracks |  | ||||||
| 				.sort((a: any, b: any) => b.height - a.height) |  | ||||||
| 				.map((x: any, i: number) => ( |  | ||||||
| 					<Menu.Item |  | ||||||
| 						key={i} |  | ||||||
| 						label={`${x.height}p`} |  | ||||||
| 						selected={video === x.height} |  | ||||||
| 						onSelect={() => { |  | ||||||
| 							setPlayMode(PlayMode.Hls); |  | ||||||
| 							setVideo(x.height); |  | ||||||
| 						}} |  | ||||||
| 					/> |  | ||||||
| 				))} |  | ||||||
| 		</Menu> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| @ -1,452 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { type Audio, type Subtitle, getToken } from "@kyoo/models"; |  | ||||||
| import { Menu, tooltip } from "@kyoo/primitives"; |  | ||||||
| import Hls, { type Level, type LoadPolicy } from "hls.js"; |  | ||||||
| import Jassub from "jassub"; |  | ||||||
| import { useAtom, useAtomValue, useSetAtom } from "jotai"; |  | ||||||
| import { |  | ||||||
| 	type ComponentProps, |  | ||||||
| 	type RefObject, |  | ||||||
| 	forwardRef, |  | ||||||
| 	useEffect, |  | ||||||
| 	useImperativeHandle, |  | ||||||
| 	useLayoutEffect, |  | ||||||
| 	useRef, |  | ||||||
| } from "react"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| import type { VideoProps } from "react-native-video"; |  | ||||||
| import toVttBlob from "srt-webvtt"; |  | ||||||
| import { useForceRerender, useYoshiki } from "yoshiki"; |  | ||||||
| import { useDisplayName } from "../utils"; |  | ||||||
| import { MediaSessionManager } from "./media-session"; |  | ||||||
| import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./state"; |  | ||||||
| 
 |  | ||||||
| let hls: Hls | null = null; |  | ||||||
| 
 |  | ||||||
| function uuidv4(): string { |  | ||||||
| 	// @ts-ignore I have no clue how this works, thanks https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
 |  | ||||||
| 	return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => |  | ||||||
| 		(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), |  | ||||||
| 	); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const client_id = typeof window === "undefined" ? "ssr" : uuidv4(); |  | ||||||
| 
 |  | ||||||
| const initHls = (): Hls => { |  | ||||||
| 	if (hls) hls.destroy(); |  | ||||||
| 	const loadPolicy: LoadPolicy = { |  | ||||||
| 		default: { |  | ||||||
| 			maxTimeToFirstByteMs: Number.POSITIVE_INFINITY, |  | ||||||
| 			maxLoadTimeMs: 60_000, |  | ||||||
| 			timeoutRetry: { |  | ||||||
| 				maxNumRetry: 2, |  | ||||||
| 				retryDelayMs: 0, |  | ||||||
| 				maxRetryDelayMs: 0, |  | ||||||
| 			}, |  | ||||||
| 			errorRetry: { |  | ||||||
| 				maxNumRetry: 1, |  | ||||||
| 				retryDelayMs: 0, |  | ||||||
| 				maxRetryDelayMs: 0, |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 	}; |  | ||||||
| 	hls = new Hls({ |  | ||||||
| 		xhrSetup: async (xhr) => { |  | ||||||
| 			const token = await getToken(); |  | ||||||
| 			if (token) xhr.setRequestHeader("Authorization", token); |  | ||||||
| 			xhr.setRequestHeader("X-CLIENT-ID", client_id); |  | ||||||
| 		}, |  | ||||||
| 		autoStartLoad: false, |  | ||||||
| 		startLevel: Number.POSITIVE_INFINITY, |  | ||||||
| 		abrEwmaDefaultEstimate: 35_000_000, |  | ||||||
| 		abrEwmaDefaultEstimateMax: 50_000_000, |  | ||||||
| 		// debug: true,
 |  | ||||||
| 		lowLatencyMode: false, |  | ||||||
| 		fragLoadPolicy: { |  | ||||||
| 			default: { |  | ||||||
| 				maxTimeToFirstByteMs: Number.POSITIVE_INFINITY, |  | ||||||
| 				maxLoadTimeMs: 60_000, |  | ||||||
| 				timeoutRetry: { |  | ||||||
| 					maxNumRetry: 5, |  | ||||||
| 					retryDelayMs: 100, |  | ||||||
| 					maxRetryDelayMs: 0, |  | ||||||
| 				}, |  | ||||||
| 				errorRetry: { |  | ||||||
| 					maxNumRetry: 5, |  | ||||||
| 					retryDelayMs: 0, |  | ||||||
| 					maxRetryDelayMs: 100, |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 		keyLoadPolicy: loadPolicy, |  | ||||||
| 		certLoadPolicy: loadPolicy, |  | ||||||
| 		playlistLoadPolicy: loadPolicy, |  | ||||||
| 		manifestLoadPolicy: loadPolicy, |  | ||||||
| 		steeringManifestLoadPolicy: loadPolicy, |  | ||||||
| 	}); |  | ||||||
| 	return hls; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function Video( |  | ||||||
| 	{ |  | ||||||
| 		source, |  | ||||||
| 		paused, |  | ||||||
| 		muted, |  | ||||||
| 		volume, |  | ||||||
| 		onBuffer, |  | ||||||
| 		onLoad, |  | ||||||
| 		onProgress, |  | ||||||
| 		onError, |  | ||||||
| 		onEnd, |  | ||||||
| 		onPlaybackStateChanged, |  | ||||||
| 		onMediaUnsupported, |  | ||||||
| 		fonts, |  | ||||||
| 	}, |  | ||||||
| 	forwaredRef, |  | ||||||
| ) { |  | ||||||
| 	const ref = useRef<HTMLVideoElement>(null); |  | ||||||
| 	const oldHls = useRef<string | null>(null); |  | ||||||
| 	const { css } = useYoshiki(); |  | ||||||
| 	const errorHandler = useRef<typeof onError>(onError); |  | ||||||
| 	errorHandler.current = onError; |  | ||||||
| 
 |  | ||||||
| 	useImperativeHandle( |  | ||||||
| 		forwaredRef, |  | ||||||
| 		() => ({ |  | ||||||
| 			seek: (value: number) => { |  | ||||||
| 				if (ref.current) ref.current.currentTime = value; |  | ||||||
| 			}, |  | ||||||
| 		}), |  | ||||||
| 		[], |  | ||||||
| 	); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!ref.current || paused === ref.current.paused) return; |  | ||||||
| 		if (paused) ref.current?.pause(); |  | ||||||
| 		else ref.current?.play().catch(() => {}); |  | ||||||
| 	}, [paused]); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!ref.current || !volume) return; |  | ||||||
| 		ref.current.volume = Math.max(0, Math.min(volume, 100)) / 100; |  | ||||||
| 	}, [volume]); |  | ||||||
| 
 |  | ||||||
| 	const subtitle = useAtomValue(subtitleAtom); |  | ||||||
| 	useSubtitle(ref, subtitle, fonts); |  | ||||||
| 
 |  | ||||||
| 	// biome-ignore lint/correctness/useExhaustiveDependencies: do not restart on startPosition change
 |  | ||||||
| 	useLayoutEffect(() => { |  | ||||||
| 		if (!ref?.current || !source.uri) return; |  | ||||||
| 		if (!hls || oldHls.current !== source.hls) { |  | ||||||
| 			// Reinit the hls player when we change track.
 |  | ||||||
| 			hls = initHls(); |  | ||||||
| 			hls.loadSource(source.hls!); |  | ||||||
| 			oldHls.current = source.hls; |  | ||||||
| 		} |  | ||||||
| 		if (!source.uri.endsWith(".m3u8")) { |  | ||||||
| 			hls.detachMedia(); |  | ||||||
| 			ref.current.src = source.uri; |  | ||||||
| 		} else { |  | ||||||
| 			hls.attachMedia(ref.current); |  | ||||||
| 			hls.startLoad(source.startPosition ? source.startPosition / 1000 : 0); |  | ||||||
| 			hls.on(Hls.Events.ERROR, (_, d) => { |  | ||||||
| 				if (!d.fatal || !hls?.media) return; |  | ||||||
| 				console.warn("Hls error", d); |  | ||||||
| 				errorHandler.current?.({ |  | ||||||
| 					error: { errorString: d.reason ?? d.error?.message ?? "Unknown hls error" }, |  | ||||||
| 				}); |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 	}, [source.uri, source.hls]); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		return () => { |  | ||||||
| 			console.log("hls cleanup"); |  | ||||||
| 			if (hls) hls.destroy(); |  | ||||||
| 			hls = null; |  | ||||||
| 		}; |  | ||||||
| 	}, []); |  | ||||||
| 
 |  | ||||||
| 	const mode = useAtomValue(playModeAtom); |  | ||||||
| 	const audio = useAtomValue(audioAtom); |  | ||||||
| 	// biome-ignore lint/correctness/useExhaustiveDependencies: also change when the mode change
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!hls) return; |  | ||||||
| 		const update = () => { |  | ||||||
| 			if (!hls) return; |  | ||||||
| 			hls.audioTrack = audio?.index ?? 0; |  | ||||||
| 		}; |  | ||||||
| 		update(); |  | ||||||
| 		hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, update); |  | ||||||
| 		return () => hls?.off(Hls.Events.AUDIO_TRACKS_UPDATED, update); |  | ||||||
| 	}, [audio, mode]); |  | ||||||
| 
 |  | ||||||
| 	const setPlay = useSetAtom(playAtom); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!ref.current) return; |  | ||||||
| 		// Set play state to the player's value (if autoplay is denied)
 |  | ||||||
| 		setPlay(!ref.current.paused); |  | ||||||
| 	}, [setPlay]); |  | ||||||
| 
 |  | ||||||
| 	const setProgress = useSetAtom(progressAtom); |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<> |  | ||||||
| 			<MediaSessionManager {...source.metadata} /> |  | ||||||
| 			<video |  | ||||||
| 				ref={ref} |  | ||||||
| 				src={source.uri} |  | ||||||
| 				muted={muted} |  | ||||||
| 				autoPlay={!paused} |  | ||||||
| 				controls={false} |  | ||||||
| 				playsInline |  | ||||||
| 				onCanPlay={() => onBuffer?.call(null, { isBuffering: false })} |  | ||||||
| 				onWaiting={() => onBuffer?.call(null, { isBuffering: true })} |  | ||||||
| 				onDurationChange={() => { |  | ||||||
| 					if (!ref.current) return; |  | ||||||
| 					onLoad?.call(null, { duration: ref.current.duration } as any); |  | ||||||
| 				}} |  | ||||||
| 				onTimeUpdate={() => { |  | ||||||
| 					if (!ref.current) return; |  | ||||||
| 					onProgress?.call(null, { |  | ||||||
| 						currentTime: ref.current.currentTime, |  | ||||||
| 						playableDuration: ref.current.buffered.length |  | ||||||
| 							? ref.current.buffered.end(ref.current.buffered.length - 1) |  | ||||||
| 							: 0, |  | ||||||
| 						seekableDuration: 0, |  | ||||||
| 					}); |  | ||||||
| 				}} |  | ||||||
| 				onError={() => { |  | ||||||
| 					if (ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) |  | ||||||
| 						onMediaUnsupported?.call(undefined); |  | ||||||
| 					else { |  | ||||||
| 						onError?.call(null, { |  | ||||||
| 							error: { errorString: ref.current?.error?.message ?? "Unknown error" }, |  | ||||||
| 						}); |  | ||||||
| 					} |  | ||||||
| 				}} |  | ||||||
| 				onLoadedMetadata={() => { |  | ||||||
| 					if (source.startPosition) setProgress(source.startPosition / 1000); |  | ||||||
| 				}} |  | ||||||
| 				onPlay={() => onPlaybackStateChanged?.({ isPlaying: true, isSeeking: false })} |  | ||||||
| 				onPause={() => onPlaybackStateChanged?.({ isPlaying: false, isSeeking: false })} |  | ||||||
| 				onEnded={onEnd} |  | ||||||
| 				{...css({ width: "100%", height: "100%", objectFit: "contain" })} |  | ||||||
| 			/> |  | ||||||
| 		</> |  | ||||||
| 	); |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export default Video; |  | ||||||
| 
 |  | ||||||
| export const canPlay = (codec: string) => { |  | ||||||
| 	// most chrome based browser (and safari I think) supports matroska but reports they do not.
 |  | ||||||
| 	// for those browsers, only check the codecs and not the container.
 |  | ||||||
| 	if (navigator.userAgent.search("Firefox") === -1) |  | ||||||
| 		codec = codec.replace("video/x-matroska", "video/mp4"); |  | ||||||
| 	const videos = document.getElementsByTagName("video"); |  | ||||||
| 	const video = videos.item(0) ?? document.createElement("video"); |  | ||||||
| 	return !!video.canPlayType(codec); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const useSubtitle = ( |  | ||||||
| 	player: RefObject<HTMLVideoElement>, |  | ||||||
| 	value: Subtitle | null, |  | ||||||
| 	fonts?: string[], |  | ||||||
| ) => { |  | ||||||
| 	const htmlTrack = useRef<HTMLTrackElement | null>(); |  | ||||||
| 	const subOcto = useRef<Jassub | null>(); |  | ||||||
| 	const mode = useAtom(playModeAtom); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!player.current) return; |  | ||||||
| 
 |  | ||||||
| 		const removeHtmlSubtitle = () => { |  | ||||||
| 			if (htmlTrack.current) htmlTrack.current.remove(); |  | ||||||
| 			htmlTrack.current = null; |  | ||||||
| 		}; |  | ||||||
| 
 |  | ||||||
| 		const removeOctoSub = () => { |  | ||||||
| 			if (subOcto.current) subOcto.current.destroy(); |  | ||||||
| 			subOcto.current = null; |  | ||||||
| 		}; |  | ||||||
| 
 |  | ||||||
| 		if (!value || !value.link) { |  | ||||||
| 			removeHtmlSubtitle(); |  | ||||||
| 			removeOctoSub(); |  | ||||||
| 		} else if (value.codec === "vtt" || value.codec === "subrip") { |  | ||||||
| 			removeOctoSub(); |  | ||||||
| 			if (player.current.textTracks.length > 0) player.current.textTracks[0].mode = "hidden"; |  | ||||||
| 			const addSubtitle = async () => { |  | ||||||
| 				const track: HTMLTrackElement = htmlTrack.current ?? document.createElement("track"); |  | ||||||
| 				track.kind = "subtitles"; |  | ||||||
| 				track.label = value.title ?? value.language ?? "Subtitle"; |  | ||||||
| 				if (value.language) track.srclang = value.language; |  | ||||||
| 				track.src = value.codec === "subrip" ? await toWebVtt(value.link!) : value.link!; |  | ||||||
| 				track.className = "subtitle_container"; |  | ||||||
| 				track.default = true; |  | ||||||
| 				track.onload = () => { |  | ||||||
| 					if (player.current) player.current.textTracks[0].mode = "showing"; |  | ||||||
| 				}; |  | ||||||
| 				if (!htmlTrack.current) { |  | ||||||
| 					htmlTrack.current = track; |  | ||||||
| 					if (player.current) player.current.appendChild(track); |  | ||||||
| 				} |  | ||||||
| 			}; |  | ||||||
| 			addSubtitle(); |  | ||||||
| 		} else if (value.codec === "ass") { |  | ||||||
| 			removeHtmlSubtitle(); |  | ||||||
| 			// Also recreate jassub when the player changes (this is not the most effective but
 |  | ||||||
| 			// since it creates a div/canvas, it needs to be recreated when the UI rerender)
 |  | ||||||
| 			// @ts-expect-error We are accessing the private _video field here.
 |  | ||||||
| 			if (!subOcto.current || subOcto.current._video !== player.current) { |  | ||||||
| 				removeOctoSub(); |  | ||||||
| 				subOcto.current = new Jassub({ |  | ||||||
| 					video: player.current, |  | ||||||
| 					workerUrl: "/_next/static/chunks/jassub-worker.js", |  | ||||||
| 					wasmUrl: "/_next/static/chunks/jassub-worker.wasm", |  | ||||||
| 					legacyWasmUrl: "/_next/static/chunks/jassub-worker.wasm.js", |  | ||||||
| 					// Disable offscreen renderer due to bugs on firefox and chrome android
 |  | ||||||
| 					// (see https://github.com/ThaUnknown/jassub/issues/31)
 |  | ||||||
| 					offscreenRender: false, |  | ||||||
| 					subUrl: value.link, |  | ||||||
| 					fonts: fonts, |  | ||||||
| 				}); |  | ||||||
| 			} else { |  | ||||||
| 				subOcto.current.freeTrack(); |  | ||||||
| 				subOcto.current.setTrackByUrl(value.link); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		// also include mode because srt get's disabled when the mode change (no idea why)
 |  | ||||||
| 		mode; |  | ||||||
| 	}, [player.current, value, fonts, mode]); |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		return () => { |  | ||||||
| 			if (subOcto.current) subOcto.current.destroy(); |  | ||||||
| 			subOcto.current = null; |  | ||||||
| 			if (htmlTrack.current) htmlTrack.current.remove(); |  | ||||||
| 			htmlTrack.current = null; |  | ||||||
| 		}; |  | ||||||
| 	}, []); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const toWebVtt = async (srtUrl: string) => { |  | ||||||
| 	const token = await getToken(); |  | ||||||
| 	const query = await fetch(srtUrl, { |  | ||||||
| 		headers: token |  | ||||||
| 			? { |  | ||||||
| 					Authorization: token, |  | ||||||
| 				} |  | ||||||
| 			: undefined, |  | ||||||
| 	}); |  | ||||||
| 	const srt = await query.blob(); |  | ||||||
| 	return await toVttBlob(srt); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const AudiosMenu = ({ |  | ||||||
| 	audios, |  | ||||||
| 	...props |  | ||||||
| }: ComponentProps<typeof Menu<{ disabled?: boolean }>> & { audios?: Audio[] }) => { |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 	const rerender = useForceRerender(); |  | ||||||
| 	const [_, setAudio] = useAtom(audioAtom); |  | ||||||
| 	const getDisplayName = useDisplayName(); |  | ||||||
| 	// force rerender when mode changes
 |  | ||||||
| 	useAtomValue(playModeAtom); |  | ||||||
| 
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!hls) return; |  | ||||||
| 		hls.on(Hls.Events.AUDIO_TRACK_LOADED, rerender); |  | ||||||
| 		return () => hls?.off(Hls.Events.AUDIO_TRACK_LOADED, rerender); |  | ||||||
| 	}); |  | ||||||
| 
 |  | ||||||
| 	if (!hls) return <Menu {...props} disabled {...tooltip(t("player.notInPristine"))} />; |  | ||||||
| 	if (hls.audioTracks.length < 2) return null; |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<Menu {...props}> |  | ||||||
| 			{hls.audioTracks.map((x, i) => ( |  | ||||||
| 				<Menu.Item |  | ||||||
| 					key={i.toString()} |  | ||||||
| 					label={audios ? getDisplayName(audios[i]) : x.name} |  | ||||||
| 					selected={hls!.audioTrack === i} |  | ||||||
| 					onSelect={() => setAudio(audios?.[i] ?? ({ index: i } as any))} |  | ||||||
| 				/> |  | ||||||
| 			))} |  | ||||||
| 		</Menu> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const QualitiesMenu = (props: ComponentProps<typeof Menu>) => { |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 	const [mode, setPlayMode] = useAtom(playModeAtom); |  | ||||||
| 	const rerender = useForceRerender(); |  | ||||||
| 
 |  | ||||||
| 	// biome-ignore lint/correctness/useExhaustiveDependencies: Inculde hls in dependency array
 |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		if (!hls) return; |  | ||||||
| 		// Also rerender when hls instance changes
 |  | ||||||
| 		rerender(); |  | ||||||
| 		hls.on(Hls.Events.LEVEL_SWITCHED, rerender); |  | ||||||
| 		return () => hls?.off(Hls.Events.LEVEL_SWITCHED, rerender); |  | ||||||
| 	}, [hls]); |  | ||||||
| 
 |  | ||||||
| 	const levelName = (label: Level, auto?: boolean): string => { |  | ||||||
| 		const height = `${label.height}p`; |  | ||||||
| 		if (auto) return height; |  | ||||||
| 		return label.uri.includes("original") ? `${t("player.transmux")} (${height})` : height; |  | ||||||
| 	}; |  | ||||||
| 
 |  | ||||||
| 	return ( |  | ||||||
| 		<Menu {...props}> |  | ||||||
| 			<Menu.Item |  | ||||||
| 				label={t("player.direct")} |  | ||||||
| 				selected={hls === null || mode === PlayMode.Direct} |  | ||||||
| 				onSelect={() => setPlayMode(PlayMode.Direct)} |  | ||||||
| 			/> |  | ||||||
| 			<Menu.Item |  | ||||||
| 				label={ |  | ||||||
| 					hls?.autoLevelEnabled && hls.currentLevel >= 0 |  | ||||||
| 						? `${t("player.auto")} (${levelName(hls.levels[hls.currentLevel], true)})` |  | ||||||
| 						: t("player.auto") |  | ||||||
| 				} |  | ||||||
| 				selected={hls?.autoLevelEnabled && mode === PlayMode.Hls} |  | ||||||
| 				onSelect={() => { |  | ||||||
| 					setPlayMode(PlayMode.Hls); |  | ||||||
| 					if (hls) hls.currentLevel = -1; |  | ||||||
| 				}} |  | ||||||
| 			/> |  | ||||||
| 			{hls?.levels |  | ||||||
| 				.map((x, i) => ( |  | ||||||
| 					<Menu.Item |  | ||||||
| 						key={i.toString()} |  | ||||||
| 						label={levelName(x)} |  | ||||||
| 						selected={mode === PlayMode.Hls && hls!.currentLevel === i && !hls?.autoLevelEnabled} |  | ||||||
| 						onSelect={() => { |  | ||||||
| 							setPlayMode(PlayMode.Hls); |  | ||||||
| 							hls!.currentLevel = i; |  | ||||||
| 						}} |  | ||||||
| 					/> |  | ||||||
| 				)) |  | ||||||
| 				.reverse()} |  | ||||||
| 		</Menu> |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| @ -1,43 +1,4 @@ | |||||||
| import type { Subtitle, Track } from "@kyoo/models"; |  | ||||||
| 
 |  | ||||||
| import intl from "langmap"; | import intl from "langmap"; | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| 
 |  | ||||||
| export const useLanguageName = () => { |  | ||||||
| 	return (lang: string) => intl[lang]?.nativeName; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const useDisplayName = () => { |  | ||||||
| 	const getLanguageName = useLanguageName(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 
 |  | ||||||
| 	return (sub: Track) => { |  | ||||||
| 		const lng = sub.language ? getLanguageName(sub.language) : null; |  | ||||||
| 
 |  | ||||||
| 		if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`; |  | ||||||
| 		if (lng) return lng; |  | ||||||
| 		if (sub.title) return sub.title; |  | ||||||
| 		if (sub.index !== null) return `${t("mediainfo.unknown")} (${sub.index})`; |  | ||||||
| 		return t("mediainfo.unknown"); |  | ||||||
| 	}; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const useSubtitleName = () => { |  | ||||||
| 	const getDisplayName = useDisplayName(); |  | ||||||
| 	const { t } = useTranslation(); |  | ||||||
| 
 |  | ||||||
| 	return (sub: Subtitle) => { |  | ||||||
| 		const name = getDisplayName(sub); |  | ||||||
| 		const attributes = [name]; |  | ||||||
| 
 |  | ||||||
| 		if (sub.isDefault) attributes.push(t("mediainfo.default")); |  | ||||||
| 		if (sub.isForced) attributes.push(t("mediainfo.forced")); |  | ||||||
| 		if (sub.isHearingImpaired) attributes.push(t("mediainfo.hearing-impaired")); |  | ||||||
| 		if (sub.isExternal) attributes.push(t("mediainfo.external")); |  | ||||||
| 
 |  | ||||||
| 		return attributes.join(" - "); |  | ||||||
| 	}; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const seenNativeNames = new Set(); | const seenNativeNames = new Set(); | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								front/public/jassub/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								front/public/jassub/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | |||||||
|  | * | ||||||
|  | !.gitignore | ||||||
| @ -198,6 +198,7 @@ | |||||||
| 		"volume": "Volume", | 		"volume": "Volume", | ||||||
| 		"quality": "Quality", | 		"quality": "Quality", | ||||||
| 		"audios": "Audio", | 		"audios": "Audio", | ||||||
|  | 		"videos": "Video", | ||||||
| 		"subtitles": "Subtitles", | 		"subtitles": "Subtitles", | ||||||
| 		"subtitle-none": "None", | 		"subtitle-none": "None", | ||||||
| 		"fullscreen": "Fullscreen", | 		"fullscreen": "Fullscreen", | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								front/scripts/postinstall.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								front/scripts/postinstall.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | import { readdir , mkdir } from 'node:fs/promises'; | ||||||
|  | 
 | ||||||
|  | const srcDir = new URL("../node_modules/jassub/dist/", import.meta.url); | ||||||
|  | const destDir = new URL("../public/jassub/", import.meta.url); | ||||||
|  | 
 | ||||||
|  | await mkdir(destDir, { recursive: true }); | ||||||
|  | 
 | ||||||
|  | const files = await readdir(srcDir); | ||||||
|  | for (const file of files) { | ||||||
|  | 	const src = await Bun.file(new URL(file, srcDir)).arrayBuffer(); | ||||||
|  | 	await Bun.write(new URL(file, destDir), src); | ||||||
|  | } | ||||||
| @ -1,5 +1,5 @@ | |||||||
| import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg"; | import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg"; | ||||||
| import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg"; | // import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg";
 | ||||||
| import Home from "@material-symbols/svg-400/rounded/home-fill.svg"; | import Home from "@material-symbols/svg-400/rounded/home-fill.svg"; | ||||||
| import { Tabs } from "expo-router"; | import { Tabs } from "expo-router"; | ||||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||||
| @ -32,15 +32,15 @@ export default function TabsLayout() { | |||||||
| 					), | 					), | ||||||
| 				}} | 				}} | ||||||
| 			/> | 			/> | ||||||
| 			<Tabs.Screen | 			{/* <Tabs.Screen */} | ||||||
| 				name="downloads" | 			{/* 	name="downloads" */} | ||||||
| 				options={{ | 			{/* 	options={{ */} | ||||||
| 					tabBarLabel: t("navbar.download"), | 			{/* 		tabBarLabel: t("navbar.download"), */} | ||||||
| 					tabBarIcon: ({ color, size }) => ( | 			{/* 		tabBarIcon: ({ color, size }) => ( */} | ||||||
| 						<Icon icon={Downloading} color={color} size={size} /> | 			{/* 			<Icon icon={Downloading} color={color} size={size} /> */} | ||||||
| 					), | 			{/* 		), */} | ||||||
| 				}} | 			{/* 	}} */} | ||||||
| 			/> | 			{/* /> */} | ||||||
| 		</Tabs> | 		</Tabs> | ||||||
| 	); | 	); | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								front/src/app/(app)/watch/[slug].tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								front/src/app/(app)/watch/[slug].tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | import { Player } from "~/ui/player"; | ||||||
|  | 
 | ||||||
|  | export default Player; | ||||||
| @ -1,6 +1,6 @@ | |||||||
| import { useState } from "react"; | import { useState } from "react"; | ||||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||||
| import { Platform, View } from "react-native"; | import { View } from "react-native"; | ||||||
| import { | import { | ||||||
| 	percent, | 	percent, | ||||||
| 	rem, | 	rem, | ||||||
| @ -8,14 +8,11 @@ import { | |||||||
| 	type Theme, | 	type Theme, | ||||||
| 	useYoshiki, | 	useYoshiki, | ||||||
| } from "yoshiki/native"; | } from "yoshiki/native"; | ||||||
| import { EntryContext } from "~/components/items/context-menus"; | import type { KImage } from "~/models"; | ||||||
| import { ItemProgress } from "~/components/items/item-grid"; |  | ||||||
| import type { KImage, WatchStatusV } from "~/models"; |  | ||||||
| import { | import { | ||||||
| 	focusReset, | 	focusReset, | ||||||
| 	Image, | 	Image, | ||||||
| 	ImageBackground, | 	ImageBackground, | ||||||
| 	important, |  | ||||||
| 	Link, | 	Link, | ||||||
| 	P, | 	P, | ||||||
| 	Skeleton, | 	Skeleton, | ||||||
| @ -31,7 +28,7 @@ export const EntryBox = ({ | |||||||
| 	thumbnail, | 	thumbnail, | ||||||
| 	href, | 	href, | ||||||
| 	watchedPercent, | 	watchedPercent, | ||||||
| 	watchedStatus, | 	// watchedStatus,
 | ||||||
| 	...props | 	...props | ||||||
| }: Stylable & { | }: Stylable & { | ||||||
| 	slug: string; | 	slug: string; | ||||||
| @ -42,7 +39,7 @@ export const EntryBox = ({ | |||||||
| 	href: string; | 	href: string; | ||||||
| 	thumbnail: KImage | null; | 	thumbnail: KImage | null; | ||||||
| 	watchedPercent: number | null; | 	watchedPercent: number | null; | ||||||
| 	watchedStatus: WatchStatusV | null; | 	// watchedStatus: WatchStatusV | null;
 | ||||||
| }) => { | }) => { | ||||||
| 	const [moreOpened, setMoreOpened] = useState(false); | 	const [moreOpened, setMoreOpened] = useState(false); | ||||||
| 	const { css } = useYoshiki("episodebox"); | 	const { css } = useYoshiki("episodebox"); | ||||||
| @ -89,27 +86,27 @@ export const EntryBox = ({ | |||||||
| 				layout={{ width: percent(100), aspectRatio: 16 / 9 }} | 				layout={{ width: percent(100), aspectRatio: 16 / 9 }} | ||||||
| 				{...(css("poster") as any)} | 				{...(css("poster") as any)} | ||||||
| 			> | 			> | ||||||
| 				{(watchedPercent || watchedStatus === "completed") && ( | 				{/* 	{(watchedPercent || watchedStatus === "completed") && ( */} | ||||||
| 					<ItemProgress watchPercent={watchedPercent ?? 100} /> | 				{/* 		<ItemProgress watchPercent={watchedPercent ?? 100} /> */} | ||||||
| 				)} | 				{/* 	)} */} | ||||||
| 				<EntryContext | 				{/* 	<EntryContext */} | ||||||
| 					slug={slug} | 				{/* 		slug={slug} */} | ||||||
| 					serieSlug={serieSlug} | 				{/* 		serieSlug={serieSlug} */} | ||||||
| 					status={watchedStatus} | 				{/* 		status={watchedStatus} */} | ||||||
| 					isOpen={moreOpened} | 				{/* 		isOpen={moreOpened} */} | ||||||
| 					setOpen={(v) => setMoreOpened(v)} | 				{/* 		setOpen={(v) => setMoreOpened(v)} */} | ||||||
| 					{...css([ | 				{/* 		{...css([ */} | ||||||
| 						{ | 				{/* 			{ */} | ||||||
| 							position: "absolute", | 				{/* 				position: "absolute", */} | ||||||
| 							top: 0, | 				{/* 				top: 0, */} | ||||||
| 							right: 0, | 				{/* 				right: 0, */} | ||||||
| 							bg: (theme) => theme.darkOverlay, | 				{/* 				bg: (theme) => theme.darkOverlay, */} | ||||||
| 						}, | 				{/* 			}, */} | ||||||
| 						"more", | 				{/* 			"more", */} | ||||||
| 						Platform.OS === "web" && | 				{/* 			Platform.OS === "web" && */} | ||||||
| 							moreOpened && { display: important("flex") }, | 				{/* 				moreOpened && { display: important("flex") }, */} | ||||||
| 					])} | 				{/* 		])} */} | ||||||
| 				/> | 				{/* 	/> */} | ||||||
| 			</ImageBackground> | 			</ImageBackground> | ||||||
| 			<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}> | 			<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}> | ||||||
| 				{name ?? t("show.episodeNoMetadata")} | 				{name ?? t("show.episodeNoMetadata")} | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ import { HR, IconButton, Menu, tooltip } from "~/primitives"; | |||||||
| import { useAccount } from "~/providers/account-context"; | import { useAccount } from "~/providers/account-context"; | ||||||
| import { useMutation } from "~/query"; | import { useMutation } from "~/query"; | ||||||
| import { watchListIcon } from "./watchlist-info"; | import { watchListIcon } from "./watchlist-info"; | ||||||
| // import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
 | // import { useDownloader } from "../../packages/ui/src/downloads/ui/src/downloads";
 | ||||||
| 
 | 
 | ||||||
| export const EntryContext = ({ | export const EntryContext = ({ | ||||||
| 	slug, | 	slug, | ||||||
| @ -27,7 +27,6 @@ export const EntryContext = ({ | |||||||
| 	const { t } = useTranslation(); | 	const { t } = useTranslation(); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<> |  | ||||||
| 		<Menu | 		<Menu | ||||||
| 			Trigger={IconButton} | 			Trigger={IconButton} | ||||||
| 			icon={MoreVert} | 			icon={MoreVert} | ||||||
| @ -52,7 +51,6 @@ export const EntryContext = ({ | |||||||
| 				href={`/entries/${slug}/info`} | 				href={`/entries/${slug}/info`} | ||||||
| 			/> | 			/> | ||||||
| 		</Menu> | 		</Menu> | ||||||
| 		</> |  | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| @ -87,7 +85,6 @@ export const ItemContext = ({ | |||||||
| 	}); | 	}); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<> |  | ||||||
| 		<Menu | 		<Menu | ||||||
| 			Trigger={IconButton} | 			Trigger={IconButton} | ||||||
| 			icon={MoreVert} | 			icon={MoreVert} | ||||||
| @ -141,6 +138,5 @@ export const ItemContext = ({ | |||||||
| 				</> | 				</> | ||||||
| 			)} | 			)} | ||||||
| 		</Menu> | 		</Menu> | ||||||
| 		</> |  | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| import Done from "@material-symbols/svg-400/rounded/check-fill.svg"; | import Done from "@material-symbols/svg-400/rounded/check-fill.svg"; | ||||||
| import { View } from "react-native"; | import { View } from "react-native"; | ||||||
| import { max, rem, useYoshiki } from "yoshiki/native"; | import { max, rem, useYoshiki } from "yoshiki/native"; | ||||||
| import { WatchStatusV } from "~/models"; | import type { WatchStatusV } from "~/models"; | ||||||
| import { Icon, P, ts } from "~/primitives"; | import { Icon, P, ts } from "~/primitives"; | ||||||
| 
 | 
 | ||||||
| export const ItemWatchStatus = ({ | export const ItemWatchStatus = ({ | ||||||
| @ -14,8 +14,7 @@ export const ItemWatchStatus = ({ | |||||||
| }) => { | }) => { | ||||||
| 	const { css } = useYoshiki(); | 	const { css } = useYoshiki(); | ||||||
| 
 | 
 | ||||||
| 	if (watchStatus !== WatchStatusV.Completed && !unseenEpisodesCount) | 	if (watchStatus !== "completed" && !unseenEpisodesCount) return null; | ||||||
| 		return null; |  | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<View | 		<View | ||||||
| @ -36,7 +35,7 @@ export const ItemWatchStatus = ({ | |||||||
| 				props, | 				props, | ||||||
| 			)} | 			)} | ||||||
| 		> | 		> | ||||||
| 			{watchStatus === WatchStatusV.Completed ? ( | 			{watchStatus === "completed" ? ( | ||||||
| 				<Icon icon={Done} size={16} /> | 				<Icon icon={Done} size={16} /> | ||||||
| 			) : ( | 			) : ( | ||||||
| 				<P | 				<P | ||||||
|  | |||||||
| @ -1,6 +1,9 @@ | |||||||
| import { z } from "zod"; | import { z } from "zod"; | ||||||
| import { User } from "./user"; | import { User } from "./user"; | ||||||
| 
 | 
 | ||||||
|  | // TODO: actually parse the token
 | ||||||
|  | const TokenP = z.string(); | ||||||
|  | 
 | ||||||
| export const AccountP = User.and( | export const AccountP = User.and( | ||||||
| 	z.object({ | 	z.object({ | ||||||
| 		token: TokenP, | 		token: TokenP, | ||||||
|  | |||||||
| @ -29,7 +29,7 @@ const Base = z.object({ | |||||||
| 	), | 	), | ||||||
| 	progress: z.object({ | 	progress: z.object({ | ||||||
| 		percent: z.int().min(0).max(100), | 		percent: z.int().min(0).max(100), | ||||||
| 		time: z.int().min(0).nullable(), | 		time: z.int().min(0), | ||||||
| 		playedDate: zdate().nullable(), | 		playedDate: zdate().nullable(), | ||||||
| 		videoId: z.string().nullable(), | 		videoId: z.string().nullable(), | ||||||
| 	}), | 	}), | ||||||
|  | |||||||
| @ -22,7 +22,7 @@ export const Extra = z.object({ | |||||||
| 
 | 
 | ||||||
| 	progress: z.object({ | 	progress: z.object({ | ||||||
| 		percent: z.int().min(0).max(100), | 		percent: z.int().min(0).max(100), | ||||||
| 		time: z.int().min(0).nullable(), | 		time: z.int().min(0), | ||||||
| 		playedDate: zdate().nullable(), | 		playedDate: zdate().nullable(), | ||||||
| 	}), | 	}), | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -12,3 +12,4 @@ export * from "./utils/genre"; | |||||||
| export * from "./utils/images"; | export * from "./utils/images"; | ||||||
| export * from "./utils/page"; | export * from "./utils/page"; | ||||||
| export * from "./video"; | export * from "./video"; | ||||||
|  | export * from "./video-info"; | ||||||
|  | |||||||
| @ -4,7 +4,6 @@ import { Genre } from "./utils/genre"; | |||||||
| import { KImage } from "./utils/images"; | import { KImage } from "./utils/images"; | ||||||
| import { Metadata } from "./utils/metadata"; | import { Metadata } from "./utils/metadata"; | ||||||
| import { zdate } from "./utils/utils"; | import { zdate } from "./utils/utils"; | ||||||
| import { EmbeddedVideo } from "./video"; |  | ||||||
| 
 | 
 | ||||||
| export const Movie = z | export const Movie = z | ||||||
| 	.object({ | 	.object({ | ||||||
| @ -39,7 +38,18 @@ export const Movie = z | |||||||
| 		updatedAt: zdate(), | 		updatedAt: zdate(), | ||||||
| 
 | 
 | ||||||
| 		studios: z.array(Studio).optional(), | 		studios: z.array(Studio).optional(), | ||||||
| 		videos: z.array(EmbeddedVideo).optional(), | 		videos: z | ||||||
|  | 			.array( | ||||||
|  | 				z.object({ | ||||||
|  | 					id: z.string(), | ||||||
|  | 					slug: z.string(), | ||||||
|  | 					path: z.string(), | ||||||
|  | 					rendering: z.string(), | ||||||
|  | 					part: z.number().int().gt(0).nullable(), | ||||||
|  | 					version: z.number().gt(0), | ||||||
|  | 				}), | ||||||
|  | 			) | ||||||
|  | 			.optional(), | ||||||
| 		watchStatus: z | 		watchStatus: z | ||||||
| 			.object({ | 			.object({ | ||||||
| 				status: z.enum([ | 				status: z.enum([ | ||||||
|  | |||||||
| @ -1,20 +0,0 @@ | |||||||
| import { z } from "zod"; |  | ||||||
| 
 |  | ||||||
| export const QualityP = z |  | ||||||
| 	.union([ |  | ||||||
| 		z.literal("original"), |  | ||||||
| 		z.literal("8k"), |  | ||||||
| 		z.literal("4k"), |  | ||||||
| 		z.literal("1440p"), |  | ||||||
| 		z.literal("1080p"), |  | ||||||
| 		z.literal("720p"), |  | ||||||
| 		z.literal("480p"), |  | ||||||
| 		z.literal("360p"), |  | ||||||
| 		z.literal("240p"), |  | ||||||
| 	]) |  | ||||||
| 	.default("original"); |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * A Video Quality Enum. |  | ||||||
|  */ |  | ||||||
| export type Quality = z.infer<typeof QualityP>; |  | ||||||
| @ -1,182 +0,0 @@ | |||||||
| import { z } from "zod"; |  | ||||||
| import { QualityP } from "./quality"; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * A audio or subtitle track. |  | ||||||
|  */ |  | ||||||
| export const TrackP = z.object({ |  | ||||||
| 	/** |  | ||||||
| 	 * The index of this track on the episode. |  | ||||||
| 	 * NOTE: external subtitles can have a null index |  | ||||||
| 	 */ |  | ||||||
| 	index: z.number().nullable(), |  | ||||||
| 	/** |  | ||||||
| 	 * The title of the stream. |  | ||||||
| 	 */ |  | ||||||
| 	title: z.string().nullable(), |  | ||||||
| 	/** |  | ||||||
| 	 * The language of this stream (as a ISO-639-2 language code) |  | ||||||
| 	 */ |  | ||||||
| 	language: z.string().nullable(), |  | ||||||
| 	/** |  | ||||||
| 	 * The codec of this stream. |  | ||||||
| 	 */ |  | ||||||
| 	codec: z.string(), |  | ||||||
| 	/** |  | ||||||
| 	 * Is this stream the default one of it's type? |  | ||||||
| 	 */ |  | ||||||
| 	isDefault: z.boolean(), |  | ||||||
| 	/** |  | ||||||
| 	 * Is this stream tagged as forced? |  | ||||||
| 	 * NOTE: not available for videos |  | ||||||
| 	 */ |  | ||||||
| 	isForced: z.boolean().optional(), |  | ||||||
| }); |  | ||||||
| export type Track = z.infer<typeof TrackP>; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * A Video track |  | ||||||
|  */ |  | ||||||
| export const VideoP = TrackP.extend({ |  | ||||||
| 	/** |  | ||||||
| 	 * The Quality of the Video |  | ||||||
| 	 * E.g. "1080p" |  | ||||||
| 	 */ |  | ||||||
| 	quality: QualityP, |  | ||||||
| 	/** |  | ||||||
| 	 * The Width of the Video Frame |  | ||||||
| 	 * E.g. 1424 |  | ||||||
| 	 */ |  | ||||||
| 	width: z.number(), |  | ||||||
| 	/** |  | ||||||
| 	 * The Height of the Video Frame |  | ||||||
| 	 * E.g. 1072 |  | ||||||
| 	 */ |  | ||||||
| 	height: z.number(), |  | ||||||
| 	/** |  | ||||||
| 	 * The Bitrate (in bits/seconds) of the video track |  | ||||||
| 	 * E.g. 2693245 |  | ||||||
| 	 */ |  | ||||||
| 	bitrate: z.number(), |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export type Video = z.infer<typeof VideoP>; |  | ||||||
| 
 |  | ||||||
| export const AudioP = TrackP; |  | ||||||
| export type Audio = z.infer<typeof AudioP>; |  | ||||||
| 
 |  | ||||||
| export const SubtitleP = TrackP.extend({ |  | ||||||
| 	/* |  | ||||||
| 	 * The url of this track (only if this is a subtitle).. |  | ||||||
| 	 */ |  | ||||||
| 	link: z.string().nullable(), |  | ||||||
| 	/* |  | ||||||
| 	 * Is this an external subtitle (as in stored in a different file) |  | ||||||
| 	 */ |  | ||||||
| 	isExternal: z.boolean(), |  | ||||||
| 	/** |  | ||||||
| 	 * Is this a hearing impaired subtitle? |  | ||||||
| 	 */ |  | ||||||
| 	isHearingImpaired: z.boolean(), |  | ||||||
| }); |  | ||||||
| export type Subtitle = z.infer<typeof SubtitleP>; |  | ||||||
| 
 |  | ||||||
| export const ChapterP = z.object({ |  | ||||||
| 	/** |  | ||||||
| 	 * The start time of the chapter (in second from the start of the episode). |  | ||||||
| 	 */ |  | ||||||
| 	startTime: z.number(), |  | ||||||
| 	/** |  | ||||||
| 	 * The end time of the chapter (in second from the start of the episode). |  | ||||||
| 	 */ |  | ||||||
| 	endTime: z.number(), |  | ||||||
| 	/** |  | ||||||
| 	 * The name of this chapter. This should be a human-readable name that could be presented to the |  | ||||||
| 	 * user. There should be well-known chapters name for commonly used chapters. For example, use |  | ||||||
| 	 * "Opening" for the introduction-song and "Credits" for the end chapter with credits. |  | ||||||
| 	 */ |  | ||||||
| 	name: z.string(), |  | ||||||
| }); |  | ||||||
| export type Chapter = z.infer<typeof ChapterP>; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * The transcoder's info for this item. This include subtitles, fonts, chapters... |  | ||||||
|  */ |  | ||||||
| export const WatchInfoP = z |  | ||||||
| 	.object({ |  | ||||||
| 		/** |  | ||||||
| 		 * The sha1 of the video file. |  | ||||||
| 		 */ |  | ||||||
| 		sha: z.string(), |  | ||||||
| 		/** |  | ||||||
| 		 * The internal path of the video file. |  | ||||||
| 		 */ |  | ||||||
| 		path: z.string(), |  | ||||||
| 		/** |  | ||||||
| 		 * The extension used to store this video file. |  | ||||||
| 		 */ |  | ||||||
| 		extension: z.string(), |  | ||||||
| 		/** |  | ||||||
| 		 * The whole mimetype (defined as the RFC 6381). |  | ||||||
| 		 * ex: `video/mp4; codecs="avc1.640028, mp4a.40.2"` |  | ||||||
| 		 */ |  | ||||||
| 		mimeCodec: z.string(), |  | ||||||
| 		/** |  | ||||||
| 		 * The file size of the video file. |  | ||||||
| 		 */ |  | ||||||
| 		size: z.number(), |  | ||||||
| 		/** |  | ||||||
| 		 * The duration of the video (in seconds). |  | ||||||
| 		 */ |  | ||||||
| 		duration: z.number(), |  | ||||||
| 		/** |  | ||||||
| 		 * The container of the video file of this episode. Common containers are mp4, mkv, avi and so on. |  | ||||||
| 		 */ |  | ||||||
| 		container: z.string().nullable(), |  | ||||||
| 		/** |  | ||||||
| 		 * The video track. |  | ||||||
| 		 */ |  | ||||||
| 		videos: z.array(VideoP), |  | ||||||
| 		/** |  | ||||||
| 		 * The list of audio tracks. |  | ||||||
| 		 */ |  | ||||||
| 		audios: z.array(AudioP), |  | ||||||
| 		/** |  | ||||||
| 		 * The list of subtitles tracks. |  | ||||||
| 		 */ |  | ||||||
| 		subtitles: z.array(SubtitleP), |  | ||||||
| 		/** |  | ||||||
| 		 * The list of fonts that can be used to display subtitles. |  | ||||||
| 		 */ |  | ||||||
| 		fonts: z.array(z.string()), |  | ||||||
| 		/** |  | ||||||
| 		 * The list of chapters. See Chapter for more information. |  | ||||||
| 		 */ |  | ||||||
| 		chapters: z.array(ChapterP), |  | ||||||
| 	}) |  | ||||||
| 	.transform((x) => { |  | ||||||
| 		const hour = Math.floor(x.duration / 3600); |  | ||||||
| 		const minutes = Math.ceil((x.duration % 3600) / 60); |  | ||||||
| 
 |  | ||||||
| 		return { |  | ||||||
| 			...x, |  | ||||||
| 			duration: `${hour ? `${hour}h` : ""}${minutes}m`, |  | ||||||
| 			durationSeconds: x.duration, |  | ||||||
| 			size: humanFileSize(x.size), |  | ||||||
| 		}; |  | ||||||
| 	}); |  | ||||||
| 
 |  | ||||||
| // from https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
 |  | ||||||
| const humanFileSize = (size: number): string => { |  | ||||||
| 	const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); |  | ||||||
| 	return ( |  | ||||||
| 		// @ts-ignore I'm not gonna fix stackoverflow's working code.
 |  | ||||||
| 		// biome-ignore lint/style/useTemplate: same as above
 |  | ||||||
| 		(size / 1024 ** i).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i] |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * A watch info for a video |  | ||||||
|  */ |  | ||||||
| export type WatchInfo = z.infer<typeof WatchInfoP>; |  | ||||||
							
								
								
									
										108
									
								
								front/src/models/video-info.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								front/src/models/video-info.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,108 @@ | |||||||
|  | import { z } from "zod/v4"; | ||||||
|  | 
 | ||||||
|  | export const Quality = z | ||||||
|  | 	.enum([ | ||||||
|  | 		"original", | ||||||
|  | 		"8k", | ||||||
|  | 		"4k", | ||||||
|  | 		"1440p", | ||||||
|  | 		"1080p", | ||||||
|  | 		"720p", | ||||||
|  | 		"480p", | ||||||
|  | 		"360p", | ||||||
|  | 		"240p", | ||||||
|  | 	]) | ||||||
|  | 	.default("original"); | ||||||
|  | export type Quality = z.infer<typeof Quality>; | ||||||
|  | 
 | ||||||
|  | export const VideoTrack = z.object({ | ||||||
|  | 	index: z.number(), | ||||||
|  | 	title: z.string().nullable(), | ||||||
|  | 	language: z.string().nullable(), | ||||||
|  | 	codec: z.string(), | ||||||
|  | 	mimeCodec: z.string(), | ||||||
|  | 	width: z.number(), | ||||||
|  | 	height: z.number(), | ||||||
|  | 	bitrate: z.number(), | ||||||
|  | 	isDefault: z.boolean(), | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export type VideoTrack = z.infer<typeof VideoTrack>; | ||||||
|  | 
 | ||||||
|  | export const AudioTrack = z.object({ | ||||||
|  | 	index: z.number(), | ||||||
|  | 	title: z.string().nullable(), | ||||||
|  | 	language: z.string().nullable(), | ||||||
|  | 	codec: z.string(), | ||||||
|  | 	mimeCodec: z.string(), | ||||||
|  | 	bitrate: z.number(), | ||||||
|  | 	isDefault: z.boolean(), | ||||||
|  | }); | ||||||
|  | export type AudioTrack = z.infer<typeof AudioTrack>; | ||||||
|  | 
 | ||||||
|  | export const Subtitle = z.object({ | ||||||
|  | 	// external subtitles don't have indexes
 | ||||||
|  | 	index: z.number().nullable(), | ||||||
|  | 	title: z.string().nullable(), | ||||||
|  | 	language: z.string().nullable(), | ||||||
|  | 	codec: z.string(), | ||||||
|  | 	mimeCodec: z.string().nullable(), | ||||||
|  | 	extension: z.string(), | ||||||
|  | 	isDefault: z.boolean(), | ||||||
|  | 	isForced: z.boolean(), | ||||||
|  | 	isHearingImpaired: z.boolean(), | ||||||
|  | 	isExternal: z.boolean(), | ||||||
|  | 	// only non-null when `isExternal` is true
 | ||||||
|  | 	path: z.string().nullable(), | ||||||
|  | 	link: z.string().nullable(), | ||||||
|  | }); | ||||||
|  | export type Subtitle = z.infer<typeof Subtitle>; | ||||||
|  | 
 | ||||||
|  | export const Chapter = z.object({ | ||||||
|  | 	// in seconds
 | ||||||
|  | 	startTime: z.number(), | ||||||
|  | 	// in seconds
 | ||||||
|  | 	endTime: z.number(), | ||||||
|  | 	name: z.string(), | ||||||
|  | 	type: z.enum(["content", "recap", "intro", "credits", "preview"]), | ||||||
|  | }); | ||||||
|  | export type Chapter = z.infer<typeof Chapter>; | ||||||
|  | 
 | ||||||
|  | export const VideoInfo = z | ||||||
|  | 	.object({ | ||||||
|  | 		sha: z.string(), | ||||||
|  | 		path: z.string(), | ||||||
|  | 		extension: z.string(), | ||||||
|  | 		mimeCodec: z.string(), | ||||||
|  | 		size: z.number(), | ||||||
|  | 		// in seconds
 | ||||||
|  | 		duration: z.number(), | ||||||
|  | 		container: z.string().nullable(), | ||||||
|  | 		videos: z.array(VideoTrack), | ||||||
|  | 		audios: z.array(AudioTrack), | ||||||
|  | 		subtitles: z.array(Subtitle), | ||||||
|  | 		fonts: z.array(z.string()), | ||||||
|  | 		chapters: z.array(Chapter), | ||||||
|  | 	}) | ||||||
|  | 	.transform((x) => { | ||||||
|  | 		const hour = Math.floor(x.duration / 3600); | ||||||
|  | 		const minutes = Math.ceil((x.duration % 3600) / 60); | ||||||
|  | 
 | ||||||
|  | 		return { | ||||||
|  | 			...x, | ||||||
|  | 			duration: `${hour ? `${hour}h` : ""}${minutes}m`, | ||||||
|  | 			durationSeconds: x.duration, | ||||||
|  | 			size: humanFileSize(x.size), | ||||||
|  | 		}; | ||||||
|  | 	}); | ||||||
|  | export type VideoInfo = z.infer<typeof VideoInfo>; | ||||||
|  | 
 | ||||||
|  | // from https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
 | ||||||
|  | const humanFileSize = (size: number): string => { | ||||||
|  | 	const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); | ||||||
|  | 	return ( | ||||||
|  | 		// @ts-expect-error I'm not gonna fix stackoverflow's working code.
 | ||||||
|  | 		// biome-ignore lint/style/useTemplate: same as above
 | ||||||
|  | 		(size / 1024 ** i).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i] | ||||||
|  | 	); | ||||||
|  | }; | ||||||
| @ -1,11 +1,52 @@ | |||||||
| import { z } from "zod/v4"; | import { z } from "zod/v4"; | ||||||
|  | import { Entry } from "./entry"; | ||||||
|  | import { Extra } from "./extra"; | ||||||
|  | import { Show } from "./show"; | ||||||
|  | import { zdate } from "./utils/utils"; | ||||||
| 
 | 
 | ||||||
| export const EmbeddedVideo = z.object({ | export const Video = z.object({ | ||||||
| 	id: z.string(), | 	id: z.string(), | ||||||
| 	slug: z.string(), |  | ||||||
| 	path: z.string(), | 	path: z.string(), | ||||||
| 	rendering: z.string(), | 	rendering: z.string(), | ||||||
| 	part: z.number().int().gt(0).nullable(), | 	part: z.int().min(0).nullable(), | ||||||
| 	version: z.number().gt(0), | 	version: z.int().min(0).default(1), | ||||||
|  | 	guess: z.object({ | ||||||
|  | 		title: z.string(), | ||||||
|  | 		kind: z.enum(["episode", "movie", "extra"]).nullable().optional(), | ||||||
|  | 		extraKind: Extra.shape.kind.optional().nullable(), | ||||||
|  | 		years: z.array(z.int()).default([]), | ||||||
|  | 		episodes: z | ||||||
|  | 			.array( | ||||||
|  | 				z.object({ | ||||||
|  | 					season: z.int().nullable(), | ||||||
|  | 					episode: z.int(), | ||||||
|  | 				}), | ||||||
|  | 			) | ||||||
|  | 			.default([]), | ||||||
|  | 		externalId: z.record(z.string(), z.string()).default({}), | ||||||
|  | 
 | ||||||
|  | 		// Name of the tool that made the guess
 | ||||||
|  | 		from: z.string(), | ||||||
|  | 		// Adding that results in an infinite recursion
 | ||||||
|  | 		// get history() {
 | ||||||
|  | 		// 	return z.array(Video.shape.guess.omit({ history: true })).default([]);
 | ||||||
|  | 		// },
 | ||||||
|  | 	}), | ||||||
|  | 	createdAt: zdate(), | ||||||
|  | 	updatedAt: zdate(), | ||||||
| }); | }); | ||||||
| export type EmbeddedVideo = z.infer<typeof EmbeddedVideo>; | 
 | ||||||
|  | export const FullVideo = Video.extend({ | ||||||
|  | 	slugs: z.array(z.string()), | ||||||
|  | 	progress: z.object({ | ||||||
|  | 		percent: z.int().min(0).max(100), | ||||||
|  | 		time: z.int().min(0), | ||||||
|  | 		playedDate: zdate().nullable(), | ||||||
|  | 		videoId: z.string().nullable(), | ||||||
|  | 	}), | ||||||
|  | 	entries: z.array(Entry), | ||||||
|  | 	previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(), | ||||||
|  | 	next: z.object({ video: z.string(), entry: Entry }).nullable().optional(), | ||||||
|  | 	show: Show.optional(), | ||||||
|  | }); | ||||||
|  | export type FullVideo = z.infer<typeof FullVideo>; | ||||||
|  | |||||||
| @ -1,10 +1,4 @@ | |||||||
| import type React from "react"; | import type { ComponentProps, ComponentType } from "react"; | ||||||
| import { |  | ||||||
| 	type ComponentProps, |  | ||||||
| 	type ComponentType, |  | ||||||
| 	type ForwardedRef, |  | ||||||
| 	forwardRef, |  | ||||||
| } from "react"; |  | ||||||
| import { Platform, type PressableProps } from "react-native"; | import { Platform, type PressableProps } from "react-native"; | ||||||
| import type { SvgProps } from "react-native-svg"; | import type { SvgProps } from "react-native-svg"; | ||||||
| import type { YoshikiStyle } from "yoshiki"; | import type { YoshikiStyle } from "yoshiki"; | ||||||
| @ -13,12 +7,6 @@ import { PressableFeedback } from "./links"; | |||||||
| import { P } from "./text"; | import { P } from "./text"; | ||||||
| import { type Breakpoint, focusReset, ts } from "./utils"; | import { type Breakpoint, focusReset, ts } from "./utils"; | ||||||
| 
 | 
 | ||||||
| declare module "react" { |  | ||||||
| 	function forwardRef<T, P = {}>( |  | ||||||
| 		render: (props: P, ref: React.ForwardedRef<T>) => React.ReactElement | null, |  | ||||||
| 	): (props: P & React.RefAttributes<T>) => React.ReactElement | null; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export type Icon = ComponentType<SvgProps>; | export type Icon = ComponentType<SvgProps>; | ||||||
| 
 | 
 | ||||||
| type IconProps = { | type IconProps = { | ||||||
| @ -54,10 +42,7 @@ export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => { | |||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const IconButton = forwardRef(function IconButton< | export const IconButton = <AsProps = PressableProps>({ | ||||||
| 	AsProps = PressableProps, |  | ||||||
| >( |  | ||||||
| 	{ |  | ||||||
| 	icon, | 	icon, | ||||||
| 	size, | 	size, | ||||||
| 	color, | 	color, | ||||||
| @ -65,16 +50,13 @@ export const IconButton = forwardRef(function IconButton< | |||||||
| 	...asProps | 	...asProps | ||||||
| }: IconProps & { | }: IconProps & { | ||||||
| 	as?: ComponentType<AsProps>; | 	as?: ComponentType<AsProps>; | ||||||
| 	} & AsProps, | } & AsProps) => { | ||||||
| 	ref: ForwardedRef<unknown>, |  | ||||||
| ) { |  | ||||||
| 	const { css, theme } = useYoshiki(); | 	const { css, theme } = useYoshiki(); | ||||||
| 
 | 
 | ||||||
| 	const Container = as ?? PressableFeedback; | 	const Container = as ?? PressableFeedback; | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<Container | 		<Container | ||||||
| 			ref={ref as any} |  | ||||||
| 			focusRipple | 			focusRipple | ||||||
| 			{...(css( | 			{...(css( | ||||||
| 				{ | 				{ | ||||||
| @ -102,7 +84,7 @@ export const IconButton = forwardRef(function IconButton< | |||||||
| 			/> | 			/> | ||||||
| 		</Container> | 		</Container> | ||||||
| 	); | 	); | ||||||
| }); | }; | ||||||
| 
 | 
 | ||||||
| export const IconFab = <AsProps = PressableProps>( | export const IconFab = <AsProps = PressableProps>( | ||||||
| 	props: ComponentProps<typeof IconButton<AsProps>>, | 	props: ComponentProps<typeof IconButton<AsProps>>, | ||||||
|  | |||||||
| @ -25,7 +25,7 @@ export const ImageBackground = ({ | |||||||
| 	layout: ImageLayout; | 	layout: ImageLayout; | ||||||
| 	children: ReactNode; | 	children: ReactNode; | ||||||
| }) => { | }) => { | ||||||
| 	const { css } = useYoshiki(); | 	const { css, theme } = useYoshiki(); | ||||||
| 	const { apiUrl, authToken } = useToken(); | 	const { apiUrl, authToken } = useToken(); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| @ -42,7 +42,10 @@ export const ImageBackground = ({ | |||||||
| 			}} | 			}} | ||||||
| 			placeholder={{ blurhash: src?.blurhash }} | 			placeholder={{ blurhash: src?.blurhash }} | ||||||
| 			accessibilityLabel={alt} | 			accessibilityLabel={alt} | ||||||
| 			{...(css([layout, { overflow: "hidden" }], props) as any)} | 			{...(css( | ||||||
|  | 				[layout, { overflow: "hidden", backgroundColor: theme.overlay0 }], | ||||||
|  | 				props, | ||||||
|  | 			) as any)} | ||||||
| 		/> | 		/> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -34,7 +34,7 @@ export const Image = ({ | |||||||
| 	style?: ImageStyle; | 	style?: ImageStyle; | ||||||
| 	layout: ImageLayout; | 	layout: ImageLayout; | ||||||
| }) => { | }) => { | ||||||
| 	const { css } = useYoshiki(); | 	const { css, theme } = useYoshiki(); | ||||||
| 	const { apiUrl, authToken } = useToken(); | 	const { apiUrl, authToken } = useToken(); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| @ -51,7 +51,10 @@ export const Image = ({ | |||||||
| 			}} | 			}} | ||||||
| 			placeholder={{ blurhash: src?.blurhash }} | 			placeholder={{ blurhash: src?.blurhash }} | ||||||
| 			accessibilityLabel={alt} | 			accessibilityLabel={alt} | ||||||
| 			{...(css([layout, { borderRadius: 6 }], props) as any)} | 			{...(css( | ||||||
|  | 				[layout, { borderRadius: 6, backgroundColor: theme.overlay0 }], | ||||||
|  | 				props, | ||||||
|  | 			) as any)} | ||||||
| 		/> | 		/> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -7,16 +7,16 @@ export * from "./divider"; | |||||||
| export * from "./icons"; | export * from "./icons"; | ||||||
| export * from "./image"; | export * from "./image"; | ||||||
| export * from "./image-background"; | export * from "./image-background"; | ||||||
| // export * from "./popup";
 |  | ||||||
| // export * from "./select";
 |  | ||||||
| export * from "./input"; | export * from "./input"; | ||||||
| export * from "./links"; | export * from "./links"; | ||||||
| // export * from "./progress";
 |  | ||||||
| // export * from "./slider";
 |  | ||||||
| // export * from "./snackbar";
 | // export * from "./snackbar";
 | ||||||
| // export * from "./alert";
 | // export * from "./alert";
 | ||||||
| export * from "./menu"; | export * from "./menu"; | ||||||
|  | export * from "./progress"; | ||||||
|  | // export * from "./popup";
 | ||||||
|  | export * from "./select"; | ||||||
| export * from "./skeleton"; | export * from "./skeleton"; | ||||||
|  | export * from "./slider"; | ||||||
| export * from "./text"; | export * from "./text"; | ||||||
| export * from "./theme"; | export * from "./theme"; | ||||||
| export * from "./tooltip"; | export * from "./tooltip"; | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { forwardRef, type ReactNode, useState } from "react"; | import { type ReactNode, type Ref, useState } from "react"; | ||||||
| import { | import { | ||||||
| 	TextInput, | 	TextInput, | ||||||
| 	type TextInputProps, | 	type TextInputProps, | ||||||
| @ -6,20 +6,22 @@ import { | |||||||
| 	type ViewStyle, | 	type ViewStyle, | ||||||
| } from "react-native"; | } from "react-native"; | ||||||
| import { px, type Theme, useYoshiki } from "yoshiki/native"; | import { px, type Theme, useYoshiki } from "yoshiki/native"; | ||||||
| import type { YoshikiEnhanced } from "./image/base-image"; | import type { YoshikiEnhanced } from "./image"; | ||||||
| import { focusReset, ts } from "./utils"; | import { focusReset, ts } from "./utils"; | ||||||
| 
 | 
 | ||||||
| export const Input = forwardRef< | export const Input = ({ | ||||||
| 	TextInput, | 	placeholderTextColor, | ||||||
| 	{ | 	variant = "small", | ||||||
|  | 	right, | ||||||
|  | 	containerStyle, | ||||||
|  | 	ref, | ||||||
|  | 	...props | ||||||
|  | }: { | ||||||
| 	variant?: "small" | "big"; | 	variant?: "small" | "big"; | ||||||
| 	right?: ReactNode; | 	right?: ReactNode; | ||||||
| 	containerStyle?: YoshikiEnhanced<ViewStyle>; | 	containerStyle?: YoshikiEnhanced<ViewStyle>; | ||||||
| 	} & TextInputProps | 	ref?: Ref<TextInput>; | ||||||
| >(function Input( | } & TextInputProps) => { | ||||||
| 	{ placeholderTextColor, variant = "small", right, containerStyle, ...props }, |  | ||||||
| 	ref, |  | ||||||
| ) { |  | ||||||
| 	const [focused, setFocused] = useState(false); | 	const [focused, setFocused] = useState(false); | ||||||
| 	const { css, theme } = useYoshiki(); | 	const { css, theme } = useYoshiki(); | ||||||
| 
 | 
 | ||||||
| @ -64,4 +66,4 @@ export const Input = forwardRef< | |||||||
| 			{right} | 			{right} | ||||||
| 		</View> | 		</View> | ||||||
| 	); | 	); | ||||||
| }); | }; | ||||||
|  | |||||||
| @ -1,26 +1,5 @@ | |||||||
| /* | import { ActivityIndicator } from "react-native"; | ||||||
|  * Kyoo - A portable and vast media library solution. | import { type Stylable, useYoshiki } from "yoshiki/native"; | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { ActivityIndicator, Platform, View } from "react-native"; |  | ||||||
| import { Circle, Svg } from "react-native-svg"; |  | ||||||
| import { px, type Stylable, useYoshiki } from "yoshiki/native"; |  | ||||||
| 
 | 
 | ||||||
| export const CircularProgress = ({ | export const CircularProgress = ({ | ||||||
| 	size = 48, | 	size = 48, | ||||||
| @ -28,64 +7,9 @@ export const CircularProgress = ({ | |||||||
| 	color, | 	color, | ||||||
| 	...props | 	...props | ||||||
| }: { size?: number; tickness?: number; color?: string } & Stylable) => { | }: { size?: number; tickness?: number; color?: string } & Stylable) => { | ||||||
| 	const { css, theme } = useYoshiki(); | 	const { theme } = useYoshiki(); | ||||||
| 
 | 
 | ||||||
| 	if (Platform.OS !== "web") |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<ActivityIndicator size={size} color={color ?? theme.accent} {...props} /> | 		<ActivityIndicator size={size} color={color ?? theme.accent} {...props} /> | ||||||
| 	); | 	); | ||||||
| 
 |  | ||||||
| 	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> |  | ||||||
| 	); |  | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,23 +1,3 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg"; | import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg"; | ||||||
| import { Button } from "./button"; | import { Button } from "./button"; | ||||||
| import { Icon } from "./icons"; | import { Icon } from "./icons"; | ||||||
|  | |||||||
| @ -1,23 +1,3 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import Check from "@material-symbols/svg-400/rounded/check-fill.svg"; | import Check from "@material-symbols/svg-400/rounded/check-fill.svg"; | ||||||
| import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg"; | import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg"; | ||||||
| import ExpandLess from "@material-symbols/svg-400/rounded/keyboard_arrow_up-fill.svg"; | import ExpandLess from "@material-symbols/svg-400/rounded/keyboard_arrow_up-fill.svg"; | ||||||
| @ -30,7 +10,7 @@ import { Icon } from "./icons"; | |||||||
| import { PressableFeedback } from "./links"; | import { PressableFeedback } from "./links"; | ||||||
| import { InternalTriger, YoshikiProvider } from "./menu.web"; | import { InternalTriger, YoshikiProvider } from "./menu.web"; | ||||||
| import { P } from "./text"; | import { P } from "./text"; | ||||||
| import { ContrastArea, SwitchVariant } from "./themes"; | import { ContrastArea, SwitchVariant } from "./theme"; | ||||||
| import { focusReset, ts } from "./utils"; | import { focusReset, ts } from "./utils"; | ||||||
| 
 | 
 | ||||||
| export const Select = ({ | export const Select = ({ | ||||||
| @ -131,11 +111,11 @@ const Item = forwardRef<HTMLDivElement, { label: string; value: string }>( | |||||||
| 		const { css: nCss } = useNativeYoshiki(); | 		const { css: nCss } = useNativeYoshiki(); | ||||||
| 		return ( | 		return ( | ||||||
| 			<> | 			<> | ||||||
| 				<style jsx global>{` | 				{/* <style jsx global>{` */} | ||||||
| 				[data-highlighted] { | 				{/* 	[data-highlighted] { */} | ||||||
| 					background: ${theme.variant.accent}; | 				{/* 		background: ${theme.variant.accent}; */} | ||||||
| 				} | 				{/* 	} */} | ||||||
| 			`}</style>
 | 				{/* `}</style> */} | ||||||
| 				<RSelect.Item | 				<RSelect.Item | ||||||
| 					ref={ref} | 					ref={ref} | ||||||
| 					value={value} | 					value={value} | ||||||
|  | |||||||
| @ -106,11 +106,7 @@ export const Skeleton = ({ | |||||||
| 						start={{ x: 0, y: 0.5 }} | 						start={{ x: 0, y: 0.5 }} | ||||||
| 						end={{ x: 1, y: 0.5 }} | 						end={{ x: 1, y: 0.5 }} | ||||||
| 						colors={["transparent", theme.overlay1, "transparent"]} | 						colors={["transparent", theme.overlay1, "transparent"]} | ||||||
| 						style={[ | 						style={[StyleSheet.absoluteFillObject, animated]} | ||||||
| 							StyleSheet.absoluteFillObject, |  | ||||||
| 							{ transform: [{ translateX: -width.value }] }, |  | ||||||
| 							animated, |  | ||||||
| 						]} |  | ||||||
| 					/> | 					/> | ||||||
| 				</View> | 				</View> | ||||||
| 			))} | 			))} | ||||||
|  | |||||||
| @ -1,26 +1,10 @@ | |||||||
| /* |  | ||||||
|  * Kyoo - A portable and vast media library solution. |  | ||||||
|  * Copyright (c) Kyoo. |  | ||||||
|  * |  | ||||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. |  | ||||||
|  * |  | ||||||
|  * Kyoo is free software: you can redistribute it and/or modify |  | ||||||
|  * it under the terms of the GNU General Public License as published by |  | ||||||
|  * the Free Software Foundation, either version 3 of the License, or |  | ||||||
|  * any later version. |  | ||||||
|  * |  | ||||||
|  * Kyoo is distributed in the hope that it will be useful, |  | ||||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |  | ||||||
|  * GNU General Public License for more details. |  | ||||||
|  * |  | ||||||
|  * You should have received a copy of the GNU General Public License |  | ||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import { useRef, useState } from "react"; | import { useRef, useState } from "react"; | ||||||
| import { type GestureResponderEvent, Platform, View } from "react-native"; | import { | ||||||
| import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils"; | 	type GestureResponderEvent, | ||||||
|  | 	Platform, | ||||||
|  | 	View, | ||||||
|  | 	type ViewProps, | ||||||
|  | } from "react-native"; | ||||||
| import { percent, px, useYoshiki } from "yoshiki/native"; | import { percent, px, useYoshiki } from "yoshiki/native"; | ||||||
| import { focusReset } from "./utils"; | import { focusReset } from "./utils"; | ||||||
| 
 | 
 | ||||||
| @ -71,14 +55,14 @@ export const Slider = ({ | |||||||
| 	return ( | 	return ( | ||||||
| 		<View | 		<View | ||||||
| 			ref={ref} | 			ref={ref} | ||||||
| 			// @ts-ignore Web only
 | 			// @ts-expect-error Web only
 | ||||||
| 			onMouseEnter={() => setHover(true)} | 			onMouseEnter={() => setHover(true)} | ||||||
| 			// @ts-ignore Web only
 | 			// @ts-expect-error Web only
 | ||||||
| 			onMouseLeave={() => { | 			onMouseLeave={() => { | ||||||
| 				setHover(false); | 				setHover(false); | ||||||
| 				onHover?.(null, layout); | 				onHover?.(null, layout); | ||||||
| 			}} | 			}} | ||||||
| 			// @ts-ignore Web only
 | 			// @ts-expect-error Web only
 | ||||||
| 			onMouseMove={(e) => | 			onMouseMove={(e) => | ||||||
| 				onHover?.( | 				onHover?.( | ||||||
| 					Math.max(0, Math.min((e.clientX - layout.x) / layout.width, 1) * max), | 					Math.max(0, Math.min((e.clientX - layout.x) / layout.width, 1) * max), | ||||||
| @ -123,7 +107,7 @@ export const Slider = ({ | |||||||
| 			{...css( | 			{...css( | ||||||
| 				{ | 				{ | ||||||
| 					paddingVertical: ts(1), | 					paddingVertical: ts(1), | ||||||
| 					// @ts-ignore Web only
 | 					// @ts-expect-error Web only
 | ||||||
| 					cursor: "pointer", | 					cursor: "pointer", | ||||||
| 					...focusReset, | 					...focusReset, | ||||||
| 				}, | 				}, | ||||||
|  | |||||||
| @ -39,7 +39,7 @@ export const useBreakpointMap = <T extends Record<string, unknown>>( | |||||||
| 	value: T, | 	value: T, | ||||||
| ): { [key in keyof T]: T[key] extends Breakpoint<infer V> ? V : T } => { | ): { [key in keyof T]: T[key] extends Breakpoint<infer V> ? V : T } => { | ||||||
| 	const breakpoint = useBreakpoint(); | 	const breakpoint = useBreakpoint(); | ||||||
| 	// @ts-ignore
 | 	// @ts-expect-error
 | ||||||
| 	return Object.fromEntries( | 	return Object.fromEntries( | ||||||
| 		Object.entries(value).map(([key, val]) => [ | 		Object.entries(value).map(([key, val]) => [ | ||||||
| 			key, | 			key, | ||||||
|  | |||||||
| @ -62,3 +62,16 @@ export const readValue = <T extends ZodType>(key: string, parser: T) => { | |||||||
| 	if (val === undefined) return val; | 	if (val === undefined) return val; | ||||||
| 	return parser.parse(JSON.parse(val)) as z.infer<T>; | 	return parser.parse(JSON.parse(val)) as z.infer<T>; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | export const useLocalSetting = <T extends string>(setting: string, def: T) => { | ||||||
|  | 	if (Platform.OS === "web" && typeof window === "undefined") | ||||||
|  | 		return [def as T, null!] as const; | ||||||
|  | 	// biome-ignore lint/correctness/useHookAtTopLevel: ssr
 | ||||||
|  | 	const [val, setter] = useMMKVString(`settings.${setting}`, storage); | ||||||
|  | 	return [(val ?? def) as T, setter] as const; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const getLocalSetting = (setting: string, def: string) => { | ||||||
|  | 	if (Platform.OS === "web" && typeof window === "undefined") return def; | ||||||
|  | 	return storage.getString(`settings.${setting}`) ?? setting; | ||||||
|  | }; | ||||||
|  | |||||||
							
								
								
									
										39
									
								
								front/src/track-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								front/src/track-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | |||||||
|  | import intl from "langmap"; | ||||||
|  | import { useTranslation } from "react-i18next"; | ||||||
|  | import type { Subtitle } from "./models"; | ||||||
|  | 
 | ||||||
|  | export const useLanguageName = () => { | ||||||
|  | 	return (lang: string) => intl[lang]?.nativeName; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const useDisplayName = () => { | ||||||
|  | 	const getLanguageName = useLanguageName(); | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|  | 	return (sub: { language?: string; title?: string; index?: number }) => { | ||||||
|  | 		const lng = sub.language ? getLanguageName(sub.language) : null; | ||||||
|  | 
 | ||||||
|  | 		if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`; | ||||||
|  | 		if (lng) return lng; | ||||||
|  | 		if (sub.title) return sub.title; | ||||||
|  | 		if (sub.index !== null) return `${t("mediainfo.unknown")} (${sub.index})`; | ||||||
|  | 		return t("mediainfo.unknown"); | ||||||
|  | 	}; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const useSubtitleName = () => { | ||||||
|  | 	const getDisplayName = useDisplayName(); | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|  | 	return (sub: Subtitle) => { | ||||||
|  | 		const name = getDisplayName(sub); | ||||||
|  | 		const attributes = [name]; | ||||||
|  | 
 | ||||||
|  | 		if (sub.isDefault) attributes.push(t("mediainfo.default")); | ||||||
|  | 		if (sub.isForced) attributes.push(t("mediainfo.forced")); | ||||||
|  | 		if (sub.isHearingImpaired) attributes.push(t("mediainfo.hearing-impaired")); | ||||||
|  | 		if (sub.isExternal) attributes.push(t("mediainfo.external")); | ||||||
|  | 
 | ||||||
|  | 		return attributes.join(" - "); | ||||||
|  | 	}; | ||||||
|  | }; | ||||||
| @ -35,6 +35,7 @@ import { | |||||||
| 	A, | 	A, | ||||||
| 	Chip, | 	Chip, | ||||||
| 	Container, | 	Container, | ||||||
|  | 	ContrastArea, | ||||||
| 	capitalize, | 	capitalize, | ||||||
| 	DottedSeparator, | 	DottedSeparator, | ||||||
| 	GradientImageBackground, | 	GradientImageBackground, | ||||||
| @ -714,6 +715,7 @@ export const Header = ({ | |||||||
| 								}, | 								}, | ||||||
| 							}) as any)} | 							}) as any)} | ||||||
| 						/> | 						/> | ||||||
|  | 						<ContrastArea> | ||||||
| 							<TitleLine | 							<TitleLine | ||||||
| 								kind={kind} | 								kind={kind} | ||||||
| 								slug={slug} | 								slug={slug} | ||||||
| @ -727,7 +729,9 @@ export const Header = ({ | |||||||
| 								playHref={data.kind !== "collection" ? data.playHref : null} | 								playHref={data.kind !== "collection" ? data.playHref : null} | ||||||
| 								trailerUrl={data.kind !== "collection" ? data.trailerUrl : null} | 								trailerUrl={data.kind !== "collection" ? data.trailerUrl : null} | ||||||
| 								watchStatus={ | 								watchStatus={ | ||||||
| 								data.kind !== "collection" ? data.watchStatus?.status! : null | 									data.kind !== "collection" | ||||||
|  | 										? (data.watchStatus?.status ?? null) | ||||||
|  | 										: null | ||||||
| 								} | 								} | ||||||
| 								{...css({ | 								{...css({ | ||||||
| 									marginTop: { | 									marginTop: { | ||||||
| @ -738,6 +742,7 @@ export const Header = ({ | |||||||
| 									}, | 									}, | ||||||
| 								})} | 								})} | ||||||
| 							/> | 							/> | ||||||
|  | 						</ContrastArea> | ||||||
| 						<Description | 						<Description | ||||||
| 							description={data?.description} | 							description={data?.description} | ||||||
| 							genres={data?.genres} | 							genres={data?.genres} | ||||||
|  | |||||||
							
								
								
									
										9
									
								
								front/src/ui/info/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								front/src/ui/info/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | import { VideoInfo } from "~/models"; | ||||||
|  | import type { QueryIdentifier } from "~/query"; | ||||||
|  | 
 | ||||||
|  | export const Info = () => {}; | ||||||
|  | 
 | ||||||
|  | Info.infoQuery = (slug: string): QueryIdentifier<VideoInfo> => ({ | ||||||
|  | 	path: ["api", "videos", slug, "info"], | ||||||
|  | 	parser: VideoInfo, | ||||||
|  | }); | ||||||
							
								
								
									
										53
									
								
								front/src/ui/player/controls/back.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								front/src/ui/player/controls/back.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | |||||||
|  | import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; | ||||||
|  | import { useRouter } from "expo-router"; | ||||||
|  | import { useTranslation } from "react-i18next"; | ||||||
|  | import { View, type ViewProps } from "react-native"; | ||||||
|  | import { percent, rem, useYoshiki } from "yoshiki/native"; | ||||||
|  | import { | ||||||
|  | 	H1, | ||||||
|  | 	IconButton, | ||||||
|  | 	PressableFeedback, | ||||||
|  | 	Skeleton, | ||||||
|  | 	tooltip, | ||||||
|  | } from "~/primitives"; | ||||||
|  | 
 | ||||||
|  | export const Back = ({ name, ...props }: { name?: string } & ViewProps) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 	const router = useRouter(); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View | ||||||
|  | 			{...css( | ||||||
|  | 				{ | ||||||
|  | 					display: "flex", | ||||||
|  | 					flexDirection: "row", | ||||||
|  | 					alignItems: "center", | ||||||
|  | 					padding: percent(0.33), | ||||||
|  | 					color: "white", | ||||||
|  | 				}, | ||||||
|  | 				props, | ||||||
|  | 			)} | ||||||
|  | 		> | ||||||
|  | 			<IconButton | ||||||
|  | 				icon={ArrowBack} | ||||||
|  | 				as={PressableFeedback} | ||||||
|  | 				onPress={router.back} | ||||||
|  | 				{...tooltip(t("player.back"))} | ||||||
|  | 			/> | ||||||
|  | 			{name ? ( | ||||||
|  | 				<H1 | ||||||
|  | 					{...css({ | ||||||
|  | 						alignSelf: "center", | ||||||
|  | 						fontSize: rem(1.5), | ||||||
|  | 						marginLeft: rem(1), | ||||||
|  | 					})} | ||||||
|  | 				> | ||||||
|  | 					{name} | ||||||
|  | 				</H1> | ||||||
|  | 			) : ( | ||||||
|  | 				<Skeleton {...css({ width: rem(5) })} /> | ||||||
|  | 			)} | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
							
								
								
									
										174
									
								
								front/src/ui/player/controls/bottom-controls.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								front/src/ui/player/controls/bottom-controls.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,174 @@ | |||||||
|  | import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg"; | ||||||
|  | import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg"; | ||||||
|  | import type { ComponentProps } from "react"; | ||||||
|  | import { useTranslation } from "react-i18next"; | ||||||
|  | import { Platform, View, type ViewProps } from "react-native"; | ||||||
|  | import type { VideoPlayer } from "react-native-video"; | ||||||
|  | import { percent, rem, useYoshiki } from "yoshiki/native"; | ||||||
|  | import type { Chapter, KImage } from "~/models"; | ||||||
|  | import { | ||||||
|  | 	H2, | ||||||
|  | 	IconButton, | ||||||
|  | 	Link, | ||||||
|  | 	type Menu, | ||||||
|  | 	Poster, | ||||||
|  | 	Skeleton, | ||||||
|  | 	tooltip, | ||||||
|  | 	ts, | ||||||
|  | 	useIsTouch, | ||||||
|  | } from "~/primitives"; | ||||||
|  | import { FullscreenButton, PlayButton, VolumeSlider } from "./misc"; | ||||||
|  | import { ProgressBar, ProgressText } from "./progress"; | ||||||
|  | import { AudioMenu, QualityMenu, SubtitleMenu, VideoMenu } from "./tracks-menu"; | ||||||
|  | 
 | ||||||
|  | export const BottomControls = ({ | ||||||
|  | 	player, | ||||||
|  | 	poster, | ||||||
|  | 	name, | ||||||
|  | 	chapters, | ||||||
|  | 	previous, | ||||||
|  | 	next, | ||||||
|  | 	setMenu, | ||||||
|  | 	...props | ||||||
|  | }: { | ||||||
|  | 	player: VideoPlayer; | ||||||
|  | 	poster?: KImage | null; | ||||||
|  | 	name?: string; | ||||||
|  | 	chapters: Chapter[]; | ||||||
|  | 	previous?: string | null; | ||||||
|  | 	next?: string | null; | ||||||
|  | 	setMenu: (isOpen: boolean) => void; | ||||||
|  | } & ViewProps) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View | ||||||
|  | 			{...css( | ||||||
|  | 				{ | ||||||
|  | 					flexDirection: "row", | ||||||
|  | 					padding: ts(1), | ||||||
|  | 				}, | ||||||
|  | 				props, | ||||||
|  | 			)} | ||||||
|  | 		> | ||||||
|  | 			<View | ||||||
|  | 				{...css({ | ||||||
|  | 					width: "15%", | ||||||
|  | 					display: { xs: "none", sm: "flex" }, | ||||||
|  | 					position: "relative", | ||||||
|  | 				})} | ||||||
|  | 			> | ||||||
|  | 				{poster !== undefined ? ( | ||||||
|  | 					<Poster | ||||||
|  | 						src={poster} | ||||||
|  | 						quality="low" | ||||||
|  | 						layout={{ width: percent(100) }} | ||||||
|  | 						{...(css({ position: "absolute", bottom: 0 }) as any)} | ||||||
|  | 					/> | ||||||
|  | 				) : ( | ||||||
|  | 					<Poster.Loader | ||||||
|  | 						layout={{ width: percent(100) }} | ||||||
|  | 						{...(css({ position: "absolute", bottom: 0 }) as any)} | ||||||
|  | 					/> | ||||||
|  | 				)} | ||||||
|  | 			</View> | ||||||
|  | 			<View | ||||||
|  | 				{...css({ | ||||||
|  | 					marginHorizontal: { xs: ts(0.5), sm: ts(3) }, | ||||||
|  | 					flexDirection: "column", | ||||||
|  | 					flex: 1, | ||||||
|  | 				})} | ||||||
|  | 			> | ||||||
|  | 				{name ? ( | ||||||
|  | 					<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}> | ||||||
|  | 						{name} | ||||||
|  | 					</H2> | ||||||
|  | 				) : ( | ||||||
|  | 					<Skeleton {...css({ width: rem(15), height: rem(2) })} /> | ||||||
|  | 				)} | ||||||
|  | 				<ProgressBar player={player} chapters={chapters} /> | ||||||
|  | 				<ControlButtons | ||||||
|  | 					player={player} | ||||||
|  | 					previous={previous} | ||||||
|  | 					next={next} | ||||||
|  | 					setMenu={setMenu} | ||||||
|  | 				/> | ||||||
|  | 			</View> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const ControlButtons = ({ | ||||||
|  | 	player, | ||||||
|  | 	previous, | ||||||
|  | 	next, | ||||||
|  | 	setMenu, | ||||||
|  | 	...props | ||||||
|  | }: { | ||||||
|  | 	player: VideoPlayer; | ||||||
|  | 	previous?: string | null; | ||||||
|  | 	next?: string | null; | ||||||
|  | 	setMenu: (isOpen: boolean) => void; | ||||||
|  | }) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 	const isTouch = useIsTouch(); | ||||||
|  | 
 | ||||||
|  | 	const spacing = css({ marginHorizontal: ts(1) }); | ||||||
|  | 	const menuProps = { | ||||||
|  | 		onMenuOpen: () => setMenu(true), | ||||||
|  | 		onMenuClose: () => setMenu(false), | ||||||
|  | 		...spacing, | ||||||
|  | 	} satisfies Partial<ComponentProps<typeof Menu>>; | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View | ||||||
|  | 			{...css( | ||||||
|  | 				{ | ||||||
|  | 					flexDirection: "row", | ||||||
|  | 					flex: 1, | ||||||
|  | 					justifyContent: "space-between", | ||||||
|  | 					flexWrap: "wrap", | ||||||
|  | 				}, | ||||||
|  | 				props, | ||||||
|  | 			)} | ||||||
|  | 		> | ||||||
|  | 			<View {...css({ flexDirection: "row" })}> | ||||||
|  | 				{!isTouch && ( | ||||||
|  | 					<View {...css({ flexDirection: "row" })}> | ||||||
|  | 						{previous && ( | ||||||
|  | 							<IconButton | ||||||
|  | 								icon={SkipPrevious} | ||||||
|  | 								as={Link} | ||||||
|  | 								href={previous} | ||||||
|  | 								replace | ||||||
|  | 								{...tooltip(t("player.previous"), true)} | ||||||
|  | 								{...spacing} | ||||||
|  | 							/> | ||||||
|  | 						)} | ||||||
|  | 						<PlayButton player={player} {...spacing} /> | ||||||
|  | 						{next && ( | ||||||
|  | 							<IconButton | ||||||
|  | 								icon={SkipNext} | ||||||
|  | 								as={Link} | ||||||
|  | 								href={next} | ||||||
|  | 								replace | ||||||
|  | 								{...tooltip(t("player.next"), true)} | ||||||
|  | 								{...spacing} | ||||||
|  | 							/> | ||||||
|  | 						)} | ||||||
|  | 						{Platform.OS === "web" && <VolumeSlider player={player} />} | ||||||
|  | 					</View> | ||||||
|  | 				)} | ||||||
|  | 				<ProgressText player={player} {...spacing} /> | ||||||
|  | 			</View> | ||||||
|  | 			<View {...css({ flexDirection: "row" })}> | ||||||
|  | 				<SubtitleMenu player={player} {...menuProps} /> | ||||||
|  | 				<AudioMenu player={player} {...menuProps} /> | ||||||
|  | 				<VideoMenu player={player} {...menuProps} /> | ||||||
|  | 				<QualityMenu player={player} {...menuProps} /> | ||||||
|  | 				{Platform.OS === "web" && <FullscreenButton {...spacing} />} | ||||||
|  | 			</View> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
							
								
								
									
										101
									
								
								front/src/ui/player/controls/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								front/src/ui/player/controls/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,101 @@ | |||||||
|  | import { useState } from "react"; | ||||||
|  | import type { ViewProps } from "react-native"; | ||||||
|  | import { StyleSheet, View } from "react-native"; | ||||||
|  | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||||||
|  | import type { VideoPlayer } from "react-native-video"; | ||||||
|  | import { useYoshiki } from "yoshiki/native"; | ||||||
|  | import type { Chapter, KImage } from "~/models"; | ||||||
|  | import { useIsTouch } from "~/primitives"; | ||||||
|  | import { Back } from "./back"; | ||||||
|  | import { BottomControls } from "./bottom-controls"; | ||||||
|  | import { MiddleControls } from "./middle-controls"; | ||||||
|  | import { TouchControls } from "./touch"; | ||||||
|  | 
 | ||||||
|  | export const Controls = ({ | ||||||
|  | 	player, | ||||||
|  | 	name, | ||||||
|  | 	poster, | ||||||
|  | 	subName, | ||||||
|  | 	chapters, | ||||||
|  | 	previous, | ||||||
|  | 	next, | ||||||
|  | }: { | ||||||
|  | 	player: VideoPlayer; | ||||||
|  | 	name?: string; | ||||||
|  | 	poster?: KImage | null; | ||||||
|  | 	subName?: string; | ||||||
|  | 	chapters: Chapter[]; | ||||||
|  | 	previous?: string | null; | ||||||
|  | 	next?: string | null; | ||||||
|  | }) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 	const insets = useSafeAreaInsets(); | ||||||
|  | 	const isTouch = useIsTouch(); | ||||||
|  | 
 | ||||||
|  | 	const [hover, setHover] = useState(false); | ||||||
|  | 	const [menuOpenned, setMenu] = useState(false); | ||||||
|  | 
 | ||||||
|  | 	const hoverControls = { | ||||||
|  | 		onPointerEnter: (e) => { | ||||||
|  | 			if (e.nativeEvent.pointerType === "mouse") setHover(true); | ||||||
|  | 		}, | ||||||
|  | 		onPointerLeave: (e) => { | ||||||
|  | 			if (e.nativeEvent.pointerType === "mouse") setHover(false); | ||||||
|  | 		}, | ||||||
|  | 	} satisfies ViewProps; | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View {...css(StyleSheet.absoluteFillObject)}> | ||||||
|  | 			<TouchControls | ||||||
|  | 				player={player} | ||||||
|  | 				forceShow={hover || menuOpenned} | ||||||
|  | 				{...css(StyleSheet.absoluteFillObject)} | ||||||
|  | 			/> | ||||||
|  | 			<Back | ||||||
|  | 				name={name} | ||||||
|  | 				{...css( | ||||||
|  | 					{ | ||||||
|  | 						position: "absolute", | ||||||
|  | 						top: 0, | ||||||
|  | 						left: 0, | ||||||
|  | 						right: 0, | ||||||
|  | 						bg: (theme) => theme.darkOverlay, | ||||||
|  | 						paddingTop: insets.top, | ||||||
|  | 						paddingLeft: insets.left, | ||||||
|  | 						paddingRight: insets.right, | ||||||
|  | 					}, | ||||||
|  | 					hoverControls, | ||||||
|  | 				)} | ||||||
|  | 			/> | ||||||
|  | 			{isTouch && ( | ||||||
|  | 				<MiddleControls player={player} previous={previous} next={next} /> | ||||||
|  | 			)} | ||||||
|  | 			<BottomControls | ||||||
|  | 				player={player} | ||||||
|  | 				name={subName} | ||||||
|  | 				poster={poster} | ||||||
|  | 				chapters={chapters} | ||||||
|  | 				previous={previous} | ||||||
|  | 				next={next} | ||||||
|  | 				setMenu={setMenu} | ||||||
|  | 				{...css( | ||||||
|  | 					{ | ||||||
|  | 						// Fixed is used because firefox android make the hover disappear under the navigation bar in absolute
 | ||||||
|  | 						// position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
 | ||||||
|  | 						position: "absolute", | ||||||
|  | 						bottom: 0, | ||||||
|  | 						left: 0, | ||||||
|  | 						right: 0, | ||||||
|  | 						bg: (theme) => theme.darkOverlay, | ||||||
|  | 						paddingLeft: insets.left, | ||||||
|  | 						paddingRight: insets.right, | ||||||
|  | 						paddingBottom: insets.bottom, | ||||||
|  | 					}, | ||||||
|  | 					hoverControls, | ||||||
|  | 				)} | ||||||
|  | 			/> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export { LoadingIndicator } from "./misc"; | ||||||
							
								
								
									
										61
									
								
								front/src/ui/player/controls/middle-controls.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								front/src/ui/player/controls/middle-controls.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | |||||||
|  | import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg"; | ||||||
|  | import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg"; | ||||||
|  | import { View } from "react-native"; | ||||||
|  | import type { VideoPlayer } from "react-native-video"; | ||||||
|  | import { useYoshiki } from "yoshiki/native"; | ||||||
|  | import { IconButton, Link, ts } from "~/primitives"; | ||||||
|  | import { PlayButton } from "./misc"; | ||||||
|  | 
 | ||||||
|  | export const MiddleControls = ({ | ||||||
|  | 	player, | ||||||
|  | 	previous, | ||||||
|  | 	next, | ||||||
|  | 	...props | ||||||
|  | }: { | ||||||
|  | 	player: VideoPlayer; | ||||||
|  | 	previous?: string | null; | ||||||
|  | 	next?: string | null; | ||||||
|  | }) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 
 | ||||||
|  | 	const common = css({ | ||||||
|  | 		backgroundColor: (theme) => theme.darkOverlay, | ||||||
|  | 		marginHorizontal: ts(3), | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View | ||||||
|  | 			{...css( | ||||||
|  | 				{ | ||||||
|  | 					flexDirection: "row", | ||||||
|  | 					justifyContent: "center", | ||||||
|  | 					alignItems: "center", | ||||||
|  | 					position: "absolute", | ||||||
|  | 					top: 0, | ||||||
|  | 					left: 0, | ||||||
|  | 					right: 0, | ||||||
|  | 					bottom: 0, | ||||||
|  | 				}, | ||||||
|  | 				props, | ||||||
|  | 			)} | ||||||
|  | 		> | ||||||
|  | 			<IconButton | ||||||
|  | 				icon={SkipPrevious} | ||||||
|  | 				as={Link} | ||||||
|  | 				href={previous ?? ""} | ||||||
|  | 				replace | ||||||
|  | 				size={ts(4)} | ||||||
|  | 				{...css([!previous && { opacity: 0, pointerEvents: "none" }], common)} | ||||||
|  | 			/> | ||||||
|  | 			<PlayButton player={player} size={ts(8)} {...common} /> | ||||||
|  | 			<IconButton | ||||||
|  | 				icon={SkipNext} | ||||||
|  | 				as={Link} | ||||||
|  | 				href={next ?? ""} | ||||||
|  | 				replace | ||||||
|  | 				size={ts(4)} | ||||||
|  | 				{...css([!next && { opacity: 0, pointerEvents: "none" }], common)} | ||||||
|  | 			/> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
							
								
								
									
										165
									
								
								front/src/ui/player/controls/misc.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								front/src/ui/player/controls/misc.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,165 @@ | |||||||
|  | import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg"; | ||||||
|  | import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg"; | ||||||
|  | import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg"; | ||||||
|  | import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; | ||||||
|  | import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg"; | ||||||
|  | import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg"; | ||||||
|  | import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg"; | ||||||
|  | import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg"; | ||||||
|  | import { type ComponentProps, useEffect, useState } from "react"; | ||||||
|  | import { useTranslation } from "react-i18next"; | ||||||
|  | import { type PressableProps, View } from "react-native"; | ||||||
|  | import { useEvent, type VideoPlayer } from "react-native-video"; | ||||||
|  | import { px, useYoshiki } from "yoshiki/native"; | ||||||
|  | import { | ||||||
|  | 	alpha, | ||||||
|  | 	CircularProgress, | ||||||
|  | 	IconButton, | ||||||
|  | 	Slider, | ||||||
|  | 	tooltip, | ||||||
|  | 	ts, | ||||||
|  | } from "~/primitives"; | ||||||
|  | 
 | ||||||
|  | export const PlayButton = ({ | ||||||
|  | 	player, | ||||||
|  | 	...props | ||||||
|  | }: { player: VideoPlayer } & Partial< | ||||||
|  | 	ComponentProps<typeof IconButton<PressableProps>> | ||||||
|  | >) => { | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|  | 	const [playing, setPlay] = useState(player.isPlaying); | ||||||
|  | 	useEvent(player, "onPlaybackStateChange", (status) => { | ||||||
|  | 		setPlay(status.isPlaying); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<IconButton | ||||||
|  | 			icon={playing ? Pause : PlayArrow} | ||||||
|  | 			onPress={() => { | ||||||
|  | 				if (playing) player.pause(); | ||||||
|  | 				else player.play(); | ||||||
|  | 			}} | ||||||
|  | 			{...tooltip(playing ? t("player.pause") : t("player.play"), true)} | ||||||
|  | 			{...props} | ||||||
|  | 		/> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const toggleFullscreen = async (set?: boolean) => { | ||||||
|  | 	set ??= document.fullscreenElement === null; | ||||||
|  | 	try { | ||||||
|  | 		if (set) { | ||||||
|  | 			await document.body.requestFullscreen({ navigationUI: "hide" }); | ||||||
|  | 			// @ts-expect-error Firefox does not support this so ts complains
 | ||||||
|  | 			await screen.orientation.lock("landscape"); | ||||||
|  | 		} else { | ||||||
|  | 			if (document.fullscreenElement) await document.exitFullscreen(); | ||||||
|  | 			screen.orientation.unlock(); | ||||||
|  | 		} | ||||||
|  | 	} catch (e) { | ||||||
|  | 		console.log(e); | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const FullscreenButton = ( | ||||||
|  | 	props: Partial<ComponentProps<typeof IconButton<PressableProps>>>, | ||||||
|  | ) => { | ||||||
|  | 	// this is a web only component
 | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|  | 	const [fullscreen, setFullscreen] = useState(false); | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		const update = () => setFullscreen(document.fullscreenElement !== null); | ||||||
|  | 		document.addEventListener("fullscreenchange", update); | ||||||
|  | 		return () => document.removeEventListener("fullscreenchange", update); | ||||||
|  | 	}, []); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<IconButton | ||||||
|  | 			icon={fullscreen ? FullscreenExit : Fullscreen} | ||||||
|  | 			onPress={() => toggleFullscreen()} | ||||||
|  | 			{...tooltip(t("player.fullscreen"), true)} | ||||||
|  | 			{...props} | ||||||
|  | 		/> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|  | 	const [volume, setVolume] = useState(player.volume); | ||||||
|  | 	const [muted, setMuted] = useState(player.muted); | ||||||
|  | 	useEvent(player, "onVolumeChange", (info) => { | ||||||
|  | 		setVolume(info.volume); | ||||||
|  | 		setMuted(info.muted); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View | ||||||
|  | 			{...css( | ||||||
|  | 				{ | ||||||
|  | 					display: { xs: "none", sm: "flex" }, | ||||||
|  | 					alignItems: "center", | ||||||
|  | 					flexDirection: "row", | ||||||
|  | 					paddingRight: ts(1), | ||||||
|  | 				}, | ||||||
|  | 				props, | ||||||
|  | 			)} | ||||||
|  | 		> | ||||||
|  | 			<IconButton | ||||||
|  | 				icon={ | ||||||
|  | 					muted || volume === 0 | ||||||
|  | 						? VolumeOff | ||||||
|  | 						: volume < 0.25 | ||||||
|  | 							? VolumeMute | ||||||
|  | 							: volume < 0.65 | ||||||
|  | 								? VolumeDown | ||||||
|  | 								: VolumeUp | ||||||
|  | 				} | ||||||
|  | 				onPress={() => { | ||||||
|  | 					player.muted = !muted; | ||||||
|  | 				}} | ||||||
|  | 				{...tooltip(t("player.mute"), true)} | ||||||
|  | 			/> | ||||||
|  | 			<Slider | ||||||
|  | 				progress={volume * 100} | ||||||
|  | 				setProgress={(vol) => { | ||||||
|  | 					player.volume = vol / 100; | ||||||
|  | 				}} | ||||||
|  | 				size={4} | ||||||
|  | 				{...css({ width: px(100) })} | ||||||
|  | 				{...tooltip(t("player.volume"), true)} | ||||||
|  | 			/> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 	const [isLoading, setLoading] = useState(false); | ||||||
|  | 
 | ||||||
|  | 	useEvent(player, "onStatusChange", (status) => { | ||||||
|  | 		setLoading(status === "loading"); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	if (!isLoading) return null; | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View | ||||||
|  | 			{...css({ | ||||||
|  | 				position: "absolute", | ||||||
|  | 				pointerEvents: "none", | ||||||
|  | 				top: 0, | ||||||
|  | 				bottom: 0, | ||||||
|  | 				left: 0, | ||||||
|  | 				right: 0, | ||||||
|  | 				bg: (theme) => alpha(theme.colors.black, 0.3), | ||||||
|  | 				justifyContent: "center", | ||||||
|  | 			})} | ||||||
|  | 		> | ||||||
|  | 			<CircularProgress {...css({ alignSelf: "center" })} /> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
							
								
								
									
										111
									
								
								front/src/ui/player/controls/progress.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								front/src/ui/player/controls/progress.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,111 @@ | |||||||
|  | import { useState } from "react"; | ||||||
|  | import type { TextProps } from "react-native"; | ||||||
|  | import { useEvent, type VideoPlayer } from "react-native-video"; | ||||||
|  | import { useYoshiki } from "yoshiki/native"; | ||||||
|  | import type { Chapter } from "~/models"; | ||||||
|  | import { P, Slider } from "~/primitives"; | ||||||
|  | 
 | ||||||
|  | export const ProgressBar = ({ | ||||||
|  | 	player, | ||||||
|  | 	// url,
 | ||||||
|  | 	chapters, | ||||||
|  | }: { | ||||||
|  | 	player: VideoPlayer; | ||||||
|  | 	// url: string;
 | ||||||
|  | 	chapters?: Chapter[]; | ||||||
|  | }) => { | ||||||
|  | 	const [duration, setDuration] = useState(player.duration || 100); | ||||||
|  | 	useEvent(player, "onLoad", (info) => { | ||||||
|  | 		if (info.duration) setDuration(info.duration); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	const [progress, setProgress] = useState(player.currentTime || 0); | ||||||
|  | 	const [buffer, setBuffer] = useState(0); | ||||||
|  | 	useEvent(player, "onProgress", (progress) => { | ||||||
|  | 		setProgress(progress.currentTime); | ||||||
|  | 		setBuffer(progress.bufferDuration); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	const [seek, setSeek] = useState<number | null>(null); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<> | ||||||
|  | 			<Slider | ||||||
|  | 				progress={seek ?? progress} | ||||||
|  | 				subtleProgress={buffer} | ||||||
|  | 				max={duration} | ||||||
|  | 				startSeek={() => { | ||||||
|  | 					player.pause(); | ||||||
|  | 				}} | ||||||
|  | 				setProgress={setSeek} | ||||||
|  | 				endSeek={() => { | ||||||
|  | 					player.seekTo(seek!); | ||||||
|  | 					setTimeout(() => player.play(), 10); | ||||||
|  | 					setSeek(null); | ||||||
|  | 				}} | ||||||
|  | 				// onHover={(progress, layout) => {
 | ||||||
|  | 				// 	setHoverProgress(progress);
 | ||||||
|  | 				// 	setLayout(layout);
 | ||||||
|  | 				// }}
 | ||||||
|  | 				markers={chapters?.map((x) => x.startTime)} | ||||||
|  | 				// dataSet={{ tooltipId: "progress-scrubber" }}
 | ||||||
|  | 			/> | ||||||
|  | 			{/* <Tooltip */} | ||||||
|  | 			{/* 	id={"progress-scrubber"} */} | ||||||
|  | 			{/* 	isOpen={hoverProgress !== null} */} | ||||||
|  | 			{/* 	place="top" */} | ||||||
|  | 			{/* 	position={{ */} | ||||||
|  | 			{/* 		x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), */} | ||||||
|  | 			{/* 		y: layout.y, */} | ||||||
|  | 			{/* 	}} */} | ||||||
|  | 			{/* 	render={() => */} | ||||||
|  | 			{/* 		hoverProgress ? ( */} | ||||||
|  | 			{/* 			<ScrubberTooltip */} | ||||||
|  | 			{/* 				seconds={hoverProgress} */} | ||||||
|  | 			{/* 				chapters={chapters} */} | ||||||
|  | 			{/* 				url={url} */} | ||||||
|  | 			{/* 			/> */} | ||||||
|  | 			{/* 		) : null */} | ||||||
|  | 			{/* 	} */} | ||||||
|  | 			{/* 	opacity={1} */} | ||||||
|  | 			{/* 	style={{ padding: 0, borderRadius: imageBorderRadius }} */} | ||||||
|  | 			{/* /> */} | ||||||
|  | 		</> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const ProgressText = ({ | ||||||
|  | 	player, | ||||||
|  | 	...props | ||||||
|  | }: { player: VideoPlayer } & TextProps) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 
 | ||||||
|  | 	const [progress, setProgress] = useState(player.currentTime); | ||||||
|  | 	useEvent(player, "onProgress", (progress) => { | ||||||
|  | 		setProgress(progress.currentTime); | ||||||
|  | 	}); | ||||||
|  | 	const [duration, setDuration] = useState(player.duration); | ||||||
|  | 	useEvent(player, "onLoad", (info) => { | ||||||
|  | 		if (info.duration) setDuration(info.duration); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<P {...css({ alignSelf: "center" }, props)}> | ||||||
|  | 			{toTimerString(progress, duration)} : {toTimerString(duration)} | ||||||
|  | 		</P> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const toTimerString = (timer?: number, duration?: number) => { | ||||||
|  | 	if (!duration) duration = timer; | ||||||
|  | 	if (timer === undefined || !Number.isFinite(timer)) return "??:??"; | ||||||
|  | 
 | ||||||
|  | 	const h = Math.floor(timer / 3600); | ||||||
|  | 	const min = Math.floor((timer / 60) % 60); | ||||||
|  | 	const sec = Math.floor(timer % 60); | ||||||
|  | 	const fmt = (n: number) => n.toString().padStart(2, "0"); | ||||||
|  | 
 | ||||||
|  | 	return h !== 0 || (duration && duration >= 3600) | ||||||
|  | 		? `${fmt(h)}:${fmt(min)}:${fmt(sec)}` | ||||||
|  | 		: `${fmt(min)}:${fmt(sec)}`; | ||||||
|  | }; | ||||||
							
								
								
									
										125
									
								
								front/src/ui/player/controls/touch.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								front/src/ui/player/controls/touch.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,125 @@ | |||||||
|  | import { useCallback, useEffect, useRef, useState } from "react"; | ||||||
|  | import { | ||||||
|  | 	type GestureResponderEvent, | ||||||
|  | 	Platform, | ||||||
|  | 	Pressable, | ||||||
|  | 	type PressableProps, | ||||||
|  | } from "react-native"; | ||||||
|  | import { useEvent, type VideoPlayer } from "react-native-video"; | ||||||
|  | import { useYoshiki } from "yoshiki/native"; | ||||||
|  | import { useIsTouch } from "~/primitives"; | ||||||
|  | import { toggleFullscreen } from "./misc"; | ||||||
|  | 
 | ||||||
|  | export const TouchControls = ({ | ||||||
|  | 	player, | ||||||
|  | 	children, | ||||||
|  | 	forceShow = false, | ||||||
|  | 	...props | ||||||
|  | }: { player: VideoPlayer; forceShow?: boolean } & PressableProps) => { | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 	const isTouch = useIsTouch(); | ||||||
|  | 
 | ||||||
|  | 	const [playing, setPlay] = useState(player.isPlaying); | ||||||
|  | 	useEvent(player, "onPlaybackStateChange", (status) => { | ||||||
|  | 		setPlay(status.isPlaying); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	const [_show, setShow] = useState(false); | ||||||
|  | 	const hideTimeout = useRef<NodeJS.Timeout | null>(null); | ||||||
|  | 	const shouldShow = forceShow || _show || !playing; | ||||||
|  | 	const show = useCallback((val: boolean = true) => { | ||||||
|  | 		setShow(val); | ||||||
|  | 		if (hideTimeout.current) clearTimeout(hideTimeout.current); | ||||||
|  | 		hideTimeout.current = setTimeout(() => { | ||||||
|  | 			hideTimeout.current = null; | ||||||
|  | 			setShow(false); | ||||||
|  | 		}, 2500); | ||||||
|  | 	}, []); | ||||||
|  | 
 | ||||||
|  | 	// On mouse move
 | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (Platform.OS !== "web") return; | ||||||
|  | 		const handler = (e: PointerEvent) => { | ||||||
|  | 			if (e.pointerType !== "mouse") return; | ||||||
|  | 			show(); | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		document.addEventListener("pointermove", handler); | ||||||
|  | 		return () => document.removeEventListener("pointermove", handler); | ||||||
|  | 	}, [show]); | ||||||
|  | 
 | ||||||
|  | 	const playerWidth = useRef<number | null>(null); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<DoublePressable | ||||||
|  | 			tabIndex={-1} | ||||||
|  | 			onPress={() => { | ||||||
|  | 				if (isTouch) { | ||||||
|  | 					show(!shouldShow); | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  | 				if (player.isPlaying) player.pause(); | ||||||
|  | 				else player.play(); | ||||||
|  | 			}} | ||||||
|  | 			onDoublePress={(e) => { | ||||||
|  | 				if (!isTouch) { | ||||||
|  | 					toggleFullscreen(); | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				show(); | ||||||
|  | 				if (Number.isNaN(player.duration) || !playerWidth.current) return; | ||||||
|  | 
 | ||||||
|  | 				const x = e.nativeEvent.locationX ?? e.nativeEvent.pageX; | ||||||
|  | 				if (x < playerWidth.current * 0.33) player.seekBy(-10); | ||||||
|  | 				if (x > playerWidth.current * 0.66) player.seekBy(10); | ||||||
|  | 				// Do not reset press count, you can continue to seek by pressing again.
 | ||||||
|  | 				return true; | ||||||
|  | 			}} | ||||||
|  | 			onLayout={(e) => { | ||||||
|  | 				playerWidth.current = e.nativeEvent.layout.width; | ||||||
|  | 			}} | ||||||
|  | 			onPointerLeave={(e) => { | ||||||
|  | 				// instantly hide the controls when mouse leaves the view
 | ||||||
|  | 				if (e.nativeEvent.pointerType === "mouse") show(false); | ||||||
|  | 			}} | ||||||
|  | 			{...css({ cursor: (shouldShow ? "unset" : "none") as any }, props)} | ||||||
|  | 		> | ||||||
|  | 			{shouldShow && children} | ||||||
|  | 		</DoublePressable> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const DoublePressable = ({ | ||||||
|  | 	onPress, | ||||||
|  | 	onDoublePress, | ||||||
|  | 	...props | ||||||
|  | }: { | ||||||
|  | 	onDoublePress: (e: GestureResponderEvent) => boolean | undefined; | ||||||
|  | } & PressableProps) => { | ||||||
|  | 	const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ | ||||||
|  | 		count: 0, | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<Pressable | ||||||
|  | 			onPress={(e) => { | ||||||
|  | 				e.preventDefault(); | ||||||
|  | 				touch.current.count++; | ||||||
|  | 				if (touch.current.count >= 2) { | ||||||
|  | 					const keepCount = onDoublePress(e); | ||||||
|  | 					if (!keepCount) touch.current.count = 0; | ||||||
|  | 					clearTimeout(touch.current.timeout); | ||||||
|  | 				} else { | ||||||
|  | 					onPress?.(e); | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				touch.current.timeout = setTimeout(() => { | ||||||
|  | 					touch.current.count = 0; | ||||||
|  | 					touch.current.timeout = undefined; | ||||||
|  | 				}, 400); | ||||||
|  | 			}} | ||||||
|  | 			{...props} | ||||||
|  | 		/> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
							
								
								
									
										191
									
								
								front/src/ui/player/controls/tracks-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								front/src/ui/player/controls/tracks-menu.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,191 @@ | |||||||
|  | import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg"; | ||||||
|  | import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg"; | ||||||
|  | import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg"; | ||||||
|  | import VideoSettings from "@material-symbols/svg-400/rounded/video_settings-fill.svg"; | ||||||
|  | import { type ComponentProps, createContext, useContext } from "react"; | ||||||
|  | import { useTranslation } from "react-i18next"; | ||||||
|  | import { useEvent, type VideoPlayer } from "react-native-video"; | ||||||
|  | import { useForceRerender } from "yoshiki"; | ||||||
|  | import { IconButton, Menu, tooltip } from "~/primitives"; | ||||||
|  | import { useFetch } from "~/query"; | ||||||
|  | import { useDisplayName, useSubtitleName } from "~/track-utils"; | ||||||
|  | import { Info } from "~/ui/info"; | ||||||
|  | import { useQueryState } from "~/utils"; | ||||||
|  | 
 | ||||||
|  | type MenuProps = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>; | ||||||
|  | 
 | ||||||
|  | export const SubtitleMenu = ({ | ||||||
|  | 	player, | ||||||
|  | 	...props | ||||||
|  | }: { | ||||||
|  | 	player: VideoPlayer; | ||||||
|  | } & Partial<MenuProps>) => { | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 	const getDisplayName = useSubtitleName(); | ||||||
|  | 
 | ||||||
|  | 	const rerender = useForceRerender(); | ||||||
|  | 	useEvent(player, "onTrackChange", rerender); | ||||||
|  | 
 | ||||||
|  | 	const [slug] = useQueryState<string>("slug", undefined!); | ||||||
|  | 	const { data } = useFetch(Info.infoQuery(slug)); | ||||||
|  | 
 | ||||||
|  | 	if (data?.subtitles.length === 0) return null; | ||||||
|  | 
 | ||||||
|  | 	const selectedIdx = player | ||||||
|  | 		.getAvailableTextTracks() | ||||||
|  | 		.findIndex((x) => x.selected); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<Menu | ||||||
|  | 			Trigger={IconButton} | ||||||
|  | 			icon={ClosedCaption} | ||||||
|  | 			{...tooltip(t("player.subtitles"), true)} | ||||||
|  | 			{...props} | ||||||
|  | 		> | ||||||
|  | 			<Menu.Item | ||||||
|  | 				label={t("player.subtitle-none")} | ||||||
|  | 				selected={selectedIdx === -1} | ||||||
|  | 				onSelect={() => player.selectTextTrack(null)} | ||||||
|  | 			/> | ||||||
|  | 			{data?.subtitles.map((x, i) => ( | ||||||
|  | 				<Menu.Item | ||||||
|  | 					key={x.index ?? x.link} | ||||||
|  | 					label={getDisplayName(x)} | ||||||
|  | 					selected={i === selectedIdx} | ||||||
|  | 					onSelect={() => | ||||||
|  | 						player.selectTextTrack(player.getAvailableTextTracks()[i]) | ||||||
|  | 					} | ||||||
|  | 				/> | ||||||
|  | 			))} | ||||||
|  | 		</Menu> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const AudioMenu = ({ | ||||||
|  | 	player, | ||||||
|  | 	...props | ||||||
|  | }: { player: VideoPlayer } & Partial<MenuProps>) => { | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 	const getDisplayName = useDisplayName(); | ||||||
|  | 
 | ||||||
|  | 	const rerender = useForceRerender(); | ||||||
|  | 	useEvent(player, "onAudioTrackChange", rerender); | ||||||
|  | 
 | ||||||
|  | 	const tracks = player.getAvailableAudioTracks(); | ||||||
|  | 	if (tracks.length <= 1) return null; | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<Menu | ||||||
|  | 			Trigger={IconButton} | ||||||
|  | 			icon={MusicNote} | ||||||
|  | 			{...tooltip(t("player.audios"), true)} | ||||||
|  | 			{...props} | ||||||
|  | 		> | ||||||
|  | 			{tracks.map((x) => ( | ||||||
|  | 				<Menu.Item | ||||||
|  | 					key={x.id} | ||||||
|  | 					label={getDisplayName({ title: x.label, language: x.language })} | ||||||
|  | 					selected={x.selected} | ||||||
|  | 					onSelect={() => player.selectAudioTrack(x)} | ||||||
|  | 				/> | ||||||
|  | 			))} | ||||||
|  | 		</Menu> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const VideoMenu = ({ | ||||||
|  | 	player, | ||||||
|  | 	...props | ||||||
|  | }: { | ||||||
|  | 	player: VideoPlayer; | ||||||
|  | } & Partial<MenuProps>) => { | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 	const getDisplayName = useDisplayName(); | ||||||
|  | 
 | ||||||
|  | 	const rerender = useForceRerender(); | ||||||
|  | 	useEvent(player, "onVideoTrackChange", rerender); | ||||||
|  | 
 | ||||||
|  | 	const tracks = player.getAvailableVideoTracks(); | ||||||
|  | 	if (tracks.length <= 1) return null; | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<Menu | ||||||
|  | 			Trigger={IconButton} | ||||||
|  | 			icon={VideoSettings} | ||||||
|  | 			{...tooltip(t("player.audios"), true)} | ||||||
|  | 			{...props} | ||||||
|  | 		> | ||||||
|  | 			{tracks.map((x) => ( | ||||||
|  | 				<Menu.Item | ||||||
|  | 					key={x.id} | ||||||
|  | 					label={getDisplayName({ title: x.label, language: x.language })} | ||||||
|  | 					selected={x.selected} | ||||||
|  | 					onSelect={() => player.selectAudioTrack(x)} | ||||||
|  | 				/> | ||||||
|  | 			))} | ||||||
|  | 		</Menu> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const PlayModeContext = createContext< | ||||||
|  | 	["direct" | "hls", (val: "direct" | "hls") => void] | ||||||
|  | >(null!); | ||||||
|  | 
 | ||||||
|  | export const QualityMenu = ({ | ||||||
|  | 	player, | ||||||
|  | 	...props | ||||||
|  | }: { player: VideoPlayer } & Partial<MenuProps>) => { | ||||||
|  | 	const { t } = useTranslation(); | ||||||
|  | 	const [playMode, setPlayMode] = useContext(PlayModeContext); | ||||||
|  | 	const rerender = useForceRerender(); | ||||||
|  | 
 | ||||||
|  | 	useEvent(player, "onQualityChange", rerender); | ||||||
|  | 
 | ||||||
|  | 	const lvls = player.getAvailableQualities(); | ||||||
|  | 	const current = player.currentQuality; | ||||||
|  | 	const auto = player.autoQualityEnabled; | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<Menu | ||||||
|  | 			Trigger={IconButton} | ||||||
|  | 			icon={SettingsIcon} | ||||||
|  | 			{...tooltip(t("player.quality"), true)} | ||||||
|  | 			{...props} | ||||||
|  | 		> | ||||||
|  | 			<Menu.Item | ||||||
|  | 				label={t("player.direct")} | ||||||
|  | 				selected={playMode === "direct"} | ||||||
|  | 				onSelect={() => setPlayMode("direct")} | ||||||
|  | 			/> | ||||||
|  | 			<Menu.Item | ||||||
|  | 				label={ | ||||||
|  | 					auto && current | ||||||
|  | 						? `${t("player.auto")} (${current.id.includes("original") ? t("player.transmux") : `${current.height}p`})` | ||||||
|  | 						: t("player.auto") | ||||||
|  | 				} | ||||||
|  | 				selected={auto && playMode === "hls"} | ||||||
|  | 				onSelect={() => { | ||||||
|  | 					setPlayMode("hls"); | ||||||
|  | 					player.selectQuality(null); | ||||||
|  | 				}} | ||||||
|  | 			/> | ||||||
|  | 			{lvls | ||||||
|  | 				.map((x) => ( | ||||||
|  | 					<Menu.Item | ||||||
|  | 						key={x.id} | ||||||
|  | 						label={ | ||||||
|  | 							x.id.includes("original") | ||||||
|  | 								? `${t("player.transmux")} (${x.height}p)` | ||||||
|  | 								: `${x.height}p` | ||||||
|  | 						} | ||||||
|  | 						selected={x.selected && !auto} | ||||||
|  | 						onSelect={() => { | ||||||
|  | 							setPlayMode("hls"); | ||||||
|  | 							player.selectQuality(x); | ||||||
|  | 						}} | ||||||
|  | 					/> | ||||||
|  | 				)) | ||||||
|  | 				.reverse()} | ||||||
|  | 		</Menu> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
							
								
								
									
										224
									
								
								front/src/ui/player/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								front/src/ui/player/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,224 @@ | |||||||
|  | import "react-native-get-random-values"; | ||||||
|  | 
 | ||||||
|  | import { Stack, useRouter } from "expo-router"; | ||||||
|  | import { useCallback, useEffect, useState } from "react"; | ||||||
|  | import { Platform, StyleSheet, View } from "react-native"; | ||||||
|  | import { useEvent, useVideoPlayer, VideoView } from "react-native-video"; | ||||||
|  | import { v4 as uuidv4 } from "uuid"; | ||||||
|  | import { useYoshiki } from "yoshiki/native"; | ||||||
|  | import { entryDisplayNumber } from "~/components/entries"; | ||||||
|  | import { FullVideo, type KyooError } from "~/models"; | ||||||
|  | import { ContrastArea, Head } from "~/primitives"; | ||||||
|  | import { useToken } from "~/providers/account-context"; | ||||||
|  | import { useLocalSetting } from "~/providers/settings"; | ||||||
|  | import { type QueryIdentifier, useFetch } from "~/query"; | ||||||
|  | import { Info } from "~/ui/info"; | ||||||
|  | import { useQueryState } from "~/utils"; | ||||||
|  | import { ErrorView } from "../errors"; | ||||||
|  | import { Controls, LoadingIndicator } from "./controls"; | ||||||
|  | import { Back } from "./controls/back"; | ||||||
|  | import { toggleFullscreen } from "./controls/misc"; | ||||||
|  | import { PlayModeContext } from "./controls/tracks-menu"; | ||||||
|  | import { useKeyboard } from "./keyboard"; | ||||||
|  | import { enhanceSubtitles } from "./subtitles"; | ||||||
|  | 
 | ||||||
|  | const clientId = uuidv4(); | ||||||
|  | 
 | ||||||
|  | export const Player = () => { | ||||||
|  | 	const [slug, setSlug] = useQueryState<string>("slug", undefined!); | ||||||
|  | 	const [start, setStart] = useQueryState<number | undefined>("t", undefined); | ||||||
|  | 
 | ||||||
|  | 	const { data, error } = useFetch(Player.query(slug)); | ||||||
|  | 	const { data: info, error: infoError } = useFetch(Info.infoQuery(slug)); | ||||||
|  | 	// TODO: map current entry using entries' duration & the current playtime
 | ||||||
|  | 	const currentEntry = 0; | ||||||
|  | 	const entry = data?.entries[currentEntry] ?? data?.entries[0]; | ||||||
|  | 	const title = entry | ||||||
|  | 		? entry.kind === "movie" | ||||||
|  | 			? entry.name | ||||||
|  | 			: `${entry.name} (${entryDisplayNumber(entry)})` | ||||||
|  | 		: null; | ||||||
|  | 
 | ||||||
|  | 	const { apiUrl, authToken } = useToken(); | ||||||
|  | 	const [defaultPlayMode] = useLocalSetting<"direct" | "hls">( | ||||||
|  | 		"playMode", | ||||||
|  | 		"direct", | ||||||
|  | 	); | ||||||
|  | 	const playModeState = useState(defaultPlayMode); | ||||||
|  | 	const [playMode, setPlayMode] = playModeState; | ||||||
|  | 	const player = useVideoPlayer( | ||||||
|  | 		{ | ||||||
|  | 			uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`, | ||||||
|  | 			// chrome based browsers support matroska but they tell they don't
 | ||||||
|  | 			mimeType: | ||||||
|  | 				playMode === "direct" | ||||||
|  | 					? info?.mimeCodec?.replace("x-matroska", "mp4") | ||||||
|  | 					: "application/vnd.apple.mpegurl", | ||||||
|  | 			headers: authToken | ||||||
|  | 				? { | ||||||
|  | 						Authorization: `Bearer ${authToken}`, | ||||||
|  | 					} | ||||||
|  | 				: {}, | ||||||
|  | 			metadata: { | ||||||
|  | 				title: title ?? undefined, | ||||||
|  | 				artist: data?.show?.name ?? undefined, | ||||||
|  | 				description: entry?.description ?? undefined, | ||||||
|  | 				imageUri: data?.show?.thumbnail?.high ?? undefined, | ||||||
|  | 			}, | ||||||
|  | 			externalSubtitles: info?.subtitles | ||||||
|  | 				.filter( | ||||||
|  | 					(x) => Platform.OS === "web" || playMode === "hls" || x.isExternal, | ||||||
|  | 				) | ||||||
|  | 				.map((x) => ({ | ||||||
|  | 					// we also add those without link to prevent the order from getting out of sync with `info.subtitles`.
 | ||||||
|  | 					// since we never actually play those this is fine
 | ||||||
|  | 					uri: | ||||||
|  | 						x.codec === "subrip" && x.link && Platform.OS === "web" | ||||||
|  | 							? `${x.link}?format=vtt` | ||||||
|  | 							: x.link!, | ||||||
|  | 					label: x.title ?? "Unknown", | ||||||
|  | 					language: x.language ?? "und", | ||||||
|  | 					type: x.codec, | ||||||
|  | 				})), | ||||||
|  | 		}, | ||||||
|  | 		(p) => { | ||||||
|  | 			p.playWhenInactive = true; | ||||||
|  | 			p.playInBackground = true; | ||||||
|  | 			p.showNotificationControls = true; | ||||||
|  | 			enhanceSubtitles(p); | ||||||
|  | 			const seek = start ?? data?.progress.time; | ||||||
|  | 			// TODO: fix console.error bellow
 | ||||||
|  | 			if (seek) p.seekTo(seek); | ||||||
|  | 			else console.error("Player got ready before progress info was loaded."); | ||||||
|  | 			p.play(); | ||||||
|  | 		}, | ||||||
|  | 	); | ||||||
|  | 
 | ||||||
|  | 	// we'll also want to replace source here once https://github.com/TheWidlarzGroup/react-native-video/issues/4722 is ready
 | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (Platform.OS === "web") player.__ass.fonts = info?.fonts ?? []; | ||||||
|  | 	}, [player, info?.fonts]); | ||||||
|  | 
 | ||||||
|  | 	const router = useRouter(); | ||||||
|  | 	const playPrev = useCallback(() => { | ||||||
|  | 		if (!data?.previous) return false; | ||||||
|  | 		setStart(0); | ||||||
|  | 		setSlug(data.previous.video); | ||||||
|  | 		return true; | ||||||
|  | 	}, [data?.previous, setSlug, setStart]); | ||||||
|  | 	const playNext = useCallback(() => { | ||||||
|  | 		if (!data?.next) return false; | ||||||
|  | 		setStart(0); | ||||||
|  | 		setSlug(data.next.video); | ||||||
|  | 		return true; | ||||||
|  | 	}, [data?.next, setSlug, setStart]); | ||||||
|  | 
 | ||||||
|  | 	useEvent(player, "onEnd", () => { | ||||||
|  | 		const hasNext = playNext(); | ||||||
|  | 		if (!hasNext && data?.show) router.navigate(data.show.href); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	// TODO: add the equivalent of this for android
 | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (Platform.OS !== "web" || typeof window === "undefined") return; | ||||||
|  | 		window.navigator.mediaSession.setActionHandler( | ||||||
|  | 			"previoustrack", | ||||||
|  | 			data?.previous?.video ? playPrev : null, | ||||||
|  | 		); | ||||||
|  | 		window.navigator.mediaSession.setActionHandler( | ||||||
|  | 			"nexttrack", | ||||||
|  | 			data?.next?.video ? playNext : null, | ||||||
|  | 		); | ||||||
|  | 	}, [data?.next?.video, data?.previous?.video, playNext, playPrev]); | ||||||
|  | 
 | ||||||
|  | 	useKeyboard(player, playPrev, playNext); | ||||||
|  | 
 | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (Platform.OS !== "web") return; | ||||||
|  | 		if (/Mobi/i.test(window.navigator.userAgent)) toggleFullscreen(true); | ||||||
|  | 		return () => { | ||||||
|  | 			if (!document.location.href.includes("/watch")) toggleFullscreen(false); | ||||||
|  | 		}; | ||||||
|  | 	}, []); | ||||||
|  | 
 | ||||||
|  | 	const [playbackError, setPlaybackError] = useState<KyooError | undefined>(); | ||||||
|  | 	useEvent(player, "onError", (error) => { | ||||||
|  | 		if ( | ||||||
|  | 			error.code === "source/unsupported-content-type" && | ||||||
|  | 			playMode === "direct" | ||||||
|  | 		) | ||||||
|  | 			setPlayMode("hls"); | ||||||
|  | 		else setPlaybackError({ status: error.code, message: error.message }); | ||||||
|  | 	}); | ||||||
|  | 	const { css } = useYoshiki(); | ||||||
|  | 	if (error || infoError || playbackError) { | ||||||
|  | 		return ( | ||||||
|  | 			<> | ||||||
|  | 				<Back | ||||||
|  | 					name={data?.show?.name ?? "Error"} | ||||||
|  | 					{...css({ position: "relative", bg: (theme) => theme.accent })} | ||||||
|  | 				/> | ||||||
|  | 				<ErrorView error={error ?? infoError ?? playbackError!} /> | ||||||
|  | 			</> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<View | ||||||
|  | 			style={{ | ||||||
|  | 				flex: 1, | ||||||
|  | 				backgroundColor: "black", | ||||||
|  | 			}} | ||||||
|  | 		> | ||||||
|  | 			<Head | ||||||
|  | 				title={title} | ||||||
|  | 				description={entry?.description} | ||||||
|  | 				image={data?.show?.thumbnail?.high} | ||||||
|  | 			/> | ||||||
|  | 			<Stack.Screen | ||||||
|  | 				options={{ | ||||||
|  | 					headerShown: false, | ||||||
|  | 					navigationBarHidden: true, | ||||||
|  | 					statusBarHidden: true, | ||||||
|  | 					orientation: "landscape", | ||||||
|  | 					contentStyle: { paddingLeft: 0, paddingRight: 0 }, | ||||||
|  | 				}} | ||||||
|  | 			/> | ||||||
|  | 			<VideoView | ||||||
|  | 				player={player} | ||||||
|  | 				pictureInPicture | ||||||
|  | 				autoEnterPictureInPicture | ||||||
|  | 				resizeMode={"contain"} | ||||||
|  | 				style={StyleSheet.absoluteFillObject} | ||||||
|  | 			/> | ||||||
|  | 			<ContrastArea mode="dark"> | ||||||
|  | 				<LoadingIndicator player={player} /> | ||||||
|  | 				<PlayModeContext.Provider value={playModeState}> | ||||||
|  | 					<Controls | ||||||
|  | 						player={player} | ||||||
|  | 						name={data?.show?.name} | ||||||
|  | 						poster={data?.show?.poster} | ||||||
|  | 						subName={ | ||||||
|  | 							entry | ||||||
|  | 								? [entryDisplayNumber(entry), entry.name] | ||||||
|  | 										.filter((x) => x) | ||||||
|  | 										.join(" - ") | ||||||
|  | 								: undefined | ||||||
|  | 						} | ||||||
|  | 						chapters={info?.chapters ?? []} | ||||||
|  | 						previous={data?.previous?.video} | ||||||
|  | 						next={data?.next?.video} | ||||||
|  | 					/> | ||||||
|  | 				</PlayModeContext.Provider> | ||||||
|  | 			</ContrastArea> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | Player.query = (slug: string): QueryIdentifier<FullVideo> => ({ | ||||||
|  | 	path: ["api", "videos", slug], | ||||||
|  | 	params: { | ||||||
|  | 		with: ["next", "previous", "show"], | ||||||
|  | 	}, | ||||||
|  | 	parser: FullVideo, | ||||||
|  | }); | ||||||
							
								
								
									
										164
									
								
								front/src/ui/player/keyboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								front/src/ui/player/keyboard.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,164 @@ | |||||||
|  | import { useEffect } from "react"; | ||||||
|  | import { Platform } from "react-native"; | ||||||
|  | import type { VideoPlayer } from "react-native-video"; | ||||||
|  | import type { Subtitle } from "~/models"; | ||||||
|  | import { toggleFullscreen } from "./controls/misc"; | ||||||
|  | 
 | ||||||
|  | type Action = | ||||||
|  | 	| { type: "play" } | ||||||
|  | 	| { type: "mute" } | ||||||
|  | 	| { type: "fullscreen" } | ||||||
|  | 	| { type: "seek"; value: number } | ||||||
|  | 	| { type: "seekTo"; value: number } | ||||||
|  | 	| { type: "seekPercent"; value: number } | ||||||
|  | 	| { type: "volume"; value: number } | ||||||
|  | 	| { type: "subtitle"; subtitles: Subtitle[]; fonts: string[] }; | ||||||
|  | 
 | ||||||
|  | const reducer = (player: VideoPlayer, action: Action) => { | ||||||
|  | 	switch (action.type) { | ||||||
|  | 		case "play": | ||||||
|  | 			if (player.isPlaying) player.pause(); | ||||||
|  | 			else player.play(); | ||||||
|  | 			break; | ||||||
|  | 		case "mute": | ||||||
|  | 			player.muted = !player.muted; | ||||||
|  | 			break; | ||||||
|  | 		case "fullscreen": | ||||||
|  | 			toggleFullscreen(); | ||||||
|  | 			break; | ||||||
|  | 		case "seek": | ||||||
|  | 			player.seekBy(action.value); | ||||||
|  | 			break; | ||||||
|  | 		case "seekTo": | ||||||
|  | 			player.seekTo(action.value); | ||||||
|  | 			break; | ||||||
|  | 		case "seekPercent": | ||||||
|  | 			player.seekTo((player.duration * action.value) / 100); | ||||||
|  | 			break; | ||||||
|  | 		case "volume": | ||||||
|  | 			player.volume = Math.max(0, Math.min(player.volume + action.value, 100)); | ||||||
|  | 			break; | ||||||
|  | 		// case "subtitle": {
 | ||||||
|  | 		// 	const subtitle = get(subtitleAtom);
 | ||||||
|  | 		// 	const index = subtitle
 | ||||||
|  | 		// 		? action.subtitles.findIndex((x) => x.index === subtitle.index)
 | ||||||
|  | 		// 		: -1;
 | ||||||
|  | 		// 	set(
 | ||||||
|  | 		// 		subtitleAtom,
 | ||||||
|  | 		// 		index === -1
 | ||||||
|  | 		// 			? null
 | ||||||
|  | 		// 			: action.subtitles[(index + 1) % action.subtitles.length],
 | ||||||
|  | 		// 	);
 | ||||||
|  | 		// 	break;
 | ||||||
|  | 		// }
 | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const useKeyboard = ( | ||||||
|  | 	player: VideoPlayer, | ||||||
|  | 	playPrev: () => void, | ||||||
|  | 	playNext: () => void, | ||||||
|  | 	// subtitles?: Subtitle[],
 | ||||||
|  | 	// fonts?: string[],
 | ||||||
|  | ) => { | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (Platform.OS !== "web") return; | ||||||
|  | 		const handler = (event: KeyboardEvent) => { | ||||||
|  | 			if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) | ||||||
|  | 				return; | ||||||
|  | 
 | ||||||
|  | 			switch (event.key) { | ||||||
|  | 				case " ": | ||||||
|  | 				case "k": | ||||||
|  | 				case "MediaPlay": | ||||||
|  | 				case "MediaPause": | ||||||
|  | 				case "MediaPlayPause": | ||||||
|  | 					reducer(player, { type: "play" }); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				case "m": | ||||||
|  | 					reducer(player, { type: "mute" }); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				case "ArrowLeft": | ||||||
|  | 					reducer(player, { type: "seek", value: -5 }); | ||||||
|  | 					break; | ||||||
|  | 				case "ArrowRight": | ||||||
|  | 					reducer(player, { type: "seek", value: +5 }); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				case "j": | ||||||
|  | 					reducer(player, { type: "seek", value: -10 }); | ||||||
|  | 					break; | ||||||
|  | 				case "l": | ||||||
|  | 					reducer(player, { type: "seek", value: +10 }); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				case "ArrowUp": | ||||||
|  | 					reducer(player, { type: "volume", value: +0.05 }); | ||||||
|  | 					break; | ||||||
|  | 				case "ArrowDown": | ||||||
|  | 					reducer(player, { type: "volume", value: -0.05 }); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				case "f": | ||||||
|  | 					reducer(player, { type: "fullscreen" }); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				// case "v":
 | ||||||
|  | 				// case "c":
 | ||||||
|  | 				// 	if (!subtitles || !fonts) return;
 | ||||||
|  | 				// 	reducer(player, { type: "subtitle", subtitles, fonts });
 | ||||||
|  | 				// 	break;
 | ||||||
|  | 
 | ||||||
|  | 				case "n": | ||||||
|  | 				case "N": | ||||||
|  | 					playNext(); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				case "p": | ||||||
|  | 				case "P": | ||||||
|  | 					playPrev(); | ||||||
|  | 					break; | ||||||
|  | 
 | ||||||
|  | 				default: | ||||||
|  | 					break; | ||||||
|  | 			} | ||||||
|  | 			switch (event.code) { | ||||||
|  | 				case "Digit0": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 0 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit1": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 10 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit2": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 20 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit3": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 30 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit4": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 40 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit5": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 50 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit6": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 60 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit7": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 70 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit8": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 80 }); | ||||||
|  | 					break; | ||||||
|  | 				case "Digit9": | ||||||
|  | 					reducer(player, { type: "seekPercent", value: 90 }); | ||||||
|  | 					break; | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		document.addEventListener("keyup", handler); | ||||||
|  | 		return () => document.removeEventListener("keyup", handler); | ||||||
|  | 	}, [player, playPrev, playNext]); | ||||||
|  | }; | ||||||
| @ -18,16 +18,27 @@ | |||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| import { type Chapter, type QueryIdentifier, imageFn, useFetch } from "@kyoo/models"; | import { | ||||||
| import { P, Sprite, imageBorderRadius, ts } from "@kyoo/primitives"; | 	type Chapter, | ||||||
|  | 	imageFn, | ||||||
|  | 	type QueryIdentifier, | ||||||
|  | 	useFetch, | ||||||
|  | } from "@kyoo/models"; | ||||||
|  | import { imageBorderRadius, P, Sprite, ts } from "@kyoo/primitives"; | ||||||
| import { useAtomValue } from "jotai"; | import { useAtomValue } from "jotai"; | ||||||
| import { useMemo } from "react"; | import { useMemo } from "react"; | ||||||
| import { Platform, View } from "react-native"; | import { Platform, View } from "react-native"; | ||||||
| import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native"; | import { | ||||||
| import { ErrorView } from "../../../../../src/ui/errors"; | 	percent, | ||||||
| import { durationAtom } from "../state"; | 	px, | ||||||
| import { seekProgressAtom } from "./hover"; | 	type Theme, | ||||||
| import { toTimerString } from "./left-buttons"; | 	useForceRerender, | ||||||
|  | 	useYoshiki, | ||||||
|  | } from "yoshiki/native"; | ||||||
|  | import { ErrorView } from "../../errors"; | ||||||
|  | import { seekProgressAtom } from "../controls"; | ||||||
|  | import { toTimerString } from "../controls/left-buttonsttons"; | ||||||
|  | import { durationAtom } from "./state"; | ||||||
| 
 | 
 | ||||||
| type Thumb = { | type Thumb = { | ||||||
| 	from: number; | 	from: number; | ||||||
| @ -42,8 +53,8 @@ type Thumb = { | |||||||
| const parseTs = (time: string) => { | const parseTs = (time: string) => { | ||||||
| 	const times = time.split(":"); | 	const times = time.split(":"); | ||||||
| 	return ( | 	return ( | ||||||
| 		(Number.parseInt(times[0]) * 3600 + | 		(Number.parseInt(times[0], 10) * 3600 + | ||||||
| 			Number.parseInt(times[1]) * 60 + | 			Number.parseInt(times[1], 10) * 60 + | ||||||
| 			Number.parseFloat(times[2])) * | 			Number.parseFloat(times[2])) * | ||||||
| 		1000 | 		1000 | ||||||
| 	); | 	); | ||||||
| @ -69,7 +80,7 @@ export const useScrubber = (url: string) => { | |||||||
| 		for (let i = 0; i < ret.length; i++) { | 		for (let i = 0; i < ret.length; i++) { | ||||||
| 			const times = lines[i * 2].split(" --> "); | 			const times = lines[i * 2].split(" --> "); | ||||||
| 			const url = lines[i * 2 + 1].split("#xywh="); | 			const url = lines[i * 2 + 1].split("#xywh="); | ||||||
| 			const xywh = url[1].split(",").map((x) => Number.parseInt(x)); | 			const xywh = url[1].split(",").map((x) => Number.parseInt(x, 10)); | ||||||
| 			ret[i] = { | 			ret[i] = { | ||||||
| 				from: parseTs(times[0]), | 				from: parseTs(times[0]), | ||||||
| 				to: parseTs(times[1]), | 				to: parseTs(times[1]), | ||||||
| @ -123,7 +134,9 @@ export const ScrubberTooltip = ({ | |||||||
| 	const current = | 	const current = | ||||||
| 		info.findLast((x) => x.from <= seconds * 1000 && seconds * 1000 < x.to) ?? | 		info.findLast((x) => x.from <= seconds * 1000 && seconds * 1000 < x.to) ?? | ||||||
| 		info.findLast(() => true); | 		info.findLast(() => true); | ||||||
| 	const chapter = chapters?.findLast((x) => x.startTime <= seconds && seconds < x.endTime); | 	const chapter = chapters?.findLast( | ||||||
|  | 		(x) => x.startTime <= seconds && seconds < x.endTime, | ||||||
|  | 	); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<View | 		<View | ||||||
| @ -153,7 +166,13 @@ export const ScrubberTooltip = ({ | |||||||
| }; | }; | ||||||
| let scrubberWidth = 0; | let scrubberWidth = 0; | ||||||
| 
 | 
 | ||||||
| export const BottomScrubber = ({ url, chapters }: { url: string; chapters?: Chapter[] }) => { | export const BottomScrubber = ({ | ||||||
|  | 	url, | ||||||
|  | 	chapters, | ||||||
|  | }: { | ||||||
|  | 	url: string; | ||||||
|  | 	chapters?: Chapter[]; | ||||||
|  | }) => { | ||||||
| 	const { css } = useYoshiki(); | 	const { css } = useYoshiki(); | ||||||
| 	const { info, error, stats } = useScrubber(url); | 	const { info, error, stats } = useScrubber(url); | ||||||
| 	const rerender = useForceRerender(); | 	const rerender = useForceRerender(); | ||||||
| @ -164,7 +183,9 @@ export const BottomScrubber = ({ url, chapters }: { url: string; chapters?: Chap | |||||||
| 	if (error) return <ErrorView error={error} />; | 	if (error) return <ErrorView error={error} />; | ||||||
| 
 | 
 | ||||||
| 	const width = stats?.width ?? 1; | 	const width = stats?.width ?? 1; | ||||||
| 	const chapter = chapters?.findLast((x) => x.startTime <= progress && progress < x.endTime); | 	const chapter = chapters?.findLast( | ||||||
|  | 		(x) => x.startTime <= progress && progress < x.endTime, | ||||||
|  | 	); | ||||||
| 	return ( | 	return ( | ||||||
| 		<View {...css({ overflow: "hidden" })}> | 		<View {...css({ overflow: "hidden" })}> | ||||||
| 			<View | 			<View | ||||||
| @ -18,12 +18,12 @@ | |||||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 |  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| import { type MutationParam, WatchStatusV, useAccount } from "@kyoo/models"; | import { type MutationParam, useAccount, WatchStatusV } from "@kyoo/models"; | ||||||
| import { useMutation } from "@tanstack/react-query"; | import { useMutation } from "@tanstack/react-query"; | ||||||
| import { useAtomValue } from "jotai"; | import { useAtomValue } from "jotai"; | ||||||
| import { useAtomCallback } from "jotai/utils"; | import { useAtomCallback } from "jotai/utils"; | ||||||
| import { useCallback, useEffect } from "react"; | import { useCallback, useEffect } from "react"; | ||||||
| import { playAtom, progressAtom } from "./state"; | import { playAtom, progressAtom } from "./old/statee"; | ||||||
| 
 | 
 | ||||||
| export const WatchStatusObserver = ({ | export const WatchStatusObserver = ({ | ||||||
| 	type, | 	type, | ||||||
							
								
								
									
										3
									
								
								front/src/ui/player/subtitles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								front/src/ui/player/subtitles.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | import type { VideoPlayer } from "react-native-video"; | ||||||
|  | 
 | ||||||
|  | export const enhanceSubtitles = (player: VideoPlayer) => player; | ||||||
							
								
								
									
										62
									
								
								front/src/ui/player/subtitles.web.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								front/src/ui/player/subtitles.web.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | |||||||
|  | import Jassub from "jassub"; | ||||||
|  | import type { VideoPlayer } from "react-native-video"; | ||||||
|  | 
 | ||||||
|  | declare module "react-native-video" { | ||||||
|  | 	interface VideoPlayer { | ||||||
|  | 		__getNativeRef(): HTMLVideoElement; | ||||||
|  | 		__ass: { | ||||||
|  | 			currentId?: string; | ||||||
|  | 			jassub?: Jassub; | ||||||
|  | 			fonts: string[]; | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const enhanceSubtitles = (player: VideoPlayer) => { | ||||||
|  | 	player.__ass = { fonts: [] }; | ||||||
|  | 
 | ||||||
|  | 	const select = player.selectTextTrack.bind(player); | ||||||
|  | 	player.selectTextTrack = (track) => { | ||||||
|  | 		player.__ass.currentId = undefined; | ||||||
|  | 
 | ||||||
|  | 		// on the web, track.id is the url of the subtitle.
 | ||||||
|  | 		if (!track || !track.id.endsWith(".ass")) { | ||||||
|  | 			player.__ass.jassub?.destroy(); | ||||||
|  | 			player.__ass.jassub = undefined; | ||||||
|  | 			select(track); | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// since we'll use a custom renderer for ass, disable the existing sub
 | ||||||
|  | 		select(null); | ||||||
|  | 		player.__ass.currentId = track.id; | ||||||
|  | 		if (!player.__ass.jassub) { | ||||||
|  | 			player.__ass.jassub = new Jassub({ | ||||||
|  | 				video: player.__getNativeRef(), | ||||||
|  | 				workerUrl: "/jassub/jassub-worker.js", | ||||||
|  | 				wasmUrl: "/jassub/jassub-worker.wasm", | ||||||
|  | 				legacyWasmUrl: "/jassub/jassub-worker.wasm.js", | ||||||
|  | 				modernWasmUrl: "/jassub/jassub-worker-modern.wasm", | ||||||
|  | 				// Disable offscreen renderer due to bugs on firefox and chrome android
 | ||||||
|  | 				// (see https://github.com/ThaUnknown/jassub/issues/31)
 | ||||||
|  | 				// offscreenRender: false,
 | ||||||
|  | 				subUrl: track.id, | ||||||
|  | 				fonts: player.__ass.fonts, | ||||||
|  | 			}); | ||||||
|  | 		} else { | ||||||
|  | 			player.__ass.jassub.freeTrack(); | ||||||
|  | 			player.__ass.jassub.setTrackByUrl(track.id); | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	const getAvailable = player.getAvailableTextTracks.bind(player); | ||||||
|  | 	player.getAvailableTextTracks = () => { | ||||||
|  | 		const ret = getAvailable(); | ||||||
|  | 		if (player.__ass.currentId) { | ||||||
|  | 			const current = ret.find((x) => x.id === player.__ass.currentId); | ||||||
|  | 			if (current) current.selected = true; | ||||||
|  | 		} | ||||||
|  | 		return ret; | ||||||
|  | 	}; | ||||||
|  | 	return player; | ||||||
|  | }; | ||||||
| @ -1,5 +1,5 @@ | |||||||
| import { NavigationContext, useRoute } from "@react-navigation/native"; | import { NavigationContext, useRoute } from "@react-navigation/native"; | ||||||
| import { useContext } from "react"; | import { useCallback, useContext } from "react"; | ||||||
| import type { Movie, Show } from "~/models"; | import type { Movie, Show } from "~/models"; | ||||||
| 
 | 
 | ||||||
| export function setServerData(_key: string, _val: any) {} | export function setServerData(_key: string, _val: any) {} | ||||||
| @ -12,9 +12,12 @@ export const useQueryState = <S>(key: string, initial: S) => { | |||||||
| 	const nav = useContext(NavigationContext); | 	const nav = useContext(NavigationContext); | ||||||
| 
 | 
 | ||||||
| 	const state = ((route.params as any)?.[key] as S) ?? initial; | 	const state = ((route.params as any)?.[key] as S) ?? initial; | ||||||
| 	const update = (val: S | ((old: S) => S)) => { | 	const update = useCallback( | ||||||
|  | 		(val: S | ((old: S) => S)) => { | ||||||
| 			nav!.setParams({ [key]: val }); | 			nav!.setParams({ [key]: val }); | ||||||
| 	}; | 		}, | ||||||
|  | 		[nav, key], | ||||||
|  | 	); | ||||||
| 	return [state, update] as const; | 	return [state, update] as const; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -2,7 +2,9 @@ | |||||||
| 	"compilerOptions": { | 	"compilerOptions": { | ||||||
| 		"baseUrl": ".", | 		"baseUrl": ".", | ||||||
| 		"paths": { | 		"paths": { | ||||||
| 			"~/*": ["./src/*"] | 			"~/*": [ | ||||||
|  | 				"./src/*" | ||||||
|  | 			] | ||||||
| 		}, | 		}, | ||||||
| 		"strict": true, | 		"strict": true, | ||||||
| 		"rootDir": ".", | 		"rootDir": ".", | ||||||
| @ -14,13 +16,25 @@ | |||||||
| 		"skipLibCheck": true, | 		"skipLibCheck": true, | ||||||
| 		"jsx": "react-jsx", | 		"jsx": "react-jsx", | ||||||
| 		"forceConsistentCasingInFileNames": true, | 		"forceConsistentCasingInFileNames": true, | ||||||
| 		"types": ["node", "react"], | 		"types": [ | ||||||
| 		"lib": ["dom", "esnext"] | 			"node", | ||||||
|  | 			"react" | ||||||
|  | 		], | ||||||
|  | 		"lib": [ | ||||||
|  | 			"dom", | ||||||
|  | 			"esnext" | ||||||
|  | 		] | ||||||
| 	}, | 	}, | ||||||
| 	"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], | 	"include": [ | ||||||
|  | 		"**/*.ts", | ||||||
|  | 		"**/*.tsx", | ||||||
|  | 		".expo/types/**/*.ts", | ||||||
|  | 		"expo-env.d.ts" | ||||||
|  | 	], | ||||||
| 	"exclude": [ | 	"exclude": [ | ||||||
| 		"node_modules", | 		"node_modules", | ||||||
| 		".expo", | 		".expo", | ||||||
|  | 		"scripts", | ||||||
| 		"**/test", | 		"**/test", | ||||||
| 		"**/dist", | 		"**/dist", | ||||||
| 		"**/types", | 		"**/types", | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ | |||||||
|   "extends": ["config:recommended", ":disableRateLimiting"], |   "extends": ["config:recommended", ":disableRateLimiting"], | ||||||
|   "schedule": ["on monday"], |   "schedule": ["on monday"], | ||||||
|   "minimumReleaseAge": "5 days", |   "minimumReleaseAge": "5 days", | ||||||
|   "ignorePaths": ["**/front/**"], |  | ||||||
|   "packageRules": [ |   "packageRules": [ | ||||||
|     { |     { | ||||||
|       "matchDatasources": ["docker"], |       "matchDatasources": ["docker"], | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| ** | ** | ||||||
| !/go.mod | !/go.mod | ||||||
| !/go.sum | !/go.sum | ||||||
| !/**.go | !/**/*.go | ||||||
| !/migrations | !/migrations | ||||||
| # genereated via swag | # genereated via swag | ||||||
| !/docs | !/docs | ||||||
|  | |||||||
| @ -1,24 +1,4 @@ | |||||||
| # FROM golang:1.23 as build | FROM golang:1.25 as build | ||||||
| FROM debian:trixie-slim AS build |  | ||||||
| # those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template |  | ||||||
| ENV GOTOOLCHAIN=local |  | ||||||
| ENV GOPATH=/go |  | ||||||
| ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH |  | ||||||
| RUN set -eux; \ |  | ||||||
| 	apt-get update; \ |  | ||||||
| 	apt-get install -y --no-install-recommends \ |  | ||||||
| 	ca-certificates openssl \ |  | ||||||
| 	golang\ |  | ||||||
| 	g++ \ |  | ||||||
| 	gcc \ |  | ||||||
| 	libc6-dev \ |  | ||||||
| 	make \ |  | ||||||
| 	pkg-config |  | ||||||
| 
 |  | ||||||
| # https://github.com/golang/go/issues/54400 |  | ||||||
| ENV SSL_CERT_DIR=/etc/ssl/certs |  | ||||||
| RUN update-ca-certificates |  | ||||||
| 
 |  | ||||||
| RUN apt-get update \ | RUN apt-get update \ | ||||||
| 	&& apt-get install --no-install-recommends --no-install-suggests -y \ | 	&& apt-get install --no-install-recommends --no-install-suggests -y \ | ||||||
| 	ffmpeg libavformat-dev libavutil-dev libswscale-dev \ | 	ffmpeg libavformat-dev libavutil-dev libswscale-dev \ | ||||||
| @ -32,10 +12,8 @@ RUN go mod download | |||||||
| COPY . . | COPY . . | ||||||
| RUN GOOS=linux go build -o ./transcoder | RUN GOOS=linux go build -o ./transcoder | ||||||
| 
 | 
 | ||||||
| # debian is required for nvidia hardware acceleration | # https://packages.debian.org/trixie/ffmpeg for version tracking | ||||||
| # we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6) | FROM debian:trixie | ||||||
| # https://packages.debian.org/bookworm/ffmpeg for version tracking |  | ||||||
| FROM debian:trixie-slim |  | ||||||
| 
 | 
 | ||||||
| # read target arch from buildx or default to amd64 if using legacy builder. | # read target arch from buildx or default to amd64 if using legacy builder. | ||||||
| ARG TARGETARCH | ARG TARGETARCH | ||||||
|  | |||||||
| @ -1,27 +1,4 @@ | |||||||
| # we use trixie (debian's testing because ffmpeg on latest is v5 and we need v6) | FROM golang:1.25 | ||||||
| # https://packages.debian.org/bookworm/ffmpeg for version tracking |  | ||||||
| # FROM golang:1.21 |  | ||||||
| # trixie's golang is also 1.21 |  | ||||||
| FROM debian:trixie-slim |  | ||||||
| # those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template |  | ||||||
| ENV GOTOOLCHAIN=local |  | ||||||
| ENV GOPATH=/go |  | ||||||
| ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH |  | ||||||
| RUN set -eux; \ |  | ||||||
| 	apt-get update; \ |  | ||||||
| 	apt-get install -y --no-install-recommends \ |  | ||||||
| 	ca-certificates openssl \ |  | ||||||
| 	golang\ |  | ||||||
| 	g++ \ |  | ||||||
| 	gcc \ |  | ||||||
| 	libc6-dev \ |  | ||||||
| 	make \ |  | ||||||
| 	pkg-config |  | ||||||
| 
 |  | ||||||
| # https://github.com/golang/go/issues/54400 |  | ||||||
| ENV SSL_CERT_DIR=/etc/ssl/certs |  | ||||||
| RUN update-ca-certificates |  | ||||||
| 
 |  | ||||||
| # read target arch from buildx or default to amd64 if using legacy builder. | # read target arch from buildx or default to amd64 if using legacy builder. | ||||||
| ARG TARGETARCH | ARG TARGETARCH | ||||||
| ENV TARGETARCH=${TARGETARCH:-amd64} | ENV TARGETARCH=${TARGETARCH:-amd64} | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ module github.com/zoriya/kyoo/transcoder | |||||||
| go 1.24.2 | go 1.24.2 | ||||||
| 
 | 
 | ||||||
| require ( | require ( | ||||||
|  | 	github.com/asticode/go-astisub v0.35.0 | ||||||
| 	github.com/aws/aws-sdk-go-v2 v1.39.3 | 	github.com/aws/aws-sdk-go-v2 v1.39.3 | ||||||
| 	github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5 | 	github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5 | ||||||
| 	github.com/disintegration/imaging v1.6.2 | 	github.com/disintegration/imaging v1.6.2 | ||||||
| @ -19,6 +20,8 @@ require ( | |||||||
| 
 | 
 | ||||||
| require ( | require ( | ||||||
| 	github.com/KyleBanks/depth v1.2.1 // indirect | 	github.com/KyleBanks/depth v1.2.1 // indirect | ||||||
|  | 	github.com/asticode/go-astikit v0.20.0 // indirect | ||||||
|  | 	github.com/asticode/go-astits v1.8.0 // indirect | ||||||
| 	github.com/ghodss/yaml v1.0.0 // indirect | 	github.com/ghodss/yaml v1.0.0 // indirect | ||||||
| 	github.com/go-openapi/jsonpointer v0.21.1 // indirect | 	github.com/go-openapi/jsonpointer v0.21.1 // indirect | ||||||
| 	github.com/go-openapi/jsonreference v0.21.0 // indirect | 	github.com/go-openapi/jsonreference v0.21.0 // indirect | ||||||
|  | |||||||
| @ -1,9 +1,11 @@ | |||||||
| github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= |  | ||||||
| github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= |  | ||||||
| github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= | ||||||
| github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= | ||||||
| github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= | github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8= | ||||||
| github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= | github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= | ||||||
|  | github.com/asticode/go-astisub v0.35.0 h1:wnELGJMeJbavW//X7nLTy97L3iblub7tO1VSeHnZBdA= | ||||||
|  | github.com/asticode/go-astisub v0.35.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= | ||||||
|  | github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg= | ||||||
|  | github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= | ||||||
| github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM= | github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM= | ||||||
| github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= | github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= | ||||||
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= | ||||||
| @ -40,35 +42,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkE | |||||||
| github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= | github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= | ||||||
| github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= | github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= | ||||||
| github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= | github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= | ||||||
| github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= |  | ||||||
| github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= |  | ||||||
| github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= |  | ||||||
| github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= |  | ||||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |  | ||||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |  | ||||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |  | ||||||
| github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= | ||||||
| github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= | ||||||
| github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= |  | ||||||
| github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= |  | ||||||
| github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= | github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= | ||||||
| github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= | github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= | ||||||
| github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= |  | ||||||
| github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= |  | ||||||
| github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= |  | ||||||
| github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= |  | ||||||
| github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= |  | ||||||
| github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= |  | ||||||
| github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= |  | ||||||
| github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= |  | ||||||
| github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= |  | ||||||
| github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= |  | ||||||
| github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= | ||||||
| github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= | ||||||
| github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= |  | ||||||
| github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= |  | ||||||
| github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= |  | ||||||
| github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= |  | ||||||
| github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= | github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= | ||||||
| github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= | github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= | ||||||
| github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= | ||||||
| @ -79,25 +58,16 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ | |||||||
| github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= | github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= | ||||||
| github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= | ||||||
| github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= | ||||||
| github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= |  | ||||||
| github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= |  | ||||||
| github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= | ||||||
| github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | ||||||
| github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= | github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= | ||||||
| github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= | github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= | ||||||
| github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= |  | ||||||
| github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |  | ||||||
| github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= |  | ||||||
| github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= | ||||||
| github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= | ||||||
| github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= | ||||||
| github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= | ||||||
| github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= | ||||||
| github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= | ||||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= |  | ||||||
| github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= |  | ||||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= |  | ||||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= |  | ||||||
| github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE= | github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE= | ||||||
| github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= | github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= | ||||||
| github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= | github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= | ||||||
| @ -124,29 +94,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP | |||||||
| github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | ||||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||||
| github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= |  | ||||||
| github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= |  | ||||||
| github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= |  | ||||||
| github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= |  | ||||||
| github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= |  | ||||||
| github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= |  | ||||||
| github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= |  | ||||||
| github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= |  | ||||||
| github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= |  | ||||||
| github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= |  | ||||||
| github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= |  | ||||||
| github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= |  | ||||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |  | ||||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |  | ||||||
| github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= |  | ||||||
| github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= |  | ||||||
| github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= | github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= | ||||||
| github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= | github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= | ||||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |  | ||||||
| github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |  | ||||||
| github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |  | ||||||
| github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= |  | ||||||
| github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= |  | ||||||
| github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= | github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= | ||||||
| github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= | github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= | ||||||
| github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= | github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= | ||||||
| @ -155,25 +104,12 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= | |||||||
| github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= | github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= | ||||||
| github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | ||||||
| github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | ||||||
| github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= |  | ||||||
| github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= |  | ||||||
| github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= | ||||||
| github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= | ||||||
| gitlab.com/opennota/screengen v1.0.2 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw= | gitlab.com/opennota/screengen v1.0.2 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw= | ||||||
| gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU= | gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU= | ||||||
| go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= |  | ||||||
| go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= |  | ||||||
| go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= |  | ||||||
| go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= |  | ||||||
| go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= |  | ||||||
| go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= |  | ||||||
| go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= |  | ||||||
| go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= |  | ||||||
| go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= |  | ||||||
| go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= |  | ||||||
| golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= | golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= | ||||||
| golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= | golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= | ||||||
| golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= |  | ||||||
| golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= | golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= | ||||||
| golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= | golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= | ||||||
| golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= | golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= | ||||||
| @ -182,23 +118,17 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= | |||||||
| golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= | golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= | ||||||
| golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= | ||||||
| golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | ||||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |  | ||||||
| golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | ||||||
| golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |  | ||||||
| golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= | ||||||
| golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= | ||||||
| golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= | ||||||
| golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= | ||||||
| golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= | golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= | ||||||
| golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= | golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= | ||||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |  | ||||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= |  | ||||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= |  | ||||||
| gopkg.in/vansante/go-ffprobe.v2 v2.2.1 h1:sFV08OT1eZ1yroLCZVClIVd9YySgCh9eGjBWO0oRayI= | gopkg.in/vansante/go-ffprobe.v2 v2.2.1 h1:sFV08OT1eZ1yroLCZVClIVd9YySgCh9eGjBWO0oRayI= | ||||||
| gopkg.in/vansante/go-ffprobe.v2 v2.2.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= | gopkg.in/vansante/go-ffprobe.v2 v2.2.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= | ||||||
| gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= | ||||||
| gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= | ||||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |  | ||||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user