mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-12-06 06:36:25 +00:00
Compare commits
10 Commits
adfe61349b
...
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_USER=kyoousername
|
||||||
POSTGRES_PASSWORD=kyoopassword
|
POSTGRES_PASSWORD=kyoopassword
|
||||||
POSTGRES_DB=kyooDB
|
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.AddHttpContextAccessor();
|
||||||
services.AddTransient<IConfigureOptions<MvcNewtonsoftJsonOptions>, JsonOptions>();
|
services.AddTransient<IConfigureOptions<MvcNewtonsoftJsonOptions>, JsonOptions>();
|
||||||
|
|
||||||
services.AddMvcCore()
|
services
|
||||||
|
.AddMvcCore()
|
||||||
|
.AddCors()
|
||||||
.AddNewtonsoftJson()
|
.AddNewtonsoftJson()
|
||||||
.AddDataAnnotations()
|
.AddDataAnnotations()
|
||||||
.AddControllersAsServices()
|
.AddControllersAsServices()
|
||||||
@@ -167,6 +169,12 @@ namespace Kyoo.Core
|
|||||||
}, SA.Before),
|
}, SA.Before),
|
||||||
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
|
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
|
||||||
SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing),
|
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)
|
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
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- KYOO_URL=http://back:5000
|
- KYOO_URL=http://back:5000
|
||||||
|
- NEXT_PUBLIC_BACK_URL=${PUBLIC_BACK_URL}
|
||||||
|
- NEXT_PUBLIC_CAST_APPLICATION_ID=${CAST_APPLICATION_ID}
|
||||||
ingress:
|
ingress:
|
||||||
image: nginx
|
image: nginx
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
@@ -59,6 +61,18 @@ services:
|
|||||||
- POSTGRES_DB=${POSTGRES_DB}
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
volumes:
|
volumes:
|
||||||
- db:/var/lib/postgresql/data
|
- 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:
|
volumes:
|
||||||
kyoo:
|
kyoo:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- KYOO_URL=http://back:5000
|
- KYOO_URL=http://back:5000
|
||||||
|
- NEXT_PUBLIC_CAST_APPLICATION_ID=${CAST_APPLICATION_ID}
|
||||||
ingress:
|
ingress:
|
||||||
image: nginx
|
image: nginx
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
|||||||
@@ -2,5 +2,8 @@
|
|||||||
"navbar": {
|
"navbar": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"login": "Login"
|
"login": "Login"
|
||||||
|
},
|
||||||
|
"cast": {
|
||||||
|
"start": "Cast to device"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,8 @@
|
|||||||
"navbar": {
|
"navbar": {
|
||||||
"home": "Accueil",
|
"home": "Accueil",
|
||||||
"login": "Connexion"
|
"login": "Connexion"
|
||||||
|
},
|
||||||
|
"cast": {
|
||||||
|
"start": "Caster sur un appareil"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,33 +21,34 @@
|
|||||||
"tsdoc": true
|
"tsdoc": true
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.9.3",
|
"@emotion/react": "^11.10.4",
|
||||||
"@emotion/styled": "^11.9.3",
|
"@emotion/styled": "^11.10.4",
|
||||||
"@jellyfin/libass-wasm": "^4.1.1",
|
"@jellyfin/libass-wasm": "^4.1.1",
|
||||||
"@mui/icons-material": "^5.8.4",
|
"@mui/icons-material": "^5.10.9",
|
||||||
"@mui/material": "^5.8.7",
|
"@mui/material": "^5.10.10",
|
||||||
"hls.js": "^1.2.4",
|
"hls.js": "^1.2.4",
|
||||||
"jotai": "^1.8.4",
|
"jotai": "^1.8.6",
|
||||||
"next": "12.2.2",
|
"next": "12.3.1",
|
||||||
"next-translate": "^1.5.0",
|
"next-translate": "^1.6.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-infinite-scroll-component": "^6.1.0",
|
"react-infinite-scroll-component": "^6.1.0",
|
||||||
"react-query": "^4.0.0-beta.23",
|
"react-query": "^4.0.0-beta.23",
|
||||||
"superjson": "^1.9.1",
|
"superjson": "^1.10.1",
|
||||||
"zod": "^3.18.0"
|
"zod": "^3.19.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "18.0.3",
|
"@types/chromecast-caf-sender": "^1.0.5",
|
||||||
"@types/react": "18.0.15",
|
"@types/node": "18.11.2",
|
||||||
|
"@types/react": "18.0.21",
|
||||||
"@types/react-dom": "18.0.6",
|
"@types/react-dom": "18.0.6",
|
||||||
"copy-webpack-plugin": "^11.0.0",
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
"eslint": "8.19.0",
|
"eslint": "8.25.0",
|
||||||
"eslint-config-next": "12.2.2",
|
"eslint-config-next": "12.3.1",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-header": "^3.1.1",
|
"eslint-plugin-header": "^3.1.1",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"prettier-plugin-jsdoc": "^0.3.38",
|
"prettier-plugin-jsdoc": "^0.4.2",
|
||||||
"typescript": "4.7.4"
|
"typescript": "4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const Navbar = (barProps: AppBarProps) => {
|
|||||||
const { data, error, isSuccess, isError } = useFetch(Navbar.query());
|
const { data, error, isSuccess, isError } = useFetch(Navbar.query());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="sticky" {...barProps}>
|
<AppBar position="relative" {...barProps}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, Skeleton, styled } from "@mui/material";
|
import { Box, Skeleton, SxProps } from "@mui/material";
|
||||||
import { SyntheticEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { SyntheticEvent, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { ComponentsOverrides, ComponentsProps, ComponentsVariants } from "@mui/material";
|
import { ComponentsOverrides, ComponentsProps, ComponentsVariants } from "@mui/material";
|
||||||
import { withThemeProps } from "~/utils/with-theme";
|
import { withThemeProps } from "~/utils/with-theme";
|
||||||
import type { Property } from "csstype";
|
import type { Property } from "csstype";
|
||||||
@@ -42,7 +42,7 @@ type ImagePropsWithLoading =
|
|||||||
type Width = ResponsiveStyleValue<Property.Width<(string & {}) | 0>>;
|
type Width = ResponsiveStyleValue<Property.Width<(string & {}) | 0>>;
|
||||||
type Height = ResponsiveStyleValue<Property.Height<(string & {}) | 0>>;
|
type Height = ResponsiveStyleValue<Property.Height<(string & {}) | 0>>;
|
||||||
|
|
||||||
const _Image = ({
|
export const Image = ({
|
||||||
img,
|
img,
|
||||||
alt,
|
alt,
|
||||||
radius,
|
radius,
|
||||||
@@ -51,9 +51,9 @@ const _Image = ({
|
|||||||
aspectRatio = undefined,
|
aspectRatio = undefined,
|
||||||
width = undefined,
|
width = undefined,
|
||||||
height = undefined,
|
height = undefined,
|
||||||
|
sx,
|
||||||
...others
|
...others
|
||||||
}: ImagePropsWithLoading &
|
}: ImagePropsWithLoading & { sx?: SxProps } & (
|
||||||
(
|
|
||||||
| { aspectRatio?: string; width: Width; height: Height }
|
| { aspectRatio?: string; width: Width; height: Height }
|
||||||
| { aspectRatio: string; width?: Width; height?: Height }
|
| { aspectRatio: string; width?: Width; height?: Height }
|
||||||
)) => {
|
)) => {
|
||||||
@@ -76,6 +76,7 @@ const _Image = ({
|
|||||||
height,
|
height,
|
||||||
backgroundColor: "grey.300",
|
backgroundColor: "grey.300",
|
||||||
"& > *": { width: "100%", height: "100%" },
|
"& > *": { width: "100%", height: "100%" },
|
||||||
|
...sx,
|
||||||
}}
|
}}
|
||||||
{...others}
|
{...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 }) => (
|
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" {
|
declare module "@mui/material/styles" {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import appWithI18n from "next-translate/appWithI18n";
|
import appWithI18n from "next-translate/appWithI18n";
|
||||||
import { ThemeProvider } from "@mui/material";
|
import { Box, ThemeProvider } from "@mui/material";
|
||||||
import NextApp, { AppContext } from "next/app";
|
import NextApp, { AppContext } from "next/app";
|
||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { Hydrate, QueryClientProvider } from "react-query";
|
import { Hydrate, QueryClientProvider } from "react-query";
|
||||||
@@ -29,6 +29,7 @@ import { defaultTheme } from "~/utils/themes/default-theme";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useMobileHover } from "~/utils/utils";
|
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)
|
// Simply silence a SSR warning (see https://github.com/facebook/react/issues/14927 for more details)
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -37,7 +38,7 @@ if (typeof window === "undefined") {
|
|||||||
|
|
||||||
const App = ({ Component, pageProps }: AppProps) => {
|
const App = ({ Component, pageProps }: AppProps) => {
|
||||||
const [queryClient] = useState(() => createQueryClient());
|
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);
|
const getLayout = (Component as QueryPage).getLayout ?? ((page) => page);
|
||||||
|
|
||||||
useMobileHover();
|
useMobileHover();
|
||||||
@@ -70,7 +71,12 @@ const App = ({ Component, pageProps }: AppProps) => {
|
|||||||
</Head>
|
</Head>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Hydrate state={queryState}>
|
<Hydrate state={queryState}>
|
||||||
<ThemeProvider theme={defaultTheme}>{getLayout(<Component {...props} />)}</ThemeProvider>
|
<ThemeProvider theme={defaultTheme}>
|
||||||
|
<Box >
|
||||||
|
{getLayout(<Component {...props} />)}
|
||||||
|
<CastProvider />
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
</Hydrate>
|
</Hydrate>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import { InfiniteScroll } from "~/utils/infinite-scroll";
|
|||||||
import { Link } from "~/utils/link";
|
import { Link } from "~/utils/link";
|
||||||
import { withRoute } from "~/utils/router";
|
import { withRoute } from "~/utils/router";
|
||||||
import { QueryIdentifier, QueryPage, useInfiniteFetch } from "~/utils/query";
|
import { QueryIdentifier, QueryPage, useInfiniteFetch } from "~/utils/query";
|
||||||
|
import { CastMiniPlayer } from "~/player/cast/mini-player";
|
||||||
|
import { styled } from "@mui/system";
|
||||||
|
|
||||||
enum SortBy {
|
enum SortBy {
|
||||||
Name = "name",
|
Name = "name",
|
||||||
@@ -422,12 +424,17 @@ const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Main = styled("main")({});
|
||||||
|
|
||||||
BrowsePage.getLayout = (page) => {
|
BrowsePage.getLayout = (page) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Box sx={{ display: "flex", flexDirection: "column", height: "100vh" }}>
|
||||||
<Navbar />
|
<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 NextLink from "next/link";
|
||||||
import { Poster } from "~/components/poster";
|
import { Poster } from "~/components/poster";
|
||||||
import { WatchItem } from "~/models/resources/watch-item";
|
import { WatchItem } from "~/models/resources/watch-item";
|
||||||
import { loadAtom } from "../state";
|
import { durationAtom, loadAtom, progressAtom } from "../state";
|
||||||
import { episodeDisplayNumber } from "~/components/episode";
|
import { episodeDisplayNumber } from "~/components/episode";
|
||||||
import { LeftButtons } from "./left-buttons";
|
import { LeftButtons } from "./left-buttons";
|
||||||
import { RightButtons } from "./right-buttons";
|
import { RightButtons } from "./right-buttons";
|
||||||
@@ -76,7 +76,7 @@ export const Hover = ({
|
|||||||
{name ?? <Skeleton />}
|
{name ?? <Skeleton />}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<ProgressBar chapters={data?.chapters} />
|
<ProgressBar chapters={data?.chapters} progressAtom={progressAtom} durationAtom={durationAtom} />
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between" }}>
|
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
<LeftButtons
|
<LeftButtons
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, IconButton, Slider, Tooltip, Typography } from "@mui/material";
|
import { Box, IconButton, Slider, SxProps, Tooltip, Typography } from "@mui/material";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { Atom, useAtom, useAtomValue } from "jotai";
|
||||||
import useTranslation from "next-translate/useTranslation";
|
import useTranslation from "next-translate/useTranslation";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
|
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
|
||||||
@@ -83,13 +83,13 @@ export const LeftButtons = ({
|
|||||||
</NextLink>
|
</NextLink>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<VolumeSlider />
|
<VolumeSlider color="white" />
|
||||||
<ProgressText />
|
<ProgressText sx={{ color: "white" }} progressAtom={progressAtom} durationAtom={durationAtom} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const VolumeSlider = () => {
|
export const VolumeSlider = ({ color, className }: { color?: string; className?: string }) => {
|
||||||
const [volume, setVolume] = useAtom(volumeAtom);
|
const [volume, setVolume] = useAtom(volumeAtom);
|
||||||
const [isMuted, setMuted] = useAtom(mutedAtom);
|
const [isMuted, setMuted] = useAtom(mutedAtom);
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
@@ -102,13 +102,10 @@ const VolumeSlider = () => {
|
|||||||
p: "8px",
|
p: "8px",
|
||||||
"body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" },
|
"body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" },
|
||||||
}}
|
}}
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
<Tooltip title={t("mute")}>
|
<Tooltip title={t("mute")}>
|
||||||
<IconButton
|
<IconButton onClick={() => setMuted(!isMuted)} aria-label={t("mute")} sx={{ color: color }}>
|
||||||
onClick={() => setMuted(!isMuted)}
|
|
||||||
aria-label={t("mute")}
|
|
||||||
sx={{ color: "white" }}
|
|
||||||
>
|
|
||||||
{isMuted || volume == 0 ? (
|
{isMuted || volume == 0 ? (
|
||||||
<VolumeOff />
|
<VolumeOff />
|
||||||
) : volume < 25 ? (
|
) : 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 progress = useAtomValue(progressAtom);
|
||||||
const duration = useAtomValue(durationAtom);
|
const duration = useAtomValue(durationAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Typography color="white" sx={{ alignSelf: "center" }}>
|
<Typography sx={{ alignSelf: "center", ...sx }}>
|
||||||
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,13 +18,23 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box } from "@mui/material";
|
import { Box, SxProps } from "@mui/material";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { Atom, WritableAtom, useAtom, useAtomValue } from "jotai";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Chapter } from "~/models/resources/watch-item";
|
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 ref = useRef<HTMLDivElement>(null);
|
||||||
const [isSeeking, setSeek] = useState(false);
|
const [isSeeking, setSeek] = useState(false);
|
||||||
const [progress, setProgress] = useAtom(progressAtom);
|
const [progress, setProgress] = useAtom(progressAtom);
|
||||||
@@ -75,6 +85,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
|||||||
".thumb": { opacity: 1 },
|
".thumb": { opacity: 1 },
|
||||||
".bar": { transform: "unset" },
|
".bar": { transform: "unset" },
|
||||||
},
|
},
|
||||||
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useRouter } from "next/router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Font, Track } from "~/models/resources/watch-item";
|
import { Font, Track } from "~/models/resources/watch-item";
|
||||||
import { Link } from "~/utils/link";
|
import { Link } from "~/utils/link";
|
||||||
|
import { CastButton } from "../cast/cast-button";
|
||||||
import { fullscreenAtom, subtitleAtom } from "../state";
|
import { fullscreenAtom, subtitleAtom } from "../state";
|
||||||
|
|
||||||
export const RightButtons = ({
|
export const RightButtons = ({
|
||||||
@@ -40,6 +41,7 @@ export const RightButtons = ({
|
|||||||
onMenuClose: () => void;
|
onMenuClose: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
const { t: tc } = useTranslation("common");
|
||||||
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
|
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
|
||||||
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
|
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
|
||||||
|
|
||||||
@@ -71,6 +73,16 @@ export const RightButtons = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip title={tc("cast.start")}>
|
||||||
|
<CastButton
|
||||||
|
sx={{
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
"--connected-color": "white",
|
||||||
|
"--disconnected-color": "white",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title={t("fullscreen")}>
|
<Tooltip title={t("fullscreen")}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setFullscreen(!isFullscreen)}
|
onClick={() => setFullscreen(!isFullscreen)}
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ import { WatchItem, WatchItemP } from "~/models/resources/watch-item";
|
|||||||
import { useFetch } from "~/utils/query";
|
import { useFetch } from "~/utils/query";
|
||||||
import { ErrorPage } from "~/components/errors";
|
import { ErrorPage } from "~/components/errors";
|
||||||
import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
|
import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { Box } from "@mui/material";
|
import { Box, styled } from "@mui/material";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { Hover, LoadingIndicator } from "./components/hover";
|
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 { useRouter } from "next/router";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { makeTitle } from "~/utils/utils";
|
import { makeTitle } from "~/utils/utils";
|
||||||
@@ -35,19 +41,23 @@ import { episodeDisplayNumber } from "~/components/episode";
|
|||||||
import { useVideoKeyboard } from "./keyboard";
|
import { useVideoKeyboard } from "./keyboard";
|
||||||
import { MediaSessionManager } from "./media-session";
|
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
|
// 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)
|
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
|
||||||
let mouseCallback: NodeJS.Timeout;
|
let mouseCallback: NodeJS.Timeout;
|
||||||
|
|
||||||
const query = (slug: string): QueryIdentifier<WatchItem> => ({
|
const query = (slug: string): QueryIdentifier<WatchItem> => ({
|
||||||
path: ["watch", slug],
|
path: ["watch", slug],
|
||||||
|
// @ts-ignore
|
||||||
parser: WatchItemP,
|
parser: WatchItemP,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||||
const { data, error } = useFetch(query(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 setFullscreen = useSetAtom(fullscreenAtom);
|
||||||
|
const setStopCallback = useSetAtom(stopAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||||
@@ -94,6 +104,15 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
|||||||
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
|
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
|
||||||
useVideoKeyboard(data?.subtitles, data?.fonts, previous, next);
|
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} />;
|
if (error) return <ErrorPage {...error} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,8 +151,7 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
|||||||
onMouseLeave={() => setMouseMoved(false)}
|
onMouseLeave={() => setMouseMoved(false)}
|
||||||
sx={{ cursor: displayControls ? "unset" : "none" }}
|
sx={{ cursor: displayControls ? "unset" : "none" }}
|
||||||
>
|
>
|
||||||
<Box
|
<Video
|
||||||
component="video"
|
|
||||||
{...videoProps}
|
{...videoProps}
|
||||||
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
|
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
|
||||||
if (e.pointerType === "mouse") {
|
if (e.pointerType === "mouse") {
|
||||||
|
|||||||
@@ -18,10 +18,9 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BoxProps } from "@mui/material";
|
|
||||||
import { atom, useAtom, useSetAtom } from "jotai";
|
import { atom, useAtom, useSetAtom } from "jotai";
|
||||||
import { useRouter } from "next/router";
|
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 { Font, Track } from "~/models/resources/watch-item";
|
||||||
import { bakedAtom } from "~/utils/jotai-utils";
|
import { bakedAtom } from "~/utils/jotai-utils";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -85,10 +84,13 @@ export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker)
|
|||||||
}
|
}
|
||||||
} catch {}
|
} 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;
|
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 player = useRef<HTMLVideoElement>(null);
|
||||||
const setPlayer = useSetAtom(playerAtom);
|
const setPlayer = useSetAtom(playerAtom);
|
||||||
const setPlay = useSetAtom(_playAtom);
|
const setPlay = useSetAtom(_playAtom);
|
||||||
@@ -100,6 +102,7 @@ export const useVideoController = (links?: { direct: string; transmux: string })
|
|||||||
const setVolume = useSetAtom(_volumeAtom);
|
const setVolume = useSetAtom(_volumeAtom);
|
||||||
const setMuted = useSetAtom(_mutedAtom);
|
const setMuted = useSetAtom(_mutedAtom);
|
||||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||||
|
const setLocalMedia = useSetAtom(localMediaAtom);
|
||||||
const [playMode, setPlayMode] = useAtom(playModeAtom);
|
const [playMode, setPlayMode] = useAtom(playModeAtom);
|
||||||
|
|
||||||
setPlayer(player);
|
setPlayer(player);
|
||||||
@@ -111,7 +114,8 @@ export const useVideoController = (links?: { direct: string; transmux: string })
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlayMode(PlayMode.Direct);
|
setPlayMode(PlayMode.Direct);
|
||||||
}, [links, setPlayMode]);
|
setLocalMedia(slug);
|
||||||
|
}, [slug, links, setPlayMode, setLocalMedia]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const src = playMode === PlayMode.Direct ? links?.direct : links?.transmux;
|
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);
|
setDuration(player.current.duration);
|
||||||
}, [player, setDuration]);
|
}, [player, setDuration]);
|
||||||
|
|
||||||
const videoProps: BoxProps<"video"> = {
|
const videoProps: ComponentProps<"video"> = {
|
||||||
ref: player,
|
ref: player,
|
||||||
onDoubleClick: () => {
|
onDoubleClick: () => {
|
||||||
setFullscreen(!document.fullscreenElement);
|
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