feat: Add Vidstack replacing Plyr

This commit is contained in:
mbaharip
2025-01-21 21:55:00 +07:00
parent ec795f49a1
commit 287cb9b903
4 changed files with 763 additions and 195 deletions
-98
View File
@@ -1,98 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import "react-h5-audio-player/lib/styles.css";
import { z } from "zod";
import { Loader, Status } from "~/components/global";
import useLoading from "~/hooks/useLoading";
import { Schema_File } from "~/types/schema";
import { CreateDownloadToken } from "actions";
const Plyr = dynamic(() => import("plyr-react"), {
ssr: false,
loading: () => <Loader message='Loading player...' />,
});
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewAudio({ file }: Props) {
const [audioSrc, setAudioSrc] = useState<string>("");
const [error, setError] = useState<string>("");
const loading = useLoading(async () => {
try {
if (!file.encryptedWebContentLink) {
setError("No audio to preview");
return;
}
const token = await CreateDownloadToken();
setAudioSrc(`/api/stream/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
}
}, [file]);
return (
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<Loader message='Loading audio...' />
) : error ? (
<Status
icon='TriangleAlert'
message={error}
destructive
/>
) : (
<div className='w-full'>
<Plyr
source={{
type: "audio",
sources: [
{
src: audioSrc,
type: file.mimeType,
size: file.size,
},
],
title: file.name,
}}
options={{
controls: [
"play-large",
"play",
"progress",
"current-time",
"duration",
"mute",
"volume",
"settings",
"fullscreen",
],
volume: 0.5,
muted: false,
loop: {
active: false,
},
speed: {
selected: 1,
options: [0.5, 1, 1.5, 2],
},
keyboard: {
focused: true,
global: false,
},
}}
/>
</div>
)}
</div>
);
}
+754
View File
@@ -0,0 +1,754 @@
"use client";
import {
type AudioMimeType,
MediaPlayer,
type MediaPlayerInstance,
type MediaPlayerQuery,
MediaProvider,
Menu,
PlayButton,
SeekButton,
Time,
TimeSlider,
type VideoMimeType,
useAudioGainOptions,
usePlaybackRateOptions,
} from "@vidstack/react";
import { DefaultAudioLayout, DefaultVideoLayout, defaultLayoutIcons } from "@vidstack/react/player/layouts/default";
import "@vidstack/react/player/styles/base.css";
import "@vidstack/react/player/styles/default/layouts/audio.css";
import "@vidstack/react/player/styles/default/layouts/video.css";
import "@vidstack/react/player/styles/default/theme.css";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { type z } from "zod";
import Icon from "~/components/ui/icon";
import useLoading from "~/hooks/useLoading";
import { type Schema_File } from "~/types/schema";
import "~/styles/vidstack.css";
import { PageLoader } from "../layout";
export const MediaPlayerIcons = {
...defaultLayoutIcons,
AirPlayButton: {
Default: () => (
<Icon
hideWrapper
name='Airplay'
className='vds-icon size-5'
/>
),
Connecting: () => (
<Icon
hideWrapper
name='LoaderCircle'
className='vds-icon size-5 animate-spin'
/>
),
Connected: () => (
<Icon
hideWrapper
name='Airplay'
className='vds-icon size-5'
/>
),
},
GoogleCastButton: {
Default: () => (
<Icon
hideWrapper
name='Cast'
className='vds-icon size-5'
/>
),
Connecting: () => (
<Icon
hideWrapper
name='LoaderCircle'
className='vds-icon size-5 animate-spin'
/>
),
Connected: () => (
<Icon
hideWrapper
name='Cast'
className='vds-icon size-5'
/>
),
},
PlayButton: {
Play: () => (
<Icon
hideWrapper
name='Play'
className='vds-icon size-5 fill-current media-ended:hidden media-playing:hidden'
/>
),
Pause: () => (
<Icon
hideWrapper
name='Pause'
className='vds-icon size-5 fill-current media-paused:hidden'
/>
),
Replay: () => (
<Icon
hideWrapper
name='RotateCcw'
className='vds-icon hidden size-5 media-ended:block'
/>
),
},
MuteButton: {
Mute: () => (
<Icon
hideWrapper
name='VolumeX'
className='vds-icon mute-icon size-5'
/>
),
VolumeLow: () => (
<Icon
hideWrapper
name='Volume1'
className='vds-icon volume-low-icon size-5'
/>
),
VolumeHigh: () => (
<Icon
hideWrapper
name='Volume2'
className='vds-icon volume-high-icon size-5'
/>
),
},
CaptionButton: {
On: () => (
<Icon
hideWrapper
name='Captions'
className='vds-icon size-5'
/>
),
Off: () => (
<Icon
hideWrapper
name='CaptionsOff'
className='vds-icon size-5'
/>
),
},
PIPButton: {
Enter: () => (
<Icon
hideWrapper
name='PictureInPicture'
className='vds-icon size-5'
/>
),
Exit: () => (
<Icon
hideWrapper
name='X'
className='vds-icon size-5'
/>
),
},
FullscreenButton: {
Enter: () => (
<Icon
hideWrapper
name='Maximize2'
className='vds-icon size-5'
/>
),
Exit: () => (
<Icon
hideWrapper
name='Minimize2'
className='vds-icon size-5'
/>
),
},
SeekButton: {
Backward: () => (
<Icon
hideWrapper
name='ChevronsLeft'
className='vds-icon size-5'
/>
),
Forward: () => (
<Icon
hideWrapper
name='ChevronsRight'
className='vds-icon size-5'
/>
),
},
DownloadButton: {
Default: () => (
<Icon
hideWrapper
name='Download'
className='vds-icon size-5'
/>
),
},
Menu: {
Accessibility: () => (
<Icon
hideWrapper
name='PersonStanding'
className='vds-icon mr-2 size-5'
/>
),
ArrowLeft: () => null,
ArrowRight: () => (
<Icon
hideWrapper
name='ChevronRight'
className='vds-icon size-5'
/>
),
Audio: () => (
<Icon
hideWrapper
name='Volume2'
className='vds-icon mr-2 size-5'
/>
),
AudioBoostUp: () => (
<Icon
hideWrapper
name='Volume2'
className='vds-icon size-5'
/>
),
AudioBoostDown: () => (
<Icon
hideWrapper
name='Volume'
className='vds-icon size-5'
/>
),
Chapters: () => (
<Icon
hideWrapper
name='TableOfContents'
className='vds-icon size-5'
/>
),
Captions: () => (
<Icon
hideWrapper
name='Captions'
className='vds-icon size-5'
/>
),
Playback: () => (
<Icon
hideWrapper
name='ListVideo'
className='size5 mr-2 '
/>
),
Settings: () => (
<Icon
hideWrapper
name='Settings'
className='vds-icon vds-rotate-icon size-5'
/>
),
SpeedUp: () => (
<Icon
hideWrapper
name='ChevronsUp'
className='vds-icon size-5'
/>
),
SpeedDown: () => (
<Icon
hideWrapper
name='ChevronsDown'
className='vds-icon size-5'
/>
),
QualityUp: () => null,
QualityDown: () => null,
FontSizeUp: () => null,
FontSizeDown: () => null,
OpacityUp: () => null,
OpacityDown: () => null,
RadioCheck: () => null,
},
KeyboardDisplay: {
Play: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Play'
className='size-6'
/>
</div>
),
Pause: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Pause'
className='size-6'
/>
</div>
),
Mute: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='VolumeX'
className='size-6'
/>
</div>
),
VolumeUp: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Volume2'
className='size-6'
/>
</div>
),
VolumeDown: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Volume1'
className='size-6'
/>
</div>
),
EnterFullscreen: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Maximize2'
className='size-6'
/>
</div>
),
ExitFullscreen: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Minimize2'
className='size-6'
/>
</div>
),
// EnterPiP: () => null,
// ExitPiP: () => null,
CaptionsOn: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Captions'
className='size-6'
/>
</div>
),
CaptionsOff: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='CaptionsOff'
className='size-6'
/>
</div>
),
SeekForward: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='ChevronsRight'
className='size-6'
/>
</div>
),
SeekBackward: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='ChevronsLeft'
className='size-6'
/>
</div>
),
},
};
type Props = {
file: z.infer<typeof Schema_File>;
type: "video" | "audio";
};
export default function PreviewMedia({ file, type }: Props) {
const loading = useLoading();
const player = useRef<MediaPlayerInstance>(null);
const [canPlay, setCanPlay] = useState<boolean>(false);
const [isLoop, setIsLoop] = useState<boolean>(false);
const smallAudioLayoutQuery = useCallback<MediaPlayerQuery>(({ width }) => {
return width < 576;
}, []);
const smallVideoLayoutQuery = useCallback<MediaPlayerQuery>(({ width, height }) => {
return width < 576 || height < 380;
}, []);
return (
<div className='flex h-full w-full items-center justify-center py-3'>
{loading ? (
<PageLoader message='Loading media...' />
) : (
<MediaPlayer
ref={player}
key={file.encryptedId}
src={{
src: `/api/preview/${file.encryptedId}`,
type: file.mimeType as AudioMimeType | VideoMimeType,
}}
loop={isLoop}
autoPlay
playsInline
crossOrigin
viewType={type === "audio" ? "audio" : "video"}
className='media-player h-full w-full rounded-xl'
preload='auto'
onLoadedMetadata={() => {
setCanPlay(true);
}}
>
<MediaProvider />
<DefaultAudioLayout
icons={MediaPlayerIcons}
colorScheme='default'
smallLayoutWhen={smallAudioLayoutQuery}
showTooltipDelay={150}
className='h-fit !px-2 !py-4 shadow-md [&.vds-button>svg]:stroke-[--media-controls-color] [&.vds-play-button>svg]:fill-[--audio-play-button-color] [&.vds-play-button>svg]:stroke-[--audio-play-button-color] [&>.vds-captions]:hidden [&>.vds-controls>.vds-controls-group]:inline-block [&>.vds-controls>.vds-controls-group]:md:inline-flex [&>.vds-controls>.vds-controls-group]:md:flex-row [&>.vds-controls>.vds-controls-group]:md:gap-1'
slots={{
seekBackwardButton: null,
seekForwardButton: null,
playButton: (
<div className='flex w-full items-center justify-center gap-2 md:w-fit'>
<SeekButton
className='vds-button'
seconds={-10}
>
<MediaPlayerIcons.SeekButton.Backward />
</SeekButton>
<PlayButton
className='vds-button vds-play-button'
disabled={canPlay === false}
>
{canPlay ? (
<>
<MediaPlayerIcons.PlayButton.Play />
<MediaPlayerIcons.PlayButton.Pause />
<MediaPlayerIcons.PlayButton.Replay />
</>
) : (
<Icon
hideWrapper
name='LoaderCircle'
className='vds-icon size-5 animate-spin'
/>
)}
</PlayButton>
<SeekButton
className='vds-button'
seconds={10}
>
<MediaPlayerIcons.SeekButton.Forward />
</SeekButton>
</div>
),
timeSlider: (
<div className='grid w-auto grow place-items-center md:w-full'>
<TimeSlider.Root className='vds-time-slider vds-slider !opacity-100'>
<TimeSlider.Track className='vds-slider-track'>
<TimeSlider.Progress className='vds-slider-progress' />
<TimeSlider.TrackFill className='vds-slider-track-fill vds-slider-track' />
</TimeSlider.Track>
</TimeSlider.Root>
</div>
),
beforeEndTime: <div className='vds-controls-spacer' />,
endTime: (
<div className='inline-flex w-full items-center justify-between gap-2 md:w-fit'>
<div className='inline-flex items-center text-sm'>
<Time
className='time'
type='current'
/>
<span className='mx-1'>/</span>
<Time
className='time'
type='duration'
/>
</div>
<div className='flex items-center'>
<Menu.Root className='vds-menu'>
<Menu.Button
className='vds-menu-button vds-button'
aria-label='Settings'
>
<MediaPlayerIcons.Menu.Settings />
</Menu.Button>
<Menu.Items
className='vds-menu-items'
placement={"bottom"}
offset={0}
>
{/* Loop */}
<Menu.Root>
<Menu.Button className='vds-menu-item'>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Repeat'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Loop</span>
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={String(isLoop)}
>
<Menu.Radio
className='vds-radio'
value='true'
onSelect={() => setIsLoop(true)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>On</span>
</Menu.Radio>
<Menu.Radio
className='vds-radio'
value='false'
onSelect={() => setIsLoop(false)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>Off</span>
</Menu.Radio>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
{/* Playback Speed */}
<PlaybackMenu />
{/* Audio Gain */}
<AudioGain />
</Menu.Items>
</Menu.Root>
</div>
</div>
),
settingsMenu: null,
}}
/>
<DefaultVideoLayout
icons={MediaPlayerIcons}
colorScheme='default'
smallLayoutWhen={smallVideoLayoutQuery}
showTooltipDelay={150}
slots={{
currentTime: (
<Time
className='text-sm'
type='current'
/>
),
endTime: (
<Time
className='text-sm'
type='duration'
/>
),
settingsMenu: (
<Menu.Root className='vds-menu'>
<Menu.Button
className='vds-menu-button vds-button'
aria-label='Settings'
>
<MediaPlayerIcons.Menu.Settings />
</Menu.Button>
<Menu.Items
className='vds-menu-items'
placement={"top"}
offset={0}
>
{/* Loop */}
<Menu.Root>
<Menu.Button className='vds-menu-item'>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Repeat'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Loop</span>
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={String(isLoop)}
>
<Menu.Radio
className='vds-radio'
value='true'
onSelect={() => setIsLoop(true)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>On</span>
</Menu.Radio>
<Menu.Radio
className='vds-radio'
value='false'
onSelect={() => setIsLoop(false)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>Off</span>
</Menu.Radio>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
{/* Playback Speed */}
<PlaybackMenu />
{/* Audio Gain */}
<AudioGain />
</Menu.Items>
</Menu.Root>
),
}}
/>
</MediaPlayer>
)}
</div>
);
}
function PlaybackMenu() {
const options = usePlaybackRateOptions();
const hint = options.selectedValue === "1" ? "Normal" : `${options.selectedValue}x`;
return (
<Menu.Root>
<Menu.Button
className='vds-menu-item'
disabled={options.disabled}
>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Gauge'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Playback Speed</span>
<span className='vds-menu-item-hint'>{hint}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={options.selectedValue}
>
{/* eslint-disable-next-line @typescript-eslint/unbound-method */}
{options.map(({ value, select, label }) => (
<Menu.Radio
key={value}
className='vds-radio'
value={value}
onSelect={select}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>{label}</span>
</Menu.Radio>
))}
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
);
}
function AudioGain() {
const options = useAudioGainOptions({
disabledLabel: "100%",
});
const hint = options.selectedValue ? `${Number(options.selectedValue) * 100}%` : "100%";
return (
<Menu.Root>
<Menu.Button
className='vds-menu-item'
disabled={options.disabled}
>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Volume2'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Audio Boost</span>
<span className='vds-menu-item-hint'>{hint}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={options.selectedValue}
>
{/* eslint-disable-next-line @typescript-eslint/unbound-method */}
{options.map(({ value, select, label }) => (
<Menu.Radio
key={value}
className='vds-radio'
value={value}
onSelect={select}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label capitalize'>{label}</span>
</Menu.Radio>
))}
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
);
}
-96
View File
@@ -1,96 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import { z } from "zod";
import { Loader, Status } from "~/components/global";
import useLoading from "~/hooks/useLoading";
import { Schema_File } from "~/types/schema";
import { CreateDownloadToken } from "actions";
const Plyr = dynamic(() => import("plyr-react"), {
ssr: false,
loading: () => <Loader message='Loading player...' />,
});
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewVideo({ file }: Props) {
const [videoSrc, setVideoSrc] = useState<string>("");
const [error, setError] = useState<string>("");
const loading = useLoading(async () => {
try {
if (!file.encryptedWebContentLink) {
setError("No video to preview");
return;
}
const token = await CreateDownloadToken();
setVideoSrc(`/api/stream/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
}
}, [file]);
return (
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<Loader message='Loading video...' />
) : error ? (
<Status
icon='TriangleAlert'
message={error}
destructive
/>
) : (
<div className='h-full w-full'>
<Plyr
source={{
type: "video",
sources: [
{
src: videoSrc,
type: file.mimeType,
size: file.size,
},
],
poster: `/api/thumb/${file.encryptedId}?size=1000`,
}}
options={{
toggleInvert: true,
settings: ["quality", "speed"],
ratio: "16:9",
controls: [
"play-large",
"play",
"progress",
"current-time",
"duration",
"mute",
"volume",
"settings",
"pip",
"download",
"fullscreen",
],
volume: 0.5,
muted: false,
loop: { active: false },
speed: { selected: 1, options: [0.5, 1, 1.5, 2] },
keyboard: {
focused: true,
global: true,
},
}}
/>
</div>
)}
</div>
);
}
+9 -1
View File
@@ -1,4 +1,5 @@
import twTypography from "@tailwindcss/typography"; import twTypography from "@tailwindcss/typography";
import twVidstack from "@vidstack/react/tailwind.cjs";
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import twAnimate from "tailwindcss-animate"; import twAnimate from "tailwindcss-animate";
import tw from "tailwindcss/defaultTheme"; import tw from "tailwindcss/defaultTheme";
@@ -103,7 +104,14 @@ const config: Config = {
}, },
}, },
}, },
plugins: [twAnimate, twTypography], plugins: [
twAnimate,
twTypography,
twVidstack({
selector: ".media-player",
prefix: "media",
}),
],
}; };
export default config; export default config;