10 Commits

Author SHA1 Message Date
Zoe Roux
f4e734a17f Fix .env.example 2022-12-02 00:38:56 +09:00
Zoe Roux
c9d977916b Add a custom queue 2022-12-01 21:48:58 +09:00
Zoe Roux
2c724eae5c Add mini player features 2022-12-01 21:48:58 +09:00
Zoe Roux
a978ed6aeb Cast app receive videos 2022-12-01 21:48:58 +09:00
Zoe Roux
8a9a6a5f29 Add receiver app 2022-12-01 21:48:58 +09:00
Zoe Roux
a7f0bd5a91 wip start cast 2022-12-01 21:48:58 +09:00
Zoe Roux
de3fda6a1a wip: cast state 2022-12-01 21:48:58 +09:00
Zoe Roux
846c0d77d3 Add mini cast player 2022-12-01 21:48:58 +09:00
Zoe Roux
92a38d2c0a Add remote cast page 2022-12-01 21:48:58 +09:00
Zoe Roux
c985a972f0 Add cast button 2022-12-01 21:48:58 +09:00
37 changed files with 3782 additions and 420 deletions

View File

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

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,3 @@
node_modules
.parcel-cache
dist

6
chromecast/.parcelrc Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "@parcel/config-default",
"validators": {
"*.{ts,tsx}": ["@parcel/validator-typescript"]
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"include": ["src/**/*", "./node_modules/@types/chromecast-caf-receiver/*" ],
"compilerOptions": {
"target": "es2021",
"strict": true
}
}

2261
chromecast/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,5 +2,8 @@
"navbar": {
"home": "Home",
"login": "Login"
},
"cast": {
"start": "Cast to device"
}
}

View File

@@ -2,5 +2,8 @@
"navbar": {
"home": "Accueil",
"login": "Connexion"
},
"cast": {
"start": "Caster sur un appareil"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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") {

View File

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

File diff suppressed because it is too large Load Diff