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:
Max Reinartz
2026-01-04 20:02:24 -06:00
committed by GitHub
parent 3ab709de25
commit 74a9affcfb
7 changed files with 432 additions and 116 deletions
+18 -3
View File
@@ -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,14 +72,27 @@ 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);
if (!context) {
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;
+225 -92
View File
@@ -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
View File
@@ -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();
+5
View File
@@ -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",