Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/components/HoverVideo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

interface HoverVideoProps {
src: string;
poster?: string;
className?: string;
/** Frame (in seconds) used as the static thumbnail. */
thumbnailTime?: number;
ariaLabel?: string;
/** Play when the video receives keyboard focus (adds it to the tab order). */
focusable?: boolean;
/** Allow touch users to toggle playback by tapping the preview. */
tapToPlay?: boolean;
}

/**
* Displays the first frame of a video as a lightweight thumbnail
* (`preload="metadata"` + a `#t=` fragment) and only decodes/plays it while
* hovered, focused, or tapped. Avoids autoplaying every card at once.
*/
const HoverVideo: React.FC<HoverVideoProps> = ({
src,
poster,
className,
thumbnailTime = 0.1,
ariaLabel,
focusable = false,
tapToPlay = false,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isActive, setIsActive] = useState(false);
const [isReady, setIsReady] = useState(false);
const [hasError, setHasError] = useState(false);

const seekToThumbnail = useCallback(() => {
const video = videoRef.current;
if (!video) return;
try {
video.currentTime = thumbnailTime;
} catch {
/* not seekable yet */
}
}, [thumbnailTime]);

// Reset transient state when the source changes (memoized component reuses state).
useEffect(() => {
setIsActive(false);
setIsReady(false);
setHasError(false);
}, [src]);

useEffect(() => {
const video = videoRef.current;
if (!video || hasError) return;

if (isActive) {
void video.play().catch((err: DOMException) => {
// A pending play() rejects with AbortError when interrupted by pause()
// (e.g. a quick hover in/out). That is not a real failure.
if (err.name !== "AbortError") setHasError(true);
});
} else {
video.pause();
seekToThumbnail();
}
}, [isActive, hasError, seekToThumbnail]);

useEffect(() => {
const onVisibilityChange = () => {
if (document.hidden) videoRef.current?.pause();
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
}, []);

if (hasError) return null;

return (
<video
ref={videoRef}
src={`${src}#t=${thumbnailTime}`}
poster={poster}
preload="metadata"
loop
muted
playsInline
aria-label={ariaLabel}
tabIndex={focusable ? 0 : undefined}
className={cn(
"w-full h-full object-cover transition-opacity duration-300",
isReady ? "opacity-100" : "opacity-0",
className,
)}
onLoadedData={() => setIsReady(true)}
onSeeked={() => setIsReady(true)}
onLoadedMetadata={seekToThumbnail}
onPointerEnter={(e) => {
if (e.pointerType !== "touch") setIsActive(true);
}}
onPointerLeave={(e) => {
if (e.pointerType !== "touch") setIsActive(false);
}}
onPointerDown={(e) => {
if (tapToPlay && e.pointerType === "touch") {
e.stopPropagation();
setIsActive((active) => !active);
}
}}
onFocus={() => setIsActive(true)}
onBlur={() => setIsActive(false)}
onError={() => setHasError(true)}
/>
);
};

export default React.memo(HoverVideo);
34 changes: 23 additions & 11 deletions src/components/resources/ResourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Resource } from "@/types/resources";
import { cn } from "@/lib/utils";
import { useUserFavorites } from "@/hooks/useUserFavorites";
import AudioPlayer from "@/components/AudioPlayer";
import HoverVideo from "@/components/HoverVideo";
import { getCategoryIcon, getCategoryColor } from "@/utils/resourceCategories";

interface ResourceCardProps {
Expand Down Expand Up @@ -41,10 +42,22 @@ const ResourceCard = ({ resource, onClick, onMusicLink }: ResourceCardProps) =>
const isFavorite = isFavorited(String(resource.id));

const [isInView, setIsInView] = useState(false);
const [isPreviewReady, setIsPreviewReady] = useState(false);
const [isFontLoaded, setIsFontLoaded] = useState(false);
const [isLinkHovered, setIsLinkHovered] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);

// Debounce preview loading so fast scrolling doesn't fire a burst of
// media requests for every card that briefly crosses the viewport.
useEffect(() => {
if (!isInView) {
setIsPreviewReady(false);
return;
}
const timer = setTimeout(() => setIsPreviewReady(true), 150);
return () => clearTimeout(timer);
}, [isInView]);

useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
Expand Down Expand Up @@ -242,19 +255,18 @@ const ResourceCard = ({ resource, onClick, onMusicLink }: ResourceCardProps) =>
onClick={handlePreviewClick}
className="relative aspect-video bg-muted/20 rounded-md overflow-hidden mb-3 cursor-default"
>
{isInView ? (
<video
<div className="absolute inset-0 flex items-center justify-center bg-muted/10">
<IconVideo className="h-8 w-8 text-muted-foreground/30" />
</div>
{isInView && isPreviewReady && (
<HoverVideo
src={previewUrl}
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover"
poster={resource.image_url}
ariaLabel={`Preview of ${resource.title}`}
className="absolute inset-0"
focusable
tapToPlay
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-muted/10">
<IconVideo className="h-8 w-8 text-muted-foreground/30" />
</div>
)}
</div>
);
Expand Down
11 changes: 4 additions & 7 deletions src/pages/Showcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { supabase } from "@/integrations/supabase/client";
import Footer from "@/components/Footer";
import AuthDialog from "@/components/auth/AuthDialog";
import AudioPlayer from "@/components/AudioPlayer";
import VideoPlayer from "@/components/VideoPlayer";
import HoverVideo from "@/components/HoverVideo";



Expand Down Expand Up @@ -55,15 +55,12 @@ const ShowcaseCard: React.FC<{ item: ShowcaseWithAssets }> = ({ item }) => {
if (isVideo) {
return (
<div className="w-full h-full bg-black relative group/vid">
<video
<HoverVideo
src={url}
muted
loop
autoPlay
playsInline
ariaLabel="Showcase video preview"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/20 group-hover/vid:bg-transparent transition-colors" />
<div className="absolute inset-0 bg-black/20 group-hover/vid:bg-transparent transition-colors pointer-events-none" />
</div>
);
}
Expand Down
Loading