diff --git a/apps/web/src/components/media/library/AssetGrid.tsx b/apps/web/src/components/media/library/AssetGrid.tsx new file mode 100644 index 0000000..80eebec --- /dev/null +++ b/apps/web/src/components/media/library/AssetGrid.tsx @@ -0,0 +1,80 @@ +import { useLingui } from "@lingui/react/macro"; +import type { MediaAsset } from "@rakazo/contracts"; +import type { ReactNode } from "react"; +import { useCallback, useRef, useState } from "react"; +import { AssetTile } from "./AssetTile"; +import { VirtualGrid } from "./VirtualGrid"; + +/** Selected asset ids; shift-click selects the range since the last click. */ +export function useAssetSelection() { + const [selected, setSelected] = useState>(() => new Set()); + const anchor = useRef(null); + + const toggle = useCallback((assets: readonly MediaAsset[], index: number, range: boolean) => { + const target = assets[index]; + if (!target) return; + const start = anchor.current; + setSelected((current) => { + const next = new Set(current); + if (range && start !== null) { + const [from, to] = [Math.min(start, index), Math.max(start, index)]; + for (const asset of assets.slice(from, to + 1)) next.add(asset.id); + } else if (next.has(target.id)) { + next.delete(target.id); + } else { + next.add(target.id); + } + return next; + }); + anchor.current = index; + }, []); + + const clear = useCallback(() => { + anchor.current = null; + setSelected(new Set()); + }, []); + + return { selected, toggle, clear }; +} + +export function AssetGrid({ + assets, + selection, + scrub = false, + onNearEnd, + footer, +}: { + assets: readonly MediaAsset[]; + selection: ReturnType; + scrub?: boolean; + onNearEnd?: () => void; + footer?: ReactNode; +}) { + const { t } = useLingui(); + const selecting = selection.selected.size > 0; + return ( + assets[index]?.id ?? index} + minTileWidth={150} + tileAspect={9 / 16} + onNearEnd={onNearEnd} + footer={footer} + renderItem={(index) => { + const asset = assets[index]; + if (!asset) return null; + return ( + selection.toggle(assets, index, event.shiftKey)} + /> + ); + }} + /> + ); +} diff --git a/apps/web/src/components/media/library/AssetTile.tsx b/apps/web/src/components/media/library/AssetTile.tsx new file mode 100644 index 0000000..53049ad --- /dev/null +++ b/apps/web/src/components/media/library/AssetTile.tsx @@ -0,0 +1,155 @@ +import { useLingui } from "@lingui/react/macro"; +import type { MediaAsset } from "@rakazo/contracts"; +import { Checkbox, cn } from "@rakazo/ui-web"; +import { Image as ImageIcon, Star, Video } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { mediaAssetPath, useMediaObjectUrl } from "../../../lib/media-bytes"; +import { formatDuration, scrubFraction } from "../../../lib/media-library"; + +/** Poster of an asset, or a quiet placeholder until the proxy job made one. */ +export function AssetThumb({ + assetId, + hasThumbnail, + kind, + className, +}: { + assetId: string | null; + hasThumbnail: boolean; + kind?: MediaAsset["kind"]; + className?: string; +}) { + const thumb = useMediaObjectUrl( + assetId && hasThumbnail ? mediaAssetPath(assetId, "thumb") : null, + { keep: true }, + ); + if (thumb.url) { + return ( + + ); + } + const Icon = kind === "photo" || kind === "live_photo" ? ImageIcon : Video; + return ( +
+
+ ); +} + +/** Plays the proxy under the pointer: left edge is the first frame, right edge the last. */ +function Scrubber({ asset, fraction }: { asset: MediaAsset; fraction: number }) { + const proxy = useMediaObjectUrl(mediaAssetPath(asset.id, "proxy")); + const videoRef = useRef(null); + const duration = (asset.durationMs ?? 0) / 1000; + useEffect(() => { + const video = videoRef.current; + if (!proxy.url || !video || duration <= 0) return; + const target = fraction * duration; + if (Math.abs(video.currentTime - target) > 0.04) video.currentTime = target; + }, [proxy.url, fraction, duration]); + return ( + <> + {proxy.url ? ( +