add custom video player
This commit is contained in:
parent
0d0f220e07
commit
458b3f0c1b
13 changed files with 1215 additions and 247 deletions
21
components.json
Normal file
21
components.json
Normal 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
874
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
|
|
||||||
73
src/components/VideoLayout.tsx
Normal file
73
src/components/VideoLayout.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/VideoPlayer.tsx
Normal file
81
src/components/VideoPlayer.tsx
Normal 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
150
src/components/buttons.tsx
Normal 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
105
src/components/sliders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/time-group.tsx
Normal file
11
src/components/time-group.tsx
Normal 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
10
src/components/title.tsx
Normal 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
6
src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)",
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-radix")()],
|
colors: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 &"]);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue