5 changed files with 249 additions and 36 deletions
@ -0,0 +1,116 @@ |
|||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { resolveMediaBlurPlaceholder } from '@/lib/media-placeholder-blurhash' |
||||||
|
import { decode } from 'blurhash' |
||||||
|
import { Music2, Play } from 'lucide-react' |
||||||
|
import { useEffect, useMemo, useRef } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
const CANVAS_W = 32 |
||||||
|
const CANVAS_H = 32 |
||||||
|
|
||||||
|
function guessMediaKindFromUrl(src: string): 'video' | 'audio' { |
||||||
|
try { |
||||||
|
const pathname = new URL(src).pathname.toLowerCase() |
||||||
|
const ext = pathname.split('.').pop() || '' |
||||||
|
if (['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka', 'oga'].includes(ext)) { |
||||||
|
return 'audio' |
||||||
|
} |
||||||
|
return 'video' |
||||||
|
} catch { |
||||||
|
return 'video' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function BlurHashLayer({ blurHash, className }: { blurHash: string; className?: string }) { |
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null) |
||||||
|
const pixels = useMemo(() => { |
||||||
|
try { |
||||||
|
return decode(blurHash, CANVAS_W, CANVAS_H) |
||||||
|
} catch { |
||||||
|
return null |
||||||
|
} |
||||||
|
}, [blurHash]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!pixels || !canvasRef.current) return |
||||||
|
const ctx = canvasRef.current.getContext('2d') |
||||||
|
if (!ctx) return |
||||||
|
const imageData = ctx.createImageData(CANVAS_W, CANVAS_H) |
||||||
|
imageData.data.set(pixels) |
||||||
|
ctx.putImageData(imageData, 0, 0) |
||||||
|
}, [pixels]) |
||||||
|
|
||||||
|
if (!pixels) { |
||||||
|
return <div className={cn('absolute inset-0 bg-muted', className)} /> |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<canvas |
||||||
|
ref={canvasRef} |
||||||
|
width={CANVAS_W} |
||||||
|
height={CANVAS_H} |
||||||
|
className={cn('absolute inset-0 h-full w-full object-cover', className)} |
||||||
|
style={{ imageRendering: 'auto', filter: 'blur(0.5px)' }} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default function LazyMediaTapPlaceholder({ |
||||||
|
src, |
||||||
|
posterUrl, |
||||||
|
blurHash, |
||||||
|
onActivate, |
||||||
|
className |
||||||
|
}: { |
||||||
|
src: string |
||||||
|
posterUrl?: string |
||||||
|
blurHash?: string |
||||||
|
onActivate: () => void |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const kind = guessMediaKindFromUrl(src) |
||||||
|
const hash = resolveMediaBlurPlaceholder(src, blurHash) |
||||||
|
const poster = posterUrl?.trim() |
||||||
|
const label = t('Click to load media') |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'group relative w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-muted/30 text-left shadow-sm outline-none transition-opacity hover:opacity-95 focus-visible:ring-2 focus-visible:ring-ring', |
||||||
|
kind === 'video' ? 'aspect-video' : 'min-h-[7.5rem] aspect-[21/9]', |
||||||
|
className |
||||||
|
)} |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
e.preventDefault() |
||||||
|
onActivate() |
||||||
|
}} |
||||||
|
aria-label={label} |
||||||
|
title={label} |
||||||
|
> |
||||||
|
{poster ? ( |
||||||
|
<img |
||||||
|
src={poster} |
||||||
|
alt="" |
||||||
|
className="absolute inset-0 h-full w-full object-cover" |
||||||
|
loading="lazy" |
||||||
|
decoding="async" |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<BlurHashLayer blurHash={hash} /> |
||||||
|
)} |
||||||
|
<span className="absolute inset-0 bg-gradient-to-t from-black/55 via-black/25 to-black/15" aria-hidden /> |
||||||
|
<span className="absolute inset-0 flex items-center justify-center" aria-hidden> |
||||||
|
<span className="flex size-14 items-center justify-center rounded-full bg-black/55 text-white shadow-md backdrop-blur-[2px] transition-transform group-hover:scale-105 group-focus-visible:scale-105"> |
||||||
|
{kind === 'video' ? ( |
||||||
|
<Play className="size-8 fill-current pl-1" strokeWidth={0} /> |
||||||
|
) : ( |
||||||
|
<Music2 className="size-8" strokeWidth={2} /> |
||||||
|
)} |
||||||
|
</span> |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
import { isBlurhashValid } from 'blurhash' |
||||||
|
|
||||||
|
/** |
||||||
|
* Stable, varied BlurHash strings for lazy media (no imeta). Picked from the reference |
||||||
|
* encoder corpus; each validates with {@link isBlurhashValid}. |
||||||
|
*/ |
||||||
|
const PLACEHOLDER_BLURHASHES = [ |
||||||
|
'LEHV6nWB2yk8pyo0adR*.7kCMdnj', |
||||||
|
'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.', |
||||||
|
'LjIY%^?bH?xu_4t8V_NHxZxbx]ae', |
||||||
|
'L6PZfSjE.Adjc0j]WCWVH?j?bHwc', |
||||||
|
'LKO2?U%2Tw[w]~RBVZRi};RPxuwH', |
||||||
|
'LdHxL5Rk^6#M@-5c,1J5@[or[Q6.', |
||||||
|
'LGF?UQ%2Tw[w]~RBVZRi};RPxuwH', |
||||||
|
'L6PZ0Si_.AyE_3t7t7R**0o#D%IU' |
||||||
|
].filter((h) => isBlurhashValid(h).result) |
||||||
|
|
||||||
|
function fallbackHashString(s: string): number { |
||||||
|
let h = 0 |
||||||
|
for (let i = 0; i < s.length; i++) { |
||||||
|
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0 |
||||||
|
} |
||||||
|
return Math.abs(h) |
||||||
|
} |
||||||
|
|
||||||
|
/** Deterministic placeholder blurhash from media URL (stable across re-renders). */ |
||||||
|
export function blurHashPlaceholderForMediaUrl(url: string): string { |
||||||
|
if (PLACEHOLDER_BLURHASHES.length === 0) { |
||||||
|
return 'LEHV6nWB2yk8pyo0adR*.7kCMdnj' |
||||||
|
} |
||||||
|
const i = fallbackHashString(url.trim()) % PLACEHOLDER_BLURHASHES.length |
||||||
|
return PLACEHOLDER_BLURHASHES[i]! |
||||||
|
} |
||||||
|
|
||||||
|
/** Use NIP-94 blurHash when valid; otherwise URL-derived placeholder. */ |
||||||
|
export function resolveMediaBlurPlaceholder(url: string, blurHash?: string): string { |
||||||
|
if (blurHash?.trim()) { |
||||||
|
const v = isBlurhashValid(blurHash.trim()) |
||||||
|
if (v.result) return blurHash.trim() |
||||||
|
} |
||||||
|
return blurHashPlaceholderForMediaUrl(url) |
||||||
|
} |
||||||
Loading…
Reference in new issue