mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-12-06 06:36:25 +00:00
Compare commits
10 Commits
v4.7.0
...
feat/chrom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e734a17f | ||
|
|
c9d977916b | ||
|
|
2c724eae5c | ||
|
|
a978ed6aeb | ||
|
|
8a9a6a5f29 | ||
|
|
a7f0bd5a91 | ||
|
|
de3fda6a1a | ||
|
|
846c0d77d3 | ||
|
|
92a38d2c0a | ||
|
|
c985a972f0 |
@@ -4,3 +4,5 @@ AUTHENTICATION_SECRET=
|
||||
POSTGRES_USER=kyoousername
|
||||
POSTGRES_PASSWORD=kyoopassword
|
||||
POSTGRES_DB=kyooDB
|
||||
CAST_APPLICATION_ID=
|
||||
PUBLIC_BACK_URL=http://192.168.0.1:8901
|
||||
|
||||
@@ -121,7 +121,9 @@ namespace Kyoo.Core
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddTransient<IConfigureOptions<MvcNewtonsoftJsonOptions>, JsonOptions>();
|
||||
|
||||
services.AddMvcCore()
|
||||
services
|
||||
.AddMvcCore()
|
||||
.AddCors()
|
||||
.AddNewtonsoftJson()
|
||||
.AddDataAnnotations()
|
||||
.AddControllersAsServices()
|
||||
@@ -167,6 +169,12 @@ namespace Kyoo.Core
|
||||
}, SA.Before),
|
||||
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
|
||||
SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing),
|
||||
SA.New<IApplicationBuilder>(app => app.UseCors(x => x
|
||||
.SetIsOriginAllowed(_ => true)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials()
|
||||
), SA.Routing + 2),
|
||||
SA.New<IApplicationBuilder>(app => app.UseEndpoints(x => x.MapControllers()), SA.Endpoint)
|
||||
};
|
||||
}
|
||||
|
||||
11
chromecast/.dockerignore
Normal file
11
chromecast/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
.eslintrc.json
|
||||
.gitignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.parcel-cache
|
||||
.git
|
||||
dist
|
||||
33
chromecast/.eslintrc.json
Executable file
33
chromecast/.eslintrc.json
Executable file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "header"],
|
||||
"rules": {
|
||||
"header/header": [
|
||||
"error",
|
||||
"block",
|
||||
[
|
||||
"",
|
||||
" * 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/>.",
|
||||
" "
|
||||
],
|
||||
2
|
||||
]
|
||||
}
|
||||
}
|
||||
3
chromecast/.gitignore
vendored
Normal file
3
chromecast/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.parcel-cache
|
||||
dist
|
||||
6
chromecast/.parcelrc
Normal file
6
chromecast/.parcelrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@parcel/config-default",
|
||||
"validators": {
|
||||
"*.{ts,tsx}": ["@parcel/validator-typescript"]
|
||||
}
|
||||
}
|
||||
9
chromecast/Dockerfile.dev
Normal file
9
chromecast/Dockerfile.dev
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
EXPOSE 1234
|
||||
ENV PORT 1234
|
||||
CMD ["yarn", "dev"]
|
||||
35
chromecast/package.json
Normal file
35
chromecast/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "kyoo-chromecast",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"source": "src/index.html",
|
||||
"scripts": {
|
||||
"dev": "parcel",
|
||||
"build": "parcel build",
|
||||
"format": "prettier --check --ignore-path .gitignore .",
|
||||
"format:fix": "prettier --write --ignore-path .gitignore ."
|
||||
},
|
||||
"prettier": {
|
||||
"useTabs": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"plugins": [
|
||||
"prettier-plugin-jsdoc"
|
||||
],
|
||||
"jsdocSingleLineComment": false,
|
||||
"tsdoc": true
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/validator-typescript": "^2.7.0",
|
||||
"@types/chromecast-caf-receiver": "^6.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.40.1",
|
||||
"@typescript-eslint/parser": "^5.40.1",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"parcel": "^2.7.0",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
145
chromecast/src/api.ts
Normal file
145
chromecast/src/api.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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 type Item = {
|
||||
/**
|
||||
* The slug of this episode.
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* The title of the show containing this episode.
|
||||
*/
|
||||
showTitle?: string;
|
||||
/**
|
||||
* The slug of the show containing this episode
|
||||
*/
|
||||
showSlug?: string;
|
||||
/**
|
||||
* The season in witch this episode is in.
|
||||
*/
|
||||
seasonNumber?: number;
|
||||
/**
|
||||
* The number of this episode is it's season.
|
||||
*/
|
||||
episodeNumber?: number;
|
||||
/**
|
||||
* The absolute number of this episode. It's an episode number that is not reset to 1 after a
|
||||
* new season.
|
||||
*/
|
||||
absoluteNumber?: number;
|
||||
/**
|
||||
* The title of this episode.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The air date of this episode.
|
||||
*/
|
||||
releaseDate: Date;
|
||||
/**
|
||||
* True if this is a movie, false otherwise.
|
||||
*/
|
||||
isMovie: boolean;
|
||||
/**
|
||||
* An url to the poster of this resource. If this resource does not have an image, the link will
|
||||
* be null. If the kyoo's instance is not capable of handling this kind of image for the
|
||||
* specific resource, this field won't be present.
|
||||
*/
|
||||
poster?: string | null;
|
||||
/**
|
||||
* An url to the thumbnail of this resource. If this resource does not have an image, the link
|
||||
* will be null. If the kyoo's instance is not capable of handling this kind of image for the
|
||||
* specific resource, this field won't be present.
|
||||
*/
|
||||
thumbnail?: string | null;
|
||||
/**
|
||||
* An url to the logo of this resource. If this resource does not have an image, the link will
|
||||
* be null. If the kyoo's instance is not capable of handling this kind of image for the
|
||||
* specific resource, this field won't be present.
|
||||
*/
|
||||
logo?: string | null;
|
||||
/**
|
||||
* The links to the videos of this watch item.
|
||||
*/
|
||||
link: {
|
||||
direct: string;
|
||||
transmux: string;
|
||||
};
|
||||
nextEpisode: {
|
||||
id: number,
|
||||
slug: string,
|
||||
},
|
||||
previousEpisode: {
|
||||
id: number,
|
||||
slug: string,
|
||||
}
|
||||
};
|
||||
|
||||
export const getItem = async (slug: string, apiUrl: string) => {
|
||||
try {
|
||||
const resp = await fetch(`${apiUrl}/watch/${slug}`);
|
||||
if (!resp.ok) {
|
||||
console.error(await resp.text());
|
||||
return null;
|
||||
}
|
||||
const ret = (await resp.json()) as Item;
|
||||
if (!ret) return null;
|
||||
ret.name = (ret as any).title;
|
||||
ret.releaseDate = new Date(ret.releaseDate);
|
||||
ret.link.direct = `${apiUrl}/${ret.link.direct}`;
|
||||
ret.link.transmux = `${apiUrl}/${ret.link.transmux}`;
|
||||
ret.thumbnail = `${apiUrl}/${ret.thumbnail}`;
|
||||
ret.poster = `${apiUrl}/${ret.poster}`;
|
||||
ret.logo = `${apiUrl}/${ret.logo}`;
|
||||
return ret;
|
||||
} catch (e) {
|
||||
console.error("Fetch error", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const itemToTvMetadata = (item: Item) => {
|
||||
const metadata = new cast.framework.messages.TvShowMediaMetadata();
|
||||
metadata.title = item.name;
|
||||
metadata.season = item.seasonNumber;
|
||||
metadata.episode = item.episodeNumber;
|
||||
metadata.seriesTitle = item.showTitle;
|
||||
metadata.originalAirdate = item.releaseDate.toISOString().substring(0, 10);
|
||||
metadata.images = item.poster ? [new cast.framework.messages.Image(item.poster)] : [];
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export const itemToMovie = (item: Item) => {
|
||||
const metadata = new cast.framework.messages.MovieMediaMetadata();
|
||||
metadata.title = item.name;
|
||||
metadata.releaseDate = item.releaseDate.toISOString().substring(0, 10);
|
||||
metadata.images = item.poster ? [new cast.framework.messages.Image(item.poster)] : [];
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export const itemToMedia = (item: Item, apiUrl: string) => {
|
||||
const media = new cast.framework.messages.MediaInformation();
|
||||
media.contentUrl = item.link.direct;
|
||||
media.metadata = item.isMovie
|
||||
? itemToMovie(item)
|
||||
: itemToTvMetadata(item);
|
||||
media.customData = item;
|
||||
media.customData.serverUrl = apiUrl;
|
||||
return media;
|
||||
}
|
||||
12
chromecast/src/index.html
Normal file
12
chromecast/src/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<cast-media-player></cast-media-player>
|
||||
<script src="index.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
57
chromecast/src/index.ts
Normal file
57
chromecast/src/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { getItem, itemToMedia } from "./api";
|
||||
import { Queue } from "./queue";
|
||||
const Command = cast.framework.messages.Command;
|
||||
|
||||
const context = cast.framework.CastReceiverContext.getInstance();
|
||||
const playerManager = context.getPlayerManager();
|
||||
|
||||
playerManager.setSupportedMediaCommands(
|
||||
Command.PAUSE |
|
||||
Command.SEEK |
|
||||
Command.QUEUE_NEXT |
|
||||
Command.QUEUE_PREV |
|
||||
Command.EDIT_TRACKS |
|
||||
Command.STREAM_MUTE |
|
||||
Command.STREAM_VOLUME |
|
||||
Command.STREAM_TRANSFER,
|
||||
);
|
||||
|
||||
playerManager.setMessageInterceptor(
|
||||
cast.framework.messages.MessageType.LOAD,
|
||||
async (loadRequestData) => {
|
||||
if (loadRequestData.media.contentUrl && loadRequestData.media.metadata) return loadRequestData;
|
||||
|
||||
const apiUrl = loadRequestData.media.customData.serverUrl;
|
||||
const item = await getItem(
|
||||
loadRequestData.media.contentId,
|
||||
apiUrl,
|
||||
);
|
||||
if (!item) {
|
||||
return new cast.framework.messages.ErrorData(cast.framework.messages.ErrorType.LOAD_FAILED);
|
||||
}
|
||||
loadRequestData.media = itemToMedia(item, apiUrl);
|
||||
return loadRequestData;
|
||||
},
|
||||
);
|
||||
|
||||
context.start({ queue: new Queue() });
|
||||
73
chromecast/src/queue.ts
Normal file
73
chromecast/src/queue.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 { QueueManager } from "chromecast-caf-receiver/cast.framework";
|
||||
import { LoadRequestData, QueueData, QueueItem } from "chromecast-caf-receiver/cast.framework.messages";
|
||||
import { getItem, Item, itemToMedia } from "./api";
|
||||
|
||||
export class Queue extends cast.framework.QueueBase {
|
||||
queue: QueueManager | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.queue = cast.framework.CastReceiverContext.getInstance().getPlayerManager().getQueueManager();
|
||||
}
|
||||
|
||||
initialize(requestData: LoadRequestData): QueueData {
|
||||
if (requestData.queueData) return requestData.queueData;
|
||||
|
||||
const queueData = new cast.framework.messages.QueueData();
|
||||
queueData.name = "queue";
|
||||
queueData.items = [requestData.media];
|
||||
return queueData;
|
||||
}
|
||||
|
||||
async nextItems(itemId?: number): Promise<QueueItem[]> {
|
||||
const current = this.queue?.getItems().find(x => x.itemId == itemId);
|
||||
if (!current || !current.media?.contentId || !current.media.customData?.serverUrl) return [];
|
||||
|
||||
const metadata = current?.media?.customData as Item;
|
||||
const apiUrl = current.media?.customData.serverUrl;
|
||||
if (!metadata.nextEpisode) return [];
|
||||
|
||||
const item = await getItem(metadata.nextEpisode.slug, apiUrl);
|
||||
if (!item) return [];
|
||||
|
||||
const data = new cast.framework.messages.QueueItem();
|
||||
data.media = itemToMedia(item, apiUrl);
|
||||
return [data];
|
||||
}
|
||||
|
||||
async prevItems(itemId?: number): Promise<QueueItem[]> {
|
||||
const current = this.queue?.getItems().find(x => x.itemId == itemId);
|
||||
if (!current || !current.media?.contentId || !current.media.customData?.serverUrl) return [];
|
||||
|
||||
const metadata = current?.media?.customData as Item;
|
||||
const apiUrl = current.media?.customData.serverUrl;
|
||||
if (!metadata.previousEpisode) return [];
|
||||
|
||||
const item = await getItem(metadata.previousEpisode.slug, apiUrl);
|
||||
if (!item) return [];
|
||||
|
||||
const data = new cast.framework.messages.QueueItem();
|
||||
data.media = itemToMedia(item, apiUrl);
|
||||
return [data];
|
||||
}
|
||||
}
|
||||
7
chromecast/tsconfig.json
Normal file
7
chromecast/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"include": ["src/**/*", "./node_modules/@types/chromecast-caf-receiver/*" ],
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
2261
chromecast/yarn.lock
Normal file
2261
chromecast/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,8 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
- KYOO_URL=http://back:5000
|
||||
- NEXT_PUBLIC_BACK_URL=${PUBLIC_BACK_URL}
|
||||
- NEXT_PUBLIC_CAST_APPLICATION_ID=${CAST_APPLICATION_ID}
|
||||
ingress:
|
||||
image: nginx
|
||||
restart: on-failure
|
||||
@@ -59,6 +61,18 @@ services:
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
volumes:
|
||||
- db:/var/lib/postgresql/data
|
||||
chromecast:
|
||||
build:
|
||||
context: ./chromecast
|
||||
dockerfile: Dockerfile.dev
|
||||
volumes:
|
||||
- ./chromecast:/app
|
||||
- /app/node_modules/
|
||||
- /app/.parcel-cache/
|
||||
- /app/dist/
|
||||
ports:
|
||||
- "1234:1234"
|
||||
restart: on-failure
|
||||
|
||||
volumes:
|
||||
kyoo:
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
- KYOO_URL=http://back:5000
|
||||
- NEXT_PUBLIC_CAST_APPLICATION_ID=${CAST_APPLICATION_ID}
|
||||
ingress:
|
||||
image: nginx
|
||||
restart: on-failure
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
"navbar": {
|
||||
"home": "Home",
|
||||
"login": "Login"
|
||||
},
|
||||
"cast": {
|
||||
"start": "Cast to device"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
"navbar": {
|
||||
"home": "Accueil",
|
||||
"login": "Connexion"
|
||||
},
|
||||
"cast": {
|
||||
"start": "Caster sur un appareil"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,33 +21,34 @@
|
||||
"tsdoc": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.9.3",
|
||||
"@emotion/styled": "^11.9.3",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@emotion/styled": "^11.10.4",
|
||||
"@jellyfin/libass-wasm": "^4.1.1",
|
||||
"@mui/icons-material": "^5.8.4",
|
||||
"@mui/material": "^5.8.7",
|
||||
"@mui/icons-material": "^5.10.9",
|
||||
"@mui/material": "^5.10.10",
|
||||
"hls.js": "^1.2.4",
|
||||
"jotai": "^1.8.4",
|
||||
"next": "12.2.2",
|
||||
"next-translate": "^1.5.0",
|
||||
"jotai": "^1.8.6",
|
||||
"next": "12.3.1",
|
||||
"next-translate": "^1.6.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-query": "^4.0.0-beta.23",
|
||||
"superjson": "^1.9.1",
|
||||
"zod": "^3.18.0"
|
||||
"superjson": "^1.10.1",
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.0.3",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/chromecast-caf-sender": "^1.0.5",
|
||||
"@types/node": "18.11.2",
|
||||
"@types/react": "18.0.21",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"eslint": "8.19.0",
|
||||
"eslint-config-next": "12.2.2",
|
||||
"eslint": "8.25.0",
|
||||
"eslint-config-next": "12.3.1",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-jsdoc": "^0.3.38",
|
||||
"typescript": "4.7.4"
|
||||
"prettier-plugin-jsdoc": "^0.4.2",
|
||||
"typescript": "4.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export const Navbar = (barProps: AppBarProps) => {
|
||||
const { data, error, isSuccess, isError } = useFetch(Navbar.query());
|
||||
|
||||
return (
|
||||
<AppBar position="sticky" {...barProps}>
|
||||
<AppBar position="relative" {...barProps}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
size="large"
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Skeleton, styled } from "@mui/material";
|
||||
import { SyntheticEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Box, Skeleton, SxProps } from "@mui/material";
|
||||
import { SyntheticEvent, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ComponentsOverrides, ComponentsProps, ComponentsVariants } from "@mui/material";
|
||||
import { withThemeProps } from "~/utils/with-theme";
|
||||
import type { Property } from "csstype";
|
||||
@@ -42,7 +42,7 @@ type ImagePropsWithLoading =
|
||||
type Width = ResponsiveStyleValue<Property.Width<(string & {}) | 0>>;
|
||||
type Height = ResponsiveStyleValue<Property.Height<(string & {}) | 0>>;
|
||||
|
||||
const _Image = ({
|
||||
export const Image = ({
|
||||
img,
|
||||
alt,
|
||||
radius,
|
||||
@@ -51,9 +51,9 @@ const _Image = ({
|
||||
aspectRatio = undefined,
|
||||
width = undefined,
|
||||
height = undefined,
|
||||
sx,
|
||||
...others
|
||||
}: ImagePropsWithLoading &
|
||||
(
|
||||
}: ImagePropsWithLoading & { sx?: SxProps } & (
|
||||
| { aspectRatio?: string; width: Width; height: Height }
|
||||
| { aspectRatio: string; width?: Width; height?: Height }
|
||||
)) => {
|
||||
@@ -76,6 +76,7 @@ const _Image = ({
|
||||
height,
|
||||
backgroundColor: "grey.300",
|
||||
"& > *": { width: "100%", height: "100%" },
|
||||
...sx,
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
@@ -98,11 +99,9 @@ const _Image = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const Image = styled(_Image)({});
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
const _Poster = (props: ImagePropsWithLoading & { width?: Width; height?: Height }) => (
|
||||
<_Image aspectRatio="2 / 3" {...props} />
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<Image aspectRatio="2 / 3" {...props} />
|
||||
);
|
||||
|
||||
declare module "@mui/material/styles" {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import appWithI18n from "next-translate/appWithI18n";
|
||||
import { ThemeProvider } from "@mui/material";
|
||||
import { Box, ThemeProvider } from "@mui/material";
|
||||
import NextApp, { AppContext } from "next/app";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Hydrate, QueryClientProvider } from "react-query";
|
||||
@@ -29,6 +29,7 @@ import { defaultTheme } from "~/utils/themes/default-theme";
|
||||
import superjson from "superjson";
|
||||
import Head from "next/head";
|
||||
import { useMobileHover } from "~/utils/utils";
|
||||
import { CastProvider } from "~/player/cast/cast-provider";
|
||||
|
||||
// Simply silence a SSR warning (see https://github.com/facebook/react/issues/14927 for more details)
|
||||
if (typeof window === "undefined") {
|
||||
@@ -37,7 +38,7 @@ if (typeof window === "undefined") {
|
||||
|
||||
const App = ({ Component, pageProps }: AppProps) => {
|
||||
const [queryClient] = useState(() => createQueryClient());
|
||||
const { queryState, ...props } = superjson.deserialize<any>(pageProps ?? {});
|
||||
const { queryState, ...props } = superjson.deserialize<any>(pageProps ?? { json: {} });
|
||||
const getLayout = (Component as QueryPage).getLayout ?? ((page) => page);
|
||||
|
||||
useMobileHover();
|
||||
@@ -70,7 +71,12 @@ const App = ({ Component, pageProps }: AppProps) => {
|
||||
</Head>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Hydrate state={queryState}>
|
||||
<ThemeProvider theme={defaultTheme}>{getLayout(<Component {...props} />)}</ThemeProvider>
|
||||
<ThemeProvider theme={defaultTheme}>
|
||||
<Box >
|
||||
{getLayout(<Component {...props} />)}
|
||||
<CastProvider />
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
</Hydrate>
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
|
||||
@@ -44,6 +44,8 @@ import { InfiniteScroll } from "~/utils/infinite-scroll";
|
||||
import { Link } from "~/utils/link";
|
||||
import { withRoute } from "~/utils/router";
|
||||
import { QueryIdentifier, QueryPage, useInfiniteFetch } from "~/utils/query";
|
||||
import { CastMiniPlayer } from "~/player/cast/mini-player";
|
||||
import { styled } from "@mui/system";
|
||||
|
||||
enum SortBy {
|
||||
Name = "name",
|
||||
@@ -422,12 +424,17 @@ const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Main = styled("main")({});
|
||||
|
||||
BrowsePage.getLayout = (page) => {
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100vh" }}>
|
||||
<Navbar />
|
||||
<main>{page}</main>
|
||||
</>
|
||||
<Main sx={{ flexGrow: 1, flexShrink: 1, overflow: "auto" }}>{page}</Main>
|
||||
<footer>
|
||||
<CastMiniPlayer />
|
||||
</footer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
32
front/src/pages/remote/index.jsx
Normal file
32
front/src/pages/remote/index.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 { CastRemote } from "~/player/cast/cast-remote";
|
||||
import { Navbar } from "~/components/navbar";
|
||||
|
||||
CastRemote.getLayout = (page) => {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>{page}</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default CastRemote;
|
||||
40
front/src/player/cast/cast-button.tsx
Normal file
40
front/src/player/cast/cast-button.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { styled } from "@mui/material";
|
||||
import { ComponentProps, forwardRef } from "react";
|
||||
|
||||
type CastProps = { class?: string };
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"google-cast-launcher": CastProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const _CastButton = forwardRef<HTMLDivElement, ComponentProps<"div">>(function Cast(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <google-cast-launcher ref={ref} class={className} {...props} />;
|
||||
});
|
||||
export const CastButton = styled(_CastButton)({});
|
||||
53
front/src/player/cast/cast-provider.tsx
Normal file
53
front/src/player/cast/cast-provider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 dynamic from "next/dynamic";
|
||||
import Script from "next/script";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// @ts-ignore
|
||||
const CastController = dynamic(() => import("./state").then((x) => x.CastController), {
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
export const CastProvider = () => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.__onGCastApiAvailable = (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: process.env.NEXT_PUBLIC_CAST_APPLICATION_ID,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
setLoaded(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
strategy="lazyOnload"
|
||||
/>
|
||||
{loaded && <CastController />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
98
front/src/player/cast/cast-remote.tsx
Normal file
98
front/src/player/cast/cast-remote.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { ClosedCaption, Pause, PlayArrow, SkipNext, SkipPrevious } from "@mui/icons-material";
|
||||
import { IconButton, Tooltip, Typography } from "@mui/material";
|
||||
import { Box } from "@mui/system";
|
||||
import useTranslation from "next-translate/useTranslation";
|
||||
import { useRouter } from "next/router";
|
||||
import { Poster } from "~/components/poster";
|
||||
import { ProgressBar } from "../components/progress-bar";
|
||||
import NextLink from "next/link";
|
||||
|
||||
export const CastRemote = () => {
|
||||
const name = "Ansatsu Kyoushitsu";
|
||||
const episodeName = "S1:E1 Assassination Time";
|
||||
const poster = "/api/show/ansatsu-kyoushitsu/poster";
|
||||
const previousSlug = "toto";
|
||||
const nextSlug = "toto";
|
||||
const isPlaying = false;
|
||||
const subtitles: never[] = [];
|
||||
const setPlay = (obol: boolean) => {};
|
||||
|
||||
const { t } = useTranslation("browse");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", background: "red", flexDirection: "column", alignItems: "center" }}>
|
||||
<Poster img={poster} alt="" width={"60%"} />
|
||||
<Typography variant="h1">{name}</Typography>
|
||||
{episodeName && <Typography variant="h2">{episodeName}</Typography>}
|
||||
{/* <ProgressBar /> */}
|
||||
<Box sx={{ display: "flex" }}>
|
||||
{previousSlug && (
|
||||
<Tooltip title={t("previous")}>
|
||||
<NextLink href={{ query: { ...router.query, slug: previousSlug } }} passHref>
|
||||
<IconButton aria-label={t("previous")} sx={{ color: "white" }}>
|
||||
<SkipPrevious />
|
||||
</IconButton>
|
||||
</NextLink>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={isPlaying ? t("pause") : t("play")}>
|
||||
<IconButton
|
||||
onClick={() => setPlay(!isPlaying)}
|
||||
aria-label={isPlaying ? t("pause") : t("play")}
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{nextSlug && (
|
||||
<Tooltip title={t("next")}>
|
||||
<NextLink href={{ query: { ...router.query, slug: nextSlug } }} passHref>
|
||||
<IconButton aria-label={t("next")} sx={{ color: "white" }}>
|
||||
<SkipNext />
|
||||
</IconButton>
|
||||
</NextLink>
|
||||
</Tooltip>
|
||||
)}
|
||||
{subtitles && (
|
||||
<Tooltip title={t("subtitles")}>
|
||||
<IconButton
|
||||
id="sortby"
|
||||
aria-label={t("subtitles")}
|
||||
/* aria-controls={subtitleAnchor ? "subtitle-menu" : undefined} */
|
||||
aria-haspopup="true"
|
||||
/* aria-expanded={subtitleAnchor ? "true" : undefined} */
|
||||
onClick={(event) => {
|
||||
/* setSubtitleAnchor(event.currentTarget); */
|
||||
/* onMenuOpen(); */
|
||||
}}
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
<ClosedCaption />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
111
front/src/player/cast/internal-mini-player.tsx
Normal file
111
front/src/player/cast/internal-mini-player.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 { Pause, PlayArrow, SkipNext, SkipPrevious } from "@mui/icons-material";
|
||||
import { Box, IconButton, Paper, Tooltip, Typography } from "@mui/material";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import useTranslation from "next-translate/useTranslation";
|
||||
import { useRouter } from "next/router";
|
||||
import { episodeDisplayNumber } from "~/components/episode";
|
||||
import { Image } from "~/components/poster";
|
||||
import { ProgressText, VolumeSlider } from "~/player/components/left-buttons";
|
||||
import { ProgressBar } from "../components/progress-bar";
|
||||
import { durationAtom, mediaAtom, progressAtom, playAtom } from "./state";
|
||||
|
||||
export const _CastMiniPlayer = () => {
|
||||
const { t } = useTranslation("player");
|
||||
const router = useRouter();
|
||||
|
||||
const [media, setMedia] = useAtom(mediaAtom);
|
||||
const [isPlaying, togglePlay] = useAtom(playAtom);
|
||||
|
||||
if (!media) return null;
|
||||
|
||||
const previousSlug = "sng";
|
||||
const nextSlug = "toto";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={16}
|
||||
/* onClick={() => router.push("/remote")} */
|
||||
sx={{ height: "100px", display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Box sx={{ height: "100%", p: 2, boxSizing: "border-box" }}>
|
||||
<Image img={media.thumbnail} alt="" height="100%" aspectRatio="16/9" />
|
||||
</Box>
|
||||
<Box>
|
||||
{!media.isMovie && (
|
||||
<Typography>{media.showTitle + " " + episodeDisplayNumber(media)}</Typography>
|
||||
)}
|
||||
<Typography>{media.name}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "none", md: "flex" },
|
||||
alignItems: "center",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
<ProgressBar
|
||||
progressAtom={progressAtom}
|
||||
durationAtom={durationAtom}
|
||||
sx={{ flexShrink: 1 }}
|
||||
/>
|
||||
<ProgressText
|
||||
sx={{ flexShrink: 0 }}
|
||||
progressAtom={progressAtom}
|
||||
durationAtom={durationAtom}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
"> *": { mx: "16px !important" },
|
||||
"> .desktop": { display: { xs: "none", md: "inline-flex" } },
|
||||
}}
|
||||
>
|
||||
<VolumeSlider className="desktop" />
|
||||
{previousSlug && (
|
||||
<Tooltip title={t("previous")} className="desktop">
|
||||
<IconButton aria-label={t("previous")}>
|
||||
<SkipPrevious />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={isPlaying ? t("pause") : t("play")}>
|
||||
<IconButton onClick={() => togglePlay()} aria-label={isPlaying ? t("pause") : t("play")}>
|
||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{nextSlug && (
|
||||
<Tooltip title={t("next")}>
|
||||
<IconButton aria-label={t("next")}>
|
||||
<SkipNext />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
35
front/src/player/cast/mini-player.tsx
Normal file
35
front/src/player/cast/mini-player.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 { atom, useAtomValue } from "jotai";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const connectedAtom = atom(false);
|
||||
|
||||
const _CastMiniPlayer = dynamic(() =>
|
||||
import("./internal-mini-player").then((x) => x._CastMiniPlayer),
|
||||
);
|
||||
|
||||
export const CastMiniPlayer = () => {
|
||||
const isConnected = useAtomValue(connectedAtom);
|
||||
|
||||
if (!isConnected) return null;
|
||||
return <_CastMiniPlayer />;
|
||||
};
|
||||
139
front/src/player/cast/state.tsx
Normal file
139
front/src/player/cast/state.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 { atom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { WatchItem } from "~/models/resources/watch-item";
|
||||
import { bakedAtom } from "~/utils/jotai-utils";
|
||||
import { stopAtom, localMediaAtom } from "../state";
|
||||
import { connectedAtom } from "./mini-player";
|
||||
|
||||
const playerAtom = atom(() => {
|
||||
const player = new cast.framework.RemotePlayer();
|
||||
return {
|
||||
player,
|
||||
controller: new cast.framework.RemotePlayerController(player),
|
||||
};
|
||||
});
|
||||
|
||||
export const [_playAtom, playAtom] = bakedAtom<boolean, undefined>(true, (get) => {
|
||||
const { controller } = get(playerAtom);
|
||||
controller.playOrPause();
|
||||
});
|
||||
export const durationAtom = atom(0);
|
||||
export const [_progressAtom, progressAtom] = bakedAtom(1, (get, _, value) => {
|
||||
const { player, controller } = get(playerAtom);
|
||||
player.currentTime = value;
|
||||
controller.seek();
|
||||
});
|
||||
|
||||
export const [_mediaAtom, mediaAtom] = bakedAtom<WatchItem | null, string>(
|
||||
null,
|
||||
async (_, _2, value) => {
|
||||
const session = cast.framework.CastContext.getInstance().getCurrentSession();
|
||||
if (!session) return;
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(value, "application/json");
|
||||
if (!process.env.NEXT_PUBLIC_BACK_URL)
|
||||
console.error("PUBLIC_BACK_URL is not defined. Chromecast won't work.");
|
||||
mediaInfo.customData = { serverUrl: process.env.NEXT_PUBLIC_BACK_URL };
|
||||
session.loadMedia(new chrome.cast.media.LoadRequest(mediaInfo));
|
||||
},
|
||||
);
|
||||
|
||||
export const useCastController = () => {
|
||||
const { player, controller } = useAtomValue(playerAtom);
|
||||
const setPlay = useSetAtom(_playAtom);
|
||||
const setProgress = useSetAtom(_progressAtom);
|
||||
const setDuration = useSetAtom(durationAtom);
|
||||
const setMedia = useSetAtom(_mediaAtom);
|
||||
const setConnected = useSetAtom(connectedAtom);
|
||||
const loadMedia = useSetAtom(mediaAtom);
|
||||
const stopPlayer = useAtomValue(stopAtom);
|
||||
const localMedia = useAtomValue(localMediaAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const context = cast.framework.CastContext.getInstance();
|
||||
|
||||
const session = cast.framework.CastContext.getInstance().getCurrentSession();
|
||||
if (session) {
|
||||
setConnected(true);
|
||||
setDuration(player.duration);
|
||||
setMedia(player.mediaInfo?.metadata);
|
||||
setPlay(!player.isPaused);
|
||||
}
|
||||
|
||||
const eventListeners: [
|
||||
cast.framework.RemotePlayerEventType,
|
||||
(event: cast.framework.RemotePlayerChangedEvent<any>) => void,
|
||||
][] = [
|
||||
[cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED, (event) => setPlay(!event.value)],
|
||||
[
|
||||
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
|
||||
(event) => setProgress(event.value),
|
||||
],
|
||||
[cast.framework.RemotePlayerEventType.DURATION_CHANGED, (event) => setDuration(event.value)],
|
||||
[
|
||||
cast.framework.RemotePlayerEventType.MEDIA_INFO_CHANGED,
|
||||
() => setMedia(player.mediaInfo?.customData ?? null),
|
||||
],
|
||||
];
|
||||
|
||||
const sessionStateHandler = (event: cast.framework.SessionStateEventData) => {
|
||||
if (event.sessionState === cast.framework.SessionState.SESSION_STARTED && localMedia) {
|
||||
stopPlayer[0]();
|
||||
loadMedia(localMedia);
|
||||
setConnected(true);
|
||||
} else if (event.sessionState === cast.framework.SessionState.SESSION_RESUMED) {
|
||||
setConnected(true);
|
||||
} else if (event.sessionState === cast.framework.SessionState.SESSION_ENDED) {
|
||||
setConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
context.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
sessionStateHandler,
|
||||
);
|
||||
for (const [key, handler] of eventListeners) controller.addEventListener(key, handler);
|
||||
return () => {
|
||||
context.removeEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
sessionStateHandler,
|
||||
);
|
||||
for (const [key, handler] of eventListeners) controller.removeEventListener(key, handler);
|
||||
};
|
||||
}, [
|
||||
player,
|
||||
controller,
|
||||
setPlay,
|
||||
setDuration,
|
||||
setMedia,
|
||||
stopPlayer,
|
||||
localMedia,
|
||||
loadMedia,
|
||||
setConnected,
|
||||
setProgress,
|
||||
]);
|
||||
};
|
||||
|
||||
export const CastController = () => {
|
||||
useCastController();
|
||||
return null;
|
||||
};
|
||||
@@ -32,7 +32,7 @@ import useTranslation from "next-translate/useTranslation";
|
||||
import NextLink from "next/link";
|
||||
import { Poster } from "~/components/poster";
|
||||
import { WatchItem } from "~/models/resources/watch-item";
|
||||
import { loadAtom } from "../state";
|
||||
import { durationAtom, loadAtom, progressAtom } from "../state";
|
||||
import { episodeDisplayNumber } from "~/components/episode";
|
||||
import { LeftButtons } from "./left-buttons";
|
||||
import { RightButtons } from "./right-buttons";
|
||||
@@ -76,7 +76,7 @@ export const Hover = ({
|
||||
{name ?? <Skeleton />}
|
||||
</Typography>
|
||||
|
||||
<ProgressBar chapters={data?.chapters} />
|
||||
<ProgressBar chapters={data?.chapters} progressAtom={progressAtom} durationAtom={durationAtom} />
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between" }}>
|
||||
<LeftButtons
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, IconButton, Slider, Tooltip, Typography } from "@mui/material";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Box, IconButton, Slider, SxProps, Tooltip, Typography } from "@mui/material";
|
||||
import { Atom, useAtom, useAtomValue } from "jotai";
|
||||
import useTranslation from "next-translate/useTranslation";
|
||||
import { useRouter } from "next/router";
|
||||
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
|
||||
@@ -83,13 +83,13 @@ export const LeftButtons = ({
|
||||
</NextLink>
|
||||
</Tooltip>
|
||||
)}
|
||||
<VolumeSlider />
|
||||
<ProgressText />
|
||||
<VolumeSlider color="white" />
|
||||
<ProgressText sx={{ color: "white" }} progressAtom={progressAtom} durationAtom={durationAtom} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const VolumeSlider = () => {
|
||||
export const VolumeSlider = ({ color, className }: { color?: string; className?: string }) => {
|
||||
const [volume, setVolume] = useAtom(volumeAtom);
|
||||
const [isMuted, setMuted] = useAtom(mutedAtom);
|
||||
const { t } = useTranslation("player");
|
||||
@@ -102,13 +102,10 @@ const VolumeSlider = () => {
|
||||
p: "8px",
|
||||
"body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" },
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<Tooltip title={t("mute")}>
|
||||
<IconButton
|
||||
onClick={() => setMuted(!isMuted)}
|
||||
aria-label={t("mute")}
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
<IconButton onClick={() => setMuted(!isMuted)} aria-label={t("mute")} sx={{ color: color }}>
|
||||
{isMuted || volume == 0 ? (
|
||||
<VolumeOff />
|
||||
) : volume < 25 ? (
|
||||
@@ -141,12 +138,20 @@ const VolumeSlider = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressText = () => {
|
||||
export const ProgressText = ({
|
||||
sx,
|
||||
progressAtom,
|
||||
durationAtom,
|
||||
}: {
|
||||
sx?: SxProps;
|
||||
progressAtom: Atom<number>;
|
||||
durationAtom: Atom<number>;
|
||||
}) => {
|
||||
const progress = useAtomValue(progressAtom);
|
||||
const duration = useAtomValue(durationAtom);
|
||||
|
||||
return (
|
||||
<Typography color="white" sx={{ alignSelf: "center" }}>
|
||||
<Typography sx={{ alignSelf: "center", ...sx }}>
|
||||
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
@@ -18,13 +18,23 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box } from "@mui/material";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Box, SxProps } from "@mui/material";
|
||||
import { Atom, WritableAtom, useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Chapter } from "~/models/resources/watch-item";
|
||||
import { bufferedAtom, durationAtom, progressAtom } from "../state";
|
||||
import { bufferedAtom } from "../state";
|
||||
|
||||
export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||
export const ProgressBar = ({
|
||||
progressAtom,
|
||||
durationAtom,
|
||||
chapters,
|
||||
sx,
|
||||
}: {
|
||||
progressAtom: WritableAtom<number, number>;
|
||||
durationAtom: Atom<number>;
|
||||
chapters?: Chapter[];
|
||||
sx?: SxProps;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isSeeking, setSeek] = useState(false);
|
||||
const [progress, setProgress] = useAtom(progressAtom);
|
||||
@@ -75,6 +85,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||
".thumb": { opacity: 1 },
|
||||
".bar": { transform: "unset" },
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Font, Track } from "~/models/resources/watch-item";
|
||||
import { Link } from "~/utils/link";
|
||||
import { CastButton } from "../cast/cast-button";
|
||||
import { fullscreenAtom, subtitleAtom } from "../state";
|
||||
|
||||
export const RightButtons = ({
|
||||
@@ -40,6 +41,7 @@ export const RightButtons = ({
|
||||
onMenuClose: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation("player");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
|
||||
|
||||
@@ -71,6 +73,16 @@ export const RightButtons = ({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={tc("cast.start")}>
|
||||
<CastButton
|
||||
sx={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
"--connected-color": "white",
|
||||
"--disconnected-color": "white",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("fullscreen")}>
|
||||
<IconButton
|
||||
onClick={() => setFullscreen(!isFullscreen)}
|
||||
|
||||
@@ -24,10 +24,16 @@ import { WatchItem, WatchItemP } from "~/models/resources/watch-item";
|
||||
import { useFetch } from "~/utils/query";
|
||||
import { ErrorPage } from "~/components/errors";
|
||||
import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Box, styled } from "@mui/material";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { Hover, LoadingIndicator } from "./components/hover";
|
||||
import { fullscreenAtom, playAtom, useSubtitleController, useVideoController } from "./state";
|
||||
import {
|
||||
fullscreenAtom,
|
||||
playAtom,
|
||||
stopAtom,
|
||||
useSubtitleController,
|
||||
useVideoController,
|
||||
} from "./state";
|
||||
import { useRouter } from "next/router";
|
||||
import Head from "next/head";
|
||||
import { makeTitle } from "~/utils/utils";
|
||||
@@ -35,19 +41,23 @@ import { episodeDisplayNumber } from "~/components/episode";
|
||||
import { useVideoKeyboard } from "./keyboard";
|
||||
import { MediaSessionManager } from "./media-session";
|
||||
|
||||
const Video = styled("video")({});
|
||||
|
||||
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
|
||||
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
|
||||
let mouseCallback: NodeJS.Timeout;
|
||||
|
||||
const query = (slug: string): QueryIdentifier<WatchItem> => ({
|
||||
path: ["watch", slug],
|
||||
// @ts-ignore
|
||||
parser: WatchItemP,
|
||||
});
|
||||
|
||||
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
const { data, error } = useFetch(query(slug));
|
||||
const { playerRef, videoProps, onVideoClick } = useVideoController(data?.link);
|
||||
const { playerRef, videoProps, onVideoClick } = useVideoController(slug, data?.link);
|
||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||
const setStopCallback = useSetAtom(stopAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
@@ -94,6 +104,15 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
|
||||
useVideoKeyboard(data?.subtitles, data?.fonts, previous, next);
|
||||
|
||||
useEffect(() => {
|
||||
setStopCallback([ () => {
|
||||
router.push(data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "/");
|
||||
}]);
|
||||
return () => {
|
||||
setStopCallback([() => {}]);
|
||||
};
|
||||
}, [setStopCallback, data, router]);
|
||||
|
||||
if (error) return <ErrorPage {...error} />;
|
||||
|
||||
return (
|
||||
@@ -132,8 +151,7 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
onMouseLeave={() => setMouseMoved(false)}
|
||||
sx={{ cursor: displayControls ? "unset" : "none" }}
|
||||
>
|
||||
<Box
|
||||
component="video"
|
||||
<Video
|
||||
{...videoProps}
|
||||
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
|
||||
if (e.pointerType === "mouse") {
|
||||
|
||||
@@ -18,10 +18,9 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { BoxProps } from "@mui/material";
|
||||
import { atom, useAtom, useSetAtom } from "jotai";
|
||||
import { useRouter } from "next/router";
|
||||
import { RefObject, useEffect, useRef } from "react";
|
||||
import { ComponentProps, RefObject, useEffect, useRef } from "react";
|
||||
import { Font, Track } from "~/models/resources/watch-item";
|
||||
import { bakedAtom } from "~/utils/jotai-utils";
|
||||
// @ts-ignore
|
||||
@@ -85,10 +84,13 @@ export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker)
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
export const localMediaAtom = atom<string | null>(null);
|
||||
// The tuple is only used to prevent jotai from thinking the function is a read func.
|
||||
export const stopAtom = atom<[() => void]>([() => {}]);
|
||||
|
||||
let hls: Hls | null = null;
|
||||
|
||||
export const useVideoController = (links?: { direct: string; transmux: string }) => {
|
||||
export const useVideoController = (slug: string, links?: { direct: string; transmux: string }) => {
|
||||
const player = useRef<HTMLVideoElement>(null);
|
||||
const setPlayer = useSetAtom(playerAtom);
|
||||
const setPlay = useSetAtom(_playAtom);
|
||||
@@ -100,6 +102,7 @@ export const useVideoController = (links?: { direct: string; transmux: string })
|
||||
const setVolume = useSetAtom(_volumeAtom);
|
||||
const setMuted = useSetAtom(_mutedAtom);
|
||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||
const setLocalMedia = useSetAtom(localMediaAtom);
|
||||
const [playMode, setPlayMode] = useAtom(playModeAtom);
|
||||
|
||||
setPlayer(player);
|
||||
@@ -111,7 +114,8 @@ export const useVideoController = (links?: { direct: string; transmux: string })
|
||||
|
||||
useEffect(() => {
|
||||
setPlayMode(PlayMode.Direct);
|
||||
}, [links, setPlayMode]);
|
||||
setLocalMedia(slug);
|
||||
}, [slug, links, setPlayMode, setLocalMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
const src = playMode === PlayMode.Direct ? links?.direct : links?.transmux;
|
||||
@@ -139,7 +143,7 @@ export const useVideoController = (links?: { direct: string; transmux: string })
|
||||
setDuration(player.current.duration);
|
||||
}, [player, setDuration]);
|
||||
|
||||
const videoProps: BoxProps<"video"> = {
|
||||
const videoProps: ComponentProps<"video"> = {
|
||||
ref: player,
|
||||
onDoubleClick: () => {
|
||||
setFullscreen(!document.fullscreenElement);
|
||||
|
||||
826
front/yarn.lock
826
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user