Implemented basic video player
parent
ba48608135
commit
6b8322e3fd
@ -0,0 +1,13 @@
|
||||
# MaterialTube
|
||||
|
||||
![Deploy site](https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/deploy.yml/badge.svg)
|
||||
![CodeQL](https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/codeql-analysis.yml/badge.svg)
|
||||
|
||||
![Docker pulls](https://shields.io/docker/pulls/guusvanmeerveld/materialtube)
|
||||
|
||||
### MaterialTube is a simple client-side only web-client for Invidious servers. It supports using an Invidious account, but also allows you to store all of your data locally. It's main goal is to provide an even greater level of privacy and improve on the current Invidious UI.
|
||||
|
||||
### Made using
|
||||
|
||||
![Typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)
|
||||
![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)
|
@ -0,0 +1,89 @@
|
||||
import { FC, MutableRefObject } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
import Fullscreen from "@mui/icons-material/Fullscreen";
|
||||
import Pause from "@mui/icons-material/Pause";
|
||||
import PlayArrow from "@mui/icons-material/PlayArrow";
|
||||
import Settings from "@mui/icons-material/Settings";
|
||||
import SkipNext from "@mui/icons-material/SkipNext";
|
||||
import Subtitles from "@mui/icons-material/Subtitles";
|
||||
import VolumeUp from "@mui/icons-material/VolumeUp";
|
||||
|
||||
import { VideoStatus } from "@interfaces/videoPlayer";
|
||||
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
import Time from "@components/Player/Time";
|
||||
|
||||
const iconStyles = {
|
||||
mr: 1.5,
|
||||
cursor: "pointer"
|
||||
};
|
||||
|
||||
const Actions: FC<{
|
||||
duration: number;
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
}> = ({ duration, videoRef }) => {
|
||||
const togglePlaying = useVideoState((state) => state.togglePlaying);
|
||||
const playing = useVideoState((state) => state.playing);
|
||||
|
||||
const muted = useVideoState((state) => state.muted);
|
||||
const toggleMuted = useVideoState((state) => state.toggleMuted);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: 40,
|
||||
px: 1.5,
|
||||
bottom: 5,
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1, display: "flex", alignItems: "center" }}>
|
||||
<Tooltip title={playing == VideoStatus.Playing ? "Pause" : "Play"}>
|
||||
<Box
|
||||
sx={{
|
||||
...iconStyles,
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
onClick={() => togglePlaying()}
|
||||
>
|
||||
{playing == VideoStatus.Playing ? <Pause /> : <PlayArrow />}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip title="Play next video">
|
||||
<SkipNext sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Tooltip title={muted ? "Unmute" : "Mute"}>
|
||||
<VolumeUp onClick={() => toggleMuted()} sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Time duration={duration} videoRef={videoRef} />
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Tooltip title="Turn on captions">
|
||||
<Subtitles sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Change quality">
|
||||
<Settings sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Fullscreen">
|
||||
<Fullscreen
|
||||
sx={{
|
||||
...iconStyles,
|
||||
transition: "font-size .2s",
|
||||
"&:hover": { fontSize: "2rem" }
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Actions;
|
@ -0,0 +1,74 @@
|
||||
import { FC, MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
const ProgressBar: FC<{
|
||||
duration: number;
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
}> = ({ videoRef, duration }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [buffer, setBuffer] = useState<number>(1);
|
||||
|
||||
const height = 5;
|
||||
|
||||
const bufferColor = "rgba(200, 200, 200, 0.5)";
|
||||
const backgroundColor = "rgba(132, 132, 132, 0.5)";
|
||||
|
||||
const progress = (useVideoState((state) => state.progress) / duration) * 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const buffered = videoRef.current.buffered;
|
||||
|
||||
if (buffered.length != 0) {
|
||||
const newBuffer =
|
||||
((buffered.end(0) - buffered.start(0)) / duration) * 100;
|
||||
|
||||
if (newBuffer != buffer) {
|
||||
setBuffer(newBuffer);
|
||||
}
|
||||
}
|
||||
}, [buffer, duration, videoRef, videoRef.current?.buffered]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
cursor: "pointer",
|
||||
width: "98%",
|
||||
backgroundColor,
|
||||
height,
|
||||
left: "1%",
|
||||
bottom: 45,
|
||||
"&:hover": {}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
position: "absolute",
|
||||
height,
|
||||
zIndex: 10
|
||||
}}
|
||||
style={{ width: `${progress}%` }}
|
||||
></Box>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: bufferColor,
|
||||
position: "absolute",
|
||||
height
|
||||
}}
|
||||
style={{ width: `${buffer}%` }}
|
||||
></Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
@ -0,0 +1,27 @@
|
||||
import { FC, MutableRefObject } from "react";
|
||||
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { formatTime } from "@src/utils/";
|
||||
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
const Time: FC<{
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
duration: number;
|
||||
}> = ({ videoRef, duration }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const progress = useVideoState((state) => state.progress);
|
||||
|
||||
return (
|
||||
<Typography variant="subtitle1" color={theme.palette.text.secondary}>
|
||||
{formatTime(Math.round(progress))}
|
||||
<> / </>
|
||||
{formatTime(duration)}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export default Time;
|
@ -0,0 +1,198 @@
|
||||
import { FC, MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import { SxProps } from "@mui/material/styles";
|
||||
|
||||
import { AdaptiveFormat, Caption, FormatStream } from "@interfaces/video";
|
||||
import { PausedBy, VideoStatus } from "@interfaces/videoPlayer";
|
||||
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
import Actions from "@components/Player/Actions";
|
||||
import ProgressBar from "@components/Player/ProgressBar";
|
||||
|
||||
const Player: FC<{
|
||||
formats: AdaptiveFormat[];
|
||||
streams: FormatStream[];
|
||||
captions: Caption[];
|
||||
length: number;
|
||||
videoId: string;
|
||||
sx: SxProps;
|
||||
}> = ({ formats, length: duration, sx }) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const playing = useVideoState((state) => state.playing);
|
||||
const togglePlaying = useVideoState((state) => state.togglePlaying);
|
||||
const setPlaying = useVideoState((state) => state.setPlaying);
|
||||
|
||||
const speed = useVideoState((state) => state.speed);
|
||||
const muted = useVideoState((state) => state.muted);
|
||||
|
||||
const error = useVideoState((state) => state.error);
|
||||
const setError = useVideoState((state) => state.setError);
|
||||
|
||||
const waiting = useVideoState((state) => state.waiting);
|
||||
const setWaiting = useVideoState((state) => state.setWaiting);
|
||||
|
||||
const setProgress = useVideoState((state) => state.setProgress);
|
||||
|
||||
const pausedBy = useVideoState((state) => state.pausedBy);
|
||||
|
||||
const videoStream = formats.find(
|
||||
(format) => format.qualityLabel == "2160p" || format.qualityLabel == "1080p"
|
||||
)?.url;
|
||||
|
||||
const audioStream = formats.find((format) =>
|
||||
format.type.includes("audio/mp4")
|
||||
)?.url as string;
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(new Audio(audioStream));
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
|
||||
audio.volume = 0.25;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
if (!video) return;
|
||||
|
||||
video.playbackRate = speed;
|
||||
|
||||
video.currentTime = 0;
|
||||
|
||||
const handleError = (e: ErrorEvent) => {
|
||||
setError(e.message || "An unknown error occurred");
|
||||
setPlaying(VideoStatus.Paused, PausedBy.Player);
|
||||
};
|
||||
|
||||
const handleWaiting = (e: Event) => {
|
||||
setWaiting(true);
|
||||
|
||||
if (playing == VideoStatus.Playing)
|
||||
setPlaying(VideoStatus.Paused, PausedBy.Player);
|
||||
};
|
||||
|
||||
const handleFinishedWaiting = (e: Event) => {
|
||||
setWaiting(false);
|
||||
|
||||
if (pausedBy == PausedBy.Player) setPlaying(VideoStatus.Playing);
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setProgress(video.currentTime ?? 0);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(VideoStatus.Paused);
|
||||
};
|
||||
|
||||
if (!videoStream) setError("Could not find video stream");
|
||||
|
||||
video.addEventListener("waiting", handleWaiting);
|
||||
video.addEventListener("canplaythrough", handleFinishedWaiting);
|
||||
video.addEventListener("error", handleError);
|
||||
video.addEventListener("pause", handlePause);
|
||||
video.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
audio.addEventListener("waiting", handleWaiting);
|
||||
audio.addEventListener("canplaythrough", handleFinishedWaiting);
|
||||
audio.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
audio.srcObject = null;
|
||||
|
||||
video.removeEventListener("waiting", handleWaiting);
|
||||
video.removeEventListener("canplaythrough", handleFinishedWaiting);
|
||||
video.removeEventListener("error", handleError);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
video.removeEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
audio.removeEventListener("waiting", handleWaiting);
|
||||
audio.removeEventListener("canplaythrough", handleFinishedWaiting);
|
||||
audio.removeEventListener("pause", handlePause);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPlaying(settings.autoPlay ? VideoStatus.Playing : VideoStatus.Paused);
|
||||
}, [setPlaying, settings.autoPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !audioRef.current) return;
|
||||
|
||||
if (playing == VideoStatus.Paused) {
|
||||
videoRef.current.pause();
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
audioRef.current.play();
|
||||
}
|
||||
}, [error, playing, waiting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
videoRef.current.playbackRate = speed;
|
||||
}, [speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
audioRef.current.muted = muted;
|
||||
}, [muted, audioRef]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...sx,
|
||||
maxWidth: "fit-content",
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
<video
|
||||
src={videoStream}
|
||||
ref={videoRef}
|
||||
style={{
|
||||
height: "100%"
|
||||
}}
|
||||
autoPlay={playing == VideoStatus.Playing}
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
<Box
|
||||
onClick={() => togglePlaying(PausedBy.User)}
|
||||
sx={{
|
||||
boxShadow: "0px -15px 30px 0px rgba(0,0,0,0.75) inset",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%"
|
||||
}}
|
||||
></Box>
|
||||
<ProgressBar videoRef={videoRef} duration={duration} />
|
||||
<Actions videoRef={videoRef} duration={duration} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Player;
|
@ -0,0 +1,11 @@
|
||||
export type VideoSpeed = 0.25 | 0.5 | 1.0 | 1.25 | 1.5 | 1.75 | 2.0;
|
||||
|
||||
export enum VideoStatus {
|
||||
Playing = "playing",
|
||||
Paused = "paused"
|
||||
}
|
||||
|
||||
export enum PausedBy {
|
||||
User = "user",
|
||||
Player = "player"
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import create from "zustand";
|
||||
|
||||
import { PausedBy, VideoSpeed, VideoStatus } from "@interfaces/videoPlayer";
|
||||
|
||||
interface VideoState {
|
||||
progress: number;
|
||||
setProgress: (progress: number) => void;
|
||||
speed: VideoSpeed;
|
||||
setSpeed: (speed: VideoSpeed) => void;
|
||||
error?: string;
|
||||
setError: (error: string) => void;
|
||||
waiting: boolean;
|
||||
setWaiting: (waiting: boolean) => void;
|
||||
muted: boolean;
|
||||
toggleMuted: () => void;
|
||||
pausedBy?: PausedBy;
|
||||
playing: VideoStatus;
|
||||
togglePlaying: (pausedBy?: PausedBy) => void;
|
||||
setPlaying: (playing: VideoStatus, pausedBy?: PausedBy) => void;
|
||||
}
|
||||
|
||||
const useVideoState = create<VideoState>((set) => ({
|
||||
progress: 0,
|
||||
setProgress: (progress) => set(() => ({ progress })),
|
||||
speed: 1,
|
||||
setSpeed: (speed) => set(() => ({ speed })),
|
||||
error: undefined,
|
||||
setError: (error) => set(() => ({ error })),
|
||||
waiting: true,
|
||||
setWaiting: (waiting) => set(() => ({ waiting })),
|
||||
muted: false,
|
||||
toggleMuted: () => set((state) => ({ muted: !state.muted })),
|
||||
pausedBy: undefined,
|
||||
playing: VideoStatus.Paused,
|
||||
togglePlaying: (pausedBy?: PausedBy) =>
|
||||
set((state) => ({
|
||||
playing:
|
||||
state.playing == VideoStatus.Playing
|
||||
? VideoStatus.Paused
|
||||
: VideoStatus.Playing,
|
||||
pausedBy: state.playing == VideoStatus.Playing ? pausedBy : undefined
|
||||
})),
|
||||
setPlaying: (playing, pausedBy) =>
|
||||
set(() => ({
|
||||
playing,
|
||||
pausedBy: playing == VideoStatus.Paused ? pausedBy : undefined
|
||||
}))
|
||||
}));
|
||||
|
||||
export default useVideoState;
|
Loading…
Reference in new issue