Enhance ChannelPlayer component: implement custom controls for play/pause, mute, and fullscreen functionality, and update UI to reflect live status. Integrate i18n support for live translations in both Russian and English.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import Hls from 'hls.js'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Maximize, Pause, Play, Volume2, VolumeX } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому
|
||||
* запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS —
|
||||
* фолбэк для Safari/iOS. О фатальной ошибке (нет эфира / 503) сообщает через onUnavailable —
|
||||
* страница показывает аккуратную заглушку вместо сломанного видео.
|
||||
* HLS-плеер линейного канала. Перемотка невозможна: своя минимальная панель без таймлайна, а на
|
||||
* play/после ошибки плеер прыгает к живому краю. Cookie tw_stream уже выдана к монтированию.
|
||||
*/
|
||||
export function ChannelPlayer({
|
||||
slug,
|
||||
@@ -14,7 +14,23 @@ export function ChannelPlayer({
|
||||
slug: string
|
||||
onUnavailable?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
|
||||
const seekToLiveEdge = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const live = hlsRef.current?.liveSyncPosition
|
||||
if (typeof live === 'number' && Number.isFinite(live)) {
|
||||
video.currentTime = live
|
||||
} else if (video.seekable.length > 0) {
|
||||
video.currentTime = video.seekable.end(video.seekable.length - 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
@@ -24,6 +40,7 @@ export function ChannelPlayer({
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
|
||||
@@ -31,6 +48,7 @@ export function ChannelPlayer({
|
||||
if (data.fatal) {
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
hlsRef.current = null
|
||||
onUnavailable?.()
|
||||
}
|
||||
})
|
||||
@@ -44,16 +62,67 @@ export function ChannelPlayer({
|
||||
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
}, [slug, onUnavailable])
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) {
|
||||
seekToLiveEdge()
|
||||
void video.play().catch(() => undefined)
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.muted = !video.muted
|
||||
setMuted(video.muted)
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (document.fullscreenElement) void document.exitFullscreen()
|
||||
else void container.requestFullscreen().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
muted
|
||||
className="aspect-video w-full rounded-md border border-border bg-black"
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black"
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
muted
|
||||
className="h-full w-full"
|
||||
onClick={togglePlay}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button type="button" onClick={togglePlay} aria-label="play/pause">
|
||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</button>
|
||||
<button type="button" onClick={toggleMute} aria-label="mute">
|
||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
{t('air.live')}
|
||||
</span>
|
||||
|
||||
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
||||
<Maximize className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user