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 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-плеер линейного канала. Перемотка невозможна: своя минимальная панель без таймлайна, а на
|
||||||
* запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS —
|
* play/после ошибки плеер прыгает к живому краю. Cookie tw_stream уже выдана к монтированию.
|
||||||
* фолбэк для Safari/iOS. О фатальной ошибке (нет эфира / 503) сообщает через onUnavailable —
|
|
||||||
* страница показывает аккуратную заглушку вместо сломанного видео.
|
|
||||||
*/
|
*/
|
||||||
export function ChannelPlayer({
|
export function ChannelPlayer({
|
||||||
slug,
|
slug,
|
||||||
@@ -14,7 +14,23 @@ export function ChannelPlayer({
|
|||||||
slug: string
|
slug: string
|
||||||
onUnavailable?: () => void
|
onUnavailable?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(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(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
@@ -24,6 +40,7 @@ export function ChannelPlayer({
|
|||||||
|
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||||
|
hlsRef.current = hls
|
||||||
hls.loadSource(src)
|
hls.loadSource(src)
|
||||||
hls.attachMedia(video)
|
hls.attachMedia(video)
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
|
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
|
||||||
@@ -31,6 +48,7 @@ export function ChannelPlayer({
|
|||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
hls?.destroy()
|
hls?.destroy()
|
||||||
hls = null
|
hls = null
|
||||||
|
hlsRef.current = null
|
||||||
onUnavailable?.()
|
onUnavailable?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -44,16 +62,67 @@ export function ChannelPlayer({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
hls?.destroy()
|
hls?.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
}
|
}
|
||||||
}, [slug, onUnavailable])
|
}, [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 (
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="group relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black"
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
controls
|
|
||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
className="aspect-video w-full rounded-md border border-border bg-black"
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const resources = {
|
|||||||
next: 'Далее',
|
next: 'Далее',
|
||||||
ad: 'Реклама',
|
ad: 'Реклама',
|
||||||
episode: 'Серия',
|
episode: 'Серия',
|
||||||
|
live: 'В эфире',
|
||||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||||
offline: 'Канал сейчас не в эфире',
|
offline: 'Канал сейчас не в эфире',
|
||||||
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
||||||
@@ -255,6 +256,7 @@ const resources = {
|
|||||||
next: 'Up next',
|
next: 'Up next',
|
||||||
ad: 'Ad',
|
ad: 'Ad',
|
||||||
episode: 'Episode',
|
episode: 'Episode',
|
||||||
|
live: 'Live',
|
||||||
noChannels: 'No channels available yet. Check back later.',
|
noChannels: 'No channels available yet. Check back later.',
|
||||||
offline: 'This channel is off the air',
|
offline: 'This channel is off the air',
|
||||||
offlineHint: 'No schedule or content yet. Check back later.',
|
offlineHint: 'No schedule or content yet. Check back later.',
|
||||||
|
|||||||
Reference in New Issue
Block a user