diff --git a/package-lock.json b/package-lock.json index 714ac317..8f989495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "emoji-picker-element": "^1.29.1", "flexsearch": "^0.7.43", "highlight.js": "^11.9.0", + "hls.js": "^1.6.15", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "katex": "^0.16.25", @@ -9921,6 +9922,12 @@ "node": ">=12.0.0" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", diff --git a/package.json b/package.json index 7775fd34..206fe9b4 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "emoji-picker-element": "^1.29.1", "flexsearch": "^0.7.43", "highlight.js": "^11.9.0", + "hls.js": "^1.6.15", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "katex": "^0.16.25", diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 8f34e483..6a5caa1e 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -24,10 +24,14 @@ import PaytoLink from '../PaytoLink' import Emoji from '../Emoji' import ImageGallery from '../ImageGallery' import MediaPlayer from '../MediaPlayer' +import SpotifyEmbeddedPlayer from '../SpotifyEmbeddedPlayer' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' +import ZapStreamEmbeddedPlayer from '../ZapStreamEmbeddedPlayer' import WebPreview from '../WebPreview' import { toNote } from '@/lib/link' import { YOUTUBE_URL_REGEX } from '@/constants' +import { isSpotifyOpenUrl } from '@/lib/spotify-url' +import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url' // Helper function to check if a URL is a YouTube URL function isYouTubeUrl(url: string): boolean { @@ -120,7 +124,9 @@ export default function Content({ !isPseudoNostrHttpsUrl(url) && !isImage(url) && !isMedia(url) && - !isYouTubeUrl(url) + !isYouTubeUrl(url) && + !isSpotifyOpenUrl(url) && + !isZapStreamWatchUrl(url) ) { const cleaned = cleanUrl(url) if ( @@ -147,7 +153,7 @@ export default function Content({ // Check if YouTube URL is already in content const hasYouTubeInContent = nodes?.some(node => node.type === 'youtube') || false - + event.tags .filter(tag => tag[0] === 'r' && tag[1]) .forEach(tag => { @@ -161,10 +167,61 @@ export default function Content({ } } }) - + return urls }, [event, nodes]) - + + const spotifyUrlsFromTags = useMemo(() => { + if (!event) return [] + const urls: string[] = [] + const seenUrls = new Set() + const hasSpotifyInContent = nodes?.some((node) => node.type === 'spotify') || false + + event.tags + .filter((tag) => tag[0] === 'r' && tag[1]) + .forEach((tag) => { + const url = tag[1]! + if (isSpotifyOpenUrl(url)) { + const cleaned = cleanUrl(url) + if (cleaned && !hasSpotifyInContent && !seenUrls.has(cleaned)) { + urls.push(cleaned) + seenUrls.add(cleaned) + } + } + }) + + return urls + }, [event, nodes]) + + const zapStreamCanonicalInContent = useMemo(() => { + if (!nodes) return new Set() + const s = new Set() + for (const n of nodes) { + if (n.type !== 'zapstream') continue + const raw = cleanUrl(n.data) || n.data + const c = canonicalZapStreamWatchUrl(raw) + if (c) s.add(c) + } + return s + }, [nodes]) + + const zapstreamUrlsFromTags = useMemo(() => { + if (!event) return [] + const urls: string[] = [] + const seen = new Set() + event.tags + .filter((tag) => tag[0] === 'r' && tag[1]) + .forEach((tag) => { + const url = tag[1]! + if (!isZapStreamWatchUrl(url)) return + const canon = canonicalZapStreamWatchUrl(cleanUrl(url) || url) + if (!canon || zapStreamCanonicalInContent.has(canon) || seen.has(canon)) return + seen.add(canon) + urls.push(canon) + }) + return urls + }, [event, zapStreamCanonicalInContent]) + // Extract HTTP/HTTPS links from r tags (excluding those already in content, YouTube URLs, images, and media) const tagLinks = useMemo(() => { if (!event) return [] @@ -183,7 +240,9 @@ export default function Content({ !isPseudoNostrHttpsUrl(url) && !isImage(url) && !isMedia(url) && - !isYouTubeUrl(url) + !isYouTubeUrl(url) && + !isSpotifyOpenUrl(url) && + !isZapStreamWatchUrl(url) ) { const cleaned = cleanUrl(url) // Only include if not already in content links and not already seen in tags @@ -193,7 +252,7 @@ export default function Content({ } } }) - + return links }, [event, contentLinks]) @@ -389,6 +448,24 @@ export default function Content({ mustLoad={mustLoadMedia} /> ))} + + {spotifyUrlsFromTags.map((url) => ( + + ))} + + {zapstreamUrlsFromTags.map((url) => ( + + ))} {nodes && nodes.length > 0 && nodes.map((node, index) => { if (node.type === 'text') { @@ -528,6 +605,26 @@ export default function Content({ /> ) } + if (node.type === 'spotify') { + return ( + + ) + } + if (node.type === 'zapstream') { + return ( + + ) + } return null })} diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 4fa21bbc..b6ddb616 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -48,6 +48,7 @@ export default function Image({ errorPlaceholder = , style: wrapperStyleProp, holdUntilClick = false, + fetchPriority, onClick, ...props }: HTMLAttributes & { @@ -59,6 +60,8 @@ export default function Image({ alt?: string hideIfError?: boolean errorPlaceholder?: React.ReactNode + /** Passed to the inner `` (e.g. profile banner vs avatar load order). */ + fetchPriority?: 'high' | 'low' | 'auto' /** * When true, the full image is not loaded until the user interacts. * The first click runs {@link onClick} (e.g. open lightbox) and also reveals the @@ -137,7 +140,9 @@ export default function Image({ clearLoadWatch() setIsLoading(false) setHasError(false) - setTimeout(() => setDisplaySkeleton(false), 600) + // Unmount blurhash/skeleton immediately — keeping z-10 overlay (even at opacity-0) leaves bg-muted/40 + // and canvas layers visible as odd tinted bands until delayed teardown. + setDisplaySkeleton(false) }, []) // Cached images are often `complete` before `onLoad` is attached (feed mounts many cards at once). @@ -258,6 +263,7 @@ export default function Image({ decoding={effectiveHoldUntilClick ? 'async' : 'sync'} // `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly. loading="eager" + fetchPriority={fetchPriority} draggable={false} onLoad={handleLoad} onError={handleError} diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index d448d330..afa5e807 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -26,6 +26,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: [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/LazyMediaTapPlaceholder.tsx b/src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx index b063c5ee..69f61cc4 100644 --- a/src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx +++ b/src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx @@ -1,7 +1,7 @@ import { cn } from '@/lib/utils' import { resolveMediaBlurPlaceholder } from '@/lib/media-placeholder-blurhash' import { decode } from 'blurhash' -import { Music2, Play } from 'lucide-react' +import { Loader2, Music2, Play } from 'lucide-react' import { useLayoutEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -58,7 +58,7 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?: const frameClass = (kind: 'video' | 'audio', className?: string) => cn( - 'relative w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-muted/30 shadow-sm', + 'relative w-full max-w-[400px] shrink-0 self-start overflow-hidden rounded-lg border border-border bg-muted/30 shadow-sm', kind === 'video' ? 'aspect-video' : 'min-h-[7.5rem] aspect-[21/9]', className ) @@ -67,14 +67,17 @@ function MediaPlaceholderLayers({ src, posterUrl, blurHash, - showTapChrome + showTapChrome, + mediaKind }: { src: string posterUrl?: string blurHash?: string showTapChrome: boolean + /** When set, overrides extension-based guess (e.g. Spotify / YouTube URLs). */ + mediaKind?: 'video' | 'audio' }) { - const kind = guessMediaKindFromUrl(src) + const kind = mediaKind ?? guessMediaKindFromUrl(src) const hash = resolveMediaBlurPlaceholder(src, blurHash) const poster = posterUrl?.trim() @@ -93,7 +96,10 @@ function MediaPlaceholderLayers({ ) : null} {showTapChrome ? ( - + {kind === 'video' ? ( @@ -112,20 +118,46 @@ export function MediaEmbedBlurFrame({ src, posterUrl, blurHash, - className + className, + mediaKind, + loadingHint }: { src: string posterUrl?: string blurHash?: string className?: string + mediaKind?: 'video' | 'audio' + /** Shown over the frame (e.g. live HLS) so long stalls are not a silent blank. */ + loadingHint?: string }) { - const kind = guessMediaKindFromUrl(src) + const kind = mediaKind ?? guessMediaKindFromUrl(src) return (
- +
+ + {loadingHint ? ( +
+ + + {loadingHint} + +
+ ) : null} +
) } @@ -135,23 +167,27 @@ export default function LazyMediaTapPlaceholder({ posterUrl, blurHash, onActivate, - className + className, + mediaKind }: { src: string posterUrl?: string blurHash?: string onActivate: () => void className?: string + mediaKind?: 'video' | 'audio' }) { const { t } = useTranslation() - const kind = guessMediaKindFromUrl(src) + const kind = mediaKind ?? guessMediaKindFromUrl(src) const label = t('Click to load media') return ( ) } diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx index 5020984f..dc85c765 100644 --- a/src/components/MediaPlayer/index.tsx +++ b/src/components/MediaPlayer/index.tsx @@ -1,7 +1,8 @@ -import { isImage } from '@/lib/url' +import { isHlsPlaylistUrl, isImage } from '@/lib/url' import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import AudioPlayer from '../AudioPlayer' import VideoPlayer from '../VideoPlayer' import ExternalLink from '../ExternalLink' @@ -23,7 +24,7 @@ function embedMediaTypeHintFromUrl(src: string): 'video' | 'audio' | null { } if ( extension && - ['mp4', 'webm', 'm4v', 'mov', 'avi', '3gp', '3g2'].includes(extension) + ['mp4', 'webm', 'm4v', 'mov', 'avi', '3gp', '3g2', 'm3u8', 'm3u'].includes(extension) ) { return 'video' } @@ -47,6 +48,7 @@ export default function MediaPlayer({ /** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */ blurHash?: string }) { + const { t } = useTranslation() const { autoLoadMedia } = useContentPolicy() /** Tap-to-load when {@link autoLoadMedia} is off; cleared when policy switches back to never. */ const [userClickedLoad, setUserClickedLoad] = useState(false) @@ -99,6 +101,12 @@ export default function MediaPlayer({ let cancelled = false try { + // Firefox/Chrome do not expose HLS via