diff --git a/frontend/src/features/streaming/ChannelPlayer.tsx b/frontend/src/features/streaming/ChannelPlayer.tsx index 96cc7a6..235989c 100644 --- a/frontend/src/features/streaming/ChannelPlayer.tsx +++ b/frontend/src/features/streaming/ChannelPlayer.tsx @@ -3,6 +3,25 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Maximize, Pause, Play, Volume2, VolumeX } from 'lucide-react' +const STORAGE_KEY = 'tw:player' + +/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */ +function readStoredAudio(): { volume: number; muted: boolean } { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown } + const volume = + typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1 + const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true + return { volume, muted } + } + } catch { + /* недоступен/битый localStorage — дефолты */ + } + return { volume: 1, muted: true } +} + /** * HLS-плеер линейного канала. Перемотка невозможна: своя минимальная панель без таймлайна, а на * play/после ошибки плеер прыгает к живому краю. Cookie tw_stream уже выдана к монтированию. @@ -19,8 +38,19 @@ export function ChannelPlayer({ const videoRef = useRef(null) const hlsRef = useRef(null) const [playing, setPlaying] = useState(false) - const [muted, setMuted] = useState(true) - const [volume, setVolume] = useState(1) + const [muted, setMuted] = useState(() => readStoredAudio().muted) + const [volume, setVolume] = useState(() => readStoredAudio().volume) + + // Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage. + const audioRef = useRef({ volume, muted }) + useEffect(() => { + audioRef.current = { volume, muted } + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted })) + } catch { + /* localStorage недоступен — не критично */ + } + }, [volume, muted]) const [controlsVisible, setControlsVisible] = useState(true) const hideTimerRef = useRef(null) const overControlsRef = useRef(false) @@ -66,12 +96,25 @@ export function ChannelPlayer({ const src = `/api/channels/${slug}/live.m3u8` let hls: Hls | null = null + // Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со + // звуком — откатываемся на воспроизведение без звука. + const startPlayback = () => { + const audio = audioRef.current + video.volume = audio.volume + video.muted = audio.muted + video.play().catch(() => { + video.muted = true + setMuted(true) + void video.play().catch(() => undefined) + }) + } + 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)) + hls.on(Hls.Events.MANIFEST_PARSED, startPlayback) hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { hls?.destroy() @@ -82,7 +125,7 @@ export function ChannelPlayer({ }) } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src - video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined)) + video.addEventListener('loadedmetadata', startPlayback) video.addEventListener('error', () => onUnavailable?.()) } else { onUnavailable?.()