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
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> |
|
) |
|
}
|
|
|