diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 77a08ba..9ee0093 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -148,14 +148,16 @@ export default function Content({ const imageMap = new Map() const mediaMap = new Map() extractedMedia.all.forEach((img: TImetaInfo) => { + const cleaned = cleanUrl(img.url) + if (!cleaned) return if (img.m?.startsWith('image/')) { - imageMap.set(img.url, img) + imageMap.set(cleaned, img) } else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') { - mediaMap.set(img.url, img) - } else if (isImage(img.url)) { - imageMap.set(img.url, img) - } else if (isMedia(img.url)) { - mediaMap.set(img.url, img) + mediaMap.set(cleaned, img) + } else if (isImage(cleaned)) { + imageMap.set(cleaned, img) + } else if (isMedia(cleaned)) { + mediaMap.set(cleaned, img) } }) @@ -335,12 +337,15 @@ export default function Content({ // Check video/audio first - never put them in ImageGallery if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) { renderedUrls.add(cleanedUrl) + const mediaInfo = mediaMap.get(cleanedUrl) + const poster = mediaInfo?.image return ( ) } diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx index bfda00c..2732d43 100644 --- a/src/components/MediaPlayer/index.tsx +++ b/src/components/MediaPlayer/index.tsx @@ -8,11 +8,13 @@ import ExternalLink from '../ExternalLink' export default function MediaPlayer({ src, className, - mustLoad = false + mustLoad = false, + poster }: { src: string className?: string mustLoad?: boolean + poster?: string }) { const { t } = useTranslation() const { autoLoadMedia } = useContentPolicy() @@ -83,7 +85,7 @@ export default function MediaPlayer({ } if (mediaType === 'video') { - return + return } return diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 0c9164c..b5c96a6 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -75,7 +75,7 @@ export default function AsciidocArticle({ // 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' }> = [] + const media: Array<{ url: string; type: 'image' | 'video' | 'audio'; poster?: string }> = [] // Extract from imeta tags const imetaInfos = getImetaInfosFromEvent(event) @@ -88,7 +88,7 @@ export default function AsciidocArticle({ 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' }) + media.push({ url: info.url, type: 'video', poster: info.image }) } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { media.push({ url: info.url, type: 'audio' }) } @@ -877,6 +877,7 @@ export default function AsciidocArticle({ src={media.url} className="max-w-[400px]" mustLoad={true} + poster={media.poster} /> ) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 0b82956..0038e20 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -55,9 +55,10 @@ function parseMarkdownContent( openLightbox: (index: number) => void navigateToHashtag: (href: string) => void navigateToRelay: (url: string) => void + videoPosterMap?: Map } ): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map } { - const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay } = options + const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap } = options const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() const footnotes = new Map() @@ -484,12 +485,14 @@ function parseMarkdownContent( ) } else if (isVideo(cleaned) || isAudio(cleaned)) { + const poster = videoPosterMap?.get(cleaned) parts.push(
) @@ -1127,7 +1130,7 @@ 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' }> = [] + const media: Array<{ url: string; type: 'image' | 'video' | 'audio'; poster?: string }> = [] // Extract from imeta tags const imetaInfos = getImetaInfosFromEvent(event) @@ -1140,7 +1143,7 @@ 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' }) + media.push({ url: info.url, type: 'video', poster: info.image }) } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { media.push({ url: info.url, type: 'audio' }) } @@ -1349,6 +1352,21 @@ export default function MarkdownArticle({ return preprocessMarkdownMediaLinks(event.content) }, [event.content]) + // Create video poster map from imeta tags + const videoPosterMap = useMemo(() => { + const map = new Map() + const imetaInfos = getImetaInfosFromEvent(event) + imetaInfos.forEach((info) => { + if (info.image && (info.m?.startsWith('video/') || isVideo(info.url))) { + const cleaned = cleanUrl(info.url) + if (cleaned) { + map.set(cleaned, info.image) + } + } + }) + return map + }, [event.id, JSON.stringify(event.tags)]) + // Parse markdown content with post-processing for nostr: links and hashtags const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { const result = parseMarkdownContent(preprocessedContent, { @@ -1356,11 +1374,12 @@ export default function MarkdownArticle({ imageIndexMap, openLightbox, navigateToHashtag, - navigateToRelay + navigateToRelay, + videoPosterMap }) // Return nodes and hashtags (footnotes are already included in nodes) return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent } - }, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay]) + }, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap]) // Filter metadata tags to only show what's not already in content const leftoverMetadataTags = useMemo(() => { @@ -1453,6 +1472,7 @@ export default function MarkdownArticle({ src={media.url} className="max-w-[400px]" mustLoad={true} + poster={media.poster} /> ) diff --git a/src/components/VideoPlayer/index.tsx b/src/components/VideoPlayer/index.tsx index 8e3546c..3196a1f 100644 --- a/src/components/VideoPlayer/index.tsx +++ b/src/components/VideoPlayer/index.tsx @@ -6,7 +6,7 @@ import ExternalLink from '../ExternalLink' import { MediaErrorBoundary } from '../MediaErrorBoundary' import logger from '@/lib/logger' -export default function VideoPlayer({ src, className }: { src: string; className?: string }) { +export default function VideoPlayer({ src, className, poster }: { src: string; className?: string; poster?: string }) { const { autoplay } = useContentPolicy() const [error, setError] = useState(false) const videoRef = useRef(null) @@ -64,6 +64,7 @@ export default function VideoPlayer({ src, className }: { src: string; className playsInline className={cn('rounded-lg max-h-[80vh] sm:max-h-[60vh] border', className)} src={src} + poster={poster} onClick={(e) => e.stopPropagation()} onPlay={(event) => { mediaManager.play(event.currentTarget) diff --git a/src/lib/tag.ts b/src/lib/tag.ts index 6597211..3ebe13b 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -180,6 +180,25 @@ export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImeta imeta.fallback = fallbackUrls } + // Parse image/poster URL (for videos) + let imageUrl: string | undefined + + // First try the space-separated format + const imageItem = tag.find((item) => item.startsWith('image ')) + if (imageItem) { + imageUrl = imageItem.slice(6) + } else { + // Try the separate element format + const imageIndex = tag.findIndex((item) => item === 'image') + if (imageIndex !== -1 && imageIndex + 1 < tag.length) { + imageUrl = tag[imageIndex + 1] + } + } + + if (imageUrl) { + imeta.image = cleanUrl(imageUrl) + } + return imeta } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 8c49d0b..dd5564b 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -123,6 +123,7 @@ export type TImetaInfo = { alt?: string // Alternative text x?: string // SHA256 hash as specified in NIP 94 fallback?: string[] // Array of fallback URLs + image?: string // Poster/thumbnail image URL (for videos) } export type TPublishOptions = {