Browse Source

expand and correct live streaming

imwald
Silberengel 2 weeks ago
parent
commit
8e8b428aaa
  1. 33
      src/components/Content/index.tsx
  2. 3
      src/components/KindFilter/index.tsx
  3. 34
      src/components/MediaPlayer/index.tsx
  4. 4
      src/components/Note/LiveEvent.tsx
  5. 176
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 1
      src/i18n/locales/de.ts
  7. 1
      src/i18n/locales/en.ts
  8. 4
      src/lib/content-parser.ts
  9. 12
      src/lib/event-metadata.ts
  10. 121
      src/lib/live-activities.test.ts
  11. 130
      src/lib/live-activities.ts
  12. 17
      src/lib/url.ts
  13. 27
      src/services/media-extraction.service.ts

33
src/components/Content/index.tsx

@ -7,7 +7,7 @@ import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getHttpUrlFromITags } from '@/lib/event' import { getHttpUrlFromITags } from '@/lib/event'
import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url' import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url'
import { cleanUrl, isImage, isMedia, isAudio, isVideo, isPseudoNostrHttpsUrl } from '@/lib/url' import { cleanUrl, isImage, isMedia, isAudio, isVideo, isHlsPlaylistUrl, isPseudoNostrHttpsUrl } from '@/lib/url'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides' import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
@ -174,6 +174,7 @@ export default function Content({
!isPseudoNostrHttpsUrl(url) && !isPseudoNostrHttpsUrl(url) &&
!isImage(url) && !isImage(url) &&
!isMedia(url) && !isMedia(url) &&
!isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) && !isYouTubeUrl(url) &&
!isSpotifyOpenUrl(url) && !isSpotifyOpenUrl(url) &&
!isZapStreamWatchUrl(url) !isZapStreamWatchUrl(url)
@ -290,6 +291,7 @@ export default function Content({
!isPseudoNostrHttpsUrl(url) && !isPseudoNostrHttpsUrl(url) &&
!isImage(url) && !isImage(url) &&
!isMedia(url) && !isMedia(url) &&
!isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) && !isYouTubeUrl(url) &&
!isSpotifyOpenUrl(url) && !isSpotifyOpenUrl(url) &&
!isZapStreamWatchUrl(url) !isZapStreamWatchUrl(url)
@ -314,7 +316,12 @@ export default function Content({
if (!cleaned) return if (!cleaned) return
if (img.m?.startsWith('image/')) { if (img.m?.startsWith('image/')) {
imageMap.set(cleaned, img) imageMap.set(cleaned, img)
} else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') { } else if (
img.m?.startsWith('video/') ||
img.m?.startsWith('audio/') ||
img.m === 'media/*' ||
isHlsPlaylistUrl(cleaned)
) {
mediaMap.set(cleaned, img) mediaMap.set(cleaned, img)
} else if (isImage(cleaned)) { } else if (isImage(cleaned)) {
imageMap.set(cleaned, img) imageMap.set(cleaned, img)
@ -365,15 +372,15 @@ export default function Content({
const cleanedUrl = cleanUrl(node.data) const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl) mediaInContent.add(cleanedUrl)
const mediaInfo = mediaMap.get(cleanedUrl) const mediaInfo = mediaMap.get(cleanedUrl)
if (mediaInfo) { if (isVideo(cleanedUrl) || isHlsPlaylistUrl(cleanedUrl) || mediaInfo?.m?.startsWith('video/')) {
if (isVideo(cleanedUrl) || mediaInfo.m?.startsWith('video/')) { const row = mediaInfo || { url: cleanedUrl, pubkey: event?.pubkey, m: 'video/*' }
if (!videosInContent.find(v => v.url === cleanedUrl)) { if (!videosInContent.find((v) => v.url === cleanedUrl)) {
videosInContent.push(mediaInfo) videosInContent.push(row)
}
} else if (isAudio(cleanedUrl) || mediaInfo.m?.startsWith('audio/')) {
if (!audioInContent.find(a => a.url === cleanedUrl)) {
audioInContent.push(mediaInfo)
} }
} else if (isAudio(cleanedUrl) || mediaInfo?.m?.startsWith('audio/')) {
const row = mediaInfo || { url: cleanedUrl, pubkey: event?.pubkey, m: 'audio/*' }
if (!audioInContent.find((a) => a.url === cleanedUrl)) {
audioInContent.push(row)
} }
} }
} else if (node.type === 'url') { } else if (node.type === 'url') {
@ -384,7 +391,7 @@ export default function Content({
if (!imagesInContent.find(img => img.url === cleanedUrl)) { if (!imagesInContent.find(img => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo) imagesInContent.push(imageInfo)
} }
} else if (isVideo(cleanedUrl)) { } else if (isVideo(cleanedUrl) || isHlsPlaylistUrl(cleanedUrl)) {
mediaInContent.add(cleanedUrl) mediaInContent.add(cleanedUrl)
const videoInfo = mediaMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey, m: 'video/*' } const videoInfo = mediaMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey, m: 'video/*' }
if (!videosInContent.find(v => v.url === cleanedUrl)) { if (!videosInContent.find(v => v.url === cleanedUrl)) {
@ -562,8 +569,8 @@ export default function Content({
return null return null
} }
// Check video/audio first - never put them in ImageGallery // Check video/audio/HLS first - never put them in ImageGallery
if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) { if (isVideoUrl || isAudioUrl || isHlsPlaylistUrl(cleanedUrl) || mediaMap.has(cleanedUrl)) {
renderedUrls.add(cleanedUrl) renderedUrls.add(cleanedUrl)
const mediaInfo = mediaMap.get(cleanedUrl) const mediaInfo = mediaMap.get(cleanedUrl)
const poster = mediaInfo?.image || mediaInfo?.thumb const poster = mediaInfo?.image || mediaInfo?.thumb

3
src/components/KindFilter/index.tsx

@ -5,6 +5,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants' import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants'
import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -26,7 +27,7 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' }, { kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' },
{ kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' }, { kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' },
{ kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' }, { kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' },
{ kindGroup: [kinds.LiveEvent], label: 'Live streams' }, { kindGroup: [...LIVE_ACTIVITY_KINDS], label: 'Live streams' },
{ kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' }, { kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' },
{ kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' }, { kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' },
{ kindGroup: [ExtendedKind.GIT_REPO_ANNOUNCEMENT], label: 'Git repositories' }, { kindGroup: [ExtendedKind.GIT_REPO_ANNOUNCEMENT], label: 'Git repositories' },

34
src/components/MediaPlayer/index.tsx

@ -1,4 +1,4 @@
import { isHlsPlaylistUrl, isImage } from '@/lib/url' import { isHlsPlaylistUrl, isImage, isZapStreamWatchPageUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
@ -8,8 +8,11 @@ import VideoPlayer from '../VideoPlayer'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
import LazyMediaTapPlaceholder, { MediaEmbedBlurFrame } from './LazyMediaTapPlaceholder' import LazyMediaTapPlaceholder, { MediaEmbedBlurFrame } from './LazyMediaTapPlaceholder'
type MediaSurface = 'video' | 'audio' | 'iframe' | null
/** Same rules as the metadata probe, but synchronous so the first paint can show the embed stack. */ /** Same rules as the metadata probe, but synchronous so the first paint can show the embed stack. */
function embedMediaTypeHintFromUrl(src: string): 'video' | 'audio' | null { function embedMediaSurfaceHintFromUrl(src: string): MediaSurface {
if (isZapStreamWatchPageUrl(src)) return 'iframe'
try { try {
const url = new URL(src) const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase() const extension = url.pathname.split('.').pop()?.toLowerCase()
@ -55,7 +58,7 @@ export default function MediaPlayer({
const { autoLoadMedia } = useContentPolicy() const { autoLoadMedia } = useContentPolicy()
/** Tap-to-load when {@link autoLoadMedia} is off; cleared when policy switches back to never. */ /** Tap-to-load when {@link autoLoadMedia} is off; cleared when policy switches back to never. */
const [userClickedLoad, setUserClickedLoad] = useState(false) const [userClickedLoad, setUserClickedLoad] = useState(false)
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null) const [mediaType, setMediaType] = useState<MediaSurface>(null)
const [probeFailed, setProbeFailed] = useState(false) const [probeFailed, setProbeFailed] = useState(false)
const [embedPainted, setEmbedPainted] = useState(false) const [embedPainted, setEmbedPainted] = useState(false)
const readyOnceRef = useRef(false) const readyOnceRef = useRef(false)
@ -68,9 +71,9 @@ export default function MediaPlayer({
return isImage(p) ? p : undefined return isImage(p) ? p : undefined
}, [poster]) }, [poster])
const urlEmbedTypeHint = useMemo(() => embedMediaTypeHintFromUrl(src), [src]) const urlEmbedSurfaceHint = useMemo(() => embedMediaSurfaceHintFromUrl(src), [src])
/** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */ /** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */
const effectiveMediaType = mediaType ?? urlEmbedTypeHint const effectiveMediaType = mediaType ?? urlEmbedSurfaceHint
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
@ -110,6 +113,11 @@ export default function MediaPlayer({
return return
} }
if (isZapStreamWatchPageUrl(src)) {
setMediaType('iframe')
return
}
const url = new URL(src) const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase() const extension = url.pathname.split('.').pop()?.toLowerCase()
@ -162,6 +170,9 @@ export default function MediaPlayer({
return t('Preparing player…', { defaultValue: 'Preparing player…' }) return t('Preparing player…', { defaultValue: 'Preparing player…' })
} }
if (!embedPainted) { if (!embedPainted) {
if (isZapStreamWatchPageUrl(src)) {
return t('Starting stream…', { defaultValue: 'Starting stream…' })
}
if (isHlsPlaylistUrl(src)) { if (isHlsPlaylistUrl(src)) {
return t('Starting stream…', { defaultValue: 'Starting stream…' }) return t('Starting stream…', { defaultValue: 'Starting stream…' })
} }
@ -223,7 +234,18 @@ export default function MediaPlayer({
)} )}
aria-hidden={!embedPainted} aria-hidden={!embedPainted}
> >
{effectiveMediaType === 'video' ? ( {effectiveMediaType === 'iframe' ? (
<iframe
src={src}
title={t('liveEvent.zapStreamPlayer')}
className={cn('aspect-video h-[min(520px,70dvh)] w-full rounded-md border border-border bg-black', className)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
allow="autoplay; encrypted-media; microphone; clipboard-write"
onLoad={onEmbedReady}
/>
) : effectiveMediaType === 'video' ? (
<VideoPlayer <VideoPlayer
src={src} src={src}
className={className} className={className}

4
src/components/Note/LiveEvent.tsx

@ -4,6 +4,7 @@ import { createFakeEvent } from '@/lib/event'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata' import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import { import {
liveEventInlinePlaybackFromEvent, liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl,
preferredLiveJoinUrlForEvent preferredLiveJoinUrlForEvent
} from '@/lib/live-activities' } from '@/lib/live-activities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -27,6 +28,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event]) const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event])
const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event]) const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event])
const zapStreamFallbackUrl = useMemo(() => liveEventZapStreamWatchUrl(event), [event])
/** Video/HLS: prefer `thumb`, then `image`. Audio: prefer NIP-53 `image`, then `thumb` (still on the player). */ /** Video/HLS: prefer `thumb`, then `image`. Audio: prefer NIP-53 `image`, then `thumb` (still on the player). */
const posterUrl = metadata.thumb ?? metadata.image const posterUrl = metadata.thumb ?? metadata.image
const inlinePlayerPoster = const inlinePlayerPoster =
@ -131,7 +133,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
src={playback.src} src={playback.src}
poster={inlinePlayerPoster} poster={inlinePlayerPoster}
className="w-full" className="w-full"
fallbackPageUrl={joinUrl ?? undefined} fallbackPageUrl={zapStreamFallbackUrl ?? joinUrl ?? undefined}
/> />
</div> </div>
) : null} ) : null}

176
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -18,7 +18,8 @@ import {
isAudio, isAudio,
isWebsocketUrl, isWebsocketUrl,
isPseudoNostrHttpsUrl, isPseudoNostrHttpsUrl,
isSafeMediaUrl isSafeMediaUrl,
isHlsPlaylistUrl
} from '@/lib/url' } from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article' import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
@ -2029,7 +2030,7 @@ function parseMarkdownContentLegacy(
/> />
</div> </div>
) )
} else if (isVideo(cleaned) || isAudio(cleaned)) { } else if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned) const poster = videoPosterMap?.get(cleaned)
parts.push( parts.push(
<div key={`media-${patternIdx}`} className="my-2"> <div key={`media-${patternIdx}`} className="my-2">
@ -2128,7 +2129,7 @@ function parseMarkdownContentLegacy(
} else if (pattern.type === 'markdown-link-standalone') { } else if (pattern.type === 'markdown-link-standalone') {
const { url } = pattern.data const { url } = pattern.data
const cleanedStandalone = cleanUrl(url) const cleanedStandalone = cleanUrl(url)
if (cleanedStandalone && (isVideo(cleanedStandalone) || isAudio(cleanedStandalone))) { if (cleanedStandalone && (isVideo(cleanedStandalone) || isAudio(cleanedStandalone) || isHlsPlaylistUrl(cleanedStandalone))) {
const poster = videoPosterMap?.get(cleanedStandalone) const poster = videoPosterMap?.get(cleanedStandalone)
parts.push( parts.push(
<div key={`media-standalone-${patternIdx}`} className="my-2"> <div key={`media-standalone-${patternIdx}`} className="my-2">
@ -3243,17 +3244,18 @@ function parseMarkdownContentMarked(
const cleaned = cleanUrl(src) const cleaned = cleanUrl(src)
if (!cleaned) break if (!cleaned) break
const label = String(token.text ?? '') const label = String(token.text ?? '')
if (isVideo(cleaned) || isAudio(cleaned)) { if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
out.push( out.push(
<a <div key={`${key}-media-inline`} className="my-2 not-prose">
key={`${key}-media-link`} <MediaPlayer
href={src} src={cleaned}
target="_blank" poster={poster}
rel="noopener noreferrer" blurHash={mediaBlurHashMap?.get(cleaned)}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className="max-w-[400px]"
> mustLoad={!lazyMedia}
{label || src} />
</a> </div>
) )
break break
} }
@ -3439,7 +3441,7 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
if (isVideo(cleaned) || isAudio(cleaned)) { if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned) const poster = videoPosterMap?.get(cleaned)
return ( return (
<div key={`${key}-line-media-${lineIdx}`} className="my-2"> <div key={`${key}-line-media-${lineIdx}`} className="my-2">
@ -3604,7 +3606,7 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
if (isVideo(cleaned) || isAudio(cleaned)) { if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned) const poster = videoPosterMap?.get(cleaned)
return ( return (
<div key={`${key}-media-url`} className="my-2"> <div key={`${key}-media-url`} className="my-2">
@ -3678,6 +3680,20 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
if (soleHref && (isVideo(soleHref) || isAudio(soleHref) || isHlsPlaylistUrl(soleHref))) {
const poster = videoPosterMap?.get(soleHref)
return (
<div key={`${key}-direct-media-sole-link`} className="my-2">
<MediaPlayer
src={soleHref}
className="max-w-[400px]"
mustLoad={!lazyMedia}
poster={poster}
blurHash={mediaBlurHashMap?.get(soleHref)}
/>
</div>
)
}
} }
const parseNostrHref = (href: string): string | null => { const parseNostrHref = (href: string): string | null => {
@ -3694,7 +3710,7 @@ function parseMarkdownContentMarked(
const hasInlineMediaImageToken = paragraphTokens.some((t) => { const hasInlineMediaImageToken = paragraphTokens.some((t) => {
if (t?.type !== 'image') return false if (t?.type !== 'image') return false
const cleaned = cleanUrl(String(t.href ?? '')) const cleaned = cleanUrl(String(t.href ?? ''))
return !!cleaned && (isVideo(cleaned) || isAudio(cleaned)) return !!cleaned && (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned))
}) })
if (hasInlineMediaImageToken) { if (hasInlineMediaImageToken) {
const nodes: React.ReactNode[] = [] const nodes: React.ReactNode[] = []
@ -3751,6 +3767,22 @@ function parseMarkdownContentMarked(
) )
return return
} }
if (cleaned && (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned))) {
flushInlineSegment(segmentIdx++)
const poster = videoPosterMap?.get(cleaned)
nodes.push(
<div key={`${key}-inline-direct-media-with-mixed-${idx}`} className="my-2">
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
return
}
} }
if (t?.type !== 'image') { if (t?.type !== 'image') {
inlineSegment.push(t) inlineSegment.push(t)
@ -3758,7 +3790,7 @@ function parseMarkdownContentMarked(
} }
const src = String(t.href ?? '') const src = String(t.href ?? '')
const cleaned = cleanUrl(src) const cleaned = cleanUrl(src)
if (!cleaned || (!isVideo(cleaned) && !isAudio(cleaned))) { if (!cleaned || (!isVideo(cleaned) && !isAudio(cleaned) && !isHlsPlaylistUrl(cleaned))) {
inlineSegment.push(t) inlineSegment.push(t)
return return
} }
@ -3885,6 +3917,57 @@ function parseMarkdownContentMarked(
return <div key={`${key}-yt-inline-mix`}>{nodes}</div> return <div key={`${key}-yt-inline-mix`}>{nodes}</div>
} }
} }
const hasInlineDirectMediaLink = paragraphTokens.some((t: any) => {
if (t?.type !== 'link') return false
const cleaned = cleanUrl(String(t.href ?? ''))
return !!cleaned && (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned))
})
if (hasInlineDirectMediaLink) {
const nodes: React.ReactNode[] = []
let inlineSegment: any[] = []
const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return
nodes.push(
<p key={`${key}-direct-media-inline-segment-${segmentIdx}`} className="mb-1 last:mb-0">
{renderInlineTokens(inlineSegment, `${key}-direct-media-inline-segment-${segmentIdx}`)}
</p>
)
inlineSegment = []
}
let segmentIdx = 0
paragraphTokens.forEach((t: any, idx: number) => {
if (t?.type !== 'link') {
inlineSegment.push(t)
return
}
const cleaned = cleanUrl(String(t.href ?? ''))
if (!cleaned || (!isVideo(cleaned) && !isAudio(cleaned) && !isHlsPlaylistUrl(cleaned))) {
inlineSegment.push(t)
return
}
flushInlineSegment(segmentIdx++)
const poster = videoPosterMap?.get(cleaned)
nodes.push(
<div key={`${key}-inline-direct-media-${idx}`} className="my-2">
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
})
flushInlineSegment(segmentIdx++)
if (nodes.length > 0) {
return <div key={`${key}-direct-media-inline-mix`}>{nodes}</div>
}
}
} }
// If the paragraph is a single markdown image token, render it as block media/image // If the paragraph is a single markdown image token, render it as block media/image
@ -3894,7 +3977,7 @@ function parseMarkdownContentMarked(
const src = String(imageToken.href ?? '') const src = String(imageToken.href ?? '')
const cleaned = cleanUrl(src) const cleaned = cleanUrl(src)
if (cleaned) { if (cleaned) {
if (isVideo(cleaned) || isAudio(cleaned)) { if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned) const poster = videoPosterMap?.get(cleaned)
return ( return (
<div key={`${key}-media-block`} className="my-2"> <div key={`${key}-media-block`} className="my-2">
@ -4806,12 +4889,17 @@ export default function MarkdownArticle({
imetaInfos.forEach((info) => { imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url) const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return if (!cleaned || seenUrls.has(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned)) return if (!isImage(cleaned) && !isMedia(cleaned) && !isHlsPlaylistUrl(cleaned)) return
seenUrls.add(cleaned) seenUrls.add(cleaned)
if (info.m?.startsWith('image/') || isImage(cleaned)) { if (info.m?.startsWith('image/') || isImage(cleaned)) {
media.push({ url: info.url, type: 'image' }) media.push({ url: info.url, type: 'image' })
} else if (info.m?.startsWith('video/') || isVideo(cleaned)) { } else if (
info.m?.startsWith('video/') ||
isVideo(cleaned) ||
isHlsPlaylistUrl(cleaned) ||
/mpegurl/i.test(info.m || '')
) {
media.push({ media.push({
url: info.url, url: info.url,
type: 'video', type: 'video',
@ -4833,12 +4921,12 @@ export default function MarkdownArticle({
const url = tag[1] const url = tag[1]
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return if (!cleaned || seenUrls.has(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned)) return if (!isImage(cleaned) && !isMedia(cleaned) && !isHlsPlaylistUrl(cleaned)) return
seenUrls.add(cleaned) seenUrls.add(cleaned)
if (isImage(cleaned)) { if (isImage(cleaned)) {
media.push({ url, type: 'image' }) media.push({ url, type: 'image' })
} else if (isVideo(cleaned)) { } else if (isVideo(cleaned) || isHlsPlaylistUrl(cleaned)) {
media.push({ url, type: 'video' }) media.push({ url, type: 'video' })
} else if (isAudio(cleaned)) { } else if (isAudio(cleaned)) {
media.push({ url, type: 'audio' }) media.push({ url, type: 'audio' })
@ -4932,7 +5020,7 @@ export default function MarkdownArticle({
const url = tag[1] const url = tag[1]
if (!url.startsWith('http://') && !url.startsWith('https://')) return if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (isPseudoNostrHttpsUrl(url)) return if (isPseudoNostrHttpsUrl(url)) return
if (isImage(url) || isMedia(url)) return if (isImage(url) || isMedia(url) || isHlsPlaylistUrl(url)) return
if (isYouTubeUrl(url)) return // Exclude YouTube URLs if (isYouTubeUrl(url)) return // Exclude YouTube URLs
if (isSpotifyUrl(url)) return if (isSpotifyUrl(url)) return
if (isZapStreamWatchUrl(url)) return if (isZapStreamWatchUrl(url)) return
@ -5043,7 +5131,7 @@ export default function MarkdownArticle({
while ((match = urlRegex.exec(event.content)) !== null) { while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0] const url = match[0]
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned))) { if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned))) {
urls.add(cleaned) urls.add(cleaned)
// Also add image identifier for filename-based matching // Also add image identifier for filename-based matching
const identifier = getImageIdentifier(cleaned) const identifier = getImageIdentifier(cleaned)
@ -5112,6 +5200,7 @@ export default function MarkdownArticle({
(url.startsWith('http://') || url.startsWith('https://')) && (url.startsWith('http://') || url.startsWith('https://')) &&
!isImage(url) && !isImage(url) &&
!isMedia(url) && !isMedia(url) &&
!isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) && !isYouTubeUrl(url) &&
!isSpotifyUrl(url) && !isSpotifyUrl(url) &&
!isZapStreamWatchUrl(url) !isZapStreamWatchUrl(url)
@ -5226,17 +5315,48 @@ export default function MarkdownArticle({
const map = new Map<string, string>() const map = new Map<string, string>()
const imetaInfos = getImetaInfosFromEvent(event) const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach((info) => { imetaInfos.forEach((info) => {
if (info.m?.startsWith('video/') || isVideo(info.url)) {
const cleaned = cleanUrl(info.url) const cleaned = cleanUrl(info.url)
if (!cleaned) return
const isHls = isHlsPlaylistUrl(cleaned) || /mpegurl/i.test(info.m || '')
if (info.m?.startsWith('video/') || isVideo(info.url) || isHls) {
const posterUrl = info.image || info.thumb const posterUrl = info.image || info.thumb
// thumb is often wrongly set to the same video URL; only real image URLs work as <img poster>. // thumb is often wrongly set to the same video URL; only real image URLs work as <img poster>.
if (cleaned && posterUrl && isImage(posterUrl)) { if (posterUrl && isImage(posterUrl)) {
map.set(cleaned, posterUrl) map.set(cleaned, posterUrl)
} }
} }
}) })
const imetaImageCleaned = new Set<string>()
imetaInfos.forEach((info) => {
const c = cleanUrl(info.url)
if (!c || isHlsPlaylistUrl(c)) return
if (isImage(c) || info.m?.startsWith('image/')) {
imetaImageCleaned.add(c)
}
})
if (imetaImageCleaned.size === 1) {
const solePoster = [...imetaImageCleaned][0]!
const hlsOnNote = new Set<string>()
for (const v of extractedMedia.videos) {
const c = cleanUrl(v.url)
if (c && isHlsPlaylistUrl(c)) hlsOnNote.add(c)
}
event.tags
.filter((t) => t[0] === 'r' && t[1])
.forEach((t) => {
const c = cleanUrl(t[1]!)
if (c && isHlsPlaylistUrl(c)) hlsOnNote.add(c)
})
for (const h of hlsOnNote) {
if (h !== solePoster && !map.has(h)) {
map.set(h, solePoster)
}
}
}
return map return map
}, [event.id, JSON.stringify(event.tags)]) }, [event.id, JSON.stringify(event.tags), extractedMedia.videos])
// Create thumbnail map from imeta tags (for images) // Create thumbnail map from imeta tags (for images)
// Maps original image URL to thumbnail URL // Maps original image URL to thumbnail URL
@ -5508,7 +5628,7 @@ export default function MarkdownArticle({
src={media.url} src={media.url}
className="max-w-full sm:max-w-[400px] w-full" className="max-w-full sm:max-w-[400px] w-full"
mustLoad={!lazyMedia} mustLoad={!lazyMedia}
poster={media.poster} poster={media.poster ?? videoPosterMap?.get(cleaned)}
blurHash={media.blurHash} blurHash={media.blurHash}
/> />
</div> </div>

1
src/i18n/locales/de.ts

@ -499,6 +499,7 @@ export default {
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.': 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.':
'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.', 'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.',
'Open in browser': 'Im Browser öffnen', 'Open in browser': 'Im Browser öffnen',
'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable': 'liveEvent.hlsPlaybackUnavailable':
'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.', 'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.',
'Web page': 'Webseite', 'Web page': 'Webseite',

1
src/i18n/locales/en.ts

@ -496,6 +496,7 @@ export default {
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.': 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.':
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.', 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.',
'Open in browser': 'Open in browser', 'Open in browser': 'Open in browser',
'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable': 'liveEvent.hlsPlaybackUnavailable':
'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.', 'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.',
'Web page': 'Web page', 'Web page': 'Web page',

4
src/lib/content-parser.ts

@ -12,7 +12,7 @@ import {
} from '@/lib/content-patterns' } from '@/lib/content-patterns'
import { PAYTO_URI_REGEX } from '@/lib/payto' import { PAYTO_URI_REGEX } from '@/lib/payto'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import { isImage, isMedia } from './url' import { isImage, isMedia, isHlsPlaylistUrl } from './url'
import { isSpotifyOpenUrl } from './spotify-url' import { isSpotifyOpenUrl } from './spotify-url'
import { isZapStreamWatchUrl } from './zap-stream-url' import { isZapStreamWatchUrl } from './zap-stream-url'
@ -111,6 +111,8 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
let type: TEmbeddedNodeType = 'url' let type: TEmbeddedNodeType = 'url'
if (isImage(url)) { if (isImage(url)) {
type = 'image' type = 'image'
} else if (isHlsPlaylistUrl(url)) {
type = 'media'
} else if (isMedia(url)) { } else if (isMedia(url)) {
type = 'media' type = 'media'
} else if (isYouTubeUrl(url)) { } else if (isYouTubeUrl(url)) {

12
src/lib/event-metadata.ts

@ -549,20 +549,20 @@ export function getLiveEventMetadataFromEvent(event: Event) {
const tags = new Set<string>() const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => { event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'title') { if (tagName === 'title' && tagValue?.trim()) {
title = tagValue title = tagValue.trim()
} else if (tagName === 'room' && tagValue?.trim()) { } else if (tagName === 'room' && tagValue?.trim()) {
room = tagValue.trim() room = tagValue.trim()
} else if (tagName === 'summary') { } else if (tagName === 'summary' && tagValue?.trim()) {
summary = tagValue summary = tagValue.trim()
} else if (tagName === 'image' && tagValue?.trim()) { } else if (tagName === 'image' && tagValue?.trim()) {
image = tagValue.trim() image = tagValue.trim()
} else if (tagName === 'thumb' && tagValue?.trim()) { } else if (tagName === 'thumb' && tagValue?.trim()) {
thumb = tagValue.trim() thumb = tagValue.trim()
} else if (tagName === 'status' && tagValue?.trim()) { } else if (tagName === 'status' && tagValue?.trim()) {
status = tagValue.trim().toLowerCase() status = tagValue.trim().toLowerCase()
} else if (tagName === 't' && tagValue && tags.size < 6) { } else if (tagName === 't' && tagValue?.trim() && tags.size < 6) {
tags.add(tagValue.toLowerCase()) tags.add(tagValue.trim().toLowerCase())
} }
}) })

121
src/lib/live-activities.test.ts

@ -2,10 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
filterLiveActivityItemsByReachableMedia, filterLiveActivityItemsByReachableMedia,
liveEventInlinePlaybackFromEvent, liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl,
parseLiveActivityEvent, parseLiveActivityEvent,
preferredLiveJoinUrlForEvent, preferredLiveJoinUrlForEvent,
resolveParentSpacesForLiveActivities resolveParentSpacesForLiveActivities
} from './live-activities' } from './live-activities'
import { isZapStreamWatchPageUrl } from '@/lib/url'
import { nip19, type Event } from 'nostr-tools' import { nip19, type Event } from 'nostr-tools'
const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event => const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event =>
@ -182,6 +184,21 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
expect(preferredLiveJoinUrlForEvent(ev)).toBe(join) expect(preferredLiveJoinUrlForEvent(ev)).toBe(join)
}) })
it('30311 Nostr Nests LiveKit does not use zap.stream for inline playback when only LiveKit tags', () => {
const pk = 'f'.repeat(64)
const ev = base(
30311,
[
['d', 'eaf66800-fdaa-4796-b755-e34cec4fd485'],
['status', 'live'],
['service', 'https://nostrnests.com'],
['streaming', 'wss+livekit://nostrnests.com:443']
],
pk
)
expect(liveEventInlinePlaybackFromEvent(ev)).toBeNull()
})
it('accepts 30311 when status is LIVE (case-insensitive)', () => { it('accepts 30311 when status is LIVE (case-insensitive)', () => {
const pk = 'a'.repeat(64) const pk = 'a'.repeat(64)
const ev = base( const ev = base(
@ -226,7 +243,7 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(`https://zap.stream/${naddr}`) expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(`https://zap.stream/${naddr}`)
}) })
it('30311 Corny Chat uses instance /_/integrations/nostr/<naddr> (not zap.stream)', () => { it('30311 Corny Chat uses tagged HTTPS room URL for join (open in browser)', () => {
const pk = 'd'.repeat(64) const pk = 'd'.repeat(64)
const dVal = '1700000000123' const dVal = '1700000000123'
const ev = base( const ev = base(
@ -243,15 +260,25 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
], ],
pk pk
) )
const naddr = nip19.naddrEncode({ expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe('https://cornychat.com/myroom')
kind: 30311, expect(preferredLiveJoinUrlForEvent(ev)).toBe('https://cornychat.com/myroom')
pubkey: pk,
identifier: dVal,
relays: ['wss://nos.lol']
}) })
const expected = `https://cornychat.com/_/integrations/nostr/${naddr}`
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(expected) it('30311 Corny Chat resolves jam host when audiospace `l` label comes before hostname `l`', () => {
expect(preferredLiveJoinUrlForEvent(ev)).toBe(expected) const pk = 'd'.repeat(64)
const ev = base(
30311,
[
['d', '1'],
['status', 'live'],
['L', 'com.cornychat'],
['l', 'audiospace', 'com.cornychat'],
['l', 'cornychat.com', 'com.cornychat'],
['r', 'https://cornychat.com/moooooonboi']
],
pk
)
expect(preferredLiveJoinUrlForEvent(ev)).toBe('https://cornychat.com/moooooonboi')
}) })
it('30311 Corny Chat falls back to zap.stream when `l` host disagrees with `r`', () => { it('30311 Corny Chat falls back to zap.stream when `l` host disagrees with `r`', () => {
@ -300,6 +327,26 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
}) })
}) })
describe('isZapStreamWatchPageUrl', () => {
it('matches zap.stream naddr watch paths only', () => {
expect(isZapStreamWatchPageUrl('https://zap.stream/naddr1qvzqqqqqqqqqqqq')).toBe(true)
expect(isZapStreamWatchPageUrl('https://www.zap.stream/naddr1qabc')).toBe(true)
expect(isZapStreamWatchPageUrl('https://zap.stream/')).toBe(false)
expect(isZapStreamWatchPageUrl('https://zap.stream/about')).toBe(false)
expect(isZapStreamWatchPageUrl('https://evil.test/naddr1qxxx')).toBe(false)
})
})
describe('liveEventZapStreamWatchUrl', () => {
it('returns zap.stream naddr only for kind 30311 with d tag', () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [['d', 'sid'], ['status', 'live']], pk)
const naddr = nip19.naddrEncode({ kind: 30311, pubkey: pk, identifier: 'sid' })
expect(liveEventZapStreamWatchUrl(ev)).toBe(`https://zap.stream/${naddr}`)
expect(liveEventZapStreamWatchUrl(base(1, [['d', 'x']]))).toBeUndefined()
})
})
describe('liveEventInlinePlaybackFromEvent', () => { describe('liveEventInlinePlaybackFromEvent', () => {
it('prefers MP3 r tag over HLS streaming', () => { it('prefers MP3 r tag over HLS streaming', () => {
const ev = base(30311, [ const ev = base(30311, [
@ -340,6 +387,34 @@ describe('liveEventInlinePlaybackFromEvent', () => {
it('returns null for non-30311', () => { it('returns null for non-30311', () => {
expect(liveEventInlinePlaybackFromEvent(base(1, [['d', 'x']]))).toBeNull() expect(liveEventInlinePlaybackFromEvent(base(1, [['d', 'x']]))).toBeNull()
}) })
it('30311 Corny room-page tags use zap.stream naddr for inline playback', () => {
const pk = 'd'.repeat(64)
const dVal = '1700000000123'
const ev = base(
30311,
[
['d', dVal],
['status', 'live'],
['L', 'com.cornychat'],
['l', 'cornychat.com', 'com.cornychat'],
['r', 'https://cornychat.com/myroom'],
['streaming', 'https://cornychat.com/myroom'],
['relays', 'wss://nos.lol']
],
pk
)
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey: pk,
identifier: dVal,
relays: ['wss://nos.lol']
})
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: `https://zap.stream/${naddr}`,
mode: 'video'
})
})
}) })
describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => { describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
@ -364,7 +439,7 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nostrnests.com/${naddr}`) expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nostrnests.com/${naddr}`)
}) })
it('30312 Nests fork: prefers web origin /naddr over API service URL', () => { it('30312 Nests fork: rejects docker-internal LiveKit host (no public join URL)', () => {
const pk = '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24' const pk = '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24'
const ev = base( const ev = base(
30312, 30312,
@ -378,15 +453,31 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
], ],
pk pk
) )
expect(preferredLiveJoinUrlForEvent(ev)).toBeUndefined()
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('30312 Nests fork: prefers web origin /naddr when LiveKit host matches the tagged instance', () => {
const pk = 'a'.repeat(64)
const ev = base(
30312,
[
['d', 'room-d'],
['room', 'Fork room'],
['status', 'open'],
['client', 'fork.example.com'],
['service', 'https://fork.example.com/api/v1/nests'],
['streaming', 'wss+livekit://media.fork.example.com:7880']
],
pk
)
const naddr = nip19.naddrEncode({ const naddr = nip19.naddrEncode({
kind: 30312, kind: 30312,
pubkey: pk, pubkey: pk,
identifier: 'c69dfcf4-627c-4227-bc73-c6fa47f99a13' identifier: 'room-d'
}) })
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nestsdev.derekross.me/${naddr}`) expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://fork.example.com/${naddr}`)
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe( expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(`https://fork.example.com/${naddr}`)
`https://nestsdev.derekross.me/${naddr}`
)
}) })
it('Corny Chat kind 1: prefers r over service when they differ', () => { it('Corny Chat kind 1: prefers r over service when they differ', () => {

130
src/lib/live-activities.ts

@ -151,6 +151,15 @@ function zapStreamUrlForAddressable(ev: Event): string | undefined {
return naddrPageUrlForAddressable(ev, ZAP_STREAM_ORIGIN) return naddrPageUrlForAddressable(ev, ZAP_STREAM_ORIGIN)
} }
/**
* Canonical [zap.stream](https://zap.stream) watch URL for a kind **30311** ticker (`naddr` from `d` + relay hints).
* Use as {@link MediaPlayer} `fallbackPageUrl` when direct HLS/audio in-app playback fails or is unavailable.
*/
export function liveEventZapStreamWatchUrl(ev: Event): string | undefined {
if (ev.kind !== 30311) return undefined
return zapStreamUrlForAddressable(ev)
}
/** /**
* Official Nostr Nests ([nostrnests/nests](https://github.com/nostrnests/nests)) rooms tag MoQ relay + moq-auth; * Official Nostr Nests ([nostrnests/nests](https://github.com/nostrnests/nests)) rooms tag MoQ relay + moq-auth;
* `streaming` is not a browser join URL prefer the web app naddr route. * `streaming` is not a browser join URL prefer the web app naddr route.
@ -207,6 +216,51 @@ function nestsForkLiveKit30312WebOrigin(ev: Event): string | undefined {
return svcUrl.origin return svcUrl.origin
} }
/**
* `wss+livekit://livekit:7880`-style tags use Docker-internal DNS; browsers cannot reach them while `service`
* points at a public HTTPS origin. Skip join/carousel for that misconfiguration instead of surfacing a dead room.
*/
function liveKitWssHostPlausibleForNests30312(ev: Event): boolean {
if (ev.kind !== 30312) return true
const stream = firstTagValue(ev, 'streaming')?.trim() ?? ''
if (!stream.startsWith('wss+livekit://')) return true
const rest = stream.slice('wss+livekit://'.length)
const hostPart = rest.split('/')[0] ?? ''
const streamHost = (hostPart.split(':')[0] ?? '').toLowerCase()
if (!streamHost) return false
const blocked = new Set([
'livekit',
'localhost',
'127.0.0.1',
'0.0.0.0',
'host.docker.internal'
])
if (blocked.has(streamHost)) return false
const svcRaw = firstTagValue(ev, 'service')?.trim()
let serviceHostname = ''
if (svcRaw) {
try {
serviceHostname = new URL(svcRaw).hostname.toLowerCase().replace(/^www\./, '')
} catch {
return false
}
}
const clientRaw = firstTagValue(ev, 'client')?.trim()
const clientHost = clientRaw ? clientRaw.split(':')[0].toLowerCase().replace(/^www\./, '') : ''
if (streamHost === serviceHostname) return true
if (clientHost && streamHost === clientHost) return true
if (serviceHostname && streamHost.endsWith(`.${serviceHostname}`)) return true
if (clientHost && streamHost.endsWith(`.${clientHost}`)) return true
if (!streamHost.includes('.')) return false
return false
}
/** /**
* Nostr Nests [publishes](https://github.com/nostrnests/nests) kind 30311 tickers with `wss+livekit://…` and * Nostr Nests [publishes](https://github.com/nostrnests/nests) kind 30311 tickers with `wss+livekit://…` and
* `service` on `nostrnests.com`. Those streams are not playable on [zap.stream](https://github.com/v0l/zap.stream); * `service` on `nostrnests.com`. Those streams are not playable on [zap.stream](https://github.com/v0l/zap.stream);
@ -256,39 +310,25 @@ function isCornyChat30311(ev: Event): boolean {
/** /**
* `l` tag value `jamHost` from Corny pantry (`['l', jamHost, 'com.cornychat']`), when present. * `l` tag value `jamHost` from Corny pantry (`['l', jamHost, 'com.cornychat']`), when present.
* Used to ensure `r`/`service` URLs belong to the same instance before building an integration link. * Used to ensure `r`/`service` room URLs belong to the same instance as the tagged jam host.
*/ */
function cornyChatJamHost(ev: Event): string | undefined { function cornyChatJamHost(ev: Event): string | undefined {
/** Pantry emits both `['l','<jamHost>','com.cornychat']` and `['l','audiospace','com.cornychat']`; prefer a hostname-like value for URL checks. */
let nonHost: string | undefined
for (const t of ev.tags) { for (const t of ev.tags) {
if (t[0] === 'l' && t[1] && t[2] === CORNYCHAT_LABEL_NAMESPACE) { if (t[0] === 'l' && t[1] && t[2] === CORNYCHAT_LABEL_NAMESPACE) {
return t[1].trim().toLowerCase() const v = t[1].trim().toLowerCase()
} if (v.includes('.')) return v
} nonHost ??= v
return undefined
} }
/** `https://<instance>` from Corny room links in `r` / `service` / `streaming`. */
function cornyChatWebOriginFromEvent(ev: Event): string | undefined {
const raw = firstHttpsJoinFromTagNames(ev, ['r', 'service', 'streaming'])
if (!raw) return undefined
try {
const u = new URL(raw)
if (u.protocol !== 'https:') return undefined
const jamHost = cornyChatJamHost(ev)
if (jamHost && u.hostname.toLowerCase() !== jamHost) return undefined
return u.origin
} catch {
return undefined
} }
return nonHost
} }
/** `https://<corny-instance>/_/integrations/nostr/<naddr>` — matches Corny’s nostr handler route. */ function cornyPageHostMatchesJamHost(pageHost: string, jamHost: string): boolean {
function cornyChatNaddrIntegrationUrl(ev: Event): string | undefined { const h = pageHost.toLowerCase()
if (!isCornyChat30311(ev)) return undefined const j = jamHost.toLowerCase()
const origin = cornyChatWebOriginFromEvent(ev) return h === j || h === `www.${j}`
if (!origin) return undefined
const base = `${origin}/_/integrations/nostr`
return naddrPageUrlForAddressable(ev, base)
} }
/** [Corny Chat](https://github.com/vicariousdrama/cornychat) kind-1 invites: same room URL on `r` / `service` / `streaming`; prefer `r` (explicit room link). */ /** [Corny Chat](https://github.com/vicariousdrama/cornychat) kind-1 invites: same room URL on `r` / `service` / `streaming`; prefer `r` (explicit room link). */
@ -306,11 +346,9 @@ function isCornyChatKind1Invite(ev: Event): boolean {
/** /**
* URL to open for this activity. * URL to open for this activity.
* **30311 (Nostr Nests + LiveKit):** `service` / `wss+livekit://…` on `nostrnests.com` [nostrnests.com/naddr](https://nostrnests.com/). * **30311 (Nostr Nests + LiveKit):** `service` / `wss+livekit://…` on `nostrnests.com` [nostrnests.com/naddr](https://nostrnests.com/).
* **30311 (Corny Chat):** Prefer [`origin/_/integrations/nostr/naddr…`](https://github.com/vicariousdrama/cornychat) when * **30311 (Corny Chat):** Prefer the tagged HTTPS room page on `r` / `service` / `streaming` for Open in browser
* `L`/`com.cornychat` is present (instance origin from `r`/`service`, host checked against `l` when tagged). * (host must match `l` jam host when present). In-app playback uses [zap.stream](https://zap.stream) via {@link liveEventInlinePlaybackFromEvent}.
* **30311 (other):** Always use canonical [zap.stream/naddr](https://zap.stream) when `d` is present so we never * **30311 (other):** [zap.stream/naddr](https://zap.stream) when `d` is present.
* stick on stale `service`/`r` URLs publishers no longer use. zap.stream loads the same NIP-53 event and
* plays `streaming` / etc. Fallbacks only if naddr cannot be built.
* **30312 (Nostr Nests official MoQ):** Prefer [nostrnests.com/naddr](https://nostrnests.com/) over `streaming` (MoQ). * **30312 (Nostr Nests official MoQ):** Prefer [nostrnests.com/naddr](https://nostrnests.com/) over `streaming` (MoQ).
* **30312 (Nests fork + LiveKit):** `wss+livekit://…` and HTTPS `service` on the same host as `client` (or `…/nests` in the path) `https://<instance>/<naddr>`. * **30312 (Nests fork + LiveKit):** `wss+livekit://…` and HTTPS `service` on the same host as `client` (or `…/nests` in the path) `https://<instance>/<naddr>`.
* **Kind 1 (Corny Chat invite):** Prefer `r` `service` `streaming` per pantry publish shape. * **Kind 1 (Corny Chat invite):** Prefer `r` `service` `streaming` per pantry publish shape.
@ -318,9 +356,8 @@ function isCornyChatKind1Invite(ev: Event): boolean {
*/ */
/** /**
* Kind 30311 is shared by every NIP-53 live stream ticker (zap.stream, Corny Chat, etc.). * Kind 30311 is shared by every NIP-53 live stream ticker (zap.stream, Corny Chat, etc.).
* There is no single tag that means zap.stream; we only special-case publishers that label themselves * Corny-labelled events use [`L`, `com.cornychat`](https://github.com/vicariousdrama/cornychat/blob/main/pantry/nostr/nostr.js).
* (Corny uses [`L`, `com.cornychat`](https://github.com/vicariousdrama/cornychat/blob/main/pantry/nostr/nostr.js)). * In-app playback without direct media tags uses zap.stream; browser join for Corny uses the room URL when valid.
* Everyone else gets the zap.stream player URL, which resolves the same replaceable event by naddr.
*/ */
function joinUrlFor30311Ticker(ev: Event): string | undefined { function joinUrlFor30311Ticker(ev: Event): string | undefined {
if (isNostrNests30311WebJoin(ev)) { if (isNostrNests30311WebJoin(ev)) {
@ -328,9 +365,18 @@ function joinUrlFor30311Ticker(ev: Event): string | undefined {
if (nests) return nests if (nests) return nests
} }
if (isCornyChat30311(ev)) { if (isCornyChat30311(ev)) {
const corny = cornyChatNaddrIntegrationUrl(ev) const direct = firstHttpsJoinFromTagNames(ev, ['r', 'service', 'streaming'])
if (corny) return corny if (direct) {
// Corny-labelled but unsafe/missing room URL vs `l` host, or missing `d`: fall through to zap.stream. try {
const host = new URL(direct).hostname
const jamHost = cornyChatJamHost(ev)
if (!jamHost || cornyPageHostMatchesJamHost(host, jamHost)) {
return direct
}
} catch {
/* fall through */
}
}
} }
return zapStreamUrlForAddressable(ev) return zapStreamUrlForAddressable(ev)
} }
@ -348,6 +394,7 @@ function joinUrlFor30312Space(ev: Event): string | undefined {
} }
const forkOrigin = nestsForkLiveKit30312WebOrigin(ev) const forkOrigin = nestsForkLiveKit30312WebOrigin(ev)
if (forkOrigin) { if (forkOrigin) {
if (!liveKitWssHostPlausibleForNests30312(ev)) return undefined
return naddrPageUrlForAddressable(ev, forkOrigin) return naddrPageUrlForAddressable(ev, forkOrigin)
} }
return undefined return undefined
@ -362,6 +409,10 @@ function pickJoinUrl(ev: Event): string | undefined {
if (ev.kind === 30312) { if (ev.kind === 30312) {
const nests = joinUrlFor30312Space(ev) const nests = joinUrlFor30312Space(ev)
if (nests) return nests if (nests) return nests
const stream = firstTagValue(ev, 'streaming')?.trim() ?? ''
if (stream.startsWith('wss+livekit://') && nestsForkLiveKit30312WebOrigin(ev)) {
return undefined
}
} }
if (isCornyChatKind1Invite(ev)) { if (isCornyChatKind1Invite(ev)) {
@ -647,7 +698,8 @@ export type LiveEventInlinePlayback = { src: string; mode: 'audio' | 'video' }
/** /**
* Pick a URL the in-app {@link MediaPlayer} can use for NIP-53 kind 30311 (live / radio). * Pick a URL the in-app {@link MediaPlayer} can use for NIP-53 kind 30311 (live / radio).
* Prefers direct audio (`r` or `streaming`, e.g. Icecast `.mp3`) over HLS manifests. * Prefers direct audio (`r` or `streaming`, e.g. Icecast `.mp3`) over HLS manifests, then
* [zap.stream `naddr`](https://zap.stream) when there is no playable URL in tags (except Nostr Nests LiveKit tickers).
*/ */
export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlayback | null { export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlayback | null {
if (ev.kind !== 30311) return null if (ev.kind !== 30311) return null
@ -667,6 +719,10 @@ export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlay
for (const u of streamingUrls) { for (const u of streamingUrls) {
if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' } if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' }
} }
if (!isNostrNests30311WebJoin(ev)) {
const zapWatch = zapStreamUrlForAddressable(ev)
if (zapWatch) return { src: zapWatch, mode: 'video' }
}
return null return null
} }

17
src/lib/url.ts

@ -365,6 +365,23 @@ export function isHlsPlaylistUrl(url: string): boolean {
} }
} }
const ZAP_STREAM_EMBED_HOSTS = new Set(['zap.stream', 'www.zap.stream'])
/**
* True for [zap.stream](https://zap.stream) watch URLs (`/naddr1…` / `nevent1…`), which are HTML apps — not `<video src>`.
* Used so the in-app player embeds an iframe instead of probing metadata on a document URL.
*/
export function isZapStreamWatchPageUrl(url: string): boolean {
try {
const u = new URL(url.trim())
if (!ZAP_STREAM_EMBED_HOSTS.has(u.hostname.toLowerCase())) return false
const firstSeg = u.pathname.split('/').filter(Boolean)[0] ?? ''
return firstSeg.startsWith('naddr1') || firstSeg.startsWith('nevent1')
} catch {
return false
}
}
/** /**
* Return true if the URL looks like a fetchable web page (http(s) with a plausible host). * Return true if the URL looks like a fetchable web page (http(s) with a plausible host).
* Used to skip OG metadata fetch for invalid or non-http URLs (e.g. "https://1.4ghz/"). * Used to skip OG metadata fetch for invalid or non-http URLs (e.g. "https://1.4ghz/").

27
src/services/media-extraction.service.ts

@ -1,10 +1,10 @@
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url' import { cleanUrl, isImage, isMedia, isAudio, isVideo, isHlsPlaylistUrl } from '@/lib/url'
/** Any URL we may embed or extract from note bodies (incl. video-only extensions like .3gp). */ /** Any URL we may embed or extract from note bodies (incl. video-only extensions like .3gp, HLS manifests). */
function isEmbeddableMediaUrl(cleaned: string): boolean { function isEmbeddableMediaUrl(cleaned: string): boolean {
return isImage(cleaned) || isMedia(cleaned) || isVideo(cleaned) || isAudio(cleaned) return isImage(cleaned) || isMedia(cleaned) || isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)
} }
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import mediaUpload from './media-upload.service' import mediaUpload from './media-upload.service'
@ -43,6 +43,8 @@ export function extractAllMediaFromEvent(
if (!mime) { if (!mime) {
if (isImage(cleaned)) { if (isImage(cleaned)) {
mime = 'image/*' mime = 'image/*'
} else if (isHlsPlaylistUrl(cleaned)) {
mime = 'video/*'
} else if (isAudio(cleaned)) { } else if (isAudio(cleaned)) {
mime = 'audio/*' mime = 'audio/*'
} else if (isVideo(cleaned)) { } else if (isVideo(cleaned)) {
@ -69,10 +71,12 @@ export function extractAllMediaFromEvent(
info.m?.startsWith('image/') || info.m?.startsWith('image/') ||
info.m?.startsWith('video/') || info.m?.startsWith('video/') ||
info.m?.startsWith('audio/') || info.m?.startsWith('audio/') ||
info.m === 'application/vnd.apple.mpegurl' ||
isImage(info.url) || isImage(info.url) ||
isMedia(info.url) || isMedia(info.url) ||
isVideo(info.url) || isVideo(info.url) ||
isAudio(info.url) || isAudio(info.url) ||
isHlsPlaylistUrl(info.url) ||
// Blossom / NIP-94 URLs often have no file extension; metadata still identifies the blob. // Blossom / NIP-94 URLs often have no file extension; metadata still identifies the blob.
(nip94Signals && !!info.url) (nip94Signals && !!info.url)
) { ) {
@ -111,7 +115,16 @@ export function extractAllMediaFromEvent(
addMedia(imageTag[1]) addMedia(imageTag[1])
} }
// 3. Extract from content (if provided) // 3. Live streams in `r` tags (often next to imeta for poster / blurhash)
event.tags.forEach((tag) => {
if (tag[0] !== 'r' || !tag[1]) return
const c = cleanUrl(tag[1]) || tag[1]
if (isHlsPlaylistUrl(c)) {
addMedia(tag[1], event.pubkey, 'video/*')
}
})
// 4. Extract from content (if provided)
if (content) { if (content) {
// First, extract from markdown image syntax: ![alt](url) or [![](url)](link) // First, extract from markdown image syntax: ![alt](url) or [![](url)](link)
// This handles images inside links // This handles images inside links
@ -138,7 +151,7 @@ export function extractAllMediaFromEvent(
} }
} }
// 5. Try to match content URLs with imeta tags for better metadata (alt, dim, blurHash, m) // 6. Try to match content URLs with imeta tags for better metadata (alt, dim, blurHash, m)
const imageIdentityKey = (url: string): string | null => { const imageIdentityKey = (url: string): string | null => {
try { try {
const u = cleanUrl(url) const u = cleanUrl(url)
@ -183,7 +196,7 @@ export function extractAllMediaFromEvent(
allMedia.forEach((media) => { allMedia.forEach((media) => {
if (media.m?.startsWith('image/') || isImage(media.url)) { if (media.m?.startsWith('image/') || isImage(media.url)) {
images.push(media) images.push(media)
} else if (media.m?.startsWith('video/') || isVideo(media.url)) { } else if (media.m?.startsWith('video/') || isVideo(media.url) || isHlsPlaylistUrl(media.url)) {
videos.push(media) videos.push(media)
} else if (media.m?.startsWith('audio/') || isAudio(media.url)) { } else if (media.m?.startsWith('audio/') || isAudio(media.url)) {
audio.push(media) audio.push(media)
@ -191,7 +204,7 @@ export function extractAllMediaFromEvent(
// Fallback: try to determine by URL extension // Fallback: try to determine by URL extension
if (isImage(media.url)) { if (isImage(media.url)) {
images.push(media) images.push(media)
} else if (isVideo(media.url)) { } else if (isVideo(media.url) || isHlsPlaylistUrl(media.url)) {
videos.push(media) videos.push(media)
} else if (isAudio(media.url)) { } else if (isAudio(media.url)) {
audio.push(media) audio.push(media)

Loading…
Cancel
Save