mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-08 10:44:20 -04:00
Use jotai for player state and split
This commit is contained in:
parent
07e8504cea
commit
0652ffef68
@ -26,6 +26,7 @@
|
|||||||
"@jellyfin/libass-wasm": "^4.1.1",
|
"@jellyfin/libass-wasm": "^4.1.1",
|
||||||
"@mui/icons-material": "^5.8.4",
|
"@mui/icons-material": "^5.8.4",
|
||||||
"@mui/material": "^5.8.7",
|
"@mui/material": "^5.8.7",
|
||||||
|
"jotai": "^1.8.4",
|
||||||
"next": "12.2.2",
|
"next": "12.2.2",
|
||||||
"next-translate": "^1.5.0",
|
"next-translate": "^1.5.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
@ -75,6 +75,10 @@ export const FontP = z.object({
|
|||||||
* The format of this font (the extension).
|
* The format of this font (the extension).
|
||||||
*/
|
*/
|
||||||
format: z.string(),
|
format: z.string(),
|
||||||
|
/*
|
||||||
|
* The url of the font.
|
||||||
|
*/
|
||||||
|
link: z.string(),
|
||||||
});
|
});
|
||||||
export type Font = z.infer<typeof FontP>;
|
export type Font = z.infer<typeof FontP>;
|
||||||
|
|
||||||
@ -104,6 +108,10 @@ const WatchMovieP = z.preprocess(
|
|||||||
x.link = {
|
x.link = {
|
||||||
direct: `/api/video/${x.slug}`,
|
direct: `/api/video/${x.slug}`,
|
||||||
};
|
};
|
||||||
|
x.fonts = x.fonts?.map((y: Font) => {
|
||||||
|
y.link = `/api/watch/${x.slug}/font/${y.slug}.${y.format}`;
|
||||||
|
return y;
|
||||||
|
})
|
||||||
return x;
|
return x;
|
||||||
},
|
},
|
||||||
ImagesP.extend({
|
ImagesP.extend({
|
||||||
|
@ -318,9 +318,8 @@ const BrowseSettings = ({
|
|||||||
const [sortAnchor, setSortAnchor] = useState<HTMLElement | null>(null);
|
const [sortAnchor, setSortAnchor] = useState<HTMLElement | null>(null);
|
||||||
const { t } = useTranslation("browse");
|
const { t } = useTranslation("browse");
|
||||||
|
|
||||||
const switchViewTitle = layout === Layout.Grid
|
const switchViewTitle =
|
||||||
? t("browse.switchToList")
|
layout === Layout.Grid ? t("browse.switchToList") : t("browse.switchToGrid");
|
||||||
: t("browse.switchToGrid");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -18,774 +18,6 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { QueryIdentifier, QueryPage } from "~/utils/query";
|
import Player from "~/player/player";
|
||||||
import { withRoute } from "~/utils/router";
|
|
||||||
import { WatchItem, WatchItemP, Chapter, Track, Font } from "~/models/resources/watch-item";
|
|
||||||
import { useFetch } from "~/utils/query";
|
|
||||||
import { ErrorPage } from "~/components/errors";
|
|
||||||
import { useState, useRef, useEffect, memo, useMemo, useCallback, RefObject } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
CircularProgress,
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
Skeleton,
|
|
||||||
Slider,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
ListItemText,
|
|
||||||
BoxProps,
|
|
||||||
} from "@mui/material";
|
|
||||||
import useTranslation from "next-translate/useTranslation";
|
|
||||||
import {
|
|
||||||
ArrowBack,
|
|
||||||
ClosedCaption,
|
|
||||||
Fullscreen,
|
|
||||||
FullscreenExit,
|
|
||||||
Pause,
|
|
||||||
PlayArrow,
|
|
||||||
SkipNext,
|
|
||||||
SkipPrevious,
|
|
||||||
VolumeDown,
|
|
||||||
VolumeMute,
|
|
||||||
VolumeOff,
|
|
||||||
VolumeUp,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import { Poster } from "~/components/poster";
|
|
||||||
import { episodeDisplayNumber } from "~/components/episode";
|
|
||||||
import { Link } from "~/utils/link";
|
|
||||||
import NextLink from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
// @ts-ignore
|
|
||||||
import SubtitleOctopus from "@jellyfin/libass-wasm";
|
|
||||||
|
|
||||||
const toTimerString = (timer: number, duration?: number) => {
|
export default Player;
|
||||||
if (!duration) duration = timer;
|
|
||||||
if (duration >= 3600) return new Date(timer * 1000).toISOString().substring(11, 19);
|
|
||||||
return new Date(timer * 1000).toISOString().substring(14, 19);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SubtitleMenu = ({
|
|
||||||
subtitles,
|
|
||||||
setSubtitle,
|
|
||||||
selectedID,
|
|
||||||
anchor,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
subtitles: Track[];
|
|
||||||
setSubtitle: (subtitle: Track | null) => void;
|
|
||||||
selectedID?: number;
|
|
||||||
anchor: HTMLElement;
|
|
||||||
onClose: () => void;
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation("player");
|
|
||||||
const { subtitle, ...queryWithoutSubs } = router.query;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu
|
|
||||||
id="subtitle-menu"
|
|
||||||
MenuListProps={{
|
|
||||||
"aria-labelledby": "subtitle",
|
|
||||||
}}
|
|
||||||
anchorEl={anchor}
|
|
||||||
open={!!anchor}
|
|
||||||
onClose={onClose}
|
|
||||||
anchorOrigin={{
|
|
||||||
vertical: "top",
|
|
||||||
horizontal: "center",
|
|
||||||
}}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: "bottom",
|
|
||||||
horizontal: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
selected={!selectedID}
|
|
||||||
onClick={() => {
|
|
||||||
setSubtitle(null);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={{ query: queryWithoutSubs }}
|
|
||||||
shallow
|
|
||||||
replace
|
|
||||||
>
|
|
||||||
<ListItemText>{t("subtitle-none")}</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
{subtitles.map((sub) => (
|
|
||||||
<MenuItem
|
|
||||||
key={sub.id}
|
|
||||||
selected={selectedID == sub.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSubtitle(sub);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={{ query: { ...router.query, subtitle: sub.language ?? sub.id } }}
|
|
||||||
shallow
|
|
||||||
replace
|
|
||||||
>
|
|
||||||
<ListItemText>{sub.displayName}</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LoadingIndicator = () => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
background: "rgba(0, 0, 0, 0.3)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress thickness={5} sx={{ color: "white", alignSelf: "center" }} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProgressBar = ({
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
buffered,
|
|
||||||
chapters,
|
|
||||||
setProgress,
|
|
||||||
}: {
|
|
||||||
progress: number;
|
|
||||||
duration: number;
|
|
||||||
buffered: number;
|
|
||||||
chapters?: Chapter[];
|
|
||||||
setProgress: (value: number) => void;
|
|
||||||
}) => {
|
|
||||||
const [isSeeking, setSeek] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const updateProgress = (event: MouseEvent, skipSeek?: boolean) => {
|
|
||||||
if (!(isSeeking || skipSeek) || !ref?.current) return;
|
|
||||||
const value: number = (event.pageX - ref.current.offsetLeft) / ref.current.clientWidth;
|
|
||||||
setProgress(Math.max(0, Math.min(value, 1)) * duration);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => setSeek(false);
|
|
||||||
|
|
||||||
document.addEventListener("mouseup", handler);
|
|
||||||
return () => document.removeEventListener("mouseup", handler);
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener("mousemove", updateProgress);
|
|
||||||
return () => document.removeEventListener("mousemove", updateProgress);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSeek(true);
|
|
||||||
}}
|
|
||||||
onTouchStart={() => setSeek(true)}
|
|
||||||
onClick={(event) => updateProgress(event.nativeEvent, true)}
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
py: 1,
|
|
||||||
cursor: "pointer",
|
|
||||||
"&:hover": {
|
|
||||||
".thumb": { opacity: 1 },
|
|
||||||
".bar": { transform: "unset" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
className="bar"
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
height: "4px",
|
|
||||||
background: "rgba(255, 255, 255, 0.2)",
|
|
||||||
transform: isSeeking ? "unset" : "scaleY(.6)",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${(buffered / duration) * 100}%`,
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
background: "rgba(255, 255, 255, 0.5)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${(progress / duration) * 100}%`,
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
background: (theme) => theme.palette.primary.main,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
className="thumb"
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `calc(${(progress / duration) * 100}% - 6px)`,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
margin: "auto",
|
|
||||||
opacity: +isSeeking,
|
|
||||||
width: "12px",
|
|
||||||
height: "12px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
background: (theme) => theme.palette.primary.main,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{chapters?.map((x) => (
|
|
||||||
<Box
|
|
||||||
key={x.startTime}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "4px",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: `${(x.startTime / duration) * 100}%`,
|
|
||||||
background: (theme) => theme.palette.primary.dark,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VideoPoster = memo(function VideoPoster({ poster }: { poster?: string | null }) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "15%",
|
|
||||||
display: { xs: "none", sm: "block" },
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Poster img={poster} width="100%" sx={{ position: "absolute", bottom: 0 }} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const LeftButtons = memo(function LeftButtons({
|
|
||||||
previousSlug,
|
|
||||||
nextSlug,
|
|
||||||
isPlaying,
|
|
||||||
isMuted,
|
|
||||||
volume,
|
|
||||||
togglePlay,
|
|
||||||
toggleMute,
|
|
||||||
setVolume,
|
|
||||||
}: {
|
|
||||||
previousSlug?: string;
|
|
||||||
nextSlug?: string;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isMuted: boolean;
|
|
||||||
volume: number;
|
|
||||||
togglePlay: () => void;
|
|
||||||
toggleMute: () => void;
|
|
||||||
setVolume: (value: number) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("player");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "flex", "> *": { mx: "8px !important" } }}>
|
|
||||||
{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={togglePlay}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
m: "0 !important",
|
|
||||||
p: "8px",
|
|
||||||
"&:hover .slider": { width: "100px", px: "16px" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip title={t("mute")}>
|
|
||||||
<IconButton onClick={toggleMute} aria-label={t("mute")} sx={{ color: "white" }}>
|
|
||||||
{isMuted || volume == 0 ? (
|
|
||||||
<VolumeOff />
|
|
||||||
) : volume < 25 ? (
|
|
||||||
<VolumeMute />
|
|
||||||
) : volume < 65 ? (
|
|
||||||
<VolumeDown />
|
|
||||||
) : (
|
|
||||||
<VolumeUp />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Box
|
|
||||||
className="slider"
|
|
||||||
sx={{
|
|
||||||
width: 0,
|
|
||||||
transition:
|
|
||||||
"width .2s cubic-bezier(0.4,0, 1, 1), padding .2s cubic-bezier(0.4,0, 1, 1)",
|
|
||||||
overflow: "hidden",
|
|
||||||
alignSelf: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
value={volume}
|
|
||||||
onChange={(_, value) => setVolume(value as number)}
|
|
||||||
size="small"
|
|
||||||
aria-label={t("volume")}
|
|
||||||
sx={{ alignSelf: "center" }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const RightButtons = memo(function RightButton({
|
|
||||||
isFullscreen,
|
|
||||||
toggleFullscreen,
|
|
||||||
subtitles,
|
|
||||||
selectedSubtitle,
|
|
||||||
selectSubtitle,
|
|
||||||
}: {
|
|
||||||
isFullscreen: boolean;
|
|
||||||
toggleFullscreen: () => void;
|
|
||||||
subtitles?: Track[];
|
|
||||||
selectedSubtitle: Track | null;
|
|
||||||
selectSubtitle: (track: Track | null) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("player");
|
|
||||||
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ "> *": { m: "8px !important" } }}>
|
|
||||||
{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)}
|
|
||||||
sx={{ color: "white" }}
|
|
||||||
>
|
|
||||||
<ClosedCaption />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip title={t("fullscreen")}>
|
|
||||||
<IconButton onClick={toggleFullscreen} aria-label={t("fullscreen")} sx={{ color: "white" }}>
|
|
||||||
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{subtitleAnchor && (
|
|
||||||
<SubtitleMenu
|
|
||||||
subtitles={subtitles!}
|
|
||||||
anchor={subtitleAnchor}
|
|
||||||
setSubtitle={selectSubtitle}
|
|
||||||
selectedID={selectedSubtitle?.id}
|
|
||||||
onClose={() => setSubtitleAnchor(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const Back = memo(function Back({ name, href }: { name?: string; href: string }) {
|
|
||||||
const { t } = useTranslation("player");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
background: "rgba(0, 0, 0, 0.6)",
|
|
||||||
display: "flex",
|
|
||||||
p: "0.33%",
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip title={t("back")}>
|
|
||||||
<NextLink href={href} passHref>
|
|
||||||
<IconButton aria-label={t("back")} sx={{ color: "white" }}>
|
|
||||||
<ArrowBack />
|
|
||||||
</IconButton>
|
|
||||||
</NextLink>
|
|
||||||
</Tooltip>
|
|
||||||
<Typography component="h1" variant="h5" sx={{ alignSelf: "center", ml: "1rem" }}>
|
|
||||||
{name ? name : <Skeleton />}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const useSubtitleController = (
|
|
||||||
player: RefObject<HTMLVideoElement>,
|
|
||||||
slug: string,
|
|
||||||
fonts?: Font[],
|
|
||||||
subtitles?: Track[],
|
|
||||||
): [Track | null, (value: Track | null) => void] => {
|
|
||||||
const [selectedSubtitle, setSubtitle] = useState<Track | null>(null);
|
|
||||||
const [htmlTrack, setHtmlTrack] = useState<HTMLTrackElement | null>(null);
|
|
||||||
const [subocto, setSubOcto] = useState<SubtitleOctopus | null>(null);
|
|
||||||
const { query: { subtitle } } = useRouter();
|
|
||||||
|
|
||||||
const selectSubtitle = useCallback(
|
|
||||||
(value: Track | null) => {
|
|
||||||
const removeHtmlSubtitle = () => {
|
|
||||||
if (htmlTrack) htmlTrack.remove();
|
|
||||||
setHtmlTrack(null);
|
|
||||||
};
|
|
||||||
const removeOctoSub = () => {
|
|
||||||
if (subocto) {
|
|
||||||
subocto.freeTrack();
|
|
||||||
subocto.dispose();
|
|
||||||
}
|
|
||||||
setSubOcto(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!player.current) return;
|
|
||||||
|
|
||||||
setSubtitle(value);
|
|
||||||
if (!value) {
|
|
||||||
removeHtmlSubtitle();
|
|
||||||
removeOctoSub();
|
|
||||||
} else if (value.codec === "vtt" || value.codec === "srt") {
|
|
||||||
removeOctoSub();
|
|
||||||
const track: HTMLTrackElement = htmlTrack ?? document.createElement("track");
|
|
||||||
track.kind = "subtitles";
|
|
||||||
track.label = value.displayName;
|
|
||||||
if (value.language) track.srclang = value.language;
|
|
||||||
track.src = `subtitle/${value.slug}.vtt`;
|
|
||||||
track.className = "subtitle_container";
|
|
||||||
track.default = true;
|
|
||||||
track.onload = () => {
|
|
||||||
if (player.current) player.current.textTracks[0].mode = "showing";
|
|
||||||
};
|
|
||||||
player.current.appendChild(track);
|
|
||||||
setHtmlTrack(track);
|
|
||||||
} else if (value.codec === "ass") {
|
|
||||||
removeHtmlSubtitle();
|
|
||||||
removeOctoSub();
|
|
||||||
setSubOcto(
|
|
||||||
new SubtitleOctopus({
|
|
||||||
video: player.current,
|
|
||||||
subUrl: `/api/subtitle/${value.slug}`,
|
|
||||||
workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js",
|
|
||||||
legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js",
|
|
||||||
fonts: fonts?.map((x) => `/api/watch/${slug}/font/${x.slug}.${x.format}`),
|
|
||||||
renderMode: "wasm-blend",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[htmlTrack, subocto, player, fonts, slug],
|
|
||||||
);
|
|
||||||
|
|
||||||
const newSub = subtitles?.find(x => x.language === subtitle);
|
|
||||||
useEffect(() => {
|
|
||||||
if (newSub === undefined) return;
|
|
||||||
console.log("old", selectedSubtitle)
|
|
||||||
console.log("new", newSub)
|
|
||||||
if (newSub?.id !== selectedSubtitle?.id) selectSubtitle(newSub);
|
|
||||||
}, [player.current?.src, newSub, selectedSubtitle, selectSubtitle]);
|
|
||||||
|
|
||||||
return [selectedSubtitle, selectSubtitle];
|
|
||||||
};
|
|
||||||
|
|
||||||
const useVideoController = () => {
|
|
||||||
const player = useRef<HTMLVideoElement>(null);
|
|
||||||
const [isPlaying, setPlay] = useState(true);
|
|
||||||
const [isLoading, setLoad] = useState(false);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [buffered, setBuffered] = useState(0);
|
|
||||||
const [volume, setVolume] = useState(100);
|
|
||||||
const [isMuted, setMute] = useState(false);
|
|
||||||
const [isFullscreen, setFullscreen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!player.current) return;
|
|
||||||
if (player.current.paused) player.current.play();
|
|
||||||
setPlay(!player.current.paused);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!player?.current?.duration) return;
|
|
||||||
setDuration(player.current.duration);
|
|
||||||
}, [player]);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
|
||||||
if (!player.current) return;
|
|
||||||
if (!isPlaying) {
|
|
||||||
player.current.play();
|
|
||||||
} else {
|
|
||||||
player.current.pause();
|
|
||||||
}
|
|
||||||
}, [isPlaying, player]);
|
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
|
||||||
setFullscreen(!isFullscreen);
|
|
||||||
if (isFullscreen) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
document.body.requestFullscreen();
|
|
||||||
}
|
|
||||||
}, [isFullscreen]);
|
|
||||||
|
|
||||||
const videoProps: BoxProps<"video"> = useMemo(
|
|
||||||
() => ({
|
|
||||||
ref: player,
|
|
||||||
onClick: togglePlay,
|
|
||||||
onDoubleClick: () => toggleFullscreen,
|
|
||||||
onPlay: () => setPlay(true),
|
|
||||||
onPause: () => setPlay(false),
|
|
||||||
onWaiting: () => setLoad(true),
|
|
||||||
onCanPlay: () => setLoad(false),
|
|
||||||
onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
|
|
||||||
onDurationChange: () => setDuration(player?.current?.duration ?? 0),
|
|
||||||
onProgress: () =>
|
|
||||||
setBuffered(
|
|
||||||
player?.current?.buffered.length
|
|
||||||
? player.current.buffered.end(player.current.buffered.length - 1)
|
|
||||||
: 0,
|
|
||||||
),
|
|
||||||
onVolumeChange: () => {
|
|
||||||
if (!player.current) return;
|
|
||||||
setVolume(player.current.volume * 100);
|
|
||||||
setMute(player?.current.muted);
|
|
||||||
},
|
|
||||||
autoPlay: true,
|
|
||||||
controls: false,
|
|
||||||
}),
|
|
||||||
[player, togglePlay, toggleFullscreen],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
playerRef: player,
|
|
||||||
state: {
|
|
||||||
isPlaying,
|
|
||||||
isLoading,
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
buffered,
|
|
||||||
volume,
|
|
||||||
isMuted,
|
|
||||||
isFullscreen,
|
|
||||||
},
|
|
||||||
videoProps,
|
|
||||||
togglePlay,
|
|
||||||
toggleMute: useCallback(() => {
|
|
||||||
if (player.current) player.current.muted = !isMuted;
|
|
||||||
}, [player, isMuted]),
|
|
||||||
toggleFullscreen,
|
|
||||||
setVolume: useCallback(
|
|
||||||
(value: number) => {
|
|
||||||
setVolume(value);
|
|
||||||
if (player.current) player.current.volume = value / 100;
|
|
||||||
},
|
|
||||||
[player],
|
|
||||||
),
|
|
||||||
setProgress: useCallback(
|
|
||||||
(value: number) => {
|
|
||||||
setProgress(value);
|
|
||||||
if (player.current) player.current.currentTime = value;
|
|
||||||
},
|
|
||||||
[player],
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const query = (slug: string): QueryIdentifier<WatchItem> => ({
|
|
||||||
path: ["watch", slug],
|
|
||||||
parser: WatchItemP,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
|
||||||
let mouseCallback: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
|
||||||
const { data, error } = useFetch(query(slug));
|
|
||||||
const {
|
|
||||||
playerRef,
|
|
||||||
state: { isPlaying, isLoading, progress, duration, buffered, volume, isMuted, isFullscreen },
|
|
||||||
videoProps,
|
|
||||||
togglePlay,
|
|
||||||
toggleMute,
|
|
||||||
toggleFullscreen,
|
|
||||||
setProgress,
|
|
||||||
setVolume,
|
|
||||||
} = useVideoController();
|
|
||||||
const [selectedSubtitle, selectSubtitle] = useSubtitleController(
|
|
||||||
playerRef,
|
|
||||||
slug,
|
|
||||||
data?.fonts,
|
|
||||||
data?.subtitles,
|
|
||||||
);
|
|
||||||
const [showHover, setHover] = useState(false);
|
|
||||||
const [mouseMoved, setMouseMoved] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => {
|
|
||||||
setMouseMoved(true);
|
|
||||||
if (mouseCallback) clearTimeout(mouseCallback);
|
|
||||||
mouseCallback = setTimeout(() => {
|
|
||||||
setMouseMoved(false);
|
|
||||||
}, 2500);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handler);
|
|
||||||
return () => document.removeEventListener("mousemove", handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
const name = data
|
|
||||||
? data.isMovie
|
|
||||||
? data.name
|
|
||||||
: `${episodeDisplayNumber(data, "")} ${data.name}`
|
|
||||||
: undefined;
|
|
||||||
const displayControls = showHover || !isPlaying || mouseMoved;
|
|
||||||
|
|
||||||
if (error) return <ErrorPage {...error} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onMouseLeave={() => setMouseMoved(false)}
|
|
||||||
sx={{ cursor: displayControls ? "unset" : "none" }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="video"
|
|
||||||
src={data?.link.direct}
|
|
||||||
{...videoProps}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "contain",
|
|
||||||
background: "black",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isLoading && <LoadingIndicator />}
|
|
||||||
<Box
|
|
||||||
onMouseEnter={() => setHover(true)}
|
|
||||||
onMouseLeave={() => setHover(false)}
|
|
||||||
sx={
|
|
||||||
displayControls
|
|
||||||
? {
|
|
||||||
visibility: "visible",
|
|
||||||
opacity: 1,
|
|
||||||
transition: "opacity .2s ease-in",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
visibility: "hidden",
|
|
||||||
opacity: 0,
|
|
||||||
transition: "opacity .4s ease-out, visibility 0s .4s",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Back
|
|
||||||
name={data?.name}
|
|
||||||
href={data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#"}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
background: "rgba(0, 0, 0, 0.6)",
|
|
||||||
display: "flex",
|
|
||||||
padding: "1%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VideoPoster poster={data?.poster} />
|
|
||||||
<Box sx={{ width: "100%", ml: 3, display: "flex", flexDirection: "column" }}>
|
|
||||||
<Typography variant="h4" component="h2" color="white" sx={{ pb: 1 }}>
|
|
||||||
{name ?? <Skeleton />}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
progress={progress}
|
|
||||||
duration={duration}
|
|
||||||
buffered={buffered}
|
|
||||||
setProgress={setProgress}
|
|
||||||
chapters={data?.chapters}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between" }}>
|
|
||||||
<Box sx={{ display: "flex" }}>
|
|
||||||
<LeftButtons
|
|
||||||
previousSlug={data && !data.isMovie ? data.previousEpisode?.slug : undefined}
|
|
||||||
nextSlug={data && !data.isMovie ? data.nextEpisode?.slug : undefined}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
volume={volume}
|
|
||||||
isMuted={isMuted}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
toggleMute={toggleMute}
|
|
||||||
setVolume={setVolume}
|
|
||||||
/>
|
|
||||||
<Typography color="white" sx={{ alignSelf: "center" }}>
|
|
||||||
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<RightButtons
|
|
||||||
isFullscreen={isFullscreen}
|
|
||||||
toggleFullscreen={toggleFullscreen}
|
|
||||||
subtitles={data?.subtitles}
|
|
||||||
selectedSubtitle={selectedSubtitle}
|
|
||||||
selectSubtitle={selectSubtitle}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Player.getFetchUrls = ({ slug }) => [query(slug)];
|
|
||||||
|
|
||||||
export default withRoute(Player);
|
|
||||||
|
149
front/src/player/components/hover.tsx
Normal file
149
front/src/player/components/hover.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* 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 { ArrowBack } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
BoxProps,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Skeleton,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
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 { episodeDisplayNumber } from "~/components/episode";
|
||||||
|
import { LeftButtons } from "./left-buttons";
|
||||||
|
import { RightButtons } from "./right-buttons";
|
||||||
|
import { ProgressBar } from "./progress-bar";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
|
export const Hover = ({ data, ...props }: { data?: WatchItem } & BoxProps) => {
|
||||||
|
const name = data
|
||||||
|
? data.isMovie
|
||||||
|
? data.name
|
||||||
|
: `${episodeDisplayNumber(data, "")} ${data.name}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box {...props}>
|
||||||
|
<Back
|
||||||
|
name={data?.name}
|
||||||
|
href={data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#"}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "rgba(0, 0, 0, 0.6)",
|
||||||
|
display: "flex",
|
||||||
|
padding: "1%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VideoPoster poster={data?.poster} />
|
||||||
|
<Box sx={{ width: "100%", ml: 3, display: "flex", flexDirection: "column" }}>
|
||||||
|
<Typography variant="h4" component="h2" color="white" sx={{ pb: 1 }}>
|
||||||
|
{name ?? <Skeleton />}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<ProgressBar chapters={data?.chapters} />
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
|
<LeftButtons
|
||||||
|
previousSlug={data && !data.isMovie ? data.previousEpisode?.slug : undefined}
|
||||||
|
nextSlug={data && !data.isMovie ? data.nextEpisode?.slug : undefined}
|
||||||
|
/>
|
||||||
|
<RightButtons subtitles={data?.subtitles} fonts={data?.fonts} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const Back = ({ name, href }: { name?: string; href: string }) => {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "rgba(0, 0, 0, 0.6)",
|
||||||
|
display: "flex",
|
||||||
|
p: "0.33%",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={t("back")}>
|
||||||
|
<NextLink href={href} passHref>
|
||||||
|
<IconButton aria-label={t("back")} sx={{ color: "white" }}>
|
||||||
|
<ArrowBack />
|
||||||
|
</IconButton>
|
||||||
|
</NextLink>
|
||||||
|
</Tooltip>
|
||||||
|
<Typography component="h1" variant="h5" sx={{ alignSelf: "center", ml: "1rem" }}>
|
||||||
|
{name ? name : <Skeleton />}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoPoster = ({ poster }: { poster?: string | null }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "15%",
|
||||||
|
display: { xs: "none", sm: "block" },
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Poster img={poster} width="100%" sx={{ position: "absolute", bottom: 0 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoadingIndicator = () => {
|
||||||
|
const isLoading = useAtomValue(loadAtom);
|
||||||
|
if (!isLoading) return null;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "rgba(0, 0, 0, 0.3)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress thickness={5} sx={{ color: "white", alignSelf: "center" }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
136
front/src/player/components/left-buttons.tsx
Normal file
136
front/src/player/components/left-buttons.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* 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 { Box, IconButton, Slider, Tooltip, Typography } from "@mui/material";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import useTranslation from "next-translate/useTranslation";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
|
||||||
|
import NextLink from "next/link";
|
||||||
|
import { Pause, PlayArrow, SkipNext, SkipPrevious, VolumeDown, VolumeMute, VolumeOff, VolumeUp } from "@mui/icons-material";
|
||||||
|
|
||||||
|
export const LeftButtons = ({ previousSlug, nextSlug }: { previousSlug?: string; nextSlug?: string }) => {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", "> *": { mx: "8px !important" } }}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<VolumeSlider />
|
||||||
|
<ProgressText />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VolumeSlider = () => {
|
||||||
|
const [volume, setVolume] = useAtom(volumeAtom);
|
||||||
|
const [isMuted, setMuted] = useAtom(mutedAtom);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
m: "0 !important",
|
||||||
|
p: "8px",
|
||||||
|
"&:hover .slider": { width: "100px", px: "16px" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={t("mute")}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setMuted(!isMuted)}
|
||||||
|
aria-label={t("mute")}
|
||||||
|
sx={{ color: "white" }}
|
||||||
|
>
|
||||||
|
{isMuted || volume == 0 ? (
|
||||||
|
<VolumeOff />
|
||||||
|
) : volume < 25 ? (
|
||||||
|
<VolumeMute />
|
||||||
|
) : volume < 65 ? (
|
||||||
|
<VolumeDown />
|
||||||
|
) : (
|
||||||
|
<VolumeUp />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Box
|
||||||
|
className="slider"
|
||||||
|
sx={{
|
||||||
|
width: 0,
|
||||||
|
transition: "width .2s cubic-bezier(0.4,0, 1, 1), padding .2s cubic-bezier(0.4,0, 1, 1)",
|
||||||
|
overflow: "hidden",
|
||||||
|
alignSelf: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
value={volume}
|
||||||
|
onChange={(_, value) => setVolume(value as number)}
|
||||||
|
size="small"
|
||||||
|
aria-label={t("volume")}
|
||||||
|
sx={{ alignSelf: "center" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressText = () => {
|
||||||
|
const progress = useAtomValue(progressAtom);
|
||||||
|
const duration = useAtomValue(durationAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography color="white" sx={{ alignSelf: "center" }}>
|
||||||
|
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toTimerString = (timer: number, duration?: number) => {
|
||||||
|
if (!duration) duration = timer;
|
||||||
|
if (duration >= 3600) return new Date(timer * 1000).toISOString().substring(11, 19);
|
||||||
|
return new Date(timer * 1000).toISOString().substring(14, 19);
|
||||||
|
};
|
133
front/src/player/components/progress-bar.tsx
Normal file
133
front/src/player/components/progress-bar.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* 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 { Box } from "@mui/material";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Chapter } from "~/models/resources/watch-item";
|
||||||
|
import { bufferedAtom, durationAtom, progressAtom } from "../state";
|
||||||
|
|
||||||
|
export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [isSeeking, setSeek] = useState(false);
|
||||||
|
const [progress, setProgress] = useAtom(progressAtom);
|
||||||
|
const buffered = useAtomValue(bufferedAtom);
|
||||||
|
const duration = useAtomValue(durationAtom);
|
||||||
|
|
||||||
|
const updateProgress = (event: MouseEvent, skipSeek?: boolean) => {
|
||||||
|
if (!(isSeeking || skipSeek) || !ref?.current) return;
|
||||||
|
const value: number = (event.pageX - ref.current.offsetLeft) / ref.current.clientWidth;
|
||||||
|
setProgress(Math.max(0, Math.min(value, 1)) * duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setSeek(false);
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", handler);
|
||||||
|
return () => document.removeEventListener("mouseup", handler);
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("mousemove", updateProgress);
|
||||||
|
return () => document.removeEventListener("mousemove", updateProgress);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSeek(true);
|
||||||
|
}}
|
||||||
|
onTouchStart={() => setSeek(true)}
|
||||||
|
onClick={(event) => updateProgress(event.nativeEvent, true)}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
py: 1,
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
".thumb": { opacity: 1 },
|
||||||
|
".bar": { transform: "unset" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
className="bar"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "4px",
|
||||||
|
background: "rgba(255, 255, 255, 0.2)",
|
||||||
|
transform: isSeeking ? "unset" : "scaleY(.6)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${(buffered / duration) * 100}%`,
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
background: "rgba(255, 255, 255, 0.5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${(progress / duration) * 100}%`,
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
background: (theme) => theme.palette.primary.main,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
className="thumb"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `calc(${(progress / duration) * 100}% - 6px)`,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
margin: "auto",
|
||||||
|
opacity: +isSeeking,
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
background: (theme) => theme.palette.primary.main,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{chapters?.map((x) => (
|
||||||
|
<Box
|
||||||
|
key={x.startTime}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "4px",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: `${Math.min(100, (x.startTime / duration) * 100)}%`,
|
||||||
|
background: (theme) => theme.palette.primary.dark,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
146
front/src/player/components/right-buttons.tsx
Normal file
146
front/src/player/components/right-buttons.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/*
|
||||||
|
* 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, Fullscreen, FullscreenExit } from "@mui/icons-material";
|
||||||
|
import { Box, IconButton, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import useTranslation from "next-translate/useTranslation";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Font, Track } from "~/models/resources/watch-item";
|
||||||
|
import { Link } from "~/utils/link";
|
||||||
|
import { fullscreenAtom, subtitleAtom } from "../state";
|
||||||
|
|
||||||
|
export const RightButtons = ({
|
||||||
|
subtitles,
|
||||||
|
fonts,
|
||||||
|
}: {
|
||||||
|
subtitles?: Track[];
|
||||||
|
fonts?: Font[];
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
|
||||||
|
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ "> *": { m: "8px !important" } }}>
|
||||||
|
{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)}
|
||||||
|
sx={{ color: "white" }}
|
||||||
|
>
|
||||||
|
<ClosedCaption />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title={t("fullscreen")}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setFullscreen(!isFullscreen)}
|
||||||
|
aria-label={t("fullscreen")}
|
||||||
|
sx={{ color: "white" }}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{subtitleAnchor && (
|
||||||
|
<SubtitleMenu
|
||||||
|
subtitles={subtitles!}
|
||||||
|
fonts={fonts!}
|
||||||
|
anchor={subtitleAnchor}
|
||||||
|
onClose={() => setSubtitleAnchor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubtitleMenu = ({
|
||||||
|
subtitles,
|
||||||
|
fonts,
|
||||||
|
anchor,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
subtitles: Track[];
|
||||||
|
fonts: Font[],
|
||||||
|
anchor: HTMLElement;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const [selectedSubtitle, setSubtitle] = useAtom(subtitleAtom);
|
||||||
|
const { subtitle, ...queryWithoutSubs } = router.query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
id="subtitle-menu"
|
||||||
|
MenuListProps={{
|
||||||
|
"aria-labelledby": "subtitle",
|
||||||
|
}}
|
||||||
|
anchorEl={anchor}
|
||||||
|
open={!!anchor}
|
||||||
|
onClose={onClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "center",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
selected={!selectedSubtitle}
|
||||||
|
onClick={() => {
|
||||||
|
setSubtitle(null);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
component={Link}
|
||||||
|
to={{ query: queryWithoutSubs }}
|
||||||
|
shallow
|
||||||
|
replace
|
||||||
|
>
|
||||||
|
<ListItemText>{t("subtitle-none")}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
{subtitles.map((sub) => (
|
||||||
|
<MenuItem
|
||||||
|
key={sub.id}
|
||||||
|
selected={selectedSubtitle?.id === sub.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSubtitle({track: sub, fonts});
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
component={Link}
|
||||||
|
to={{ query: { ...router.query, subtitle: sub.language ?? sub.id } }}
|
||||||
|
shallow
|
||||||
|
replace
|
||||||
|
>
|
||||||
|
<ListItemText>{sub.displayName}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
113
front/src/player/player.tsx
Normal file
113
front/src/player/player.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* 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 { QueryIdentifier, QueryPage } from "~/utils/query";
|
||||||
|
import { withRoute } from "~/utils/router";
|
||||||
|
import { WatchItem, WatchItemP } from "~/models/resources/watch-item";
|
||||||
|
import { useFetch } from "~/utils/query";
|
||||||
|
import { ErrorPage } from "~/components/errors";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { Hover, LoadingIndicator } from "./components/hover";
|
||||||
|
import { playAtom, useSubtitleController, useVideoController } from "./state";
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
parser: WatchItemP,
|
||||||
|
});
|
||||||
|
|
||||||
|
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||||
|
const { data, error } = useFetch(query(slug));
|
||||||
|
const { playerRef, videoProps } = useVideoController();
|
||||||
|
|
||||||
|
const isPlaying = useAtomValue(playAtom);
|
||||||
|
const [showHover, setHover] = useState(false);
|
||||||
|
const [mouseMoved, setMouseMoved] = useState(false);
|
||||||
|
const displayControls = showHover || !isPlaying || mouseMoved;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setMouseMoved(true);
|
||||||
|
if (mouseCallback) clearTimeout(mouseCallback);
|
||||||
|
mouseCallback = setTimeout(() => {
|
||||||
|
setMouseMoved(false);
|
||||||
|
}, 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handler);
|
||||||
|
return () => document.removeEventListener("mousemove", handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
|
||||||
|
|
||||||
|
if (error) return <ErrorPage {...error} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onMouseLeave={() => setMouseMoved(false)}
|
||||||
|
sx={{ cursor: displayControls ? "unset" : "none" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="video"
|
||||||
|
src={data?.link.direct}
|
||||||
|
{...videoProps}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "contain",
|
||||||
|
background: "black",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LoadingIndicator />
|
||||||
|
<Hover
|
||||||
|
data={data}
|
||||||
|
onMouseEnter={() => setHover(true)}
|
||||||
|
onMouseLeave={() => setHover(false)}
|
||||||
|
sx={
|
||||||
|
displayControls
|
||||||
|
? {
|
||||||
|
visibility: "visible",
|
||||||
|
opacity: 1,
|
||||||
|
transition: "opacity .2s ease-in",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
visibility: "hidden",
|
||||||
|
opacity: 0,
|
||||||
|
transition: "opacity .4s ease-out, visibility 0s .4s",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Player.getFetchUrls = ({ slug }) => [query(slug)];
|
||||||
|
|
||||||
|
export default withRoute(Player);
|
213
front/src/player/state.tsx
Normal file
213
front/src/player/state.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
* 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 { BoxProps } from "@mui/material";
|
||||||
|
import { atom, useSetAtom } from "jotai";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Font, Track } from "~/models/resources/watch-item";
|
||||||
|
import { bakedAtom } from "~/utils/jotai-utils";
|
||||||
|
// @ts-ignore
|
||||||
|
import SubtitleOctopus from "@jellyfin/libass-wasm/dist/js/subtitles-octopus";
|
||||||
|
|
||||||
|
export const playerAtom = atom<RefObject<HTMLVideoElement> | null>(null);
|
||||||
|
export const [_playAtom, playAtom] = bakedAtom(true, (get, _, value) => {
|
||||||
|
const player = get(playerAtom);
|
||||||
|
if (!player?.current) return;
|
||||||
|
if (value) {
|
||||||
|
player.current.play();
|
||||||
|
} else {
|
||||||
|
player.current.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export const loadAtom = atom(false);
|
||||||
|
export const [_progressAtom, progressAtom] = bakedAtom(0, (get, set, value, baker) => {
|
||||||
|
const player = get(playerAtom);
|
||||||
|
if (!player?.current) return;
|
||||||
|
set(baker, value);
|
||||||
|
player.current.currentTime = value;
|
||||||
|
});
|
||||||
|
export const bufferedAtom = atom(0);
|
||||||
|
export const durationAtom = atom(1);
|
||||||
|
export const [_volumeAtom, volumeAtom] = bakedAtom(100, (get, set, value, baker) => {
|
||||||
|
const player = get(playerAtom);
|
||||||
|
if (!player?.current) return;
|
||||||
|
set(baker, value);
|
||||||
|
if (player.current) player.current.volume = value / 100;
|
||||||
|
});
|
||||||
|
export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker) => {
|
||||||
|
const player = get(playerAtom);
|
||||||
|
if (!player?.current) return;
|
||||||
|
set(baker, value);
|
||||||
|
if (player.current) player.current.muted = value;
|
||||||
|
});
|
||||||
|
export const [_, fullscreenAtom] = bakedAtom(false, (_, set, value, baker) => {
|
||||||
|
set(baker, value);
|
||||||
|
if (value) {
|
||||||
|
document.body.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useVideoController = () => {
|
||||||
|
const player = useRef<HTMLVideoElement>(null);
|
||||||
|
const setPlayer = useSetAtom(playerAtom);
|
||||||
|
const setPlay = useSetAtom(_playAtom);
|
||||||
|
const setLoad = useSetAtom(loadAtom);
|
||||||
|
const setProgress = useSetAtom(_progressAtom);
|
||||||
|
const setBuffered = useSetAtom(bufferedAtom);
|
||||||
|
const setDuration = useSetAtom(durationAtom);
|
||||||
|
const setVolume = useSetAtom(_volumeAtom);
|
||||||
|
const setMuted = useSetAtom(_mutedAtom);
|
||||||
|
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||||
|
|
||||||
|
setPlayer(player);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!player.current) return;
|
||||||
|
if (player.current.paused) player.current.play();
|
||||||
|
setPlay(!player.current.paused);
|
||||||
|
}, [setPlay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!player?.current?.duration) return;
|
||||||
|
setDuration(player.current.duration);
|
||||||
|
}, [player, setDuration]);
|
||||||
|
|
||||||
|
const videoProps: BoxProps<"video"> = {
|
||||||
|
ref: player,
|
||||||
|
onClick: () => {
|
||||||
|
if (!player.current) return;
|
||||||
|
if (player.current.paused) {
|
||||||
|
player.current.play();
|
||||||
|
} else {
|
||||||
|
player.current.pause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDoubleClick: () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
setFullscreen(false);
|
||||||
|
} else {
|
||||||
|
setFullscreen(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlay: () => setPlay(true),
|
||||||
|
onPause: () => setPlay(false),
|
||||||
|
onWaiting: () => setLoad(true),
|
||||||
|
onCanPlay: () => setLoad(false),
|
||||||
|
onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
|
||||||
|
onDurationChange: () => setDuration(player?.current?.duration ?? 0),
|
||||||
|
onProgress: () =>
|
||||||
|
setBuffered(
|
||||||
|
player?.current?.buffered.length
|
||||||
|
? player.current.buffered.end(player.current.buffered.length - 1)
|
||||||
|
: 0,
|
||||||
|
),
|
||||||
|
onVolumeChange: () => {
|
||||||
|
if (!player.current) return;
|
||||||
|
setVolume(player.current.volume * 100);
|
||||||
|
setMuted(player?.current.muted);
|
||||||
|
},
|
||||||
|
autoPlay: true,
|
||||||
|
controls: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
playerRef: player,
|
||||||
|
videoProps,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlTrackAtom = atom<HTMLTrackElement | null>(null);
|
||||||
|
const suboctoAtom = atom<SubtitleOctopus | null>(null);
|
||||||
|
export const [_subtitleAtom, subtitleAtom] = bakedAtom<Track | null, { track: Track, fonts: Font[] } | null>(
|
||||||
|
null,
|
||||||
|
(get, set, value, baked) => {
|
||||||
|
const removeHtmlSubtitle = () => {
|
||||||
|
const htmlTrack = get(htmlTrackAtom);
|
||||||
|
if (htmlTrack) htmlTrack.remove();
|
||||||
|
set(htmlTrackAtom, null);
|
||||||
|
};
|
||||||
|
const removeOctoSub = () => {
|
||||||
|
const subocto = get(suboctoAtom);
|
||||||
|
if (subocto) {
|
||||||
|
subocto.freeTrack();
|
||||||
|
subocto.dispose();
|
||||||
|
}
|
||||||
|
set(suboctoAtom, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const player = get(playerAtom);
|
||||||
|
if (!player?.current) return;
|
||||||
|
|
||||||
|
if (get(baked)?.id === value?.track.id) return;
|
||||||
|
|
||||||
|
set(baked, value?.track ?? null);
|
||||||
|
if (!value) {
|
||||||
|
removeHtmlSubtitle();
|
||||||
|
removeOctoSub();
|
||||||
|
} else if (value.track.codec === "vtt" || value.track.codec === "srt") {
|
||||||
|
removeOctoSub();
|
||||||
|
const track: HTMLTrackElement = get(htmlTrackAtom) ?? document.createElement("track");
|
||||||
|
track.kind = "subtitles";
|
||||||
|
track.label = value.track.displayName;
|
||||||
|
if (value.track.language) track.srclang = value.track.language;
|
||||||
|
track.src = `subtitle/${value.track.slug}.vtt`;
|
||||||
|
track.className = "subtitle_container";
|
||||||
|
track.default = true;
|
||||||
|
track.onload = () => {
|
||||||
|
if (player.current) player.current.textTracks[0].mode = "showing";
|
||||||
|
};
|
||||||
|
player.current.appendChild(track);
|
||||||
|
set(htmlTrackAtom, track);
|
||||||
|
} else if (value.track.codec === "ass") {
|
||||||
|
removeHtmlSubtitle();
|
||||||
|
removeOctoSub();
|
||||||
|
set(
|
||||||
|
suboctoAtom,
|
||||||
|
new SubtitleOctopus({
|
||||||
|
video: player.current,
|
||||||
|
subUrl: `/api/subtitle/${value.track.slug}`,
|
||||||
|
workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js",
|
||||||
|
legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js",
|
||||||
|
fonts: value.fonts?.map((x) => x.link),
|
||||||
|
renderMode: "wasm-blend",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useSubtitleController = (
|
||||||
|
player: RefObject<HTMLVideoElement>,
|
||||||
|
subtitles?: Track[],
|
||||||
|
fonts?: Font[],
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
query: { subtitle },
|
||||||
|
} = useRouter();
|
||||||
|
const selectSubtitle = useSetAtom(subtitleAtom);
|
||||||
|
|
||||||
|
const newSub = subtitles?.find((x) => x.language === subtitle);
|
||||||
|
useEffect(() => {
|
||||||
|
if (newSub === undefined) return;
|
||||||
|
selectSubtitle({track: newSub, fonts: fonts ?? []});
|
||||||
|
}, [player.current?.src, newSub, fonts, selectSubtitle]);
|
||||||
|
};
|
35
front/src/utils/jotai-utils.tsx
Normal file
35
front/src/utils/jotai-utils.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Kyoo - A portable and vast media library solution.
|
||||||
|
* Copyright (c) Kyoo.
|
||||||
|
*
|
||||||
|
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||||
|
*
|
||||||
|
* Kyoo is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* any later version.
|
||||||
|
*
|
||||||
|
* Kyoo is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { atom, PrimitiveAtom, WritableAtom } from "jotai";
|
||||||
|
|
||||||
|
type AtomWritter<T, Update> = Parameters<typeof atom<T, Update>>[1];
|
||||||
|
export const bakedAtom = <T, Update = T>(
|
||||||
|
initialValue: T,
|
||||||
|
writter: (...args: [...Parameters<AtomWritter<T, Update>>, PrimitiveAtom<T>]) => void,
|
||||||
|
): [PrimitiveAtom<T>, WritableAtom<T, Update>] => {
|
||||||
|
const baker = atom(initialValue);
|
||||||
|
const pub = atom<T, Update>(
|
||||||
|
(get) => get(baker),
|
||||||
|
(...args) => writter(...args, baker),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [baker, pub];
|
||||||
|
};
|
@ -1621,6 +1621,11 @@ isexe@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||||
|
|
||||||
|
jotai@^1.8.4:
|
||||||
|
version "1.8.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.8.4.tgz#e188bff3cc790c758d25646f6f5daf9854c98eef"
|
||||||
|
integrity sha512-bkHDHNxm7bU4+bJL4z96fTlJYN34UDRTu3ghEajJrDepayON9YEaxPrXr7xhLnIRntoFC6eDYYhMNA/ilbj2RQ==
|
||||||
|
|
||||||
js-sha3@0.8.0:
|
js-sha3@0.8.0:
|
||||||
version "0.8.0"
|
version "0.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
|
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user