add custom video player

This commit is contained in:
MarconLP 2024-11-20 21:44:37 +01:00
parent 0d0f220e07
commit 458b3f0c1b
No known key found for this signature in database
GPG key ID: A08A9C8B623F5EA5
13 changed files with 1215 additions and 247 deletions

21
components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}

874
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,7 @@
"@popperjs/core": "^2.11.7", "@popperjs/core": "^2.11.7",
"@prisma/client": "^4.11.0", "@prisma/client": "^4.11.0",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-tooltip": "^1.0.5", "@radix-ui/react-tooltip": "^1.0.5",
"@tanstack/react-query": "^4.28.0", "@tanstack/react-query": "^4.28.0",
"@trpc/client": "^10.18.0", "@trpc/client": "^10.18.0",
@ -35,12 +36,16 @@
"@upstash/qstash": "^0.3.6", "@upstash/qstash": "^0.3.6",
"@upstash/ratelimit": "^0.4.2", "@upstash/ratelimit": "^0.4.2",
"@upstash/redis": "^1.20.4", "@upstash/redis": "^1.20.4",
"@vidstack/react": "^1.12.12",
"axios": "^1.3.5", "axios": "^1.3.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"crisp-sdk-web": "^1.0.18", "crisp-sdk-web": "^1.0.18",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"ebml": "^3.0.0", "ebml": "^3.0.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jotai": "^2.0.4", "jotai": "^2.0.4",
"lucide-react": "^0.460.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"micro-cors": "^0.1.1", "micro-cors": "^0.1.1",
"next": "^13.4.10", "next": "^13.4.10",
@ -55,6 +60,8 @@
"sharp": "^0.32.3", "sharp": "^0.32.3",
"stripe": "^12.12.0", "stripe": "^12.12.0",
"superjson": "1.12.2", "superjson": "1.12.2",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-radix": "^2.8.0", "tailwindcss-radix": "^2.8.0",
"ts-ebml": "^2.0.2", "ts-ebml": "^2.0.2",
"zod": "^3.21.4" "zod": "^3.21.4"

View file

@ -0,0 +1,73 @@
import * as Tooltip from "@radix-ui/react-tooltip";
import { Controls, Gesture } from "@vidstack/react";
import * as Buttons from "./buttons";
import * as Sliders from "./sliders";
import { TimeGroup } from "./time-group";
// Offset tooltips/menus/slider previews in the lower controls group so they're clearly visible.
const popupOffset = 30;
export interface VideoLayoutProps {
thumbnails?: string;
}
export function VideoLayout({ thumbnails }: VideoLayoutProps) {
return (
<>
<Gestures />
<Controls.Root
hideDelay={50}
hideOnMouseLeave={true}
className="absolute inset-0 z-10 flex h-full w-full flex-col bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity duration-300 media-controls:opacity-100"
>
<Tooltip.Provider>
<div className="flex-1" />
<Controls.Group className="flex w-full items-center px-2">
<Sliders.Time thumbnails={thumbnails} />
</Controls.Group>
<Controls.Group className="-mt-0.5 flex w-full items-center px-2 pb-2">
<Buttons.Play tooltipAlign="start" tooltipOffset={popupOffset} />
<Buttons.Mute tooltipOffset={popupOffset} />
<Sliders.Volume />
<TimeGroup />
<div className="flex-1" />
<Buttons.PIP tooltipOffset={popupOffset} />
<Buttons.Fullscreen
tooltipAlign="end"
tooltipOffset={popupOffset}
/>
</Controls.Group>
<div className="controlsShadow absolute bottom-0 z-[-1] h-full w-full" />
</Tooltip.Provider>
</Controls.Root>
</>
);
}
function Gestures() {
return (
<>
<Gesture
className="absolute inset-0 z-0 block h-full w-full"
event="pointerup"
action="toggle:paused"
/>
<Gesture
className="absolute inset-0 z-0 block h-full w-full"
event="dblpointerup"
action="toggle:fullscreen"
/>
<Gesture
className="absolute left-0 top-0 z-10 block h-full w-1/5"
event="dblpointerup"
action="seek:-10"
/>
<Gesture
className="absolute right-0 top-0 z-10 block h-full w-1/5"
event="dblpointerup"
action="seek:10"
/>
</>
);
}

