mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add mini player features
This commit is contained in:
parent
a978ed6aeb
commit
2c724eae5c
@ -19,57 +19,68 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
/**
|
/**
|
||||||
* The slug of this episode.
|
* The slug of this episode.
|
||||||
*/
|
*/
|
||||||
slug: string;
|
slug: string;
|
||||||
/**
|
/**
|
||||||
* The title of the show containing this episode.
|
* The title of the show containing this episode.
|
||||||
*/
|
*/
|
||||||
showTitle?: string;
|
showTitle?: string;
|
||||||
/**
|
/**
|
||||||
* The slug of the show containing this episode
|
* The slug of the show containing this episode
|
||||||
*/
|
*/
|
||||||
showSlug?: string;
|
showSlug?: string;
|
||||||
/**
|
/**
|
||||||
* The season in witch this episode is in.
|
* The season in witch this episode is in.
|
||||||
*/
|
*/
|
||||||
seasonNumber?: number;
|
seasonNumber?: number;
|
||||||
/**
|
/**
|
||||||
* The number of this episode is it's season.
|
* The number of this episode is it's season.
|
||||||
*/
|
*/
|
||||||
episodeNumber?: number;
|
episodeNumber?: number;
|
||||||
/**
|
/**
|
||||||
* The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
|
* The absolute number of this episode. It's an episode number that is not reset to 1 after a
|
||||||
*/
|
* new season.
|
||||||
absoluteNumber?: number;
|
*/
|
||||||
/**
|
absoluteNumber?: number;
|
||||||
* The title of this episode.
|
/**
|
||||||
*/
|
* The title of this episode.
|
||||||
name: string;
|
*/
|
||||||
/**
|
name: string;
|
||||||
* true if this is a movie, false otherwise.
|
/**
|
||||||
*/
|
* The air date of this episode.
|
||||||
isMovie: boolean;
|
*/
|
||||||
/**
|
releaseDate: Date;
|
||||||
* 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.
|
/**
|
||||||
*/
|
* True if this is a movie, false otherwise.
|
||||||
poster?: string | null;
|
*/
|
||||||
/**
|
isMovie: boolean;
|
||||||
* 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.
|
/**
|
||||||
*/
|
* An url to the poster of this resource. If this resource does not have an image, the link will
|
||||||
thumbnail?: string | null;
|
* 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.
|
||||||
* 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.
|
*/
|
||||||
*/
|
poster?: string | null;
|
||||||
logo?: 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.
|
* The links to the videos of this watch item.
|
||||||
*/
|
*/
|
||||||
link: {
|
link: {
|
||||||
direct: string,
|
direct: string;
|
||||||
transmux: string,
|
transmux: string;
|
||||||
}
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getItem = async (slug: string, apiUrl: string) => {
|
export const getItem = async (slug: string, apiUrl: string) => {
|
||||||
@ -79,13 +90,38 @@ export const getItem = async (slug: string, apiUrl: string) => {
|
|||||||
console.error(await resp.text());
|
console.error(await resp.text());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const ret = await resp.json() as Item;
|
const ret = (await resp.json()) as Item;
|
||||||
if (!ret) return null;
|
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.direct = `${apiUrl}/${ret.link.direct}`;
|
||||||
ret.link.transmux = `${apiUrl}/${ret.link.transmux}`;
|
ret.link.transmux = `${apiUrl}/${ret.link.transmux}`;
|
||||||
|
ret.thumbnail = `${apiUrl}/${ret.thumbnail}`;
|
||||||
|
ret.poster = `${apiUrl}/${ret.poster}`;
|
||||||
|
ret.logo = `${apiUrl}/${ret.logo}`;
|
||||||
return ret;
|
return ret;
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
console.error("Fetch error", e);
|
console.error("Fetch error", e);
|
||||||
return null
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -18,23 +18,44 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getItem } from "./api";
|
import { getItem, itemToMovie, itemToTvMetadata } from "./api";
|
||||||
|
const Command = cast.framework.messages.Command;
|
||||||
|
|
||||||
const context = cast.framework.CastReceiverContext.getInstance();
|
const context = cast.framework.CastReceiverContext.getInstance();
|
||||||
const playerManager = context.getPlayerManager();
|
const playerManager = context.getPlayerManager();
|
||||||
|
|
||||||
playerManager.setMessageInterceptor(cast.framework.messages.MessageType.LOAD, async (loadRequestData) => {
|
playerManager.setSupportedMediaCommands(
|
||||||
if (loadRequestData.media.contentUrl && loadRequestData.media.metadata) return loadRequestData;
|
Command.PAUSE |
|
||||||
|
Command.SEEK |
|
||||||
|
Command.QUEUE_NEXT |
|
||||||
|
Command.QUEUE_PREV |
|
||||||
|
Command.EDIT_TRACKS |
|
||||||
|
Command.STREAM_MUTE |
|
||||||
|
Command.STREAM_VOLUME |
|
||||||
|
Command.STREAM_TRANSFER,
|
||||||
|
);
|
||||||
|
|
||||||
const item = await getItem(loadRequestData.media.contentId, loadRequestData.media.customData.serverUrl);
|
|
||||||
if (!item) {
|
|
||||||
return new cast.framework.messages.ErrorData(
|
playerManager.setMessageInterceptor(
|
||||||
cast.framework.messages.ErrorType.LOAD_FAILED,
|
cast.framework.messages.MessageType.LOAD,
|
||||||
|
async (loadRequestData) => {
|
||||||
|
if (loadRequestData.media.contentUrl && loadRequestData.media.metadata) return loadRequestData;
|
||||||
|
|
||||||
|
const item = await getItem(
|
||||||
|
loadRequestData.media.contentId,
|
||||||
|
loadRequestData.media.customData.serverUrl,
|
||||||
);
|
);
|
||||||
}
|
if (!item) {
|
||||||
loadRequestData.media.contentUrl = item.link.direct;
|
return new cast.framework.messages.ErrorData(cast.framework.messages.ErrorType.LOAD_FAILED);
|
||||||
loadRequestData.media.metadata = item;
|
}
|
||||||
return loadRequestData;
|
loadRequestData.media.contentUrl = item.link.direct;
|
||||||
});
|
loadRequestData.media.metadata = item.isMovie
|
||||||
|
? itemToMovie(item)
|
||||||
|
: itemToTvMetadata(item);
|
||||||
|
loadRequestData.media.customData = item;
|
||||||
|
return loadRequestData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
context.start();
|
context.start();
|
||||||
|
@ -37,6 +37,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- KYOO_URL=http://back:5000
|
- KYOO_URL=http://back:5000
|
||||||
- NEXT_PUBLIC_BACK_URL=${PUBLIC_BACK_URL}
|
- 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
|
||||||
|
@ -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
|
||||||
@ -30,7 +31,6 @@ services:
|
|||||||
- PORT=8901
|
- PORT=8901
|
||||||
- FRONT_URL=http://front:8901
|
- FRONT_URL=http://front:8901
|
||||||
- BACK_URL=http://back:5000
|
- BACK_URL=http://back:5000
|
||||||
- CAST_APPLICATION_ID=${CAST_APPLICATION_ID}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf.template:/etc/nginx/templates/kyoo.conf.template:ro
|
- ./nginx.conf.template:/etc/nginx/templates/kyoo.conf.template:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -34,9 +34,10 @@ export const CastProvider = () => {
|
|||||||
window.__onGCastApiAvailable = (isAvailable) => {
|
window.__onGCastApiAvailable = (isAvailable) => {
|
||||||
if (!isAvailable) return;
|
if (!isAvailable) return;
|
||||||
cast.framework.CastContext.getInstance().setOptions({
|
cast.framework.CastContext.getInstance().setOptions({
|
||||||
receiverApplicationId: process.env.CAST_APPLICATION_ID,
|
receiverApplicationId: process.env.NEXT_PUBLIC_CAST_APPLICATION_ID,
|
||||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||||
});
|
});
|
||||||
|
setLoaded(true);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -45,7 +46,6 @@ export const CastProvider = () => {
|
|||||||
<Script
|
<Script
|
||||||
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||||
strategy="lazyOnload"
|
strategy="lazyOnload"
|
||||||
onReady={() => setLoaded(true)}
|
|
||||||
/>
|
/>
|
||||||
{loaded && <CastController />}
|
{loaded && <CastController />}
|
||||||
</>
|
</>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -18,82 +18,18 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pause, PlayArrow, SkipNext, SkipPrevious } from "@mui/icons-material";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { Box, IconButton, Paper, Tooltip, Typography } from "@mui/material";
|
import dynamic from "next/dynamic";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import useTranslation from "next-translate/useTranslation";
|
export const connectedAtom = atom(false);
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { Image } from "~/components/poster";
|
const _CastMiniPlayer = dynamic(() =>
|
||||||
import { ProgressText, VolumeSlider } from "~/player/components/left-buttons";
|
import("./internal-mini-player").then((x) => x._CastMiniPlayer),
|
||||||
import { ProgressBar } from "../components/progress-bar";
|
);
|
||||||
import { mediaAtom } from "./state";
|
|
||||||
|
|
||||||
export const CastMiniPlayer = () => {
|
export const CastMiniPlayer = () => {
|
||||||
const { t } = useTranslation("player");
|
const isConnected = useAtomValue(connectedAtom);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [media, setMedia] = useAtom(mediaAtom);
|
if (!isConnected) return null;
|
||||||
console.log(media)
|
return <_CastMiniPlayer />;
|
||||||
|
|
||||||
const name = "Ansatsu Kyoushitsu";
|
|
||||||
const episodeName = "S1:E1 Assassination Time";
|
|
||||||
const thumbnail = "/api/show/ansatsu-kyoushitsu/thumbnail";
|
|
||||||
const previousSlug = "sng";
|
|
||||||
const nextSlug = "toto";
|
|
||||||
const isPlaying = true;
|
|
||||||
const setPlay = (bool: boolean) => {};
|
|
||||||
|
|
||||||
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={thumbnail} alt="" height="100%" aspectRatio="16/9" />
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography>{name}</Typography>
|
|
||||||
<Typography>{episodeName}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: { xs: "none", md: "flex" }, alignItems: "center", flexGrow: 1, flexShrink: 1 }}>
|
|
||||||
<ProgressBar sx={{ flexShrink: 1 }} />
|
|
||||||
<ProgressText sx={{ flexShrink: 0 }} />
|
|
||||||
</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={() => setPlay(!isPlaying)}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
@ -20,17 +20,10 @@
|
|||||||
|
|
||||||
import { atom, useAtomValue, useSetAtom } from "jotai";
|
import { atom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { WatchItem } from "~/models/resources/watch-item";
|
||||||
import { bakedAtom } from "~/utils/jotai-utils";
|
import { bakedAtom } from "~/utils/jotai-utils";
|
||||||
import { stopAtom, localMediaAtom } from "../state";
|
import { stopAtom, localMediaAtom } from "../state";
|
||||||
|
import { connectedAtom } from "./mini-player";
|
||||||
export type Media = {
|
|
||||||
name: string;
|
|
||||||
episodeName?: null;
|
|
||||||
episodeNumber?: number;
|
|
||||||
seasonNumber?: number;
|
|
||||||
absoluteNumber?: string;
|
|
||||||
thunbnail?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const playerAtom = atom(() => {
|
const playerAtom = atom(() => {
|
||||||
const player = new cast.framework.RemotePlayer();
|
const player = new cast.framework.RemotePlayer();
|
||||||
@ -40,25 +33,25 @@ const playerAtom = atom(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const [_playAtom, playAtom] = bakedAtom<boolean, never>(true, (get) => {
|
export const [_playAtom, playAtom] = bakedAtom<boolean, undefined>(true, (get) => {
|
||||||
const { controller } = get(playerAtom);
|
const { controller } = get(playerAtom);
|
||||||
controller.playOrPause();
|
controller.playOrPause();
|
||||||
});
|
});
|
||||||
export const [_durationAtom, durationAtom] = bakedAtom(1, (get, _, value) => {
|
export const durationAtom = atom(0);
|
||||||
|
export const [_progressAtom, progressAtom] = bakedAtom(1, (get, _, value) => {
|
||||||
const { player, controller } = get(playerAtom);
|
const { player, controller } = get(playerAtom);
|
||||||
player.currentTime = value;
|
player.currentTime = value;
|
||||||
controller.seek();
|
controller.seek();
|
||||||
});
|
});
|
||||||
|
|
||||||
export const [_mediaAtom, mediaAtom] = bakedAtom<Media | null, string>(
|
export const [_mediaAtom, mediaAtom] = bakedAtom<WatchItem | null, string>(
|
||||||
null,
|
null,
|
||||||
async (_, _2, value) => {
|
async (_, _2, value) => {
|
||||||
const session = cast.framework.CastContext.getInstance().getCurrentSession();
|
const session = cast.framework.CastContext.getInstance().getCurrentSession();
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
const mediaInfo = new chrome.cast.media.MediaInfo(value, "application/json");
|
||||||
value, "application/json"
|
if (!process.env.NEXT_PUBLIC_BACK_URL)
|
||||||
);
|
console.error("PUBLIC_BACK_URL is not defined. Chromecast won't work.");
|
||||||
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 };
|
mediaInfo.customData = { serverUrl: process.env.NEXT_PUBLIC_BACK_URL };
|
||||||
session.loadMedia(new chrome.cast.media.LoadRequest(mediaInfo));
|
session.loadMedia(new chrome.cast.media.LoadRequest(mediaInfo));
|
||||||
},
|
},
|
||||||
@ -67,8 +60,10 @@ export const [_mediaAtom, mediaAtom] = bakedAtom<Media | null, string>(
|
|||||||
export const useCastController = () => {
|
export const useCastController = () => {
|
||||||
const { player, controller } = useAtomValue(playerAtom);
|
const { player, controller } = useAtomValue(playerAtom);
|
||||||
const setPlay = useSetAtom(_playAtom);
|
const setPlay = useSetAtom(_playAtom);
|
||||||
const setDuration = useSetAtom(_durationAtom);
|
const setProgress = useSetAtom(_progressAtom);
|
||||||
|
const setDuration = useSetAtom(durationAtom);
|
||||||
const setMedia = useSetAtom(_mediaAtom);
|
const setMedia = useSetAtom(_mediaAtom);
|
||||||
|
const setConnected = useSetAtom(connectedAtom);
|
||||||
const loadMedia = useSetAtom(mediaAtom);
|
const loadMedia = useSetAtom(mediaAtom);
|
||||||
const stopPlayer = useAtomValue(stopAtom);
|
const stopPlayer = useAtomValue(stopAtom);
|
||||||
const localMedia = useAtomValue(localMediaAtom);
|
const localMedia = useAtomValue(localMediaAtom);
|
||||||
@ -76,15 +71,27 @@ export const useCastController = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const context = cast.framework.CastContext.getInstance();
|
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: [
|
const eventListeners: [
|
||||||
cast.framework.RemotePlayerEventType,
|
cast.framework.RemotePlayerEventType,
|
||||||
(event: cast.framework.RemotePlayerChangedEvent<any>) => void,
|
(event: cast.framework.RemotePlayerChangedEvent<any>) => void,
|
||||||
][] = [
|
][] = [
|
||||||
[cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED, (event) => setPlay(event.value)],
|
[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.DURATION_CHANGED, (event) => setDuration(event.value)],
|
||||||
[
|
[
|
||||||
cast.framework.RemotePlayerEventType.MEDIA_INFO_CHANGED,
|
cast.framework.RemotePlayerEventType.MEDIA_INFO_CHANGED,
|
||||||
() => setMedia(player.mediaInfo?.metadata),
|
() => setMedia(player.mediaInfo?.customData),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -92,16 +99,38 @@ export const useCastController = () => {
|
|||||||
if (event.sessionState === cast.framework.SessionState.SESSION_STARTED && localMedia) {
|
if (event.sessionState === cast.framework.SessionState.SESSION_STARTED && localMedia) {
|
||||||
stopPlayer[0]();
|
stopPlayer[0]();
|
||||||
loadMedia(localMedia);
|
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);
|
context.addEventListener(
|
||||||
|
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||||
|
sessionStateHandler,
|
||||||
|
);
|
||||||
for (const [key, handler] of eventListeners) controller.addEventListener(key, handler);
|
for (const [key, handler] of eventListeners) controller.addEventListener(key, handler);
|
||||||
return () => {
|
return () => {
|
||||||
context.removeEventListener(cast.framework.CastContextEventType.SESSION_STATE_CHANGED, sessionStateHandler);
|
context.removeEventListener(
|
||||||
|
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||||
|
sessionStateHandler,
|
||||||
|
);
|
||||||
for (const [key, handler] of eventListeners) controller.removeEventListener(key, handler);
|
for (const [key, handler] of eventListeners) controller.removeEventListener(key, handler);
|
||||||
};
|
};
|
||||||
}, [player, controller, setPlay, setDuration, setMedia, stopPlayer, localMedia, loadMedia]);
|
}, [
|
||||||
|
player,
|
||||||
|
controller,
|
||||||
|
setPlay,
|
||||||
|
setDuration,
|
||||||
|
setMedia,
|
||||||
|
stopPlayer,
|
||||||
|
localMedia,
|
||||||
|
loadMedia,
|
||||||
|
setConnected,
|
||||||
|
setProgress,
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CastController = () => {
|
export const CastController = () => {
|
||||||
|
@ -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
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, IconButton, Slider, SxProps, 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";
|
||||||
@ -84,12 +84,12 @@ export const LeftButtons = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<VolumeSlider color="white" />
|
<VolumeSlider color="white" />
|
||||||
<ProgressText sx={{ color: "white" }} />
|
<ProgressText sx={{ color: "white" }} progressAtom={progressAtom} durationAtom={durationAtom} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VolumeSlider = ({ color, className }: { color?: string, className?: string }) => {
|
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");
|
||||||
@ -105,11 +105,7 @@ export const VolumeSlider = ({ color, className }: { color?: string, className?:
|
|||||||
className={className}
|
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: color }}
|
|
||||||
>
|
|
||||||
{isMuted || volume == 0 ? (
|
{isMuted || volume == 0 ? (
|
||||||
<VolumeOff />
|
<VolumeOff />
|
||||||
) : volume < 25 ? (
|
) : volume < 25 ? (
|
||||||
@ -142,7 +138,15 @@ export const VolumeSlider = ({ color, className }: { color?: string, className?:
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProgressText = ({ sx }: { sx?: SxProps }) => {
|
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);
|
||||||
|
|
||||||
|
@ -19,12 +19,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, SxProps } 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, sx }: { chapters?: Chapter[], sx?: SxProps }) => {
|
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,7 +85,7 @@ export const ProgressBar = ({ chapters, sx }: { chapters?: Chapter[], sx?: SxPro
|
|||||||
".thumb": { opacity: 1 },
|
".thumb": { opacity: 1 },
|
||||||
".bar": { transform: "unset" },
|
".bar": { transform: "unset" },
|
||||||
},
|
},
|
||||||
...sx
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
Loading…
x
Reference in New Issue
Block a user