From f93092d42e9317440680b4b09d927f00781f92c4 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 9 Apr 2026 17:34:43 +0200 Subject: [PATCH] bug-fixes --- src/components/Content/index.tsx | 28 +++-- .../MediaPlayer/LazyMediaTapPlaceholder.tsx | 116 ++++++++++++++++++ src/components/MediaPlayer/index.tsx | 27 ++-- .../Note/MarkdownArticle/MarkdownArticle.tsx | 72 +++++++++-- src/lib/media-placeholder-blurhash.ts | 42 +++++++ 5 files changed, 249 insertions(+), 36 deletions(-) create mode 100644 src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx create mode 100644 src/lib/media-placeholder-blurhash.ts diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index a36f1801..8f34e483 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -362,6 +362,8 @@ export default function Content({ src={video.url} className="w-full max-w-full" mustLoad={mustLoadMedia} + poster={video.image || video.thumb} + blurHash={video.blurHash} /> ))} @@ -373,6 +375,8 @@ export default function Content({ src={audio.url} className="mt-2" mustLoad={mustLoadMedia} + poster={audio.thumb} + blurHash={audio.blurHash} /> ))} @@ -406,12 +410,15 @@ export default function Content({ return null } renderedUrls.add(cleanedUrl) + const tagMediaInfo = mediaMap.get(cleanedUrl) return ( - ) } @@ -431,14 +438,15 @@ export default function Content({ if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) { renderedUrls.add(cleanedUrl) const mediaInfo = mediaMap.get(cleanedUrl) - const poster = mediaInfo?.image + const poster = mediaInfo?.image || mediaInfo?.thumb return ( - ) } diff --git a/src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx b/src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx new file mode 100644 index 00000000..009c24d4 --- /dev/null +++ b/src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx @@ -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(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
+ } + + return ( + + ) +} + +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 ( + + ) +} diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx index 521e4afe..04186ca9 100644 --- a/src/components/MediaPlayer/index.tsx +++ b/src/components/MediaPlayer/index.tsx @@ -1,22 +1,24 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' import AudioPlayer from '../AudioPlayer' import VideoPlayer from '../VideoPlayer' import ExternalLink from '../ExternalLink' +import LazyMediaTapPlaceholder from './LazyMediaTapPlaceholder' export default function MediaPlayer({ src, className, mustLoad = false, - poster + poster, + blurHash }: { src: string className?: string mustLoad?: boolean poster?: string + /** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */ + blurHash?: string }) { - const { t } = useTranslation() const { autoLoadMedia } = useContentPolicy() const [display, setDisplay] = useState(autoLoadMedia) const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null) @@ -50,7 +52,6 @@ export default function MediaPlayer({ return } - // Matroska / Ogg Theora: treat as video first (codec support is browser-dependent). if (extension === 'mkv' || extension === 'ogv') { setMediaType('video') return @@ -59,8 +60,6 @@ export default function MediaPlayer({ const video = document.createElement('video') video.src = src video.preload = 'metadata' - // Only set crossOrigin for resources that support it (prevents CORS errors) - // video.crossOrigin = 'anonymous' video.onloadedmetadata = () => { setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio') @@ -77,15 +76,13 @@ export default function MediaPlayer({ if (!mustLoad && !display) { return ( -
{ - e.stopPropagation() - setDisplay(true) - }} - > - [{t('Click to load media')}] -
+ setDisplay(true)} + className={className} + /> ) } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 34b873e4..14253d13 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -627,6 +627,8 @@ function parseMarkdownContentLegacy( navigateToHashtag: (href: string) => void navigateToRelay: (url: string) => void videoPosterMap?: Map + /** Cleaned media URL → blurhash (from any imeta with `blurhash` / `bh`, incl. video/audio). */ + mediaBlurHashMap?: Map imageThumbnailMap?: Map getImageIdentifier?: (url: string) => string | null emojiInfos?: TEmoji[] @@ -649,6 +651,7 @@ function parseMarkdownContentLegacy( navigateToHashtag, navigateToRelay, videoPosterMap, + mediaBlurHashMap, imageThumbnailMap, getImageIdentifier, emojiInfos = [], @@ -1955,6 +1958,7 @@ function parseMarkdownContentLegacy( className="max-w-[400px]" mustLoad={!lazyMedia} poster={poster} + blurHash={mediaBlurHashMap?.get(cleaned)} />
) @@ -2053,6 +2057,7 @@ function parseMarkdownContentLegacy( className="max-w-[400px]" mustLoad={!lazyMedia} poster={poster} + blurHash={mediaBlurHashMap?.get(cleanedStandalone)} /> ) @@ -2957,6 +2962,7 @@ function parseMarkdownContentMarked( navigateToHashtag: (href: string) => void navigateToRelay: (url: string) => void videoPosterMap?: Map + mediaBlurHashMap?: Map imageThumbnailMap?: Map getImageIdentifier?: (url: string) => string | null emojiInfos?: TEmoji[] @@ -2975,6 +2981,7 @@ function parseMarkdownContentMarked( navigateToHashtag, navigateToRelay, videoPosterMap, + mediaBlurHashMap, getImageIdentifier, emojiInfos = [], fullCalendarInvite, @@ -3317,7 +3324,13 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) return (
- +
) } @@ -3457,7 +3470,13 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) return (
- +
) } @@ -3535,7 +3554,13 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) nodes.push(
- +
) }) @@ -3611,7 +3636,13 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) return (
- +
) } @@ -4480,7 +4511,12 @@ export default function MarkdownArticle({ // Extract media from tags only (for display at top) const tagMedia = useMemo(() => { const seenUrls = new Set() - const media: Array<{ url: string; type: 'image' | 'video' | 'audio'; poster?: string }> = [] + const media: Array<{ + url: string + type: 'image' | 'video' | 'audio' + poster?: string + blurHash?: string + }> = [] // Extract from imeta tags const imetaInfos = getImetaInfosFromEvent(event) @@ -4493,9 +4529,19 @@ export default function MarkdownArticle({ if (info.m?.startsWith('image/') || isImage(cleaned)) { media.push({ url: info.url, type: 'image' }) } else if (info.m?.startsWith('video/') || isVideo(cleaned)) { - media.push({ url: info.url, type: 'video', poster: info.image }) + media.push({ + url: info.url, + type: 'video', + poster: info.image || info.thumb, + blurHash: info.blurHash + }) } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { - media.push({ url: info.url, type: 'audio' }) + media.push({ + url: info.url, + type: 'audio', + poster: info.thumb, + blurHash: info.blurHash + }) } }) @@ -4787,10 +4833,11 @@ export default function MarkdownArticle({ const map = new Map() const imetaInfos = getImetaInfosFromEvent(event) imetaInfos.forEach((info) => { - if (info.image && (info.m?.startsWith('video/') || isVideo(info.url))) { + if (info.m?.startsWith('video/') || isVideo(info.url)) { const cleaned = cleanUrl(info.url) - if (cleaned) { - map.set(cleaned, info.image) + const posterUrl = info.image || info.thumb + if (cleaned && posterUrl) { + map.set(cleaned, posterUrl) } } }) @@ -4818,7 +4865,7 @@ export default function MarkdownArticle({ return map }, [event.id, JSON.stringify(event.tags), getImageIdentifier]) - // Maps cleaned image URL → blurhash string (for inline placeholder rendering) + // Maps cleaned media URL → blurhash (any imeta with blurHash / bh — images, video, audio) const imageBlurHashMap = useMemo(() => { const map = new Map() getImetaInfosFromEvent(event).forEach((info) => { @@ -4853,6 +4900,7 @@ export default function MarkdownArticle({ navigateToHashtag, navigateToRelay, videoPosterMap, + mediaBlurHashMap: imageBlurHashMap, imageThumbnailMap, getImageIdentifier, emojiInfos, @@ -4881,6 +4929,7 @@ export default function MarkdownArticle({ navigateToHashtag, navigateToRelay, videoPosterMap, + imageBlurHashMap, imageThumbnailMap, getImageIdentifier, emojiInfos, @@ -5068,6 +5117,7 @@ export default function MarkdownArticle({ className="max-w-full sm:max-w-[400px] w-full" mustLoad={!lazyMedia} poster={media.poster} + blurHash={media.blurHash} /> ) diff --git a/src/lib/media-placeholder-blurhash.ts b/src/lib/media-placeholder-blurhash.ts new file mode 100644 index 00000000..18d44e00 --- /dev/null +++ b/src/lib/media-placeholder-blurhash.ts @@ -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) +}