Add mini player features

This commit is contained in:
Zoe Roux
2022-10-23 18:16:14 +09:00
parent a978ed6aeb
commit 2c724eae5c
11 changed files with 325 additions and 177 deletions
+2 -2
View File
@@ -34,9 +34,10 @@ export const CastProvider = () => {
window.__onGCastApiAvailable = (isAvailable) => {
if (!isAvailable) return;
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,
});
setLoaded(true);
};
}, []);
@@ -45,7 +46,6 @@ export const CastProvider = () => {
<Script
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
strategy="lazyOnload"
onReady={() => setLoaded(true)}
/>
{loaded && <CastController />}
</>
@@ -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>
);
};
+11 -75
View File
@@ -18,82 +18,18 @@
* 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 } from "jotai";
import useTranslation from "next-translate/useTranslation";
import { useRouter } from "next/router";
import { Image } from "~/components/poster";
import { ProgressText, VolumeSlider } from "~/player/components/left-buttons";
import { ProgressBar } from "../components/progress-bar";
import { mediaAtom } from "./state";
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 { t } = useTranslation("player");
const router = useRouter();
const isConnected = useAtomValue(connectedAtom);
const [media, setMedia] = useAtom(mediaAtom);
console.log(media)
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>
);
if (!isConnected) return null;
return <_CastMiniPlayer />;
};
+51 -22
View File
@@ -20,17 +20,10 @@
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";
export type Media = {
name: string;
episodeName?: null;
episodeNumber?: number;
seasonNumber?: number;
absoluteNumber?: string;
thunbnail?: string;
};
import { connectedAtom } from "./mini-player";
const playerAtom = atom(() => {
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);
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);
player.currentTime = value;
controller.seek();
});
export const [_mediaAtom, mediaAtom] = bakedAtom<Media | null, string>(
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.");
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));
},
@@ -67,8 +60,10 @@ export const [_mediaAtom, mediaAtom] = bakedAtom<Media | null, string>(
export const useCastController = () => {
const { player, controller } = useAtomValue(playerAtom);
const setPlay = useSetAtom(_playAtom);
const setDuration = useSetAtom(_durationAtom);
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);
@@ -76,15 +71,27 @@ export const useCastController = () => {
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.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?.metadata),
() => setMedia(player.mediaInfo?.customData),
],
];
@@ -92,16 +99,38 @@ export const useCastController = () => {
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);
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);
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]);
}, [
player,
controller,
setPlay,
setDuration,
setMedia,
stopPlayer,
localMedia,
loadMedia,
setConnected,
setProgress,
]);
};
export const CastController = () => {
+2 -2
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
+13 -9
View File
@@ -19,7 +19,7 @@
*/
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 { useRouter } from "next/router";
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
@@ -84,12 +84,12 @@ export const LeftButtons = ({
</Tooltip>
)}
<VolumeSlider color="white" />
<ProgressText sx={{ color: "white" }} />
<ProgressText sx={{ color: "white" }} progressAtom={progressAtom} durationAtom={durationAtom} />
</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 [isMuted, setMuted] = useAtom(mutedAtom);
const { t } = useTranslation("player");
@@ -105,11 +105,7 @@ export const VolumeSlider = ({ color, className }: { color?: string, className?:
className={className}
>
<Tooltip title={t("mute")}>
<IconButton
onClick={() => setMuted(!isMuted)}
aria-label={t("mute")}
sx={{ color: color }}
>
<IconButton onClick={() => setMuted(!isMuted)} aria-label={t("mute")} sx={{ color: color }}>
{isMuted || volume == 0 ? (
<VolumeOff />
) : 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 duration = useAtomValue(durationAtom);
+14 -4
View File
@@ -19,12 +19,22 @@
*/
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 { 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 [isSeeking, setSeek] = useState(false);
const [progress, setProgress] = useAtom(progressAtom);
@@ -75,7 +85,7 @@ export const ProgressBar = ({ chapters, sx }: { chapters?: Chapter[], sx?: SxPro
".thumb": { opacity: 1 },
".bar": { transform: "unset" },
},
...sx
...sx,
}}
>
<Box