Files
archived-linsa/packages/web/src/components/VideoPlayer.tsx
Nikita 15b92cc16b Improve fullscreen support for CloudflareStreamPlayer and VideoPlayer components
- Add missing `webkitallowfullscreen` attribute in CloudflareStreamPlayer
- Enhance VideoPlayer to detect iOS devices and use `webkitEnterFullscreen` when available
- Adjust fullscreen state setting to account for WebKit-specific fullscreen elements
2025-12-23 14:07:53 -08:00

374 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from "react"
import Hls from "hls.js"
interface VideoPlayerProps {
src: string
autoPlay?: boolean
muted?: boolean
}
export function VideoPlayer({
src,
autoPlay = true,
muted = false,
}: VideoPlayerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const [isPlaying, setIsPlaying] = useState(autoPlay)
const [isMuted, setIsMuted] = useState(muted)
const [volume, setVolume] = useState(1)
const [isFullscreen, setIsFullscreen] = useState(false)
const [showControls, setShowControls] = useState(true)
const [error, setError] = useState<string | null>(null)
const hideControlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const video = videoRef.current
if (!video || !src) return
// Check if native HLS is supported (Safari)
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src
if (autoPlay) video.play().catch(() => setIsPlaying(false))
return
}
// Use HLS.js for other browsers
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
})
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoPlay) video.play().catch(() => setIsPlaying(false))
})
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
setError("Network error - retrying...")
hls.startLoad()
break
case Hls.ErrorTypes.MEDIA_ERROR:
setError("Media error - recovering...")
hls.recoverMediaError()
break
default:
setError("Stream error")
hls.destroy()
break
}
}
})
hlsRef.current = hls
return () => {
hls.destroy()
hlsRef.current = null
}
} else {
setError("HLS playback not supported in this browser")
}
}, [src, autoPlay])
useEffect(() => {
const video = videoRef.current
if (!video) return
const doc = document as Document & {
webkitFullscreenElement?: Element | null
}
const videoEl = video as HTMLVideoElement & {
webkitDisplayingFullscreen?: boolean
}
const updateFullscreenState = () => {
const isDocFullscreen = !!doc.fullscreenElement || !!doc.webkitFullscreenElement
const isVideoFullscreen = !!videoEl.webkitDisplayingFullscreen
setIsFullscreen(isDocFullscreen || isVideoFullscreen)
}
const onWebkitBegin = () => setIsFullscreen(true)
const onWebkitEnd = () => setIsFullscreen(false)
document.addEventListener("fullscreenchange", updateFullscreenState)
document.addEventListener("webkitfullscreenchange", updateFullscreenState)
video.addEventListener("webkitbeginfullscreen", onWebkitBegin as EventListener)
video.addEventListener("webkitendfullscreen", onWebkitEnd as EventListener)
return () => {
document.removeEventListener("fullscreenchange", updateFullscreenState)
document.removeEventListener("webkitfullscreenchange", updateFullscreenState)
video.removeEventListener("webkitbeginfullscreen", onWebkitBegin as EventListener)
video.removeEventListener("webkitendfullscreen", onWebkitEnd as EventListener)
}
}, [])
const handlePlayPause = () => {
const video = videoRef.current
if (!video) return
if (video.paused) {
video.play().then(() => setIsPlaying(true))
} else {
video.pause()
setIsPlaying(false)
}
}
const handleMute = () => {
const video = videoRef.current
if (!video) return
video.muted = !video.muted
setIsMuted(video.muted)
}
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current
if (!video) return
const newVolume = parseFloat(e.target.value)
video.volume = newVolume
setVolume(newVolume)
if (newVolume === 0) {
setIsMuted(true)
video.muted = true
} else if (isMuted) {
setIsMuted(false)
video.muted = false
}
}
const handleFullscreen = async () => {
const video = videoRef.current
const container = containerRef.current
if (!video || !container) return
const doc = document as Document & {
webkitFullscreenElement?: Element | null
webkitExitFullscreen?: () => void
}
const videoEl = video as HTMLVideoElement & {
webkitEnterFullscreen?: () => void
webkitExitFullscreen?: () => void
webkitRequestFullscreen?: () => Promise<void> | void
webkitDisplayingFullscreen?: boolean
}
const containerEl = container as HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void
}
const isDocFullscreen = !!doc.fullscreenElement || !!doc.webkitFullscreenElement
const isVideoFullscreen = !!videoEl.webkitDisplayingFullscreen
const isAppleMobile =
typeof navigator !== "undefined" &&
(/iP(ad|hone|od)/.test(navigator.userAgent) ||
(navigator.userAgent.includes("Mac") && navigator.maxTouchPoints > 1))
if (isDocFullscreen) {
if (document.exitFullscreen) {
await document.exitFullscreen()
} else if (doc.webkitExitFullscreen) {
doc.webkitExitFullscreen()
}
setIsFullscreen(false)
return
}
if (isVideoFullscreen) {
if (videoEl.webkitExitFullscreen) {
videoEl.webkitExitFullscreen()
}
setIsFullscreen(false)
return
}
if (isAppleMobile && videoEl.webkitEnterFullscreen) {
try {
videoEl.webkitEnterFullscreen()
return
} catch {
// Fall back to other fullscreen methods.
}
}
const requestContainerFullscreen = async () => {
if (containerEl.requestFullscreen) {
await containerEl.requestFullscreen()
return true
}
if (containerEl.webkitRequestFullscreen) {
await containerEl.webkitRequestFullscreen()
return true
}
return false
}
try {
if (await requestContainerFullscreen()) {
if (!!doc.fullscreenElement || !!doc.webkitFullscreenElement) {
setIsFullscreen(true)
return
}
}
} catch {
// Fall through to video fullscreen methods.
}
try {
if (video.requestFullscreen) {
await video.requestFullscreen()
setIsFullscreen(true)
} else if (videoEl.webkitRequestFullscreen) {
await videoEl.webkitRequestFullscreen()
setIsFullscreen(true)
} else if (videoEl.webkitEnterFullscreen) {
videoEl.webkitEnterFullscreen()
setIsFullscreen(true)
}
} catch {
// Ignore fullscreen errors to avoid breaking playback.
}
}
const handleMouseMove = () => {
setShowControls(true)
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current)
}
hideControlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) setShowControls(false)
}, 3000)
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-neutral-900 text-neutral-400">
<p>{error}</p>
</div>
)
}
return (
<div
ref={containerRef}
className="group relative h-full w-full bg-black"
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
<video
ref={videoRef}
className="h-full w-full object-contain"
playsInline
muted={isMuted}
onClick={handlePlayPause}
/>
{/* Controls overlay */}
<div
className={`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
<div className="flex items-center gap-4">
{/* Play/Pause */}
<button
onClick={handlePlayPause}
className="text-white transition-transform hover:scale-110"
>
{isPlaying ? (
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
{/* Live indicator */}
<span className="rounded bg-red-600 px-2 py-0.5 text-xs font-bold uppercase text-white">
Live
</span>
<div className="flex-1" />
{/* Volume */}
<div className="flex items-center gap-2">
<button
onClick={handleMute}
className="text-white transition-transform hover:scale-110"
>
{isMuted || volume === 0 ? (
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-20 accent-white"
/>
</div>
{/* Fullscreen */}
<button
onClick={handleFullscreen}
className="text-white transition-transform hover:scale-110"
>
{isFullscreen ? (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
</svg>
) : (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
)}
</button>
</div>
</div>
{/* Big play button when paused */}
{!isPlaying && (
<button
onClick={handlePlayPause}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/20 p-4 backdrop-blur-sm transition-transform hover:scale-110"
>
<svg className="h-16 w-16 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
)}
</div>
)
}