diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index ae276d8b..39fcf74b 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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({ !isPseudoNostrHttpsUrl(url) && !isImage(url) && !isMedia(url) && + !isHlsPlaylistUrl(url) && !isYouTubeUrl(url) && !isSpotifyOpenUrl(url) && !isZapStreamWatchUrl(url) @@ -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({ 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({ 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({ 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 diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index afa5e807..873893d5 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -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 = [ { 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' }, diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx index 22667bb1..d0827133 100644 --- a/src/components/MediaPlayer/index.tsx +++ b/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 { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' @@ -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({ 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(null) const [probeFailed, setProbeFailed] = useState(false) const [embedPainted, setEmbedPainted] = useState(false) const readyOnceRef = useRef(false) @@ -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({ 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({ 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({ )} aria-hidden={!embedPainted} > - {effectiveMediaType === 'video' ? ( + {effectiveMediaType === 'iframe' ? ( +