diff --git a/frontend/src/features/streaming/ChannelPlayer.tsx b/frontend/src/features/streaming/ChannelPlayer.tsx index a9fde75..d1e37da 100644 --- a/frontend/src/features/streaming/ChannelPlayer.tsx +++ b/frontend/src/features/streaming/ChannelPlayer.tsx @@ -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(null) const videoRef = useRef(null) + const hlsRef = useRef(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 ( -