5 changed files with 249 additions and 36 deletions
@ -0,0 +1,116 @@
@@ -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 @@
@@ -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