diff --git a/packages/ui/src/components/molecules/FileGridItem/FileGridItem.tsx b/packages/ui/src/components/molecules/FileGridItem/FileGridItem.tsx index 9a3163e..ff5e024 100644 --- a/packages/ui/src/components/molecules/FileGridItem/FileGridItem.tsx +++ b/packages/ui/src/components/molecules/FileGridItem/FileGridItem.tsx @@ -1,9 +1,8 @@ import { Button, Card, CardActions, CardHeader, CardMedia } from '@mui/material'; import { ReadonlySignal, useComputed } from '@preact/signals-react'; import { IFileInfo, isIVideoFile, isPosterFeature, isTitleFeature, isYearFeature } from 'ipmc-interfaces'; -import React from 'react'; -import { useFileUrl } from '../../../hooks'; -import { useTranslation } from '../../../hooks/useTranslation'; +import React, { useRef } from 'react'; +import { useFileUrl, useIsVisible, useTranslation } from '../../../hooks'; import { PinButton } from '../../atoms/PinButton'; import { Display } from '../../pages/LibraryManager'; import posterFallback from './no-poster.png'; @@ -13,11 +12,18 @@ import thumbFallback from './no-thumbnail.png'; export function FileGridItem(props: { file: IFileInfo; onOpen: () => void; display: ReadonlySignal; }) { const { file, display, onOpen } = props; const _t = useTranslation(); + const imgRef = useRef(null); + const visible = useIsVisible(imgRef); - const posterUrl = useFileUrl(isPosterFeature(file) && file.posters.length > 0 ? file.posters[0]?.cid : undefined, posterFallback); - const thumbUrl = useFileUrl(isIVideoFile(file) && file.thumbnails.length > 0 ? file.thumbnails[0]?.cid : undefined, thumbFallback); - const width = useComputed(() => display.value == Display.Poster ? 240 : 640); - const height = useComputed(() => display.value == Display.Poster ? 360 : 360); + const posterUrl = useFileUrl(isPosterFeature(file) && file.posters.length > 0 ? file.posters[0]?.cid : undefined, visible, posterFallback); + const thumbUrl = useFileUrl(isIVideoFile(file) && file.thumbnails.length > 0 ? file.thumbnails[0]?.cid : undefined, visible, thumbFallback); + const size = useComputed<{ width: number; height: number; }>(() => display.value == Display.Poster ? { + width: 240, + height: 360, + } : { + width: 640, + height: 360, + }); const url = useComputed(() => { switch (display.value) { case Display.Poster: @@ -30,8 +36,8 @@ export function FileGridItem(props: { file: IFileInfo; onOpen: () => void; displ }); return useComputed(() => ( - - + + diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 25f87a1..7a351ff 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,5 +1,6 @@ export { useFileUrl } from './useFileUrl'; export { useHotkey } from './useHotkey'; +export { useIsVisible } from './useIsVisible'; export { PinStatus, usePinManager } from './usePinManager'; export { useTitle } from './useTitle'; export { useTranslation } from './useTranslation'; diff --git a/packages/ui/src/hooks/useFileUrl.ts b/packages/ui/src/hooks/useFileUrl.ts index 986128f..ea8cdec 100644 --- a/packages/ui/src/hooks/useFileUrl.ts +++ b/packages/ui/src/hooks/useFileUrl.ts @@ -1,10 +1,10 @@ import { IIpfsService, IIpfsServiceSymbol } from 'ipmc-interfaces'; import { useService } from '../context/AppContext'; -import { ReadonlySignal, useComputed, useSignal, useSignalEffect } from '@preact/signals-react'; +import { ReadonlySignal, Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals-react'; import { useLinkedSignal } from './useLinkedSignal'; import { fileTypeFromBuffer } from 'file-type'; -export function useFileUrl(cid?: string, fallback?: string): ReadonlySignal { +export function useFileUrl(cid?: string, visible?: Signal, fallback?: string): ReadonlySignal { const result = useSignal(undefined); const heliaService = useService(IIpfsServiceSymbol); const cidSig = useLinkedSignal(cid); @@ -12,8 +12,8 @@ export function useFileUrl(cid?: string, fallback?: string): ReadonlySignal { const controller = new AbortController(); const cid = cidSig.value; - if (cid !== undefined) { - + const currentlyVisible = visible?.value ?? false; + if (cid !== undefined && currentlyVisible) { (async () => { const data = await heliaService.fetch(cid); const fileType = await fileTypeFromBuffer(data); diff --git a/packages/ui/src/hooks/useIsVisible.ts b/packages/ui/src/hooks/useIsVisible.ts new file mode 100644 index 0000000..f4a1253 --- /dev/null +++ b/packages/ui/src/hooks/useIsVisible.ts @@ -0,0 +1,58 @@ +import { Signal, useSignal } from '@preact/signals-react'; +import { RefObject, useEffect } from 'react'; + + +let listenerCallbacks = new WeakMap(); + +let observer: IntersectionObserver; + +function handleIntersections(entries: any[]) { + entries.forEach(entry => { + if (listenerCallbacks.has(entry.target)) { + let cb = listenerCallbacks.get(entry.target); + + if (entry.isIntersecting || entry.intersectionRatio > 0) { + observer.unobserve(entry.target); + listenerCallbacks.delete(entry.target); + cb(); + } + } + }); +} + +function getIntersectionObserver() { + if (observer === undefined) { + observer = new IntersectionObserver(handleIntersections, { + rootMargin: '100px', + threshold: 0.15, + }); + } + return observer; +} + +export function useIsVisible(elem: RefObject): Signal { + const visible = useSignal(false); + + useEffect(() => { + let target = elem?.current; + if (target !== null) { + let observer = getIntersectionObserver(); + listenerCallbacks.set(target, () => { + visible.value = true; + }); + observer.observe(target); + + return () => { + listenerCallbacks.delete(target); + observer.unobserve(target); + }; + } else { + return () => { + // NOOP + }; + } + }, []); + + return visible; +} +