You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

245 lines
6.8 KiB

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'
import LazyMediaTapPlaceholder, { MediaEmbedBlurFrame } from './LazyMediaTapPlaceholder'
/** Same rules as the metadata probe, but synchronous so the first paint can show the embed stack. */
function embedMediaTypeHintFromUrl(src: string): 'video' | 'audio' | null {
try {
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
if (
extension &&
['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka', 'oga'].includes(extension)
) {
return 'audio'
}
if (extension === 'mkv' || extension === 'ogv') {
return 'video'
}
if (
extension &&
['mp4', 'webm', 'm4v', 'mov', 'avi', '3gp', '3g2', 'm3u8', 'm3u'].includes(extension)
) {
return 'video'
}
return null
} catch {
return null
}
}
export default function MediaPlayer({
src,
className,
mustLoad = false,
poster,
blurHash,
fallbackPageUrl
}: {
src: string
className?: string
mustLoad?: boolean
poster?: string
/** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */
blurHash?: string
/** Passed to {@link VideoPlayer} when HLS/video playback fails (e.g. NIP-53 zap.stream join URL). */
fallbackPageUrl?: 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)
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
const [probeFailed, setProbeFailed] = useState(false)
const [embedPainted, setEmbedPainted] = useState(false)
const readyOnceRef = useRef(false)
// imeta `thumb` / `image` are sometimes the same .mp4 as `url` — <img> cannot use that, and it
// would hide the blurhash placeholder in LazyMediaTapPlaceholder.
const imagePoster = useMemo(() => {
const p = poster?.trim()
if (!p) return undefined
return isImage(p) ? p : undefined
}, [poster])
const urlEmbedTypeHint = useMemo(() => embedMediaTypeHintFromUrl(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 showEmbed = mustLoad || autoLoadMedia || userClickedLoad
useLayoutEffect(() => {
if (!autoLoadMedia) setUserClickedLoad(false)
}, [autoLoadMedia])
useEffect(() => {
readyOnceRef.current = false
setEmbedPainted(false)
setMediaType(null)
setProbeFailed(false)
}, [src])
useEffect(() => {
if (!showEmbed) {
setMediaType(null)
setProbeFailed(false)
return
}
readyOnceRef.current = false
setEmbedPainted(false)
if (!src) {
setProbeFailed(true)
return
}
setProbeFailed(false)
setMediaType(null)
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()
if (
extension &&
['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka'].includes(extension)
) {
setMediaType('audio')
return
}
if (extension === 'mkv' || extension === 'ogv') {
setMediaType('video')
return
}
const video = document.createElement('video')
video.src = src
video.preload = 'metadata'
video.onloadedmetadata = () => {
if (cancelled) return
setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio')
}
video.onerror = () => {
if (cancelled) return
setProbeFailed(true)
setMediaType(null)
}
return () => {
cancelled = true
video.src = ''
}
} catch {
setProbeFailed(true)
}
}, [src, showEmbed])
const onEmbedReady = useCallback(() => {
if (readyOnceRef.current) return
readyOnceRef.current = true
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
src={src}
posterUrl={imagePoster}
blurHash={blurHash}
onActivate={() => setUserClickedLoad(true)}
className={className}
/>
)
}
if (probeFailed) {
return <ExternalLink url={src} />
}
if (effectiveMediaType === null) {
return (
<MediaEmbedBlurFrame
src={src}
posterUrl={imagePoster}
blurHash={blurHash}
className={className}
loadingHint={blurLoadingHint}
/>
)
}
const layerTransition =
'transition-opacity duration-300 ease-out motion-reduce:transition-none'
return (
<div className="not-prose 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,
embedPainted
? 'relative z-20 w-full overflow-hidden opacity-100'
: 'absolute inset-0 z-0 w-full overflow-hidden opacity-0 pointer-events-none'
)}
aria-hidden={!embedPainted}
>
{effectiveMediaType === 'video' ? (
<VideoPlayer
src={src}
className={className}
poster={imagePoster}
onReady={onEmbedReady}
fallbackPageUrl={fallbackPageUrl}
/>
) : (
<AudioPlayer
src={src}
className={className}
poster={imagePoster}
onReady={onEmbedReady}
/>
)}
</div>
</div>
)
}