Browse Source

add live streams

imwald
Silberengel 3 weeks ago
parent
commit
e5ac0482b4
  1. 7
      package-lock.json
  2. 1
      package.json
  3. 109
      src/components/Content/index.tsx
  4. 8
      src/components/Image/index.tsx
  5. 1
      src/components/KindFilter/index.tsx
  6. 70
      src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx
  7. 56
      src/components/MediaPlayer/index.tsx
  8. 94
      src/components/Note/LiveEvent.tsx
  9. 245
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  10. 14
      src/components/Note/MarkdownArticle/preprocessMarkup.ts
  11. 31
      src/components/Profile/index.tsx
  12. 7
      src/components/ProfileBanner/index.tsx
  13. 51
      src/components/SpotifyEmbeddedPlayer/index.tsx
  14. 110
      src/components/VideoPlayer/index.tsx
  15. 63
      src/components/YoutubeEmbeddedPlayer/index.tsx
  16. 56
      src/components/ZapStreamEmbeddedPlayer/index.tsx
  17. 12
      src/constants.ts
  18. 4
      src/i18n/locales/de.ts
  19. 4
      src/i18n/locales/en.ts
  20. 8
      src/lib/content-parser.ts
  21. 30
      src/lib/live-activities.test.ts
  22. 39
      src/lib/live-activities.ts
  23. 25
      src/lib/spotify-url.ts
  24. 10
      src/lib/url.ts
  25. 50
      src/lib/youtube-url.ts
  26. 35
      src/lib/zap-stream-url.test.ts
  27. 28
      src/lib/zap-stream-url.ts
  28. 52
      src/pages/secondary/ProfileEditorPage/index.tsx
  29. 8
      src/types/react-video-fetchpriority.d.ts

7
package-lock.json generated

@ -60,6 +60,7 @@ @@ -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 @@ @@ -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",

1
package.json

@ -82,6 +82,7 @@ @@ -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",

109
src/components/Content/index.tsx

