watch: added fullscreen support, added loading bar
continuous-integration/drone/push Build is failing Details

nextui
Guus van Meerveld 1 month ago
parent c29052ca61
commit 8632980212

@ -25,6 +25,7 @@
"react-player": "^2.15.1", "react-player": "^2.15.1",
"reactjs-visibility": "^0.1.4", "reactjs-visibility": "^0.1.4",
"sanitize-html": "^2.13.0", "sanitize-html": "^2.13.0",
"screenfull": "^6.0.2",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"zod": "^3.22.4", "zod": "^3.22.4",
"zustand": "^4.5.2" "zustand": "^4.5.2"

@ -1,5 +1,6 @@
"use client"; "use client";
import screenfull from "screenfull";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -8,7 +9,6 @@ import {
FiMinimize as MinimizeIcon, FiMinimize as MinimizeIcon,
FiVolumeX as MutedIcon, FiVolumeX as MutedIcon,
FiPause as PauseIcon, FiPause as PauseIcon,
FiFastForward as PlaybackRateIcon,
FiPlay as PlayIcon, FiPlay as PlayIcon,
FiVolume as VolumeIcon, FiVolume as VolumeIcon,
FiVolume1 as VolumeIcon1, FiVolume1 as VolumeIcon1,
@ -33,9 +33,12 @@ import { Component } from "@/typings/component";
export const Player: Component<{ streams: Stream[] }> = ({ streams }) => { export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
const stream = streams.find((stream) => stream.type === StreamType.Hls); const stream = streams.find((stream) => stream.type === StreamType.Hls);
const player = useRef<ReactPlayer>(null); const playerRef = useRef<ReactPlayer>(null);
const videoPlayerId = "video-player";
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [loaded, setLoaded] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [maximized, setMaximized] = useState(false); const [maximized, setMaximized] = useState(false);
const [volume, setVolume] = useState(40); const [volume, setVolume] = useState(40);
@ -44,11 +47,9 @@ export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
const [userSetProgress, setUserSetProgress] = useState(0); const [userSetProgress, setUserSetProgress] = useState(0);
const [seek] = useDebounce(userSetProgress, 100);
useEffect(() => { useEffect(() => {
player.current?.seekTo(seek); playerRef.current?.seekTo(userSetProgress);
}, [seek]); }, [userSetProgress]);
const volumeIcons = useMemo( const volumeIcons = useMemo(
() => [ () => [
@ -60,43 +61,77 @@ export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
); );
const playbackRateCategories = useMemo( const playbackRateCategories = useMemo(
() => [ () =>
{ key: 0.25, label: "0.25" }, [0.25, 0.5, 1, 1.25, 1.5, 2].map((speed) => ({
{ key: 0.5, label: "0.5" }, key: speed,
{ key: 1, label: "1.0" }, label: speed.toString()
{ key: 1.25, label: "1.25" }, })),
{ key: 1.5, label: "1.5" },
{ key: 2, label: "2.0" }
],
[] []
); );
const handleBuffering = useCallback(() => {}, []); const updateMaximized = useCallback(() => {
setMaximized(screenfull.isFullscreen);
}, [setMaximized]);
useEffect(() => {
if (screenfull.isEnabled) {
screenfull.on("change", updateMaximized);
}
return () => screenfull.off("change", updateMaximized);
}, [updateMaximized]);
useEffect(() => {
if (screenfull.isEnabled) {
const playerElement = document.getElementById(videoPlayerId) ?? undefined;
if (maximized) screenfull.request(playerElement);
else screenfull.exit();
}
}, [maximized]);
return ( return (
<> <>
{stream && ( <div className="relative" style={{ paddingTop: `${100 / (16 / 9)}%` }}>
<div className="relative" style={{ paddingTop: `${100 / (16 / 9)}%` }}> <div id={videoPlayerId}>
<div <div className="flex flex-col w-full h-full absolute bottom-0 z-10 transition-opacity ease-in duration-[2000ms] opacity-0 hover:opacity-100">
className="w-full absolute bottom-0 z-10 pb-2 px-4" <div
style={{ className="flex-1"
background: onClick={() => setPlaying((state) => !state)}
"linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%)" ></div>
}} <div
> className="flex flex-col gap-1 pb-2 px-4"
<div className="flex flex-col gap-1"> style={{
<Slider background:
aria-label="Video progress bar" "linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%)"
step={0.1} }}
onChange={(value) => { >
if (typeof value === "number") { <div
setUserSetProgress(value / 100); className="relative flex items-center"
setProgress(value / 100); style={{ height: "24px" }}
} >
}} <Slider
className="cursor-pointer" aria-label="Video progress bar"
value={progress * 100} className="w-full cursor-pointer absolute bottom-0 z-20"
/> step={0.1}
onChange={(value) => {
if (typeof value === "number") {
setProgress(value / 100);
}
}}
onChangeEnd={(value) => {
if (typeof value === "number") {
setUserSetProgress(value / 100);
}
}}
value={progress * 100}
/>
<div
className="h-3 bg-default-600/50 z-10 rounded-lg"
style={{ width: `${loaded * 100}%` }}
/>
</div>
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
<div className="flex flex-1 flex-row gap-2 items-center"> <div className="flex flex-1 flex-row gap-2 items-center">
<Button <Button
@ -110,17 +145,14 @@ export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger>
<Button <Button variant="light" isIconOnly className="text-xl">
variant="light" {volume === 0 ? (
isIconOnly <MutedIcon />
className="text-xl" ) : (
onClick={() => setMaximized((state) => !state)}
>
{
volumeIcons[ volumeIcons[
Math.floor((volume / 100) * volumeIcons.length) Math.floor((volume / 100) * volumeIcons.length)
] ]
} )}
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label="Volume menu"> <DropdownMenu aria-label="Volume menu">
@ -181,29 +213,32 @@ export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
</div> </div>
</div> </div>
<ReactPlayer {stream && (
playing={playing} <ReactPlayer
volume={volume / 100} playing={playing}
playbackRate={playbackRate} volume={volume / 100}
ref={player} muted={volume === 0}
className="absolute top-0 left-0" playbackRate={playbackRate}
width="100%" ref={playerRef}
height="100%" className="absolute top-0 left-0"
config={{ file: { forceHLS: true } }} width="100%"
onPause={() => setPlaying(false)} height="100%"
onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)}
onDuration={(duration) => setDuration(duration)} onPlay={() => setPlaying(true)}
onBuffer={handleBuffering} onDuration={(duration) => setDuration(duration)}
onProgress={({ played }) => { // onBuffer={({}) => {}}
setProgress(played); onProgress={({ played, loaded }) => {
}} setProgress(played);
// onPlaybackQualityChange={(e: unknown) => setLoaded(loaded);
// console.log("onPlaybackQualityChange", e) }}
// } // onPlaybackQualityChange={(e: unknown) =>
url={(stream as HlsStream).url} // console.log("onPlaybackQualityChange", e)
/> // }
url={(stream as HlsStream).url}
/>
)}
</div> </div>
)} </div>
</> </>
); );
}; };

@ -10,33 +10,36 @@ import formatBigNumber from "@/utils/formatBigNumber";
import { channelUrl } from "@/utils/urls"; import { channelUrl } from "@/utils/urls";
export const Author: FC<{ data: AuthorProps }> = ({ data }) => { export const Author: FC<{ data: AuthorProps }> = ({ data }) => {
const url = data.id ? channelUrl(data.id) : "#";
return ( return (
<Link <div className="flex flex-row gap-4 items-center">
as={NextLink}
href={data.id ? channelUrl(data.id) : undefined}
className="flex flex-row gap-4 items-center"
>
{data.avatar && ( {data.avatar && (
<Avatar <Link as={NextLink} href={url}>
isBordered <Avatar
name={data.name} isBordered
showFallback name={data.name}
size="lg" showFallback
src={data.avatar} size="lg"
alt={data.name} src={data.avatar}
/> alt={data.name}
/>
</Link>
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row gap-1 items-center"> <Link as={NextLink} href={url}>
<p className="text-lg text-default-600">{data.name}</p> <div className="flex flex-row gap-1 items-center">
<VerifiedIcon className="text-success" /> <p className="text-lg text-default-600">{data.name}</p>
</div> <VerifiedIcon className="text-success" />
</div>
</Link>
{data.subscribers && ( {data.subscribers && (
<p className="text-default-400 tracking-tight"> <p className="text-default-400 tracking-tight">
{formatBigNumber(data.subscribers)} subscribers {formatBigNumber(data.subscribers)} subscribers
</p> </p>
)} )}
</div> </div>
</Link> </div>
); );
}; };

@ -6201,6 +6201,11 @@ schema-utils@^3.1.1:
ajv "^6.12.5" ajv "^6.12.5"
ajv-keywords "^3.5.2" ajv-keywords "^3.5.2"
screenfull@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-6.0.2.tgz#3dbe4b8c4f8f49fb8e33caa8f69d0bca730ab238"
integrity sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw==
scroll-into-view-if-needed@3.0.10: scroll-into-view-if-needed@3.0.10:
version "3.0.10" version "3.0.10"
resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.0.10.tgz#38fbfe770d490baff0fb2ba34ae3539f6ec44e13" resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.0.10.tgz#38fbfe770d490baff0fb2ba34ae3539f6ec44e13"

Loading…
Cancel
Save