diff --git a/src/components/Preview/Audio.tsx b/src/components/Preview/Audio.tsx
deleted file mode 100644
index 87bcc1f..0000000
--- a/src/components/Preview/Audio.tsx
+++ /dev/null
@@ -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: () => ,
-});
-
-type Props = {
- file: z.infer;
-};
-export default function PreviewAudio({ file }: Props) {
- const [audioSrc, setAudioSrc] = useState("");
- const [error, setError] = useState("");
-
- 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 (
-
- {loading ? (
-
- ) : error ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/src/components/Preview/Media.tsx b/src/components/Preview/Media.tsx
new file mode 100644
index 0000000..6d7d827
--- /dev/null
+++ b/src/components/Preview/Media.tsx
@@ -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: () => (
+
+ ),
+ Connecting: () => (
+
+ ),
+ Connected: () => (
+
+ ),
+ },
+ GoogleCastButton: {
+ Default: () => (
+
+ ),
+ Connecting: () => (
+
+ ),
+ Connected: () => (
+
+ ),
+ },
+ PlayButton: {
+ Play: () => (
+
+ ),
+ Pause: () => (
+
+ ),
+ Replay: () => (
+
+ ),
+ },
+ MuteButton: {
+ Mute: () => (
+
+ ),
+ VolumeLow: () => (
+
+ ),
+ VolumeHigh: () => (
+
+ ),
+ },
+ CaptionButton: {
+ On: () => (
+
+ ),
+ Off: () => (
+
+ ),
+ },
+ PIPButton: {
+ Enter: () => (
+
+ ),
+ Exit: () => (
+
+ ),
+ },
+ FullscreenButton: {
+ Enter: () => (
+
+ ),
+ Exit: () => (
+
+ ),
+ },
+ SeekButton: {
+ Backward: () => (
+
+ ),
+ Forward: () => (
+
+ ),
+ },
+ DownloadButton: {
+ Default: () => (
+
+ ),
+ },
+ Menu: {
+ Accessibility: () => (
+
+ ),
+ ArrowLeft: () => null,
+ ArrowRight: () => (
+
+ ),
+ Audio: () => (
+
+ ),
+ AudioBoostUp: () => (
+
+ ),
+ AudioBoostDown: () => (
+
+ ),
+ Chapters: () => (
+
+ ),
+ Captions: () => (
+
+ ),
+ Playback: () => (
+
+ ),
+ Settings: () => (
+
+ ),
+ SpeedUp: () => (
+
+ ),
+ SpeedDown: () => (
+
+ ),
+ QualityUp: () => null,
+ QualityDown: () => null,
+ FontSizeUp: () => null,
+ FontSizeDown: () => null,
+ OpacityUp: () => null,
+ OpacityDown: () => null,
+ RadioCheck: () => null,
+ },
+ KeyboardDisplay: {
+ Play: () => (
+
+
+
+ ),
+ Pause: () => (
+
+
+
+ ),
+ Mute: () => (
+
+
+
+ ),
+ VolumeUp: () => (
+
+
+
+ ),
+ VolumeDown: () => (
+
+
+
+ ),
+ EnterFullscreen: () => (
+
+
+
+ ),
+ ExitFullscreen: () => (
+
+
+
+ ),
+ // EnterPiP: () => null,
+ // ExitPiP: () => null,
+ CaptionsOn: () => (
+
+
+
+ ),
+ CaptionsOff: () => (
+
+
+
+ ),
+ SeekForward: () => (
+
+
+
+ ),
+ SeekBackward: () => (
+
+
+
+ ),
+ },
+};
+
+type Props = {
+ file: z.infer;
+ type: "video" | "audio";
+};
+export default function PreviewMedia({ file, type }: Props) {
+ const loading = useLoading();
+ const player = useRef(null);
+ const [canPlay, setCanPlay] = useState(false);
+
+ const [isLoop, setIsLoop] = useState(false);
+
+ const smallAudioLayoutQuery = useCallback(({ width }) => {
+ return width < 576;
+ }, []);
+ const smallVideoLayoutQuery = useCallback(({ width, height }) => {
+ return width < 576 || height < 380;
+ }, []);
+
+ return (
+
+ {loading ? (
+
+ ) : (
+
{
+ setCanPlay(true);
+ }}
+ >
+
+
+
+
+
+
+ {canPlay ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+ ),
+ timeSlider: (
+
+
+
+
+
+
+
+
+ ),
+ beforeEndTime: ,
+ endTime: (
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+ {/* Loop */}
+
+
+
+
+ Loop
+ {isLoop ? "On" : "Off"}
+
+
+
+
+ setIsLoop(true)}
+ >
+
+ On
+
+ setIsLoop(false)}
+ >
+
+ Off
+
+
+
+
+
+ {/* Playback Speed */}
+
+
+ {/* Audio Gain */}
+
+
+
+
+
+ ),
+ settingsMenu: null,
+ }}
+ />
+
+ ),
+ endTime: (
+
+ ),
+ settingsMenu: (
+
+
+
+
+
+ {/* Loop */}
+
+
+
+
+ Loop
+ {isLoop ? "On" : "Off"}
+
+
+
+
+ setIsLoop(true)}
+ >
+
+ On
+
+ setIsLoop(false)}
+ >
+
+ Off
+
+
+
+
+
+ {/* Playback Speed */}
+
+
+ {/* Audio Gain */}
+
+
+
+ ),
+ }}
+ />
+
+ )}
+
+ );
+}
+
+function PlaybackMenu() {
+ const options = usePlaybackRateOptions();
+ const hint = options.selectedValue === "1" ? "Normal" : `${options.selectedValue}x`;
+
+ return (
+
+
+
+
+ Playback Speed
+ {hint}
+
+
+
+
+ {/* eslint-disable-next-line @typescript-eslint/unbound-method */}
+ {options.map(({ value, select, label }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+ );
+}
+function AudioGain() {
+ const options = useAudioGainOptions({
+ disabledLabel: "100%",
+ });
+ const hint = options.selectedValue ? `${Number(options.selectedValue) * 100}%` : "100%";
+
+ return (
+
+
+
+
+ Audio Boost
+ {hint}
+
+
+
+
+ {/* eslint-disable-next-line @typescript-eslint/unbound-method */}
+ {options.map(({ value, select, label }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/Preview/Video.tsx b/src/components/Preview/Video.tsx
deleted file mode 100644
index 5c4e335..0000000
--- a/src/components/Preview/Video.tsx
+++ /dev/null
@@ -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: () => ,
-});
-
-type Props = {
- file: z.infer;
-};
-export default function PreviewVideo({ file }: Props) {
- const [videoSrc, setVideoSrc] = useState("");
- const [error, setError] = useState("");
- 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 (
-
- {loading ? (
-
- ) : error ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index fefaa21..fc3d5ce 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,4 +1,5 @@
import twTypography from "@tailwindcss/typography";
+import twVidstack from "@vidstack/react/tailwind.cjs";
import type { Config } from "tailwindcss";
import twAnimate from "tailwindcss-animate";
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;