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:
Leonid Pershin
2026-07-24 21:22:19 +03:00
parent 7b5c601821
commit 1b078cdfd3
2 changed files with 83 additions and 12 deletions
@@ -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>
)
}
+2
View File
@@ -62,6 +62,7 @@ const resources = {
next: 'Далее',
ad: 'Реклама',
episode: 'Серия',
live: 'В эфире',
noChannels: 'Пока нет доступных каналов. Загляните позже.',
offline: 'Канал сейчас не в эфире',
offlineHint: 'Нет расписания или контента. Загляните позже.',
@@ -255,6 +256,7 @@ const resources = {
next: 'Up next',
ad: 'Ad',
episode: 'Episode',
live: 'Live',
noChannels: 'No channels available yet. Check back later.',
offline: 'This channel is off the air',
offlineHint: 'No schedule or content yet. Check back later.',