Add mini player features

This commit is contained in:
Zoe Roux 2022-10-23 18:16:14 +09:00
parent a978ed6aeb
commit 2c724eae5c
No known key found for this signature in database
GPG Key ID: B2AB52A2636E5C46
11 changed files with 325 additions and 177 deletions

View File

@ -40,7 +40,8 @@ export type Item = {
*/ */
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;
/** /**
@ -48,28 +49,38 @@ export type Item = {
*/ */
name: string; name: string;
/** /**
* true if this is a movie, false otherwise. * The air date of this episode.
*/
releaseDate: Date;
/**
* True if this is a movie, false otherwise.
*/ */
isMovie: boolean; isMovie: boolean;
/** /**
* An url to the poster of this resource. If this resource does not have an image, the link will be null. If the kyoo's instance is not capable of handling this kind of image for the specific resource, this field won't be present. * An url to the poster of this resource. If this resource does not have an image, the link will
* be null. If the kyoo's instance is not capable of handling this kind of image for the
* specific resource, this field won't be present.
*/ */
poster?: string | null; poster?: string | null;
/** /**
* An url to the thumbnail of this resource. If this resource does not have an image, the link will be null. If the kyoo's instance is not capable of handling this kind of image for the specific resource, this field won't be present. * 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; 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. * 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; 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;
}

View File

@ -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(
Command.PAUSE |
Command.SEEK |
Command.QUEUE_NEXT |
Command.QUEUE_PREV |
Command.EDIT_TRACKS |
Command.STREAM_MUTE |
Command.STREAM_VOLUME |
Command.STREAM_TRANSFER,
);
playerManager.setMessageInterceptor(
cast.framework.messages.MessageType.LOAD,
async (loadRequestData) => {
if (loadRequestData.media.contentUrl && loadRequestData.media.metadata) return loadRequestData; if (loadRequestData.media.contentUrl && loadRequestData.media.metadata) return loadRequestData;
const item = await getItem(loadRequestData.media.contentId, loadRequestData.media.customData.serverUrl); const item = await getItem(
if (!item) { loadRequestData.media.contentId,
return new cast.framework.messages.ErrorData( loadRequestData.media.customData.serverUrl,
cast.framework.messages.ErrorType.LOAD_FAILED,
); );
if (!item) {
return new cast.framework.messages.ErrorData(cast.framework.messages.ErrorType.LOAD_FAILED);
} }
loadRequestData.media.contentUrl = item.link.direct; loadRequestData.media.contentUrl = item.link.direct;
loadRequestData.media.metadata = item; loadRequestData.media.metadata = item.isMovie
? itemToMovie(item)
: itemToTvMetadata(item);
loadRequestData.media.customData = item;
return loadRequestData; return loadRequestData;
}); },
);
context.start(); context.start();

View File

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

View File

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

View File

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

View File

@ -0,0 +1,111 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Pause, PlayArrow, SkipNext, SkipPrevious } from "@mui/icons-material";
import { Box, IconButton, Paper, Tooltip, Typography } from "@mui/material";
import { useAtom, useAtomValue } from "jotai";
import useTranslation from "next-translate/useTranslation";
import { useRouter } from "next/router";
import { episodeDisplayNumber } from "~/components/episode";
import { Image } from "~/components/poster";
import { ProgressText, VolumeSlider } from "~/player/components/left-buttons";
import { ProgressBar } from "../components/progress-bar";
import { durationAtom, mediaAtom, progressAtom, playAtom } from "./state";
export const _CastMiniPlayer = () => {
const { t } = useTranslation("player");
const router = useRouter();
const [media, setMedia] = useAtom(mediaAtom);
const [isPlaying, togglePlay] = useAtom(playAtom);
if (!media) return null;
const previousSlug = "sng";
const nextSlug = "toto";
return (
<Paper
elevation={16}
/* onClick={() => router.push("/remote")} */
sx={{ height: "100px", display: "flex", justifyContent: "space-between" }}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box sx={{ height: "100%", p: 2, boxSizing: "border-box" }}>
<Image img={media.thumbnail} alt="" height="100%" aspectRatio="16/9" />
</Box>
<Box>
{!media.isMovie && (
<Typography>{media.showTitle + " " + episodeDisplayNumber(media)}</Typography>
)}
<Typography>{media.name}</Typography>
</Box>
</Box>
<Box
sx={{
display: { xs: "none", md: "flex" },
alignItems: "center",
flexGrow: 1,
flexShrink: 1,
}}
>
<ProgressBar
progressAtom={progressAtom}
durationAtom={durationAtom}
sx={{ flexShrink: 1 }}
/>
<ProgressText
sx={{ flexShrink: 0 }}
progressAtom={progressAtom}
durationAtom={durationAtom}
/>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
"> *": { mx: "16px !important" },
"> .desktop": { display: { xs: "none", md: "inline-flex" } },
}}
>
<VolumeSlider className="desktop" />
{previousSlug && (
<Tooltip title={t("previous")} className="desktop">
<IconButton aria-label={t("previous")}>
<SkipPrevious />
</IconButton>
</Tooltip>
)}
<Tooltip title={isPlaying ? t("pause") : t("play")}>
<IconButton onClick={() => togglePlay()} aria-label={isPlaying ? t("pause") : t("play")}>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
</Tooltip>
{nextSlug && (
<Tooltip title={t("next")}>
<IconButton aria-label={t("next")}>
<SkipNext />
</IconButton>
</Tooltip>
)}
</Box>
</Paper>
);
};

View File

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

View File

@ -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 = () => {

View File

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

View File

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

View File

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