View file

@ -0,0 +1,81 @@
import "@vidstack/react/player/styles/base.css";
import React, { useEffect, useRef } from "react";
import {
isHLSProvider,
MediaPlayer,
MediaProvider,
Poster,
type MediaCanPlayDetail,
type MediaCanPlayEvent,
type MediaPlayerInstance,
type MediaProviderAdapter,
type MediaProviderChangeEvent,
} from "@vidstack/react";
import { VideoLayout } from "./VideoLayout";
interface Props {
video_url: string;
thumbnailUrl: string;
}
export default function VideoPlayer({ video_url, thumbnailUrl }: Props) {
let player = useRef<MediaPlayerInstance>(null);
useEffect(() => {
// Subscribe to state updates.
return player.current!.subscribe(({ paused, viewType }) => {
// console.log('is paused?', '->', state.paused);
// console.log('is audio view?', '->', state.viewType === 'audio');
});
}, []);
function onProviderChange(
provider: MediaProviderAdapter | null,
nativeEvent: MediaProviderChangeEvent
) {
// We can configure provider's here.
if (isHLSProvider(provider)) {
provider.config = {};
}
}
// We can listen for the `can-play` event to be notified when the player is ready.
function onCanPlay(
detail: MediaCanPlayDetail,
nativeEvent: MediaCanPlayEvent
) {
// ...
}
return (
<MediaPlayer
className="ring-media-focus aspect-video w-full overflow-hidden rounded-md bg-slate-900 font-sans text-white data-[focus]:ring-4"
title="Sprite Fight"
src={[
{
src: video_url,
type: "video/mp4",
},
]}
crossOrigin
playsInline
onProviderChange={onProviderChange}
onCanPlay={onCanPlay}
ref={player}
keyTarget="document"
>
<MediaProvider>
<Poster
className="absolute inset-0 block h-full w-full rounded-md object-cover opacity-0 transition-opacity data-[visible]:opacity-100"
src={thumbnailUrl}
alt="Girl walks into campfire with gnomes surrounding her friend ready for their next meal!"
/>
</MediaProvider>
<VideoLayout />
</MediaPlayer>
);
}

150
src/components/buttons.tsx Normal file
View file

