(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)
+}