diff --git a/src/components/video-player/ProgressBar.tsx b/src/components/video-player/ProgressBar.tsx index e7cb47d..1d6b523 100644 --- a/src/components/video-player/ProgressBar.tsx +++ b/src/components/video-player/ProgressBar.tsx @@ -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(null); @@ -146,6 +160,7 @@ export function ProgressBar() { }} > + diff --git a/src/components/video-player/VideoPlayer.style.ts b/src/components/video-player/VideoPlayer.style.ts index 6dde26d..63229f6 100644 --- a/src/components/video-player/VideoPlayer.style.ts +++ b/src/components/video-player/VideoPlayer.style.ts @@ -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; diff --git a/src/components/video-player/VideoPlayer.tsx b/src/components/video-player/VideoPlayer.tsx index 49c20d9..19294df 100644 --- a/src/components/video-player/VideoPlayer.tsx +++ b/src/components/video-player/VideoPlayer.tsx @@ -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; progressRef: RefObject; + bufferedRef: RefObject; 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(null); @@ -68,7 +72,9 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p const containerRef = useRef(null); const playerRef = useRef(null); const progressRef = useRef(null); + const bufferedRef = useRef(null); const currentTimeBeforeModeSwitch = useRef(null); + const fpsRef = useRef(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) { - 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) { + 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, }} > {audioMode === AudioMode.ENABLED ? ( + { + setAspectRatio( + event.currentTarget.naturalWidth / event.currentTarget.naturalHeight, + ); + }} + /> 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={() => { diff --git a/src/components/video-player/VideoPlayerOverlay.tsx b/src/components/video-player/VideoPlayerOverlay.tsx index 125e70c..03f5969 100644 --- a/src/components/video-player/VideoPlayerOverlay.tsx +++ b/src/components/video-player/VideoPlayerOverlay.tsx @@ -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={} /> + + + + + + + + _ or{" "} + K + + Play / Pause + + + + Seek forward 5 seconds + + + + Seek backward 5 seconds + + L + + Seek forward 10 seconds + + J + + Seek backward 10 seconds + + . + + Seek forward 1 frame + + , + + Seek backward 1 frame + + N + + Next track + + B + + Previous track + + M + + Mute / Unmute + + + + Volume up + + + + Volume down + + D + + Download track + + F + + Toggle fullscreen + + A + + Toggle audio mode + + P + + Toggle picture-in-picture + + + - + {isPIPSupported && audioMode === AudioMode.DISABLED && ( + + )} + {!(isIOS && audioMode === AudioMode.ENABLED) && ( // Hide fullscreen button on iOS when audio mode is enabled + + )} ); diff --git a/src/components/video-player/VolumeControl.tsx b/src/components/video-player/VolumeControl.tsx index 2d08cee..06a7f0d 100644 --- a/src/components/video-player/VolumeControl.tsx +++ b/src/components/video-player/VolumeControl.tsx @@ -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) { 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 ( - setVolume(volume === 0 ? 1 : 0)} /> + setMuted(!muted)} /> setVolume(volume)} + onValueChange={([volume]) => { setVolume(volume); setMuted(false); }} min={0} max={1} step={0.01} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 1d3e782..ccaee25 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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(() => 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(); diff --git a/src/utils/settings.ts b/src/utils/settings.ts index daa5328..96c9f71 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -24,6 +24,11 @@ export const GlobalVolume: Setting = Object.freeze({ __INITIAL_VALUE__: 1, }); +export const Muted: Setting = Object.freeze({ + __KEY__: "muted", + __INITIAL_VALUE__: false, +}); + export const ColorTheme: Setting = Object.freeze({ __KEY__: "theme", __INITIAL_VALUE__: "system",