mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
feat: Added various features to the video player (#242)
* Add buffered progress to progress bar. * Update mute functionality to use setting and make player controls use globalVolume setting. * Add fullscreen keybind. * Don't show muted icon when volume is set to zero and changing the volume via slider now unmutes the player. * Update audio mode to use full area. * Update video player to be inline and fix iOS fullscreen functionality. * Add volume icon for when the volume is 0 but unmuted. * Add a menu which show keybinds in the video overlay and also add a keybind to toggle audio mode. * Added a button and keybind for PIP. * Added keybinds for single frame seek functionality.
This commit is contained in:
@@ -17,6 +17,7 @@ const StyledPlayerProgress = styled.div`
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBackground = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
|
||||
@@ -28,7 +29,7 @@ const StyledPlayerProgressBackground = styled.div`
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBar = styled.div`
|
||||
position: relative;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
@@ -71,6 +72,19 @@ const StyledPlayerProgressBarHover = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBuffered = styled.div`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
|
||||
background-color: ${theme.colors["text-muted"]};
|
||||
opacity: 0.2;
|
||||
|
||||
${StyledPlayerProgress}:hover & {
|
||||
height: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ProgressBar() {
|
||||
const context = useContext(VideoPlayerContext);
|
||||
|
||||
@@ -78,7 +92,7 @@ export function ProgressBar() {
|
||||
throw new Error("ProgressBar needs to be inside VideoPlayer!");
|
||||
}
|
||||
|
||||
const { playerRef, progressRef } = context;
|
||||
const { playerRef, progressRef, bufferedRef } = context;
|
||||
|
||||
const progressHoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -146,6 +160,7 @@ export function ProgressBar() {
|
||||
}}
|
||||
>
|
||||
<StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBuffered ref={bufferedRef} />
|
||||
<StyledPlayerProgressBar ref={progressRef} />
|
||||
</StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBarHover ref={progressHoverRef} />
|
||||
|
||||
@@ -117,8 +117,9 @@ export const StyledAudioBackground = styled.div`
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const StyledAudio = styled.audio`
|
||||
@@ -126,17 +127,32 @@ export const StyledAudio = styled.audio`
|
||||
`;
|
||||
|
||||
export const StyledAudioCover = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
|
||||
outline: none;
|
||||
object-fit: contain;
|
||||
max-width: 80%;
|
||||
height: 80%;
|
||||
border-radius: 8px;
|
||||
|
||||
object-fit: cover;
|
||||
box-shadow: ${theme.shadows.high};
|
||||
|
||||
[data-background] & {
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledAudioCoverBackground = styled.img`
|
||||
width: calc(100% + 32px);
|
||||
height: calc(100% + 32px);
|
||||
max-width: none;
|
||||
margin: -16px;
|
||||
|
||||
object-fit: cover;
|
||||
filter: blur(8px);
|
||||
`;
|
||||
|
||||
export const StyledAside = styled.aside`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
StyledAudio,
|
||||
StyledAudioBackground,
|
||||
StyledAudioCover,
|
||||
StyledAudioCoverBackground,
|
||||
StyledPlaybackArea,
|
||||
StyledPlayer,
|
||||
StyledPlayerContent,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
StyledVideoBackground,
|
||||
} from "@/components/video-player/VideoPlayer.style";
|
||||
import { VideoPlayerBar } from "@/components/video-player/VideoPlayerBar";
|
||||
import FullscreenContext from "@/context/fullscreenContext";
|
||||
import PlayerContext, { type WatchListItem } from "@/context/playerContext";
|
||||
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
import useMouseRelax from "@/hooks/useMouseRelax";
|
||||
@@ -22,7 +24,7 @@ import useWatchHistory from "@/hooks/useWatchHistory";
|
||||
import { AUDIO_URL, VIDEO_URL } from "@/utils/config";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
import { AudioMode, GlobalVolume } from "@/utils/settings";
|
||||
import { AudioMode, GlobalVolume, Muted } from "@/utils/settings";
|
||||
|
||||
interface VideoPlayerContextValue {
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
@@ -31,6 +33,7 @@ interface VideoPlayerContextValue {
|
||||
videoPagePath: string;
|
||||
playerRef: RefObject<HTMLVideoElement | HTMLAudioElement | null>;
|
||||
progressRef: RefObject<HTMLDivElement | null>;
|
||||
bufferedRef: RefObject<HTMLDivElement | null>;
|
||||
previousVideoPath: string | null;
|
||||
playPreviousTrack(navigate: boolean): void;
|
||||
nextVideoPath: string | null;
|
||||
@@ -40,6 +43,7 @@ interface VideoPlayerContextValue {
|
||||
videoUrl: string;
|
||||
audioUrl: string;
|
||||
updateAudioMode(audioMode: string): void;
|
||||
togglePip(): void;
|
||||
}
|
||||
|
||||
export const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
|
||||
@@ -68,7 +72,9 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const bufferedRef = useRef<HTMLDivElement>(null);
|
||||
const currentTimeBeforeModeSwitch = useRef<number | null>(null);
|
||||
const fpsRef = useRef<number>(24);
|
||||
|
||||
const {
|
||||
watchList,
|
||||
@@ -79,8 +85,10 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
isWatchListUsingLocalAutoPlay,
|
||||
isRepeat,
|
||||
} = useContext(PlayerContext);
|
||||
const { toggleFullscreen } = useContext(FullscreenContext);
|
||||
const router = useRouter();
|
||||
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
|
||||
const [muted, setMuted] = useSetting(Muted);
|
||||
const { smallCover, largeCover } = extractImages(anime);
|
||||
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
const { addToHistory } = useWatchHistory();
|
||||
@@ -145,82 +153,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
],
|
||||
);
|
||||
|
||||
// Handle keyboard inputs
|
||||
const onKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key.toLocaleLowerCase()) {
|
||||
case " ": // Play/Pause
|
||||
case "k":
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "arrowright": // Seek forward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 5;
|
||||
}
|
||||
break;
|
||||
case ".": // Seek forward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 10;
|
||||
}
|
||||
break;
|
||||
case "arrowleft": // Seek backward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 5;
|
||||
}
|
||||
break;
|
||||
case ",": // Seek backward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 10;
|
||||
}
|
||||
break;
|
||||
case "n": // Next track
|
||||
event.preventDefault();
|
||||
playNextTrack(true);
|
||||
break;
|
||||
case "b": // Previous track
|
||||
event.preventDefault();
|
||||
playPreviousTrack(true);
|
||||
break;
|
||||
case "m": // Mute
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.volume = playerRef.current.volume === 0 ? 1 : 0;
|
||||
}
|
||||
break;
|
||||
case "arrowup": // Volume up
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.volume = Math.min(playerRef.current.volume + 0.1, 1);
|
||||
}
|
||||
break;
|
||||
case "arrowdown": // Volume down
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.volume = Math.max(playerRef.current.volume - 0.1, 0);
|
||||
}
|
||||
break;
|
||||
case "d": // Download
|
||||
event.preventDefault();
|
||||
if (audioMode === AudioMode.ENABLED) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${audioUrl}?download`;
|
||||
link.click();
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${videoUrl}?download`;
|
||||
link.click();
|
||||
}
|
||||
}
|
||||
}, [togglePlay, playNextTrack, playPreviousTrack, audioMode, audioUrl, videoUrl]);
|
||||
|
||||
const autoPlayNextTrack = useCallback(() => {
|
||||
if (
|
||||
(isWatchListUsingLocalAutoPlay && isLocalAutoPlay) ||
|
||||
@@ -232,9 +164,10 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
|
||||
useEffect(() => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.muted = muted;
|
||||
playerRef.current.volume = globalVolume;
|
||||
}
|
||||
}, [globalVolume]);
|
||||
}, [globalVolume, muted]);
|
||||
|
||||
useEffect(() => {
|
||||
addToHistory({
|
||||
@@ -252,6 +185,10 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
progressRef.current.style.width = "0%";
|
||||
}
|
||||
|
||||
if (bufferedRef.current) {
|
||||
bufferedRef.current.style.width = "0%";
|
||||
}
|
||||
|
||||
// We don't want to re-add the theme when the history changes, because it can cause
|
||||
// various issues when multiple tabs are open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -277,11 +214,46 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
}
|
||||
}, [anime, theme, smallCover, playNextTrack, playPreviousTrack]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
// Calculate frame rate
|
||||
// Source - https://stackoverflow.com/questions/72997777/how-do-i-get-the-frame-rate-of-an-html-video-with-javascript
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
const videoElement = document.querySelector("video");
|
||||
if (!videoElement) return;
|
||||
|
||||
let lastMediaTime = 0,
|
||||
lastFrameNum = 0,
|
||||
frameNotSeeked = true;
|
||||
const fpsRounder: number[] = [];
|
||||
|
||||
const ticker: VideoFrameRequestCallback = (_, metadata) => {
|
||||
const diff =
|
||||
Math.abs(metadata.mediaTime - lastMediaTime) / Math.abs(metadata.presentedFrames - lastFrameNum);
|
||||
if (
|
||||
diff &&
|
||||
diff < 1 &&
|
||||
frameNotSeeked &&
|
||||
fpsRounder.length < 50 &&
|
||||
videoElement.playbackRate === 1 &&
|
||||
document.hasFocus()
|
||||
) {
|
||||
fpsRounder.push(diff);
|
||||
fpsRef.current = Math.round(fpsRounder.length / fpsRounder.reduce((a, b) => a + b));
|
||||
}
|
||||
frameNotSeeked = true;
|
||||
lastMediaTime = metadata.mediaTime;
|
||||
lastFrameNum = metadata.presentedFrames;
|
||||
videoElement.requestVideoFrameCallback(ticker);
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
fpsRounder.pop();
|
||||
frameNotSeeked = false;
|
||||
};
|
||||
|
||||
videoElement.requestVideoFrameCallback(ticker);
|
||||
videoElement.addEventListener("seeked", handleSeeked);
|
||||
return () => videoElement.removeEventListener("seeked", handleSeeked);
|
||||
}, []);
|
||||
|
||||
function onPlayerMount(player: HTMLVideoElement) {
|
||||
playerRef.current = player;
|
||||
@@ -304,27 +276,175 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const togglePlay = useCallback(() => {
|
||||
if (isPlaying) {
|
||||
playerRef.current?.pause();
|
||||
} else {
|
||||
playerRef.current?.play();
|
||||
}
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
|
||||
if (progressRef.current) {
|
||||
// Update the progress bar using a ref to prevent re-rendering.
|
||||
const progress = (event.currentTarget.currentTime / event.currentTarget.duration) * 100;
|
||||
progressRef.current.style.width = `${progress}%`;
|
||||
function togglePip() {
|
||||
const videoElement = document.querySelector("video");
|
||||
if (!videoElement) return;
|
||||
if (document.pictureInPictureElement) {
|
||||
void document.exitPictureInPicture();
|
||||
} else {
|
||||
void videoElement.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAudioMode(audioMode: string) {
|
||||
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
|
||||
setAudioMode(audioMode);
|
||||
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
|
||||
const duration = event.currentTarget.duration;
|
||||
|
||||
if (progressRef.current) {
|
||||
// Update the progress bar using a ref to prevent re-rendering.
|
||||
const progress = (event.currentTarget.currentTime / duration) * 100;
|
||||
progressRef.current.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
if (bufferedRef.current) {
|
||||
const buffered = event.currentTarget.buffered;
|
||||
if (buffered.length > 0) {
|
||||
const bufferedEnd = buffered.end(buffered.length - 1);
|
||||
bufferedRef.current.style.width = `${(bufferedEnd / duration) * 100}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateAudioMode = useCallback(
|
||||
(audioMode: string) => {
|
||||
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
|
||||
setAudioMode(audioMode);
|
||||
},
|
||||
[setAudioMode],
|
||||
);
|
||||
|
||||
// Handle keyboard inputs
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key.toLocaleLowerCase()) {
|
||||
case " ": // Play/Pause
|
||||
case "k":
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "arrowright": // Seek forward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 5;
|
||||
}
|
||||
break;
|
||||
case "l": // Seek forward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 10;
|
||||
}
|
||||
break;
|
||||
case "arrowleft": // Seek backward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 5;
|
||||
}
|
||||
break;
|
||||
case "j": // Seek backward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 10;
|
||||
}
|
||||
break;
|
||||
case "n": // Next track
|
||||
event.preventDefault();
|
||||
playNextTrack(true);
|
||||
break;
|
||||
case "b": // Previous track
|
||||
event.preventDefault();
|
||||
playPreviousTrack(true);
|
||||
break;
|
||||
case "m": // Mute
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setMuted(!muted);
|
||||
}
|
||||
break;
|
||||
case "arrowup": // Volume up
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setGlobalVolume(Math.min(globalVolume + 0.1, 1));
|
||||
setMuted(false);
|
||||
}
|
||||
break;
|
||||
case "arrowdown": // Volume down
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setGlobalVolume(Math.max(globalVolume - 0.1, 0));
|
||||
setMuted(false);
|
||||
}
|
||||
break;
|
||||
case "d": // Download
|
||||
event.preventDefault();
|
||||
if (audioMode === AudioMode.ENABLED) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${audioUrl}?download`;
|
||||
link.click();
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${videoUrl}?download`;
|
||||
link.click();
|
||||
}
|
||||
break;
|
||||
case "f": // Fullscreen
|
||||
event.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "a": // Toggle audio mode
|
||||
event.preventDefault();
|
||||
updateAudioMode(audioMode === AudioMode.ENABLED ? AudioMode.DISABLED : AudioMode.ENABLED);
|
||||
break;
|
||||
case "p": // Toggle Picture-in-Picture
|
||||
event.preventDefault();
|
||||
togglePip();
|
||||
break;
|
||||
case ",": // Frame back
|
||||
event.preventDefault();
|
||||
if (playerRef.current && playerRef.current.paused) {
|
||||
playerRef.current.currentTime -= 1 / fpsRef.current;
|
||||
}
|
||||
break;
|
||||
case ".": // Frame forward
|
||||
event.preventDefault();
|
||||
if (playerRef.current && playerRef.current.paused) {
|
||||
playerRef.current.currentTime += 1 / fpsRef.current;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
togglePlay,
|
||||
playNextTrack,
|
||||
playPreviousTrack,
|
||||
audioMode,
|
||||
toggleFullscreen,
|
||||
updateAudioMode,
|
||||
setMuted,
|
||||
muted,
|
||||
setGlobalVolume,
|
||||
globalVolume,
|
||||
audioUrl,
|
||||
videoUrl,
|
||||
],
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
|
||||
function getRelativeWatchListItem(offset: 1 | -1) {
|
||||
if (!currentWatchListItem) {
|
||||
return null;
|
||||
@@ -364,6 +484,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
videoPagePath,
|
||||
playerRef,
|
||||
progressRef,
|
||||
bufferedRef,
|
||||
previousVideoPath,
|
||||
playPreviousTrack,
|
||||
nextVideoPath,
|
||||
@@ -373,6 +494,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
videoUrl,
|
||||
audioUrl,
|
||||
updateAudioMode,
|
||||
togglePip,
|
||||
}}
|
||||
>
|
||||
<StyledPlayer
|
||||
@@ -401,6 +523,15 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<StyledAudioBackground style={{ aspectRatio }}>
|
||||
<StyledAudioCoverBackground
|
||||
src={largeCover}
|
||||
onPointerDown={onPlayerClick}
|
||||
onLoad={(event) => {
|
||||
setAspectRatio(
|
||||
event.currentTarget.naturalWidth / event.currentTarget.naturalHeight,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<StyledAudioCover
|
||||
src={largeCover}
|
||||
onPointerDown={onPlayerClick}
|
||||
@@ -414,6 +545,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
ref={onPlayerMount}
|
||||
src={audioUrl}
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
@@ -433,6 +565,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
ref={onPlayerMount}
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
|
||||
@@ -2,10 +2,21 @@ import { Fragment, useContext } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faCheck, faCompress, faExpand, faGear, faPlus, faShare } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faCompress,
|
||||
faExpand,
|
||||
faGear,
|
||||
faKeyboard,
|
||||
faPlus,
|
||||
faShare,
|
||||
faUpRightFromSquare,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Dialog } from "@radix-ui/react-dialog";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
@@ -19,6 +30,7 @@ import FullscreenContext from "@/context/fullscreenContext";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import type { VideoPageProps } from "@/pages/anime/[animeSlug]/[videoSlug]";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import { AudioMode } from "@/utils/settings";
|
||||
|
||||
@@ -43,6 +55,38 @@ const StyledOverlayButton = styled(IconTextButton)`
|
||||
backdrop-filter: blur(5px);
|
||||
`;
|
||||
|
||||
const StyledKeyList = styled.dl`
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
`;
|
||||
const StyledKey = styled.dt`
|
||||
text-align: right;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
`;
|
||||
const StyledKeyDescription = styled.dd`
|
||||
margin: 0;
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
`;
|
||||
const StyledKeyHint = styled.kbd`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid ${theme.colors["text-disabled"]};
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 1.25rem;
|
||||
background-color: ${theme.colors["background"]};
|
||||
`;
|
||||
const StyledKeyHintSpace = styled(StyledKeyHint)`
|
||||
width: 96px;
|
||||
`;
|
||||
|
||||
export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }: VideoPageProps) {
|
||||
const theme = anime.themes[themeIndex];
|
||||
const entry = theme.entries[entryIndex];
|
||||
@@ -50,6 +94,8 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
|
||||
const { watchList, setWatchList, currentWatchListItem } = useContext(PlayerContext);
|
||||
const { isFullscreen, toggleFullscreen } = useContext(FullscreenContext);
|
||||
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
const isPIPSupported = typeof document !== "undefined" && document.pictureInPictureEnabled;
|
||||
const context = useContext(VideoPlayerContext);
|
||||
const [audioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
|
||||
@@ -82,6 +128,80 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
entry={{ ...entry, theme }}
|
||||
trigger={<StyledOverlayButton icon={faPlus} isCircle title="Add to playlist" />}
|
||||
/>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<StyledOverlayButton icon={faKeyboard} isCircle title="Keyboard shortcuts" />
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Keyboard Shortcuts">
|
||||
<StyledKeyList>
|
||||
<StyledKey>
|
||||
<StyledKeyHintSpace title="Space">_</StyledKeyHintSpace> or{" "}
|
||||
<StyledKeyHint>K</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Play / Pause</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>→</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 5 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>←</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 5 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>L</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 10 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>J</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 10 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>.</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 1 frame</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>,</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 1 frame</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>N</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Next track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>B</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Previous track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>M</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Mute / Unmute</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>↑</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Volume up</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>↓</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Volume down</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>D</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Download track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>F</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle fullscreen</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>A</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle audio mode</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>P</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle picture-in-picture</StyledKeyDescription>
|
||||
</StyledKeyList>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ShareMenu
|
||||
pagePath={context.videoPagePath}
|
||||
videoUrl={context.videoUrl}
|
||||
@@ -162,12 +282,22 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
<StyledOverlayButton
|
||||
icon={isFullscreen ? faCompress : faExpand}
|
||||
isCircle
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle fullscreen"
|
||||
/>
|
||||
{isPIPSupported && audioMode === AudioMode.DISABLED && (
|
||||
<StyledOverlayButton
|
||||
icon={faUpRightFromSquare}
|
||||
isCircle
|
||||
onClick={context.togglePip}
|
||||
title="Toggle picture-in-picture"
|
||||
/>
|
||||
)}
|
||||
{!(isIOS && audioMode === AudioMode.ENABLED) && ( // Hide fullscreen button on iOS when audio mode is enabled
|
||||
<StyledOverlayButton
|
||||
icon={isFullscreen ? faCompress : faExpand}
|
||||
isCircle
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle fullscreen"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</StyledOverlay>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faVolumeHigh, faVolumeLow, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faVolumeHigh, faVolumeLow, faVolumeOff, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { Slider } from "@/components/slider/Slider";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import { GlobalVolume } from "@/utils/settings";
|
||||
import { GlobalVolume, Muted } from "@/utils/settings";
|
||||
|
||||
const StyledRow = styled(Row)`
|
||||
align-self: stretch;
|
||||
@@ -28,22 +28,25 @@ const StyledSlider = styled(Slider)`
|
||||
|
||||
export function VolumeControl(props: ComponentPropsWithoutRef<typeof StyledRow>) {
|
||||
const [volume, setVolume] = useSetting(GlobalVolume);
|
||||
const [muted, setMuted] = useSetting(Muted);
|
||||
|
||||
let icon;
|
||||
if (volume > 0.5) {
|
||||
if (muted) {
|
||||
icon = faVolumeXmark;
|
||||
} else if (volume > 0.5) {
|
||||
icon = faVolumeHigh;
|
||||
} else if (volume > 0) {
|
||||
icon = faVolumeLow;
|
||||
} else {
|
||||
icon = faVolumeXmark;
|
||||
icon = faVolumeOff;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRow style={{ "--gap": "8px" }} {...props}>
|
||||
<IconTextButton icon={icon} isCircle onClick={() => setVolume(volume === 0 ? 1 : 0)} />
|
||||
<IconTextButton icon={icon} isCircle onClick={() => setMuted(!muted)} />
|
||||
<StyledSlider
|
||||
value={[volume]}
|
||||
onValueChange={([volume]) => setVolume(volume)}
|
||||
onValueChange={([volume]) => { setVolume(volume); setMuted(false); }}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
|
||||
+16
-2
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import styled, { ThemeProvider } from "styled-components";
|
||||
import type { AppProps } from "next/app";
|
||||
@@ -103,7 +103,9 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
const currentVideoSlug = getVideoSlugByProps(pageProps);
|
||||
const [previousVideoSlug, setPreviousVideoSlug] = useState<string | null>(() => getVideoSlugByProps(pageProps));
|
||||
|
||||
const [isFullscreen, { toggleFullscreen }] = useFullscreen(() => document.documentElement, {
|
||||
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
|
||||
const [isFullscreen, { toggleFullscreen : nativeToggleFullscreen }] = useFullscreen(() => document.documentElement, {
|
||||
onEnter() {
|
||||
document.documentElement.dataset.fullscreen = "true";
|
||||
},
|
||||
@@ -112,6 +114,18 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isIOS) {
|
||||
// iOS does not support the Fullscreen API, so it's handled differently
|
||||
const videoElement = document.querySelector("video");
|
||||
if (videoElement && "webkitEnterFullscreen" in videoElement) {
|
||||
(videoElement as HTMLVideoElement & { webkitEnterFullscreen: () => void }).webkitEnterFullscreen();
|
||||
}
|
||||
} else {
|
||||
nativeToggleFullscreen();
|
||||
}
|
||||
}, [isIOS, nativeToggleFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideoPage && isFullscreen) {
|
||||
toggleFullscreen();
|
||||
|
||||
@@ -24,6 +24,11 @@ export const GlobalVolume: Setting<number> = Object.freeze({
|
||||
__INITIAL_VALUE__: 1,
|
||||
});
|
||||
|
||||
export const Muted: Setting<boolean> = Object.freeze({
|
||||
__KEY__: "muted",
|
||||
__INITIAL_VALUE__: false,
|
||||
});
|
||||
|
||||
export const ColorTheme: Setting<string> = Object.freeze({
|
||||
__KEY__: "theme",
|
||||
__INITIAL_VALUE__: "system",
|
||||
|
||||
Reference in New Issue
Block a user