mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
- 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
374 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|