diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index bc52542..c5101f2 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -1,10 +1,11 @@ import { useQuery } from '@tanstack/react-query' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Radio } from 'lucide-react' +import { Radio, RotateCw } from 'lucide-react' import type { ScheduleEntryDto } from '@/shared/api/types' import { cn } from '@/shared/lib/cn' import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' import { ChannelPlayer } from './ChannelPlayer' import { getEpg, listChannels, watchChannel } from './api' @@ -16,6 +17,14 @@ export function AirPage() { const { t } = useTranslation() const [selected, setSelected] = useState(null) const [watchReady, setWatchReady] = useState(false) + const [playerError, setPlayerError] = useState(false) + const [attempt, setAttempt] = useState(0) + + const handleUnavailable = useCallback(() => setPlayerError(true), []) + const retry = () => { + setPlayerError(false) + setAttempt((a) => a + 1) + } const { data: channels, isLoading } = useQuery({ queryKey: ['air', 'channels'], @@ -29,6 +38,7 @@ export function AirPage() { useEffect(() => { if (!selected) return setWatchReady(false) + setPlayerError(false) let cancelled = false void watchChannel(selected) .then(() => { @@ -87,7 +97,25 @@ export function AirPage() {
{selected && watchReady ? ( - + playerError ? ( +
+ +
+

{t('air.offline')}

+

{t('air.offlineHint')}

+
+ +
+ ) : ( + + ) ) : (
)} diff --git a/frontend/src/features/streaming/ChannelPlayer.tsx b/frontend/src/features/streaming/ChannelPlayer.tsx index 8863da5..a9fde75 100644 --- a/frontend/src/features/streaming/ChannelPlayer.tsx +++ b/frontend/src/features/streaming/ChannelPlayer.tsx @@ -1,21 +1,24 @@ import Hls from 'hls.js' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useEffect, useRef } from 'react' /** * HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому * запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS — - * фолбэк для Safari/iOS. + * фолбэк для Safari/iOS. О фатальной ошибке (нет эфира / 503) сообщает через onUnavailable — + * страница показывает аккуратную заглушку вместо сломанного видео. */ -export function ChannelPlayer({ slug }: { slug: string }) { +export function ChannelPlayer({ + slug, + onUnavailable, +}: { + slug: string + onUnavailable?: () => void +}) { const videoRef = useRef(null) - const { t } = useTranslation() - const [error, setError] = useState(null) useEffect(() => { const video = videoRef.current if (!video) return - setError(null) const src = `/api/channels/${slug}/live.m3u8` let hls: Hls | null = null @@ -25,31 +28,32 @@ export function ChannelPlayer({ slug }: { slug: string }) { hls.attachMedia(video) hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined)) hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) setError(t('air.playbackError')) + if (data.fatal) { + hls?.destroy() + hls = null + onUnavailable?.() + } }) } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined)) - video.addEventListener('error', () => setError(t('air.playbackError'))) + video.addEventListener('error', () => onUnavailable?.()) } else { - setError(t('air.unsupported')) + onUnavailable?.() } return () => { hls?.destroy() } - }, [slug, t]) + }, [slug, onUnavailable]) return ( -
-
+