Browse Source

expand and correct live streaming

imwald
Silberengel 2 weeks ago
parent
commit
8e8b428aaa
  1. 37
      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. 178
      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. 123
      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

37
src/components/Content/index.tsx

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

3
src/components/KindFilter/index.tsx

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

34
src/components/MediaPlayer/index.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { isHlsPlaylistUrl, isImage } from '@/lib/url'
import { isHlsPlaylistUrl, isImage, isZapStreamWatchPageUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
@ -8,8 +8,11 @@ import VideoPlayer from '../VideoPlayer' @@ -8,8 +8,11 @@ import VideoPlayer from '../VideoPlayer'
import ExternalLink from '../ExternalLink'
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. */
function embedMediaTypeHintFromUrl(src: string): 'video' | 'audio' | null {
function embedMediaSurfaceHintFromUrl(src: string): MediaSurface {
if (isZapStreamWatchPageUrl(src)) return 'iframe'
try {
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
@ -55,7 +58,7 @@ export default function MediaPlayer({ @@ -55,7 +58,7 @@ export default function MediaPlayer({
const { autoLoadMedia } = useContentPolicy()
/** Tap-to-load when {@link autoLoadMedia} is off; cleared when policy switches back to never. */
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 [embedPainted, setEmbedPainted] = useState(false)
const readyOnceRef = useRef(false)
@ -68,9 +71,9 @@ export default function MediaPlayer({ @@ -68,9 +71,9 @@ export default function MediaPlayer({
return isImage(p) ? p : undefined
}, [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. */
const effectiveMediaType = mediaType ?? urlEmbedTypeHint
const effectiveMediaType = mediaType ?? urlEmbedSurfaceHint
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
@ -110,6 +113,11 @@ export default function MediaPlayer({ @@ -110,6 +113,11 @@ export default function MediaPlayer({
return
}
if (isZapStreamWatchPageUrl(src)) {
setMediaType('iframe')
return
}
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
@ -162,6 +170,9 @@ export default function MediaPlayer({ @@ -162,6 +170,9 @@ export default function MediaPlayer({
return t('Preparing player…', { defaultValue: 'Preparing player…' })
}
if (!embedPainted) {
if (isZapStreamWatchPageUrl(src)) {
return t('Starting stream…', { defaultValue: 'Starting stream…' })
}
if (isHlsPlaylistUrl(src)) {
return t('Starting stream…', { defaultValue: 'Starting stream…' })
}
@ -223,7 +234,18 @@ export default function MediaPlayer({ @@ -223,7 +234,18 @@ export default function MediaPlayer({
)}
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
src={src}
className={className}

4
src/components/Note/LiveEvent.tsx

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

178
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -18,7 +18,8 @@ import { @@ -18,7 +18,8 @@ import {
isAudio,
isWebsocketUrl,
isPseudoNostrHttpsUrl,
isSafeMediaUrl
isSafeMediaUrl,
isHlsPlaylistUrl
} from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
@ -2029,7 +2030,7 @@ function parseMarkdownContentLegacy( @@ -2029,7 +2030,7 @@ function parseMarkdownContentLegacy(
/>
</div>
)
} else if (isVideo(cleaned) || isAudio(cleaned)) {
} else if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
parts.push(
<div key={`media-${patternIdx}`} className="my-2">
@ -2128,7 +2129,7 @@ function parseMarkdownContentLegacy( @@ -2128,7 +2129,7 @@ function parseMarkdownContentLegacy(
} else if (pattern.type === 'markdown-link-standalone') {
const { url } = pattern.data
const cleanedStandalone = cleanUrl(url)
if (cleanedStandalone && (isVideo(cleanedStandalone) || isAudio(cleanedStandalone))) {
if (cleanedStandalone && (isVideo(cleanedStandalone) || isAudio(cleanedStandalone) || isHlsPlaylistUrl(cleanedStandalone))) {
const poster = videoPosterMap?.get(cleanedStandalone)
parts.push(
<div key={`media-standalone-${patternIdx}`} className="my-2">
@ -3243,17 +3244,18 @@ function parseMarkdownContentMarked( @@ -3243,17 +3244,18 @@ function parseMarkdownContentMarked(
const cleaned = cleanUrl(src)
if (!cleaned) break
const label = String(token.text ?? '')
if (isVideo(cleaned) || isAudio(cleaned)) {
if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
out.push(
<a
key={`${key}-media-link`}
href={src}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
>
{label || src}
</a>
<div key={`${key}-media-inline`} className="my-2 not-prose">
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
break
}
@ -3439,7 +3441,7 @@ function parseMarkdownContentMarked( @@ -3439,7 +3441,7 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isVideo(cleaned) || isAudio(cleaned)) {
if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-line-media-${lineIdx}`} className="my-2">
@ -3604,7 +3606,7 @@ function parseMarkdownContentMarked( @@ -3604,7 +3606,7 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isVideo(cleaned) || isAudio(cleaned)) {
if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-media-url`} className="my-2">
@ -3678,6 +3680,20 @@ function parseMarkdownContentMarked( @@ -3678,6 +3680,20 @@ function parseMarkdownContentMarked(
</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 => {
@ -3694,7 +3710,7 @@ function parseMarkdownContentMarked( @@ -3694,7 +3710,7 @@ function parseMarkdownContentMarked(
const hasInlineMediaImageToken = paragraphTokens.some((t) => {
if (t?.type !== 'image') return false
const cleaned = cleanUrl(String(t.href ?? ''))
return !!cleaned && (isVideo(cleaned) || isAudio(cleaned))
return !!cleaned && (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned))
})
if (hasInlineMediaImageToken) {
const nodes: React.ReactNode[] = []
@ -3751,6 +3767,22 @@ function parseMarkdownContentMarked( @@ -3751,6 +3767,22 @@ function parseMarkdownContentMarked(
)
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') {
inlineSegment.push(t)
@ -3758,7 +3790,7 @@ function parseMarkdownContentMarked( @@ -3758,7 +3790,7 @@ function parseMarkdownContentMarked(
}
const src = String(t.href ?? '')
const cleaned = cleanUrl(src)
if (!cleaned || (!isVideo(cleaned) && !isAudio(cleaned))) {
if (!cleaned || (!isVideo(cleaned) && !isAudio(cleaned) && !isHlsPlaylistUrl(cleaned))) {
inlineSegment.push(t)
return
}
@ -3885,6 +3917,57 @@ function parseMarkdownContentMarked( @@ -3885,6 +3917,57 @@ function parseMarkdownContentMarked(
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
@ -3894,7 +3977,7 @@ function parseMarkdownContentMarked( @@ -3894,7 +3977,7 @@ function parseMarkdownContentMarked(
const src = String(imageToken.href ?? '')
const cleaned = cleanUrl(src)
if (cleaned) {
if (isVideo(cleaned) || isAudio(cleaned)) {
if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-media-block`} className="my-2">
@ -4806,12 +4889,17 @@ export default function MarkdownArticle({ @@ -4806,12 +4889,17 @@ export default function MarkdownArticle({
imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned) && !isHlsPlaylistUrl(cleaned)) return
seenUrls.add(cleaned)
if (info.m?.startsWith('image/') || isImage(cleaned)) {
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({
url: info.url,
type: 'video',
@ -4833,12 +4921,12 @@ export default function MarkdownArticle({ @@ -4833,12 +4921,12 @@ export default function MarkdownArticle({
const url = tag[1]
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned) && !isHlsPlaylistUrl(cleaned)) return
seenUrls.add(cleaned)
if (isImage(cleaned)) {
media.push({ url, type: 'image' })
} else if (isVideo(cleaned)) {
} else if (isVideo(cleaned) || isHlsPlaylistUrl(cleaned)) {
media.push({ url, type: 'video' })
} else if (isAudio(cleaned)) {
media.push({ url, type: 'audio' })
@ -4932,7 +5020,7 @@ export default function MarkdownArticle({ @@ -4932,7 +5020,7 @@ export default function MarkdownArticle({
const url = tag[1]
if (!url.startsWith('http://') && !url.startsWith('https://')) 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 (isSpotifyUrl(url)) return
if (isZapStreamWatchUrl(url)) return
@ -5043,7 +5131,7 @@ export default function MarkdownArticle({ @@ -5043,7 +5131,7 @@ export default function MarkdownArticle({
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
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)
// Also add image identifier for filename-based matching
const identifier = getImageIdentifier(cleaned)
@ -5112,6 +5200,7 @@ export default function MarkdownArticle({ @@ -5112,6 +5200,7 @@ export default function MarkdownArticle({
(url.startsWith('http://') || url.startsWith('https://')) &&
!isImage(url) &&
!isMedia(url) &&
!isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) &&
!isSpotifyUrl(url) &&
!isZapStreamWatchUrl(url)
@ -5226,17 +5315,48 @@ export default function MarkdownArticle({ @@ -5226,17 +5315,48 @@ export default function MarkdownArticle({
const map = new Map<string, string>()
const imetaInfos = getImetaInfosFromEvent(event)
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
// 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)
}
}
})
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
}, [event.id, JSON.stringify(event.tags)])
}, [event.id, JSON.stringify(event.tags), extractedMedia.videos])
// Create thumbnail map from imeta tags (for images)
// Maps original image URL to thumbnail URL
@ -5508,7 +5628,7 @@ export default function MarkdownArticle({ @@ -5508,7 +5628,7 @@ export default function MarkdownArticle({
src={media.url}
className="max-w-full sm:max-w-[400px] w-full"
mustLoad={!lazyMedia}
poster={media.poster}
poster={media.poster ?? videoPosterMap?.get(cleaned)}
blurHash={media.blurHash}
/>
</div>

1
src/i18n/locales/de.ts

@ -499,6 +499,7 @@ export default { @@ -499,6 +499,7 @@ export default {
'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.',
'Open in browser': 'Im Browser öffnen',
'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable':
'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.',
'Web page': 'Webseite',

1
src/i18n/locales/en.ts

@ -496,6 +496,7 @@ export default { @@ -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.',
'Open in browser': 'Open in browser',
'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable':
'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.',
'Web page': 'Web page',

4
src/lib/content-parser.ts

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

12
src/lib/event-metadata.ts

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

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

@ -2,10 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' @@ -2,10 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
filterLiveActivityItemsByReachableMedia,
liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl,
parseLiveActivityEvent,
preferredLiveJoinUrlForEvent,
resolveParentSpacesForLiveActivities
} from './live-activities'
import { isZapStreamWatchPageUrl } from '@/lib/url'
import { nip19, type Event } from 'nostr-tools'
const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event =>
@ -182,6 +184,21 @@ describe('parseLiveActivityEvent (NIP-53)', () => { @@ -182,6 +184,21 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
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)', () => {
const pk = 'a'.repeat(64)
const ev = base(
@ -226,7 +243,7 @@ describe('parseLiveActivityEvent (NIP-53)', () => { @@ -226,7 +243,7 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
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 dVal = '1700000000123'
const ev = base(
@ -243,15 +260,25 @@ describe('parseLiveActivityEvent (NIP-53)', () => { @@ -243,15 +260,25 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
],
pk
)
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey: pk,
identifier: dVal,
relays: ['wss://nos.lol']
})
const expected = `https://cornychat.com/_/integrations/nostr/${naddr}`
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(expected)
expect(preferredLiveJoinUrlForEvent(ev)).toBe(expected)
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe('https://cornychat.com/myroom')
expect(preferredLiveJoinUrlForEvent(ev)).toBe('https://cornychat.com/myroom')
})
it('30311 Corny Chat resolves jam host when audiospace `l` label comes before hostname `l`', () => {
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`', () => {
@ -300,6 +327,26 @@ describe('parseLiveActivityEvent (NIP-53)', () => { @@ -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', () => {
it('prefers MP3 r tag over HLS streaming', () => {
const ev = base(30311, [
@ -340,6 +387,34 @@ describe('liveEventInlinePlaybackFromEvent', () => { @@ -340,6 +387,34 @@ describe('liveEventInlinePlaybackFromEvent', () => {
it('returns null for non-30311', () => {
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)', () => {
@ -364,7 +439,7 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => { @@ -364,7 +439,7 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
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 ev = base(
30312,
@ -378,15 +453,31 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => { @@ -378,15 +453,31 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
],
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({
kind: 30312,
pubkey: pk,
identifier: 'c69dfcf4-627c-4227-bc73-c6fa47f99a13'
identifier: 'room-d'
})
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nestsdev.derekross.me/${naddr}`)
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(
`https://nestsdev.derekross.me/${naddr}`
)
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://fork.example.com/${naddr}`)
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(`https://fork.example.com/${naddr}`)
})
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 { @@ -151,6 +151,15 @@ function zapStreamUrlForAddressable(ev: Event): string | undefined {
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;
* `streaming` is not a browser join URL prefer the web app naddr route.
@ -207,6 +216,51 @@ function nestsForkLiveKit30312WebOrigin(ev: Event): string | undefined { @@ -207,6 +216,51 @@ function nestsForkLiveKit30312WebOrigin(ev: Event): string | undefined {
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
* `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 { @@ -256,39 +310,25 @@ function isCornyChat30311(ev: Event): boolean {
/**
* `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 {
/** 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) {
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 cornyChatNaddrIntegrationUrl(ev: Event): string | undefined {
if (!isCornyChat30311(ev)) return undefined
const origin = cornyChatWebOriginFromEvent(ev)
if (!origin) return undefined
const base = `${origin}/_/integrations/nostr`
return naddrPageUrlForAddressable(ev, base)
function cornyPageHostMatchesJamHost(pageHost: string, jamHost: string): boolean {
const h = pageHost.toLowerCase()
const j = jamHost.toLowerCase()
return h === j || h === `www.${j}`
}
/** [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 { @@ -306,11 +346,9 @@ function isCornyChatKind1Invite(ev: Event): boolean {
/**
* URL to open for this activity.
* **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
* `L`/`com.cornychat` is present (instance origin from `r`/`service`, host checked against `l` when tagged).
* **30311 (other):** Always use canonical [zap.stream/naddr](https://zap.stream) when `d` is present so we never
* 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.
* **30311 (Corny Chat):** Prefer the tagged HTTPS room page on `r` / `service` / `streaming` for Open in browser
* (host must match `l` jam host when present). In-app playback uses [zap.stream](https://zap.stream) via {@link liveEventInlinePlaybackFromEvent}.
* **30311 (other):** [zap.stream/naddr](https://zap.stream) when `d` is present.
* **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>`.
* **Kind 1 (Corny Chat invite):** Prefer `r` `service` `streaming` per pantry publish shape.
@ -318,9 +356,8 @@ function isCornyChatKind1Invite(ev: Event): boolean { @@ -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.).
* There is no single tag that means zap.stream; we only special-case publishers that label themselves
* (Corny uses [`L`, `com.cornychat`](https://github.com/vicariousdrama/cornychat/blob/main/pantry/nostr/nostr.js)).
* Everyone else gets the zap.stream player URL, which resolves the same replaceable event by naddr.
* Corny-labelled events use [`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.
*/
function joinUrlFor30311Ticker(ev: Event): string | undefined {
if (isNostrNests30311WebJoin(ev)) {
@ -328,9 +365,18 @@ function joinUrlFor30311Ticker(ev: Event): string | undefined { @@ -328,9 +365,18 @@ function joinUrlFor30311Ticker(ev: Event): string | undefined {
if (nests) return nests
}
if (isCornyChat30311(ev)) {
const corny = cornyChatNaddrIntegrationUrl(ev)
if (corny) return corny
// Corny-labelled but unsafe/missing room URL vs `l` host, or missing `d`: fall through to zap.stream.
const direct = firstHttpsJoinFromTagNames(ev, ['r', 'service', 'streaming'])
if (direct) {
try {
const host = new URL(direct).hostname
const jamHost = cornyChatJamHost(ev)
if (!jamHost || cornyPageHostMatchesJamHost(host, jamHost)) {
return direct
}
} catch {
/* fall through */
}
}
}
return zapStreamUrlForAddressable(ev)
}
@ -348,6 +394,7 @@ function joinUrlFor30312Space(ev: Event): string | undefined { @@ -348,6 +394,7 @@ function joinUrlFor30312Space(ev: Event): string | undefined {
}
const forkOrigin = nestsForkLiveKit30312WebOrigin(ev)
if (forkOrigin) {
if (!liveKitWssHostPlausibleForNests30312(ev)) return undefined
return naddrPageUrlForAddressable(ev, forkOrigin)
}
return undefined
@ -362,6 +409,10 @@ function pickJoinUrl(ev: Event): string | undefined { @@ -362,6 +409,10 @@ function pickJoinUrl(ev: Event): string | undefined {
if (ev.kind === 30312) {
const nests = joinUrlFor30312Space(ev)
if (nests) return nests
const stream = firstTagValue(ev, 'streaming')?.trim() ?? ''
if (stream.startsWith('wss+livekit://') && nestsForkLiveKit30312WebOrigin(ev)) {
return undefined
}
}
if (isCornyChatKind1Invite(ev)) {
@ -647,7 +698,8 @@ export type LiveEventInlinePlayback = { src: string; mode: 'audio' | 'video' } @@ -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).
* 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 {
if (ev.kind !== 30311) return null
@ -667,6 +719,10 @@ export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlay @@ -667,6 +719,10 @@ export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlay
for (const u of streamingUrls) {
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
}

17
src/lib/url.ts

@ -365,6 +365,23 @@ export function isHlsPlaylistUrl(url: string): boolean { @@ -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).
* 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 @@ @@ -1,10 +1,10 @@
import { Event } from 'nostr-tools'
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 {
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 mediaUpload from './media-upload.service'
@ -43,6 +43,8 @@ export function extractAllMediaFromEvent( @@ -43,6 +43,8 @@ export function extractAllMediaFromEvent(
if (!mime) {
if (isImage(cleaned)) {
mime = 'image/*'
} else if (isHlsPlaylistUrl(cleaned)) {
mime = 'video/*'
} else if (isAudio(cleaned)) {
mime = 'audio/*'
} else if (isVideo(cleaned)) {
@ -69,10 +71,12 @@ export function extractAllMediaFromEvent( @@ -69,10 +71,12 @@ export function extractAllMediaFromEvent(
info.m?.startsWith('image/') ||
info.m?.startsWith('video/') ||
info.m?.startsWith('audio/') ||
info.m === 'application/vnd.apple.mpegurl' ||
isImage(info.url) ||
isMedia(info.url) ||
isVideo(info.url) ||
isAudio(info.url) ||
isHlsPlaylistUrl(info.url) ||
// Blossom / NIP-94 URLs often have no file extension; metadata still identifies the blob.
(nip94Signals && !!info.url)
) {
@ -111,7 +115,16 @@ export function extractAllMediaFromEvent( @@ -111,7 +115,16 @@ export function extractAllMediaFromEvent(
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) {
// First, extract from markdown image syntax: ![alt](url) or [![](url)](link)
// This handles images inside links
@ -138,7 +151,7 @@ export function extractAllMediaFromEvent( @@ -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 => {
try {
const u = cleanUrl(url)
@ -183,7 +196,7 @@ export function extractAllMediaFromEvent( @@ -183,7 +196,7 @@ export function extractAllMediaFromEvent(
allMedia.forEach((media) => {
if (media.m?.startsWith('image/') || isImage(media.url)) {
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)
} else if (media.m?.startsWith('audio/') || isAudio(media.url)) {
audio.push(media)
@ -191,7 +204,7 @@ export function extractAllMediaFromEvent( @@ -191,7 +204,7 @@ export function extractAllMediaFromEvent(
// Fallback: try to determine by URL extension
if (isImage(media.url)) {
images.push(media)
} else if (isVideo(media.url)) {
} else if (isVideo(media.url) || isHlsPlaylistUrl(media.url)) {
videos.push(media)
} else if (isAudio(media.url)) {
audio.push(media)

Loading…
Cancel
Save