diff --git a/app/FramesList/GridFramesItem.tsx b/app/FramesList/GridFramesItem.tsx index 911b7fe..a701b15 100644 --- a/app/FramesList/GridFramesItem.tsx +++ b/app/FramesList/GridFramesItem.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; export type SourceData = { name: string; @@ -19,9 +19,13 @@ function getHostname(sourceUrl: string) { function getFileExtension(sourceUrl: string) { try { - return new URL(sourceUrl, "http://localhost").pathname.split(".").pop()?.toLowerCase(); + const pathname = new URL(sourceUrl, "http://localhost").pathname; + const lastDot = pathname.lastIndexOf("."); + return lastDot >= 0 ? pathname.slice(lastDot + 1).toLowerCase() : ""; } catch { - return sourceUrl.split("?")[0]?.split("#")[0]?.split(".").pop()?.toLowerCase(); + const clean = sourceUrl.split("?")[0]?.split("#")[0] ?? sourceUrl; + const lastDot = clean.lastIndexOf("."); + return lastDot >= 0 ? clean.slice(lastDot + 1).toLowerCase() : ""; } } @@ -29,11 +33,22 @@ function getVideoType(sourceUrl: string) { switch (getFileExtension(sourceUrl)) { case "mp4": return "video/mp4"; + case "webm": + return "video/webm"; + case "ogg": + case "ogv": + return "video/ogg"; + case "mov": + return "video/quicktime"; default: return undefined; } } +function isGif(sourceUrl: string) { + return getFileExtension(sourceUrl) === "gif"; +} + function PreviewMedia({ previewImage, previewVideo, @@ -41,78 +56,121 @@ function PreviewMedia({ previewImage?: string; previewVideo?: string | string[]; }) { - const previewVideos = Array.isArray(previewVideo) - ? previewVideo - : previewVideo - ? [previewVideo] - : []; - const gifPreview = previewVideos.find((sourceUrl) => getFileExtension(sourceUrl) === "gif"); - const videoPreviews = previewVideos.filter((sourceUrl) => getFileExtension(sourceUrl) !== "gif"); - const [isAnimatedPreviewReady, setIsAnimatedPreviewReady] = useState(!previewImage); - const posterClassName = isAnimatedPreviewReady - ? "relative z-10 h-full w-full object-cover transition-opacity duration-200 group-hover:opacity-0 group-focus-within:opacity-0" - : "relative z-10 h-full w-full object-cover"; + const sources = useMemo(() => { + const list = Array.isArray(previewVideo) ? previewVideo : previewVideo ? [previewVideo] : []; + + return { + gif: list.find(isGif), + videos: list.filter((sourceUrl) => !isGif(sourceUrl)), + }; + }, [previewVideo]); + const videoRef = useRef(null); + const [isHovered, setIsHovered] = useState(false); + const [isVideoReady, setIsVideoReady] = useState(false); + + const hasVideo = sources.videos.length > 0; + const basePreview = previewImage ?? sources.gif; + + useEffect(() => { + setIsHovered(false); + setIsVideoReady(false); + }, [previewImage, previewVideo]); const playPreview = useCallback(() => { - const v = videoRef.current; - if (!v) return; - // Attempt to play; ignore promise rejections (e.g., not ready) - const p = v.play(); - if (p && typeof p.then === "function") p.catch(() => {}); + const video = videoRef.current; + if (!video) return; + + // Restart from the beginning so repeated hover feels consistent. + try { + video.currentTime = 0; + } catch { + // Ignore if metadata is not ready yet. + } + + const promise = video.play(); + if (promise && typeof promise.then === "function") { + promise.catch(() => { + // Autoplay can still be blocked in some browsers; ignore silently. + }); + } }, []); const pausePreview = useCallback(() => { - const v = videoRef.current; - if (!v) return; + const video = videoRef.current; + if (!video) return; + try { - v.pause(); - v.currentTime = 0; + video.pause(); + video.currentTime = 0; } catch { - /* ignore */ + // Ignore. } }, []); - if (!previewImage && !previewVideos.length) return null; + useEffect(() => { + if (isHovered && isVideoReady) { + playPreview(); + } else { + pausePreview(); + } + }, [isHovered, isVideoReady, playPreview, pausePreview]); + + if (!previewImage && !sources.gif && !hasVideo) { + return null; + } return (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onFocus={() => setIsHovered(true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setIsHovered(false); + } + }} > - {videoPreviews.length ? ( + {basePreview ? ( + + ) : ( + ); } @@ -145,6 +203,7 @@ export function FrameItem({ {source.name}
+ Frame @@ -174,36 +233,34 @@ export function FrameItem({ className="inline-flex items-center justify-center rounded-md p-2 text-gray-600 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-gray-300" > {isFavorite ? ( - // Filled - // Had to put a direct svg here because the svg doesn't work for some reason and there is fill colors - // I temporarily hardcoded red for the heart color, but if we add themes we should change. ) : ( - // Unfilled