@ -0,0 +1,150 @@
import * as Tooltip from "@radix-ui/react-tooltip";
import {
FullscreenButton,
MuteButton,
PIPButton,
PlayButton,
useMediaState,
} from "@vidstack/react";
import {
Minimize as FullscreenExitIcon,
Maximize as FullscreenIcon,
VolumeX as MuteIcon,
PauseIcon,
PictureInPictureIcon as PictureInPictureExitIcon,
PictureInPicture2 as PictureInPictureIcon,
PlayIcon,
Volume2 as VolumeHighIcon,
Volume1 as VolumeLowIcon,
} from "lucide-react";
export interface MediaButtonProps {
tooltipSide?: Tooltip.TooltipContentProps["side"];
tooltipAlign?: Tooltip.TooltipContentProps["align"];
tooltipOffset?: number;
}
export const buttonClass =
"group ring-media-focus relative inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 focus-visible:ring-4 aria-disabled:hidden";
export const tooltipClass =
"animate-out fade-out slide-out-to-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in data-[state=delayed-open]:slide-in-from-bottom-4 z-10 rounded-sm bg-black/90 px-2 py-0.5 text-sm font-medium text-white parent-data-[open]:hidden";
export function Play({
tooltipOffset = 0,
tooltipSide = "top",
tooltipAlign = "center",
}: MediaButtonProps) {
const isPaused = useMediaState("paused");
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PlayButton className={buttonClass}>
{isPaused ? (
<PlayIcon className="h-7 w-7 translate-x-px" />
) : (
<PauseIcon className="h-7 w-7" />
)}
</PlayButton>
</Tooltip.Trigger>
<Tooltip.Content
className={tooltipClass}
side={tooltipSide}
align={tooltipAlign}
sideOffset={tooltipOffset}
>
{isPaused ? "Play" : "Pause"}
</Tooltip.Content>
</Tooltip.Root>
);
}
export function Mute({
tooltipOffset = 0,
tooltipSide = "top",
tooltipAlign = "center",
}: MediaButtonProps) {
const volume = useMediaState("volume"),
isMuted = useMediaState("muted");
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<MuteButton className={buttonClass}>
{isMuted || volume == 0 ? (
<MuteIcon className="h-7 w-7" />
) : volume < 0.5 ? (
<VolumeLowIcon className="h-7 w-7" />
) : (
<VolumeHighIcon className="h-7 w-7" />
)}
</MuteButton>
</Tooltip.Trigger>
<Tooltip.Content
className={tooltipClass}
side={tooltipSide}
align={tooltipAlign}
sideOffset={tooltipOffset}
>
{isMuted ? "Unmute" : "Mute"}
</Tooltip.Content>
</Tooltip.Root>
);
}
export function PIP({
tooltipOffset = 0,
tooltipSide = "top",
tooltipAlign = "center",
}: MediaButtonProps) {
const isActive = useMediaState("pictureInPicture");
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PIPButton className={buttonClass}>
{isActive ? (
<PictureInPictureExitIcon className="h-7 w-7" />
) : (
<PictureInPictureIcon className="h-7 w-7" />
)}
</PIPButton>
</Tooltip.Trigger>
<Tooltip.Content
className={tooltipClass}
side={tooltipSide}
align={tooltipAlign}
sideOffset={tooltipOffset}
>
{isActive ? "Exit PIP" : "Enter PIP"}
</Tooltip.Content>
</Tooltip.Root>
);
}
export function Fullscreen({
tooltipOffset = 0,
tooltipSide = "top",
tooltipAlign = "center",
}: MediaButtonProps) {
const isActive = useMediaState("fullscreen");
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<FullscreenButton className={buttonClass}>
{isActive ? (
<FullscreenExitIcon className="h-7 w-7" />
) : (
<FullscreenIcon className="h-7 w-7" />
)}
</FullscreenButton>
</Tooltip.Trigger>
<Tooltip.Content
className={tooltipClass}
side={tooltipSide}
align={tooltipAlign}
sideOffset={tooltipOffset}
>
{isActive ? "Exit Fullscreen" : "Enter Fullscreen"}
</Tooltip.Content>
</Tooltip.Root>
);
}

105
src/components/sliders.tsx Normal file
View file

