Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
f93092d42e
  1. 28
      src/components/Content/index.tsx
  2. 116
      src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx
  3. 27
      src/components/MediaPlayer/index.tsx
  4. 72
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 42
      src/lib/media-placeholder-blurhash.ts

28
src/components/Content/index.tsx

@ -362,6 +362,8 @@ export default function Content({ @@ -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}
/>
</div>
))}
@ -373,6 +375,8 @@ export default function Content({ @@ -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({ @@ -406,12 +410,15 @@ export default function Content({
return null
}
renderedUrls.add(cleanedUrl)
const tagMediaInfo = mediaMap.get(cleanedUrl)
return (
<MediaPlayer
className="mt-2"
key={index}
src={cleanedUrl}
mustLoad={mustLoadMedia}
<MediaPlayer
className="mt-2"
key={index}
src={cleanedUrl}
mustLoad={mustLoadMedia}
poster={tagMediaInfo?.image || tagMediaInfo?.thumb}
blurHash={tagMediaInfo?.blurHash}
/>
)
}
@ -431,14 +438,15 @@ export default function Content({ @@ -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 (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
poster={poster}
blurHash={mediaInfo?.blurHash}
/>
)
}

116
src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx

@ -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>
)
}

27
src/components/MediaPlayer/index.tsx

@ -1,22 +1,24 @@ @@ -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({ @@ -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({ @@ -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({ @@ -77,15 +76,13 @@ export default function MediaPlayer({
if (!mustLoad && !display) {
return (
<div
className="text-primary hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load media')}]
</div>
<LazyMediaTapPlaceholder
src={src}
posterUrl={poster}
blurHash={blurHash}
onActivate={() => setDisplay(true)}
className={className}
/>
)
}

72
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -627,6 +627,8 @@ function parseMarkdownContentLegacy( @@ -627,6 +627,8 @@ function parseMarkdownContentLegacy(
navigateToHashtag: (href: string) => void
navigateToRelay: (url: string) => void
videoPosterMap?: Map<string, string>
/** Cleaned media URL → blurhash (from any imeta with `blurhash` / `bh`, incl. video/audio). */
mediaBlurHashMap?: Map<string, string>
imageThumbnailMap?: Map<string, string>
getImageIdentifier?: (url: string) => string | null
emojiInfos?: TEmoji[]
@ -649,6 +651,7 @@ function parseMarkdownContentLegacy( @@ -649,6 +651,7 @@ function parseMarkdownContentLegacy(
navigateToHashtag,
navigateToRelay,
videoPosterMap,
mediaBlurHashMap,
imageThumbnailMap,
getImageIdentifier,
emojiInfos = [],
@ -1955,6 +1958,7 @@ function parseMarkdownContentLegacy( @@ -1955,6 +1958,7 @@ function parseMarkdownContentLegacy(
className="max-w-[400px]"
mustLoad={!lazyMedia}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
/>
</div>
)
@ -2053,6 +2057,7 @@ function parseMarkdownContentLegacy( @@ -2053,6 +2057,7 @@ function parseMarkdownContentLegacy(
className="max-w-[400px]"
mustLoad={!lazyMedia}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleanedStandalone)}
/>
</div>
)
@ -2957,6 +2962,7 @@ function parseMarkdownContentMarked( @@ -2957,6 +2962,7 @@ function parseMarkdownContentMarked(
navigateToHashtag: (href: string) => void
navigateToRelay: (url: string) => void
videoPosterMap?: Map<string, string>
mediaBlurHashMap?: Map<string, string>
imageThumbnailMap?: Map<string, string>
getImageIdentifier?: (url: string) => string | null
emojiInfos?: TEmoji[]
@ -2975,6 +2981,7 @@ function parseMarkdownContentMarked( @@ -2975,6 +2981,7 @@ function parseMarkdownContentMarked(
navigateToHashtag,
navigateToRelay,
videoPosterMap,
mediaBlurHashMap,
getImageIdentifier,
emojiInfos = [],
fullCalendarInvite,
@ -3317,7 +3324,13 @@ function parseMarkdownContentMarked( @@ -3317,7 +3324,13 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-line-media-${lineIdx}`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
}
@ -3457,7 +3470,13 @@ function parseMarkdownContentMarked( @@ -3457,7 +3470,13 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-media-url`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
}
@ -3535,7 +3554,13 @@ function parseMarkdownContentMarked( @@ -3535,7 +3554,13 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
nodes.push(
<div key={`${key}-inline-media-${idx}`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
})
@ -3611,7 +3636,13 @@ function parseMarkdownContentMarked( @@ -3611,7 +3636,13 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-media-block`} className="my-2">
<MediaPlayer src={src} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
}
@ -4480,7 +4511,12 @@ export default function MarkdownArticle({ @@ -4480,7 +4511,12 @@ export default function MarkdownArticle({
// Extract media from tags only (for display at top)
const tagMedia = useMemo(() => {
const seenUrls = new Set<string>()
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({ @@ -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({ @@ -4787,10 +4833,11 @@ export default function MarkdownArticle({
const map = new Map<string, string>()
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({ @@ -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<string, string>()
getImetaInfosFromEvent(event).forEach((info) => {
@ -4853,6 +4900,7 @@ export default function MarkdownArticle({ @@ -4853,6 +4900,7 @@ export default function MarkdownArticle({
navigateToHashtag,
navigateToRelay,
videoPosterMap,
mediaBlurHashMap: imageBlurHashMap,
imageThumbnailMap,
getImageIdentifier,
emojiInfos,
@ -4881,6 +4929,7 @@ export default function MarkdownArticle({ @@ -4881,6 +4929,7 @@ export default function MarkdownArticle({
navigateToHashtag,
navigateToRelay,
videoPosterMap,
imageBlurHashMap,
imageThumbnailMap,
getImageIdentifier,
emojiInfos,
@ -5068,6 +5117,7 @@ export default function MarkdownArticle({ @@ -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}
/>
</div>
)

42
src/lib/media-placeholder-blurhash.ts

@ -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…
Cancel
Save