@ -24,10 +24,14 @@ import PaytoLink from '../PaytoLink' @@ -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({ @@ -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({ @@ -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({ @@ -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<string>()
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<string>()
const s = new Set<string>()
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<string>()
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({ @@ -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({ @@ -193,7 +252,7 @@ export default function Content({
}
}
})
return links
}, [event, contentLinks])
@ -389,6 +448,24 @@ export default function Content({ @@ -389,6 +448,24 @@ export default function Content({
mustLoad={mustLoadMedia}
/>
))}
{spotifyUrlsFromTags.map((url) => (
<SpotifyEmbeddedPlayer
key={`tag-spotify-${url}`}
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
/>
))}
{zapstreamUrlsFromTags.map((url) => (
<ZapStreamEmbeddedPlayer
key={`tag-zapstream-${url}`}
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
/>
))}
{nodes && nodes.length > 0 && nodes.map((node, index) => {
if (node.type === 'text') {
@ -528,6 +605,26 @@ export default function Content({ @@ -528,6 +605,26 @@ export default function Content({
/>
)
}
if (node.type === 'spotify') {
return (
<SpotifyEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'zapstream') {
return (
<ZapStreamEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}

8
src/components/Image/index.tsx

@ -48,6 +48,7 @@ export default function Image({ @@ -48,6 +48,7 @@ export default function Image({
errorPlaceholder = <ImageOff />,
style: wrapperStyleProp,
holdUntilClick = false,
fetchPriority,
onClick,
...props
}: HTMLAttributes<HTMLSpanElement> & {
@ -59,6 +60,8 @@ export default function Image({ @@ -59,6 +60,8 @@ export default function Image({
alt?: string
hideIfError?: boolean
errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (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({ @@ -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({ @@ -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}

1
src/components/KindFilter/index.tsx

@ -26,6 +26,7 @@ const KIND_FILTER_OPTIONS = [ @@ -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' },

70
src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx

@ -1,7 +1,7 @@ @@ -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?: @@ -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({ @@ -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({ @@ -93,7 +96,10 @@ function MediaPlaceholderLayers({
) : null}
<span className="absolute inset-0 z-[2] bg-gradient-to-t from-black/55 via-black/25 to-black/15" aria-hidden />
{showTapChrome ? (
<span className="absolute inset-0 z-[3] flex items-center justify-center" aria-hidden>
<span
className="absolute inset-0 z-[3] grid place-items-center"
aria-hidden
>
<span className="flex size-14 items-center justify-center rounded-full bg-black/55 text-white shadow-md backdrop-blur-[2px] transition-transform group-hover:scale-105 group-focus-visible:scale-105">
{kind === 'video' ? (
<Play className="size-8 fill-current pl-1" strokeWidth={0} />
@ -112,20 +118,46 @@ export function MediaEmbedBlurFrame({ @@ -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 (
<div
className={cn(frameClass(kind, className), 'pointer-events-none select-none')}
aria-hidden
aria-hidden={loadingHint ? undefined : true}
aria-busy={loadingHint ? true : undefined}
>
<MediaPlaceholderLayers src={src} posterUrl={posterUrl} blurHash={blurHash} showTapChrome={false} />
<div className="absolute inset-0 overflow-hidden rounded-lg">
<MediaPlaceholderLayers
src={src}
posterUrl={posterUrl}
blurHash={blurHash}
showTapChrome={false}
mediaKind={mediaKind}
/>
{loadingHint ? (
<div
className="absolute inset-x-0 bottom-0 z-[4] flex justify-center p-3 pt-8 bg-gradient-to-t from-black/70 to-transparent"
role="status"
aria-live="polite"
>
<span className="inline-flex max-w-[min(100%,18rem)] items-center gap-2 rounded-full bg-black/65 px-3 py-1.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm">
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
<span className="truncate">{loadingHint}</span>
</span>
</div>
) : null}
</div>
</div>
)
}
@ -135,23 +167,27 @@ export default function LazyMediaTapPlaceholder({ @@ -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 (
<button
type="button"
className={cn(
'group w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-muted/30 text-left shadow-sm outline-none transition-opacity hover:opacity-95 focus-visible:ring-2 focus-visible:ring-ring',
// `block` + `p-0` + `leading-none`: native <button> keeps a line-box / padding; with only
// absolutely positioned children that shifts the stack and the play icon looks bottom-heavy.
'group relative block w-full max-w-[400px] shrink-0 self-start overflow-hidden rounded-lg border border-border bg-muted/30 p-0 text-left leading-none shadow-sm outline-none transition-opacity hover:opacity-95 focus-visible:ring-2 focus-visible:ring-ring',
kind === 'video' ? 'aspect-video' : 'min-h-[7.5rem] aspect-[21/9]',
className
)}
@ -163,7 +199,15 @@ export default function LazyMediaTapPlaceholder({ @@ -163,7 +199,15 @@ export default function LazyMediaTapPlaceholder({
aria-label={label}
title={label}
>
<MediaPlaceholderLayers src={src} posterUrl={posterUrl} blurHash={blurHash} showTapChrome />
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg">
<MediaPlaceholderLayers
src={src}
posterUrl={posterUrl}
blurHash={blurHash}
showTapChrome
mediaKind={mediaKind}
/>
</span>
</button>
)
}

56
src/components/MediaPlayer/index.tsx

@ -1,7 +1,8 @@ @@ -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 { @@ -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({ @@ -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({ @@ -99,6 +101,12 @@ export default function MediaPlayer({
let cancelled = false
try {
// Firefox/Chrome do not expose HLS via <video> metadata probe — it fails and looked like “no player”.
if (isHlsPlaylistUrl(src)) {
setMediaType('video')
return
}
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
@ -145,6 +153,20 @@ export default function MediaPlayer({ @@ -145,6 +153,20 @@ export default function MediaPlayer({
setEmbedPainted(true)
}, [])
const blurLoadingHint = useMemo(() => {
if (!showEmbed) return undefined
if (effectiveMediaType === null) {
return t('Preparing player…', { defaultValue: 'Preparing player…' })
}
if (!embedPainted) {
if (isHlsPlaylistUrl(src)) {
return t('Starting stream…', { defaultValue: 'Starting stream…' })
}
return t('Loading media…', { defaultValue: 'Loading media…' })
}
return undefined
}, [showEmbed, effectiveMediaType, embedPainted, src, t])
if (!mustLoad && !showEmbed) {
return (
<LazyMediaTapPlaceholder
@ -168,6 +190,7 @@ export default function MediaPlayer({ @@ -168,6 +190,7 @@ export default function MediaPlayer({
posterUrl={imagePoster}
blurHash={blurHash}
className={className}
loadingHint={blurLoadingHint}
/>
)
}
@ -176,23 +199,18 @@ export default function MediaPlayer({ @@ -176,23 +199,18 @@ export default function MediaPlayer({
'transition-opacity duration-300 ease-out motion-reduce:transition-none'
return (
<div className="relative w-full max-w-[400px]">
<div
className={cn(
layerTransition,
embedPainted
? 'pointer-events-none absolute inset-0 z-10 opacity-0'
: 'relative z-10 w-full opacity-100'
)}
aria-hidden={embedPainted}
>
<MediaEmbedBlurFrame
src={src}
posterUrl={imagePoster}
blurHash={blurHash}
className={className}
/>
</div>
<div className="relative w-full max-w-[400px] shrink-0 self-start">
{!embedPainted ? (
<div className="relative z-10 w-full">
<MediaEmbedBlurFrame
src={src}
posterUrl={imagePoster}
blurHash={blurHash}
className={className}
loadingHint={blurLoadingHint}
/>
</div>
) : null}
<div
className={cn(
layerTransition,

94
src/components/Note/LiveEvent.tsx

@ -1,18 +1,30 @@ @@ -1,18 +1,30 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import {
liveEventInlinePlaybackFromEvent,
preferredLiveJoinUrlForEvent
} from '@/lib/live-activities'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { ExternalLink } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
import Image from '../Image'
import MediaPlayer from '../MediaPlayer'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event])
const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event])
const liveStatusComponent =
metadata.status &&
@ -24,14 +36,21 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -24,14 +36,21 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<Badge variant="secondary">{metadata.status}</Badge>
))
const titleComponent = <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-1">{metadata.title}</div>
const titleComponent = (
<div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-1">{metadata.title}</div>
)
const nowPlaying =
event.content?.trim().length > 0 ? (
<p className="text-sm text-muted-foreground mt-1 line-clamp-4">{event.content.trim()}</p>
) : null
const summaryComponent = metadata.summary && (
<div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div>
<div className="text-base text-muted-foreground line-clamp-4 mt-1">{metadata.summary}</div>
)
const tagsComponent = metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
<div className="flex gap-1 flex-wrap mt-2">
{metadata.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
@ -40,45 +59,56 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -40,45 +59,56 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
</div>
)
if (isSmallScreen) {
return (
<div className={className}>
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-video"
hideIfError
/>
const cover =
metadata.image && autoLoadMedia ? (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className={cn(
'bg-muted shrink-0',
isSmallScreen ? 'w-full aspect-video' : 'aspect-[4/3] xl:aspect-video h-44 w-auto max-w-[min(100%,20rem)]'
)}
<div className="space-y-1">
hideIfError
/>
) : null
return (
<div className={cn(className, 'space-y-3')}>
<div className={cn('flex gap-4', isSmallScreen ? 'flex-col' : 'flex-row items-start')}>
{cover}
<div className="flex-1 min-w-0 space-y-1">
{titleComponent}
{liveStatusComponent}
{nowPlaying}
{summaryComponent}
{tagsComponent}
<ClientSelect className="w-full mt-2" event={event} />
</div>
</div>
)
}
return (
<div className={className}>
<div className="flex gap-4">
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="aspect-[4/3] xl:aspect-video bg-foreground h-44"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-1">
{titleComponent}
{liveStatusComponent}
{summaryComponent}
{tagsComponent}
{playback ? (
<div className="w-full max-w-[400px]" onClick={(e) => e.stopPropagation()}>
<MediaPlayer src={playback.src} poster={metadata.image} className="w-full" />
</div>
) : null}
<div
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
onClick={(e) => e.stopPropagation()}
>
{joinUrl ? (
<Button variant="secondary" size="sm" className="w-full sm:w-auto shrink-0" asChild>
<a
href={joinUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2"
>
<ExternalLink className="size-4 shrink-0" />
{t('Open in browser')}
</a>
</Button>
) : null}
<ClientSelect className="w-full sm:w-auto sm:min-w-[12rem] sm:flex-1" event={event} />
</div>
<ClientSelect className="w-full mt-2" event={event} />
</div>
)
}

245
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -4,6 +4,8 @@ import MediaPlayer from '@/components/MediaPlayer' @@ -4,6 +4,8 @@ import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr'
import WebPreview from '@/components/WebPreview'
import SpotifyEmbeddedPlayer from '@/components/SpotifyEmbeddedPlayer'
import ZapStreamEmbeddedPlayer from '@/components/ZapStreamEmbeddedPlayer'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
@ -22,7 +24,15 @@ import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' @@ -22,7 +24,15 @@ import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { Event, kinds } from 'nostr-tools'
import Emoji from '@/components/Emoji'
import { ExtendedKind, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import {
ExtendedKind,
SPOTIFY_OPEN_URL_REGEX,
WS_URL_REGEX,
YOUTUBE_URL_REGEX,
ZAP_STREAM_WATCH_URL_REGEX
} from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url'
import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
@ -358,6 +368,18 @@ function isYouTubeUrl(url: string): boolean { @@ -358,6 +368,18 @@ function isYouTubeUrl(url: string): boolean {
return regex.test(url)
}
function isSpotifyUrl(url: string): boolean {
const flags = SPOTIFY_OPEN_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(SPOTIFY_OPEN_URL_REGEX.source, flags)
return regex.test(url)
}
function isZapStreamUrl(url: string): boolean {
const flags = ZAP_STREAM_WATCH_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(ZAP_STREAM_WATCH_URL_REGEX.source, flags)
return regex.test(url)
}
/**
* CodeBlock component that renders code with syntax highlighting using highlight.js
*/
@ -1115,6 +1137,8 @@ function parseMarkdownContentLegacy( @@ -1115,6 +1137,8 @@ function parseMarkdownContentLegacy(
const url = match[2]
const shouldRenderAsWebPreview = isStandalone &&
!isYouTubeUrl(url) &&
!isSpotifyOpenUrl(url) &&
!isZapStreamWatchUrl(url) &&
!isWebsocketUrl(url) &&
(url.startsWith('http://') || url.startsWith('https://'))
@ -1181,6 +1205,55 @@ function parseMarkdownContentLegacy( @@ -1181,6 +1205,55 @@ function parseMarkdownContentLegacy(
}
}
})
const spotifyUrlMatches = Array.from(content.matchAll(SPOTIFY_OPEN_URL_REGEX))
spotifyUrlMatches.forEach(match => {
if (match.index !== undefined) {
const url = match[0]
const start = match.index
const end = match.index + match[0].length
const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'youtube-url') &&
start >= p.index &&
start < p.end
)
if (!isInMarkdown && !isWithinBlockPattern(start, end, blockPatterns) && isSpotifyUrl(url)) {
patterns.push({
index: start,
end: end,
type: 'spotify-url',
data: { url }
})
}
}
})
const zapstreamUrlMatches = Array.from(content.matchAll(ZAP_STREAM_WATCH_URL_REGEX))
zapstreamUrlMatches.forEach((match) => {
if (match.index !== undefined) {
const url = match[0]
const start = match.index
const end = match.index + match[0].length
const isInMarkdown = patterns.some(
(p) =>
(p.type === 'markdown-link' ||
p.type === 'markdown-image-link' ||
p.type === 'markdown-image' ||
p.type === 'youtube-url' ||
p.type === 'spotify-url') &&
start >= p.index &&
start < p.end
)
if (!isInMarkdown && !isWithinBlockPattern(start, end, blockPatterns) && isZapStreamUrl(url)) {
patterns.push({
index: start,
end: end,
type: 'zapstream-url',
data: { url }
})
}
}
})
// Relay URLs (wss:// or ws://) - not in markdown links
const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX))
@ -1191,7 +1264,7 @@ function parseMarkdownContentLegacy( @@ -1191,7 +1264,7 @@ function parseMarkdownContentLegacy(
const end = match.index + match[0].length
// Only add if not already covered by a markdown link/image-link/image or YouTube URL and not in block pattern
const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'youtube-url') &&
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'youtube-url' || p.type === 'spotify-url' || p.type === 'zapstream-url') &&
start >= p.index &&
start < p.end
)
@ -1220,7 +1293,7 @@ function parseMarkdownContentLegacy( @@ -1220,7 +1293,7 @@ function parseMarkdownContentLegacy(
// Only add if not already covered by other patterns and not in block pattern
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' ||
p.type === 'relay-url' || p.type === 'youtube-url') &&
p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'spotify-url' || p.type === 'zapstream-url') &&
start >= p.index &&
start < p.end
)
@ -1263,7 +1336,7 @@ function parseMarkdownContentLegacy( @@ -1263,7 +1336,7 @@ function parseMarkdownContentLegacy(
// Only add if not already covered by other patterns (including markdown links with bookstr URLs) and not in block pattern
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' ||
p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'bookstr-url') &&
p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'spotify-url' || p.type === 'zapstream-url' || p.type === 'bookstr-url') &&
start >= p.index &&
start < p.end
)
@ -1302,7 +1375,7 @@ function parseMarkdownContentLegacy( @@ -1302,7 +1375,7 @@ function parseMarkdownContentLegacy(
const end = match.index + match[0].length
// Only add if not already covered by other patterns and not in block pattern
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'nostr') &&
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'spotify-url' || p.type === 'zapstream-url' || p.type === 'nostr') &&
start >= p.index &&
start < p.end
)
@ -1334,7 +1407,7 @@ function parseMarkdownContentLegacy( @@ -1334,7 +1407,7 @@ function parseMarkdownContentLegacy(
const end = match.index + match[0].length
// Only add if not already covered by other patterns and not in block pattern
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'citation') &&
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'spotify-url' || p.type === 'zapstream-url' || p.type === 'citation') &&
start >= p.index &&
start < p.end
)
@ -2155,6 +2228,20 @@ function parseMarkdownContentLegacy( @@ -2155,6 +2228,20 @@ function parseMarkdownContentLegacy(
/>
</div>
)
} else if (pattern.type === 'spotify-url') {
const { url } = pattern.data
parts.push(
<div key={`spotify-url-${patternIdx}`} className="my-2">
<SpotifyEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
} else if (pattern.type === 'zapstream-url') {
const { url } = pattern.data
parts.push(
<div key={`zapstream-url-${patternIdx}`} className="my-2">
<ZapStreamEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
} else if (pattern.type === 'relay-url') {
const { url } = pattern.data
const relayPath = `/relays/${encodeURIComponent(url)}`
@ -3320,6 +3407,20 @@ function parseMarkdownContentMarked( @@ -3320,6 +3407,20 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isSpotifyUrl(cleaned)) {
return (
<div key={`${key}-line-spotify-${lineIdx}`} className="my-2">
<SpotifyEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isZapStreamUrl(cleaned)) {
return (
<div key={`${key}-line-zapstream-${lineIdx}`} className="my-2">
<ZapStreamEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isVideo(cleaned) || isAudio(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
@ -3466,6 +3567,20 @@ function parseMarkdownContentMarked( @@ -3466,6 +3567,20 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isSpotifyUrl(cleaned)) {
return (
<div key={`${key}-spotify-url`} className="my-2">
<SpotifyEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isZapStreamUrl(cleaned)) {
return (
<div key={`${key}-zapstream-url`} className="my-2">
<ZapStreamEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isVideo(cleaned) || isAudio(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
@ -4596,6 +4711,47 @@ export default function MarkdownArticle({ @@ -4596,6 +4711,47 @@ export default function MarkdownArticle({
return youtubeUrls
}, [event.id, JSON.stringify(event.tags)])
const tagSpotifyUrls = useMemo(() => {
const spotifyUrls: string[] = []
const seenUrls = new Set<string>()
event.tags
.filter((tag) => tag[0] === 'r' && tag[1])
.forEach((tag) => {
const url = tag[1]!
if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (!isSpotifyUrl(url)) return
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
spotifyUrls.push(cleaned)
seenUrls.add(cleaned)
}
})
return spotifyUrls
}, [event.id, JSON.stringify(event.tags)])
const tagZapStreamUrls = useMemo(() => {
const zapUrls: string[] = []
const seenUrls = new Set<string>()
event.tags
.filter((tag) => tag[0] === 'r' && tag[1])
.forEach((tag) => {
const url = tag[1]!
if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (!isZapStreamWatchUrl(url)) return
const c = canonicalZapStreamWatchUrl(cleanUrl(url) || url)
if (c && !seenUrls.has(c)) {
seenUrls.add(c)
zapUrls.push(c)
}
})
return zapUrls
}, [event.id, JSON.stringify(event.tags)])
// Extract non-media links from tags (excluding YouTube URLs)
const tagLinks = useMemo(() => {
@ -4610,7 +4766,9 @@ export default function MarkdownArticle({ @@ -4610,7 +4766,9 @@ export default function MarkdownArticle({
if (isPseudoNostrHttpsUrl(url)) return
if (isImage(url) || isMedia(url)) return
if (isYouTubeUrl(url)) return // Exclude YouTube URLs
if (isSpotifyUrl(url)) return
if (isZapStreamWatchUrl(url)) return
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
links.push(cleaned)
@ -4736,7 +4894,35 @@ export default function MarkdownArticle({ @@ -4736,7 +4894,35 @@ export default function MarkdownArticle({
}
return urls
}, [event.content])
const spotifyUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
const cleaned = cleanUrl(url)
if (cleaned && isSpotifyUrl(cleaned)) {
urls.add(cleaned)
}
}
return urls
}, [event.content])
const zapstreamUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
const cleaned = cleanUrl(url)
if (!cleaned) continue
const c = canonicalZapStreamWatchUrl(cleaned)
if (c) urls.add(c)
}
return urls
}, [event.content])
// Extract non-media links from content (excluding YouTube URLs)
const contentLinks = useMemo(() => {
const links: string[] = []
@ -4745,7 +4931,14 @@ export default function MarkdownArticle({ @@ -4745,7 +4931,14 @@ export default function MarkdownArticle({
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url) && !isYouTubeUrl(url)) {
if (
(url.startsWith('http://') || url.startsWith('https://')) &&
!isImage(url) &&
!isMedia(url) &&
!isYouTubeUrl(url) &&
!isSpotifyUrl(url) &&
!isZapStreamWatchUrl(url)
) {
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
links.push(cleaned)
@ -4803,6 +4996,17 @@ export default function MarkdownArticle({ @@ -4803,6 +4996,17 @@ export default function MarkdownArticle({
return cleaned && !youtubeUrlsInContent.has(cleaned)
})
}, [tagYouTubeUrls, youtubeUrlsInContent])
const leftoverTagSpotifyUrls = useMemo(() => {
return tagSpotifyUrls.filter((url) => {
const cleaned = cleanUrl(url)
return cleaned && !spotifyUrlsInContent.has(cleaned)
})
}, [tagSpotifyUrls, spotifyUrlsInContent])
const leftoverTagZapStreamUrls = useMemo(() => {
return tagZapStreamUrls.filter((canon) => !zapstreamUrlsInContent.has(canon))
}, [tagZapStreamUrls, zapstreamUrlsInContent])
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
const leftoverTagLinks = useMemo(() => {
@ -5157,6 +5361,29 @@ export default function MarkdownArticle({ @@ -5157,6 +5361,29 @@ export default function MarkdownArticle({
})}
</div>
)}
{leftoverTagSpotifyUrls.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagSpotifyUrls.map((url) => {
const cleaned = cleanUrl(url)
return (
<div key={`tag-spotify-${cleaned}`} className="my-2">
<SpotifyEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
})}
</div>
)}
{leftoverTagZapStreamUrls.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagZapStreamUrls.map((url) => (
<div key={`tag-zapstream-${url}`} className="my-2">
<ZapStreamEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
))}
</div>
)}
{/* Parsed content */}
<div className="break-words">

14
src/components/Note/MarkdownArticle/preprocessMarkup.ts

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
import { NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns'
import { isImage, isVideo, isAudio } from '@/lib/url'
import { URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url'
import { isZapStreamWatchUrl } from '@/lib/zap-stream-url'
/**
* Check if a URL is a YouTube URL
@ -80,11 +82,19 @@ export function preprocessMarkdownMediaLinks(content: string): string { @@ -80,11 +82,19 @@ export function preprocessMarkdownMediaLinks(content: string): string {
const isVideoUrl = isVideo(url)
const isAudioUrl = isAudio(url)
const isYouTube = isYouTubeUrl(url)
// Skip YouTube URLs - they should be left as plain text so they can be detected and rendered as YouTube embeds
if (isYouTube) {
continue
}
if (isSpotifyOpenUrl(url)) {
continue
}
if (isZapStreamWatchUrl(url)) {
continue
}
let replacement: string
if (isImageUrl || isVideoUrl || isAudioUrl) {
@ -194,7 +204,7 @@ export function preprocessAsciidocMediaLinks(content: string): string { @@ -194,7 +204,7 @@ export function preprocessAsciidocMediaLinks(content: string): string {
const isVideoUrl = isVideo(url)
const isAudioUrl = isAudio(url)
const isYouTube = isYouTubeUrl(url)
let replacement: string
if (isImageUrl) {
// Images: convert to image::url[]

31
src/components/Profile/index.tsx

@ -405,19 +405,38 @@ export default function Profile({ @@ -405,19 +405,38 @@ export default function Profile({
<>
<div>
<div className="relative bg-cover bg-center mb-2">
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
{/* Avatar first in DOM + higher fetch priority so it loads before the wide banner. */}
{isVideo(avatar ?? '') ? (
<div className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background overflow-hidden rounded-full bg-muted">
<video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
<div className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 z-10 translate-y-1/2 border-4 border-background overflow-hidden rounded-full bg-muted">
<video
src={avatar}
className="h-full w-full object-cover object-center"
autoPlay
muted
loop
playsInline
fetchPriority="high"
/>
</div>
) : (
<Avatar className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" />
<Avatar className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 z-10 translate-y-1/2 border-4 border-background">
<AvatarImage
src={avatar}
className="object-cover object-center"
fetchPriority="high"
loading="eager"
/>
<AvatarFallback>
<img src={defaultImage} />
<img src={defaultImage} alt="" />
</AvatarFallback>
</Avatar>
)}
<ProfileBanner
banner={banner}
pubkey={pubkey}
className="relative z-0 w-full aspect-[3/1]"
imageFetchPriority="low"
/>
</div>
<div className="px-4">
<div className="flex justify-end h-8 gap-2 items-center">

7
src/components/ProfileBanner/index.tsx

@ -7,11 +7,14 @@ import { cn } from '@/lib/utils' @@ -7,11 +7,14 @@ import { cn } from '@/lib/utils'
export default function ProfileBanner({
pubkey,
banner,
className
className,
imageFetchPriority
}: {
pubkey: string
banner?: string
className?: string
/** Prefer loading the profile picture first on profile pages (`low` defers the banner). */
imageFetchPriority?: 'high' | 'low' | 'auto'
}) {
const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
const [bannerUrl, setBannerUrl] = useState(banner ?? defaultBanner)
@ -34,6 +37,7 @@ export default function ProfileBanner({ @@ -34,6 +37,7 @@ export default function ProfileBanner({
muted
loop
playsInline
fetchPriority={imageFetchPriority}
aria-label={`${pubkey} banner`}
onError={() => setBannerUrl(defaultBanner)}
/>
@ -46,6 +50,7 @@ export default function ProfileBanner({ @@ -46,6 +50,7 @@ export default function ProfileBanner({
image={{ url: bannerUrl, pubkey }}
alt={`${pubkey} banner`}
className={cn('rounded-none', className)}
fetchPriority={imageFetchPriority}
onError={() => setBannerUrl(defaultBanner)}
/>
)

51
src/components/SpotifyEmbeddedPlayer/index.tsx

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
import { spotifyOpenUrlToEmbedSrc } from '@/lib/spotify-url'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useLayoutEffect, useMemo, useState } from 'react'
import ExternalLink from '../ExternalLink'
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
export default function SpotifyEmbeddedPlayer({
url,
className,
mustLoad = false
}: {
url: string
className?: string
mustLoad?: boolean
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [userClickedLoad, setUserClickedLoad] = useState(false)
const embedSrc = useMemo(() => spotifyOpenUrlToEmbedSrc(url), [url])
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
useLayoutEffect(() => {
if (!autoLoadMedia) setUserClickedLoad(false)
}, [autoLoadMedia])
if (!embedSrc) {
return <ExternalLink url={url} />
}
if (!mustLoad && !showEmbed) {
return (
<LazyMediaTapPlaceholder
src={url}
mediaKind="audio"
onActivate={() => setUserClickedLoad(true)}
className={cn('min-h-[152px]', className)}
/>
)
}
return (
<iframe
title="Spotify"
src={embedSrc}
className={cn('rounded-lg border w-full max-w-[400px] min-h-[152px]', className)}
allow="encrypted-media; clipboard-write"
loading="lazy"
/>
)
}

110
src/components/VideoPlayer/index.tsx

@ -1,11 +1,24 @@ @@ -1,11 +1,24 @@
import { isHlsPlaylistUrl } from '@/lib/url'
import { cn, isInViewport } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service'
import { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { Loader2 } from 'lucide-react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ExternalLink from '../ExternalLink'
import { MediaErrorBoundary } from '../MediaErrorBoundary'
import logger from '@/lib/logger'
/** Safari plays HLS natively; Chromium/Firefox need MSE via hls.js. */
function hlsPlaybackMode(src: string): 'hlsjs' | 'native' {
if (typeof document === 'undefined') return 'native'
if (!isHlsPlaylistUrl(src)) return 'native'
const probe = document.createElement('video')
if (probe.canPlayType('application/vnd.apple.mpegurl')) return 'native'
return Hls.isSupported() ? 'hlsjs' : 'native'
}
export default function VideoPlayer({
src,
className,
@ -18,11 +31,62 @@ export default function VideoPlayer({ @@ -18,11 +31,62 @@ export default function VideoPlayer({
/** Fires when the first frame is available (e.g. to swap out a blurhash placeholder). */
onReady?: () => void
}) {
const { t } = useTranslation()
const { autoplay } = useContentPolicy()
const [error, setError] = useState(false)
const [showBufferOverlay, setShowBufferOverlay] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const hlsMode = useMemo(() => hlsPlaybackMode(src), [src])
useEffect(() => {
setError(false)
setShowBufferOverlay(false)
}, [src])
useEffect(() => {
const video = videoRef.current
if (!video || error) return
const onWaiting = () => setShowBufferOverlay(true)
const clearBuffering = () => setShowBufferOverlay(false)
video.addEventListener('waiting', onWaiting)
video.addEventListener('playing', clearBuffering)
video.addEventListener('canplay', clearBuffering)
video.addEventListener('seeked', clearBuffering)
return () => {
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', clearBuffering)
video.removeEventListener('canplay', clearBuffering)
video.removeEventListener('seeked', clearBuffering)
}
}, [src, hlsMode, error])
useEffect(() => {
if (hlsMode !== 'hlsjs') return
const video = videoRef.current
if (!video) return
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true
})
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
logger.warn('HLS playback error', { type: data.type, details: data.details })
setError(true)
}
})
return () => {
hls.destroy()
video.removeAttribute('src')
video.load()
}
}, [src, hlsMode])
useEffect(() => {
if (!autoplay) return
@ -51,7 +115,7 @@ export default function VideoPlayer({ @@ -51,7 +115,7 @@ export default function VideoPlayer({
return () => {
observer.unobserve(container)
}
}, [autoplay])
}, [autoplay, src, hlsMode])
useEffect(() => {
if (error) {
@ -59,6 +123,29 @@ export default function VideoPlayer({ @@ -59,6 +123,29 @@ export default function VideoPlayer({
}
}, [error, onReady])
// `canplay` usually fires before `loadeddata` — swap the placeholder sooner for HLS and progressive video.
useLayoutEffect(() => {
if (!onReady) return
const video = videoRef.current
if (!video) return
let done = false
const notify = () => {
if (done) return
done = true
onReady()
}
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
notify()
return
}
video.addEventListener('canplay', notify, { once: true })
video.addEventListener('loadeddata', notify, { once: true })
return () => {
video.removeEventListener('canplay', notify)
video.removeEventListener('loadeddata', notify)
}
}, [src, onReady, hlsMode])
if (error) {
return <ExternalLink url={src} />
}
@ -73,23 +160,34 @@ export default function VideoPlayer({ @@ -73,23 +160,34 @@ export default function VideoPlayer({
setError(true)
}}
>
<div ref={containerRef} className="w-full max-w-full overflow-hidden">
<div ref={containerRef} className="relative w-full max-w-full overflow-hidden">
<video
ref={videoRef}
controls
playsInline
preload={onReady ? 'metadata' : 'none'}
preload={onReady ? (hlsMode === 'hlsjs' ? 'auto' : 'metadata') : 'none'}
className={cn('rounded-lg max-h-[80vh] sm:max-h-[60vh] border w-full h-auto max-w-full', className)}
src={src}
src={hlsMode === 'hlsjs' ? undefined : src}
poster={poster}
onClick={(e) => e.stopPropagation()}
onLoadedData={() => onReady?.()}
onPlay={(event) => {
mediaManager.play(event.currentTarget)
}}
muted
onError={() => setError(true)}
/>
{showBufferOverlay ? (
<div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/35 backdrop-blur-[1px]"
role="status"
aria-live="polite"
>
<span className="inline-flex items-center gap-2 rounded-full bg-black/60 px-3 py-2 text-sm font-medium text-white shadow-md">
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden />
{t('Buffering…', { defaultValue: 'Buffering…' })}
</span>
</div>
) : null}
</div>
</MediaErrorBoundary>
)

63
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -1,11 +1,12 @@ @@ -1,11 +1,12 @@
import { ensureYouTubeIframeApi } from '@/lib/youtube-iframe-api'
import { parseYoutubeUrl } from '@/lib/youtube-url'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service'
import { YouTubePlayer } from '@/types/youtube'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ExternalLink from '../ExternalLink'
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
import logger from '@/lib/logger'
export default function YoutubeEmbeddedPlayer({
@ -17,7 +18,6 @@ export default function YoutubeEmbeddedPlayer({ @@ -17,7 +18,6 @@ export default function YoutubeEmbeddedPlayer({
className?: string
mustLoad?: boolean
}) {
const { t } = useTranslation()
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [userClickedLoad, setUserClickedLoad] = useState(false)
@ -33,6 +33,20 @@ export default function YoutubeEmbeddedPlayer({ @@ -33,6 +33,20 @@ export default function YoutubeEmbeddedPlayer({
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
const posterUrl = useMemo(
() => (videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : undefined),
[videoId]
)
const frameClassName = useMemo(
() =>
cn(
isShort ? 'aspect-[9/16] max-h-[80vh] sm:max-h-[60vh]' : 'aspect-video max-h-[60vh]',
className
),
[isShort, className]
)
useEffect(() => {
if (!videoId || !containerRef.current || !showEmbed) return
@ -86,15 +100,13 @@ export default function YoutubeEmbeddedPlayer({ @@ -86,15 +100,13 @@ export default function YoutubeEmbeddedPlayer({
if (!mustLoad && !showEmbed) {
return (
<div
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setUserClickedLoad(true)
}}
>
[{t('Click to load YouTube video')}]
</div>
<LazyMediaTapPlaceholder
src={url}
mediaKind="video"
posterUrl={posterUrl}
onActivate={() => setUserClickedLoad(true)}
className={frameClassName}
/>
)
}
@ -102,35 +114,8 @@ export default function YoutubeEmbeddedPlayer({ @@ -102,35 +114,8 @@ export default function YoutubeEmbeddedPlayer({
return <ExternalLink url={url} />
}
return (
<div
className={cn(
'rounded-lg border overflow-hidden',
isShort ? 'aspect-[9/16] max-h-[80vh] sm:max-h-[60vh]' : 'aspect-video max-h-[60vh]',
className
)}
>
<div className={cn('rounded-lg border overflow-hidden w-full max-w-[400px]', frameClassName)}>
<div ref={containerRef} className="w-full h-full" />
</div>
)
}
function parseYoutubeUrl(url: string) {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/,
/youtube\.com\/shorts\/([^&\n?#]+)/,
/youtube\.com\/live\/([^&\n?#]+)/
]
let videoId: string | null = null
let isShort = false
for (const [index, pattern] of patterns.entries()) {
const match = url.match(pattern)
if (match) {
videoId = match[1].trim()
isShort = index === 2 // Check if it's a short video
break
}
}
return { videoId, isShort }
}

56
src/components/ZapStreamEmbeddedPlayer/index.tsx

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
import { canonicalZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useLayoutEffect, useMemo, useState } from 'react'
import ExternalLink from '../ExternalLink'
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
export default function ZapStreamEmbeddedPlayer({
url,
className,
mustLoad = false
}: {
url: string
className?: string
mustLoad?: boolean
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [userClickedLoad, setUserClickedLoad] = useState(false)
const embedSrc = useMemo(() => canonicalZapStreamWatchUrl(url), [url])
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
useLayoutEffect(() => {
if (!autoLoadMedia) setUserClickedLoad(false)
}, [autoLoadMedia])
if (!embedSrc) {
return <ExternalLink url={url} />
}
if (!mustLoad && !showEmbed) {
return (
<LazyMediaTapPlaceholder
src={embedSrc}
mediaKind="video"
onActivate={() => setUserClickedLoad(true)}
className={cn('aspect-video max-h-[min(70vh,28rem)]', className)}
/>
)
}
return (
<iframe
title="zap.stream"
src={embedSrc}
className={cn(
'rounded-lg border w-full max-w-[400px] aspect-video max-h-[min(70vh,28rem)]',
className
)}
allow="autoplay; encrypted-media; fullscreen; clipboard-write"
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
)
}

12
src/constants.ts

@ -675,6 +675,8 @@ export const SUPPORTED_KINDS = [ @@ -675,6 +675,8 @@ export const SUPPORTED_KINDS = [
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.CALENDAR_EVENT_DATE,
ExtendedKind.CALENDAR_EVENT_TIME,
/** NIP-53 live stream / radio ticker — shown in feed with inline play when tags allow. */
kinds.LiveEvent,
ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
@ -776,7 +778,15 @@ export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj- @@ -776,7 +778,15 @@ export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-
export const EMOJI_REGEX =
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23E9}-\u{23EF}]|[\u{23F0}]|[\u{23F3}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]/gu
export const YOUTUBE_URL_REGEX =
/https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+|shorts\/[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)(?:\?[^#\s]*)?(?:#[^\s]*)?/gi
/https?:\/\/(?:(?:(?:www|m|music)\.)?youtube\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+|shorts\/[\w-]+|live\/[\w-]+)|(?:www\.)?youtube-nocookie\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+|shorts\/[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)(?:\?[^#\s]*)?(?:#[^\s]*)?/gi
/** open.spotify.com track / album / playlist / episode / show (optional intl-xx segment). */
export const SPOTIFY_OPEN_URL_REGEX =
/https?:\/\/open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(?:track|album|playlist|episode|show)\/[a-zA-Z0-9]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi
/** zap.stream live player: path must be a bare NIP-19 naddr (`/naddr1…`). */
export const ZAP_STREAM_WATCH_URL_REGEX =
/https?:\/\/(?:www\.)?zap\.stream\/(naddr1[02-9ac-hj-np-z]+)(?:\?[^\s#]*)?(?:#[^\s]*)?/gi
/** Maintainer / official zap recipient pubkey for this distribution. */
export const IMWALD_MAINTAINER_PUBKEY =

4
src/i18n/locales/de.ts

@ -194,6 +194,10 @@ export default { @@ -194,6 +194,10 @@ export default {
'Preparing read-aloud…': 'Vorlesen wird vorbereitet…',
'Requesting audio…': 'Audio wird angefordert…',
'Loading audio…': 'Audio wird geladen…',
'Preparing player…': 'Player wird vorbereitet…',
'Starting stream…': 'Stream wird gestartet…',
'Loading media…': 'Medien werden geladen…',
'Buffering…': 'Puffern…',
Playing: 'Wiedergabe',
Paused: 'Pausiert',
'Read-aloud finished': 'Beendet',

4
src/i18n/locales/en.ts

@ -189,6 +189,10 @@ export default { @@ -189,6 +189,10 @@ export default {
'Preparing read-aloud…': 'Preparing read-aloud…',
'Requesting audio…': 'Requesting audio…',
'Loading audio…': 'Loading audio…',
'Preparing player…': 'Preparing player…',
'Starting stream…': 'Starting stream…',
'Loading media…': 'Loading media…',
'Buffering…': 'Buffering…',
Playing: 'Playing',
Paused: 'Paused',
'Read-aloud finished': 'Finished',

8
src/lib/content-parser.ts

@ -14,6 +14,8 @@ import { @@ -14,6 +14,8 @@ import {
import { PAYTO_URI_REGEX } from '@/lib/payto'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import { isImage, isMedia } from './url'
import { isSpotifyOpenUrl } from './spotify-url'
import { isZapStreamWatchUrl } from './zap-stream-url'
export type TEmbeddedNodeType =
| 'text'
@ -29,6 +31,8 @@ export type TEmbeddedNodeType = @@ -29,6 +31,8 @@ export type TEmbeddedNodeType =
| 'emoji'
| 'invoice'
| 'youtube'
| 'spotify'
| 'zapstream'
| 'payto'
export type TEmbeddedNode =
@ -117,6 +121,10 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => { @@ -117,6 +121,10 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
type = 'media'
} else if (isYouTubeUrl(url)) {
type = 'youtube'
} else if (isSpotifyOpenUrl(url)) {
type = 'spotify'
} else if (isZapStreamWatchUrl(url)) {
type = 'zapstream'
}
// Add the match as specific type

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
liveEventInlinePlaybackFromEvent,
parseLiveActivityEvent,
preferredLiveJoinUrlForEvent,
resolveParentSpacesForLiveActivities
@ -193,6 +194,35 @@ describe('parseLiveActivityEvent (NIP-53)', () => { @@ -193,6 +194,35 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
})
})
describe('liveEventInlinePlaybackFromEvent', () => {
it('prefers MP3 r tag over HLS streaming', () => {
const ev = base(30311, [
['d', 'chill'],
['streaming', 'https://cdn.example/hls/chill/index.m3u8'],
['r', 'https://stream.example/listen/chill/radio.mp3']
])
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: 'https://stream.example/listen/chill/radio.mp3',
mode: 'audio'
})
})
it('falls back to HLS streaming when no direct audio URL', () => {
const ev = base(30311, [
['d', 'chill'],
['streaming', 'https://cdn.example/hls/chill/index.m3u8']
])
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: 'https://cdn.example/hls/chill/index.m3u8',
mode: 'video'
})
})
it('returns null for non-30311', () => {
expect(liveEventInlinePlaybackFromEvent(base(1, [['d', 'x']]))).toBeNull()
})
})
describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
it('30312 Nostr Nests: prefers nostrnests.com naddr over MoQ streaming URL', () => {
const pk = 'a'.repeat(64)

39
src/lib/live-activities.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { FAST_READ_RELAY_URLS } from '@/constants'
import { isAudio, isHlsPlaylistUrl, isVideo } from '@/lib/url'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import {
dedupeNormalizeRelayUrlsOrdered,
@ -551,3 +552,41 @@ export function msUntilNextQuarterHour(): number { @@ -551,3 +552,41 @@ export function msUntilNextQuarterHour(): number {
const secsUntil = (15 - minsPastQuarter) * 60 - s - ms / 1000
return Math.max(0, Math.floor(secsUntil * 1000))
}
function isStreamableHttpUrl(s: string): boolean {
const t = s.trim()
return t.startsWith('https://') || t.startsWith('http://')
}
function tagValues(ev: Event, name: string): string[] {
const out: string[] = []
for (const t of ev.tags) {
if (t[0] === name && t[1]?.trim()) out.push(t[1].trim())
}
return out
}
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.
*/
export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlayback | null {
if (ev.kind !== 30311) return null
const rUrls = tagValues(ev, 'r').filter(isStreamableHttpUrl)
const streaming = tagValues(ev, 'streaming').find(isStreamableHttpUrl)
for (const u of rUrls) {
if (isAudio(u)) return { src: u, mode: 'audio' }
}
if (streaming && isAudio(streaming)) return { src: streaming, mode: 'audio' }
for (const u of rUrls) {
if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' }
}
if (streaming && (isHlsPlaylistUrl(streaming) || isVideo(streaming))) {
return { src: streaming, mode: 'video' }
}
return null
}

25
src/lib/spotify-url.ts

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
const EMBEDDABLE_KINDS = new Set(['track', 'album', 'playlist', 'episode', 'show'])
/**
* Build Spotify embed iframe `src` from an open.spotify.com link, or null if not embeddable.
*/
export function spotifyOpenUrlToEmbedSrc(url: string): string | null {
try {
const u = new URL(url.trim())
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
if (u.hostname.toLowerCase() !== 'open.spotify.com') return null
const parts = u.pathname.split('/').filter(Boolean)
let i = 0
if (parts[0]?.match(/^intl-[a-z]{2}$/i)) i += 1
const kind = parts[i]?.toLowerCase()
const id = parts[i + 1]
if (!kind || !id || !EMBEDDABLE_KINDS.has(kind)) return null
return `https://open.spotify.com/embed/${kind}/${id}${u.search}`
} catch {
return null
}
}
export function isSpotifyOpenUrl(url: string): boolean {
return spotifyOpenUrlToEmbedSrc(url) != null
}

10
src/lib/url.ts

@ -355,6 +355,16 @@ export function isVideo(url: string) { @@ -355,6 +355,16 @@ export function isVideo(url: string) {
}
}
/** HLS / MPEG-DASH manifests (often tagged as `streaming` on NIP-53 live events). */
export function isHlsPlaylistUrl(url: string): boolean {
try {
const path = new URL(url).pathname.toLowerCase()
return path.endsWith('.m3u8') || path.endsWith('.m3u')
} 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/").

50
src/lib/youtube-url.ts

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
/**
* Parse a YouTube watch / Shorts / Live / embed / youtu.be URL for the player API.
* Covers common host variants (www, m, music, youtube-nocookie).
*/
export function parseYoutubeUrl(url: string): { videoId: string | null; isShort: boolean } {
try {
const u = new URL(url.trim())
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return { videoId: null, isShort: false }
}
const host = u.hostname.toLowerCase()
if (host === 'youtu.be') {
const id = u.pathname.split('/').filter(Boolean)[0]
const videoId = id ? decodeURIComponent(id.split('?')[0]!).trim() : null
return { videoId: videoId && videoId.length >= 6 ? videoId : null, isShort: false }
}
if (
!(
host === 'youtube.com' ||
host === 'www.youtube.com' ||
host === 'm.youtube.com' ||
host === 'music.youtube.com' ||
host === 'youtube-nocookie.com' ||
host === 'www.youtube-nocookie.com'
)
) {
return { videoId: null, isShort: false }
}
const path = u.pathname
if (path.startsWith('/shorts/')) {
const id = path.slice('/shorts/'.length).split('/')[0]?.trim()
return { videoId: id || null, isShort: true }
}
if (path.startsWith('/live/')) {
const id = path.slice('/live/'.length).split('/')[0]?.trim()
return { videoId: id || null, isShort: false }
}
if (path.startsWith('/embed/')) {
const id = path.slice('/embed/'.length).split('/')[0]?.trim()
return { videoId: id || null, isShort: false }
}
if (path === '/watch' || path.startsWith('/watch/')) {
const v = u.searchParams.get('v')?.trim()
if (v) return { videoId: v, isShort: false }
}
return { videoId: null, isShort: false }
} catch {
return { videoId: null, isShort: false }
}
}

35
src/lib/zap-stream-url.test.ts

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from './zap-stream-url'
const SAMPLE =
'https://zap.stream/naddr1qqjrqcmzv3nr2des95ergdp3956xzdf595uxzefk94nxzefnxgukycnzxyexyqgewaehxw309aex2mrp0yh8xmn0wf6zuum0vd5kzmp0qgsv73dxhgfk8tt76gf6q788zrfyz9dwwgwfk3aar6l5gk82a76v9fgrqsqqqan84z6qnu'
describe('zap-stream-url', () => {
it('canonicalizes valid zap.stream naddr URLs', () => {
expect(canonicalZapStreamWatchUrl(SAMPLE)).toBe(SAMPLE)
expect(canonicalZapStreamWatchUrl('http://zap.stream/naddr1qqqq')).toBe(
'https://zap.stream/naddr1qqqq',
)
expect(canonicalZapStreamWatchUrl('https://www.zap.stream/naddr1qqqq')).toBe(
'https://zap.stream/naddr1qqqq',
)
})
it('preserves query on canonical URL', () => {
expect(canonicalZapStreamWatchUrl(`${SAMPLE}?t=1`)).toBe(`${SAMPLE}?t=1`)
})
it('rejects non-watch URLs', () => {
expect(canonicalZapStreamWatchUrl('https://zap.stream/')).toBeNull()
expect(canonicalZapStreamWatchUrl('https://zap.stream/foo/bar')).toBeNull()
expect(canonicalZapStreamWatchUrl('https://example.com/naddr1qqqq')).toBeNull()
expect(canonicalZapStreamWatchUrl('https://zap.stream/npub1qqqq')).toBeNull()
expect(canonicalZapStreamWatchUrl('https://zap.stream/naddr1')).toBeNull()
expect(canonicalZapStreamWatchUrl('https://zap.stream/naddr1!bad')).toBeNull()
})
it('isZapStreamWatchUrl mirrors canonical', () => {
expect(isZapStreamWatchUrl(SAMPLE)).toBe(true)
expect(isZapStreamWatchUrl('https://zap.stream/')).toBe(false)
})
})

28
src/lib/zap-stream-url.ts

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
const ZAP_STREAM_HOSTS = new Set(['zap.stream', 'www.zap.stream'])
const NADDR_BODY_RE = /^[02-9ac-hj-np-z]+$/i
/**
* If `raw` is a zap.stream watch URL whose path is a bare `naddr1…` pointer, return canonical `https://zap.stream/naddr1…`.
* Rejects unknown hosts and paths that are not a single naddr segment.
*/
export function canonicalZapStreamWatchUrl(raw: string): string | null {
try {
const u = new URL(raw.trim())
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
if (!ZAP_STREAM_HOSTS.has(u.hostname.toLowerCase())) return null
const segments = u.pathname.split('/').filter(Boolean)
if (segments.length !== 1) return null
const seg = segments[0]!
if (!seg.startsWith('naddr1')) return null
const body = seg.slice('naddr1'.length)
if (!body || !NADDR_BODY_RE.test(body)) return null
return `https://zap.stream/${seg}${u.search}`
} catch {
return null
}
}
export function isZapStreamWatchUrl(url: string): boolean {
return canonicalZapStreamWatchUrl(url) != null
}

52
src/pages/secondary/ProfileEditorPage/index.tsx

@ -473,28 +473,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -473,28 +473,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}>
{/* Banner & avatar uploaders */}
<div className="relative bg-cover bg-center mb-2">
<Uploader
onUploadSuccess={onBannerUploadSuccess}
onUploadStart={() => setUploadingBanner(true)}
onUploadEnd={() => setUploadingBanner(false)}
className="w-full relative cursor-pointer"
accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT}
maxCompressedSizeMb={5}
>
<ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" />
<div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center">
{uploadingBanner ? (
<Skeleton className="size-9 shrink-0 rounded-md" aria-hidden />
) : (
<Upload size={36} />
)}
</div>
</Uploader>
{/* Avatar first in DOM + higher fetch priority so it loads before the banner (same as profile view). */}
<Uploader
onUploadSuccess={onAvatarUploadSuccess}
onUploadStart={() => setUploadingAvatar(true)}
onUploadEnd={() => setUploadingAvatar(false)}
className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
className="z-10 w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT}
maxCompressedSizeMb={2}
>
@ -507,12 +491,18 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -507,12 +491,18 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
muted
loop
playsInline
fetchPriority="high"
/>
) : (
<Avatar className="w-full h-full">
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarImage
src={avatar}
className="object-cover object-center"
fetchPriority="high"
loading="eager"
/>
<AvatarFallback>
<img src={defaultImage} />
<img src={defaultImage} alt="" />
</AvatarFallback>
</Avatar>
)}
@ -525,6 +515,28 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -525,6 +515,28 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
)}
</div>
</Uploader>
<Uploader
onUploadSuccess={onBannerUploadSuccess}
onUploadStart={() => setUploadingBanner(true)}
onUploadEnd={() => setUploadingBanner(false)}
className="relative z-0 w-full cursor-pointer"
accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT}
maxCompressedSizeMb={5}
>
<ProfileBanner
banner={banner}
pubkey={account.pubkey}
className="w-full aspect-[3/1]"
imageFetchPriority="low"
/>
<div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center">
{uploadingBanner ? (
<Skeleton className="size-9 shrink-0 rounded-md" aria-hidden />
) : (
<Upload size={36} />
)}
</div>
</Uploader>
</div>
<div className="pt-14 px-4 flex flex-col gap-4">

8
src/types/react-video-fetchpriority.d.ts vendored

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
/** Chromium supports `fetchpriority` on `<video>`; @types/react may lag behind. */
import 'react'
declare module 'react' {
interface VideoHTMLAttributes<T> {
fetchPriority?: 'high' | 'low' | 'auto'
}
}
Loading…
Cancel
Save