@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import {
formatTime,
Thumbnail,
useMediaRemote,
useMediaState,
useSliderPreview,
} from "@vidstack/react";
import * as Slider from "@radix-ui/react-slider";
export function Volume() {
const volume = useMediaState("volume"),
canSetVolume = useMediaState("canSetVolume"),
remote = useMediaRemote();
if (!canSetVolume) return null;
return (
<Slider.Root
className="group relative inline-flex h-10 w-full max-w-[80px] cursor-pointer touch-none select-none items-center outline-none"
value={[volume * 100]}
onValueChange={([value]) => {
if (value) remote.changeVolume(value / 100);
}}
>
<Slider.Track className="relative h-[5px] w-full rounded-sm bg-white/30">
<Slider.Range className="absolute h-full rounded-sm bg-[#f5f5f5] will-change-[width]" />
</Slider.Track>
<Slider.Thumb
aria-label="Volume"
className="block h-[15px] w-[15px] rounded-full border border-[#cacaca] bg-white opacity-0 outline-none ring-white/40 transition-opacity will-change-[left] focus:opacity-100 focus:ring-4 data-[hocus]:opacity-100"
/>
</Slider.Root>
);
}
export interface TimeSliderProps {
thumbnails?: string;
}
export function Time({ thumbnails }: TimeSliderProps) {
const time = useMediaState("currentTime"),
canSeek = useMediaState("canSeek"),
duration = useMediaState("duration"),
seeking = useMediaState("seeking"),
remote = useMediaRemote(),
[value, setValue] = useState(0),
{ previewRootRef, previewRef, previewValue } = useSliderPreview({
clamp: true,
offset: 6,
orientation: "horizontal",
}),
previewTime = (previewValue / 100) * duration;
// Keep slider value in-sync with playback.
useEffect(() => {
if (seeking) return;
setValue((time / duration) * 100);
}, [time, duration]);
return (
<Slider.Root
className="group relative inline-flex h-9 w-full cursor-pointer touch-none select-none items-center outline-none"
value={[value]}
disabled={!canSeek}
ref={previewRootRef}
onValueChange={([value]) => {
if (value) {
setValue(value);
remote.seeking((value / 100) * duration);
}
}}
onValueCommit={([value]) => {
if (value) remote.seek((value / 100) * duration);
}}
>
<Slider.Track className="relative h-[5px] w-full rounded-sm bg-white/30">
<Slider.Range className="absolute h-full rounded-sm bg-[#f5f5f5] will-change-[width]" />
</Slider.Track>
<Slider.Thumb
aria-label="Current Time"
className="block h-[15px] w-[15px] rounded-full border border-[#cacaca] bg-white opacity-0 outline-none ring-white/40 transition-opacity will-change-[left] focus:opacity-100 focus:ring-4 group-hocus:opacity-100"
/>
{/* Preview */}
<div
className="pointer-events-none absolute flex flex-col items-center opacity-0 transition-opacity will-change-[left] duration-200 data-[visible]:opacity-100"
ref={previewRef}
>
{thumbnails ? (
<Thumbnail.Root
src={thumbnails}
time={previewTime}
className="mb-2 block h-[var(--thumbnail-height)] max-h-[160px] min-h-[80px] w-[var(--thumbnail-width)] min-w-[120px] max-w-[180px] overflow-hidden border border-white bg-black"
>
<Thumbnail.Img />
</Thumbnail.Root>
) : null}
<span className="text-[13px]">{formatTime(previewTime)}</span>
</div>
</Slider.Root>
);
}

View file

@ -0,0 +1,11 @@
import { Time } from "@vidstack/react";
export function TimeGroup() {
return (
<div className="ml-2.5 flex items-center text-sm font-medium">
<Time className="time" type="current" />
<div className="mx-1 text-white/80">/</div>
<Time className="time" type="duration" />
</div>
);
}

10
src/components/title.tsx Normal file
View file

@ -0,0 +1,10 @@
import { ChapterTitle } from "@vidstack/react";
export function Title() {
return (
<span className="inline-block flex-1 overflow-hidden text-ellipsis whitespace-nowrap px-2 text-sm font-medium text-white/70">
<span className="mr-1">|</span>
<ChapterTitle />
</span>
);
}

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -15,6 +15,7 @@ import { useAtom } from "jotai";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen"; import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import VideoRecordModal from "~/components/VideoRecordModal"; import VideoRecordModal from "~/components/VideoRecordModal";
import defaultProfileIcon from "~/assets/default profile icon.jpg"; import defaultProfileIcon from "~/assets/default profile icon.jpg";
import VideoPlayer from "~/components/VideoPlayer";
const VideoList: NextPage = () => { const VideoList: NextPage = () => {
const router = useRouter(); const router = useRouter();
@ -133,38 +134,42 @@ const VideoList: NextPage = () => {
<div className="flex aspect-video max-h-[calc(100vh_-_169px)] w-full justify-center bg-black 2xl:max-h-[1160px]"> <div className="flex aspect-video max-h-[calc(100vh_-_169px)] w-full justify-center bg-black 2xl:max-h-[1160px]">
{video?.video_url && ( {video?.video_url && (
<> <>
<video <VideoPlayer
controls thumbnailUrl={video.thumbnailUrl}
onPlay={() => video_url={video.video_url}
posthog?.capture("play video", { />
videoId: video.id, {/*<video*/}
videoCreatedAt: video.createdAt, {/* controls*/}
videoUpdatedAt: video.updatedAt, {/* onPlay={() =>*/}
videoUser: video.user.id, {/* posthog?.capture("play video", {*/}
videoSharing: video.sharing, {/* videoId: video.id,*/}
videoDeleteAfterLinkExpires: {/* videoCreatedAt: video.createdAt,*/}
video.delete_after_link_expires, {/* videoUpdatedAt: video.updatedAt,*/}
videoShareLinkExpiresAt: video.shareLinkExpiresAt, {/* videoUser: video.user.id,*/}
}) {/* videoSharing: video.sharing,*/}
} {/* videoDeleteAfterLinkExpires:*/}
onPause={() => {/* video.delete_after_link_expires,*/}
posthog?.capture("pause video", { {/* videoShareLinkExpiresAt: video.shareLinkExpiresAt,*/}
videoId: video.id, {/* })*/}
videoCreatedAt: video.createdAt, {/* }*/}
videoUpdatedAt: video.updatedAt, {/* onPause={() =>*/}
videoUser: video.user.id, {/* posthog?.capture("pause video", {*/}
videoSharing: video.sharing, {/* videoId: video.id,*/}
videoDeleteAfterLinkExpires: {/* videoCreatedAt: video.createdAt,*/}
video.delete_after_link_expires, {/* videoUpdatedAt: video.updatedAt,*/}
videoShareLinkExpiresAt: video.shareLinkExpiresAt, {/* videoUser: video.user.id,*/}
}) {/* videoSharing: video.sharing,*/}
} {/* videoDeleteAfterLinkExpires:*/}
className="h-full w-full" {/* video.delete_after_link_expires,*/}
controlsList="nodownload" {/* videoShareLinkExpiresAt: video.shareLinkExpiresAt,*/}
> {/* })*/}
<source src={video.video_url} /> {/* }*/}
Your browser does not support the video tag. {/* className="h-full w-full"*/}
</video> {/* controlsList="nodownload"*/}
{/*>*/}
{/* <source src={video.video_url} />*/}
{/* Your browser does not support the video tag.*/}
{/*</video>*/}
</> </>
)} )}
</div> </div>

View file

@ -1,3 +1,12 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
:root {
--radius: 0.5rem
}
}
.controlsShadow {
box-shadow: inset 0 calc(-9 * clamp(8px, 14px, 11px)) calc(3 * clamp(8px, 14px, 11px)) calc(-6 * clamp(8px, 14px, 11px)) hsl(0 0% 0% / 0.35), inset 0 calc(-18 * clamp(8px, 14px, 11px)) calc(6 * clamp(8px, 14px, 11px)) calc(-12 * clamp(8px, 14px, 11px)) hsl(0 0% 0% / 0.35), inset 0 calc(-27 * clamp(8px, 14px, 11px)) calc(9 * clamp(8px, 14px, 11px)) calc(-18 * clamp(8px, 14px, 11px)) hsl(0 0% 0% / 0.35)
}

View file

@ -1,6 +1,7 @@
import { type Config } from "tailwindcss"; import { type Config } from "tailwindcss";
export default { export default {
darkMode: ["class"],
content: ["./src/**/*.{js,ts,jsx,tsx}"], content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: { theme: {
extend: { extend: {
@ -10,15 +11,50 @@ export default {
}, },
keyframes: { keyframes: {
marquee: { marquee: {
"0%": { transform: "translateX(0%)" }, "0%": {
"100%": { transform: "translateX(-100%)" }, transform: "translateX(0%)",
},
"100%": {
transform: "translateX(-100%)",
},
}, },
marquee2: { marquee2: {
"0%": { transform: "translateX(100%)" }, "0%": {
"100%": { transform: "translateX(0%)" }, transform: "translateX(100%)",
},
"100%": {
transform: "translateX(0%)",
},
}, },
}, },
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {},
}, },
}, },
plugins: [require("tailwindcss-radix")()], plugins: [
require("tailwindcss-radix")(),
require("tailwindcss-animate"),
require("@vidstack/react/tailwind.cjs")({
prefix: "media",
}),
customVariants,
],
} satisfies Config; } satisfies Config;
function customVariants({
addVariant,
matchVariant,
}: {
addVariant: any;
matchVariant: any;
}) {
// Strict version of `.group` to help with nesting.
matchVariant("parent-data", (value: any) => `.parent[data-${value}] > &`);
addVariant("hocus", ["&:hover", "&:focus-visible"]);
addVariant("group-hocus", [".group:hover &", ".group:focus-visible &"]);
}