Enhance AirPage and ChannelPlayer components: add error handling for unavailable streams, implement retry functionality, and update translations for offline states. Refactor ChannelPlayer to accept an onUnavailable callback for better error management.

This commit is contained in:
Leonid Pershin
2026-07-24 16:27:22 +03:00
parent a2685fb602
commit 5853009d71
3 changed files with 62 additions and 28 deletions
+31 -3
View File
@@ -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<string | null>(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() {
<div className="flex flex-col gap-4">
{selected && watchReady ? (
<ChannelPlayer slug={selected} />
playerError ? (
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
<div className="flex flex-col gap-1">
<p className="font-medium">{t('air.offline')}</p>
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
</div>
<Button size="sm" variant="outline" onClick={retry}>
<RotateCw className="h-4 w-4" />
{t('air.retry')}
</Button>
</div>
) : (
<ChannelPlayer
key={`${selected}-${attempt}`}
slug={selected}
onUnavailable={handleUnavailable}
/>
)
) : (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
@@ -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<HTMLVideoElement>(null)
const { t } = useTranslation()
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-2">
<video
ref={videoRef}
controls
playsInline
muted
className="aspect-video w-full rounded-md border border-border bg-black"
/>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<video
ref={videoRef}
controls
playsInline
muted
className="aspect-video w-full rounded-md border border-border bg-black"
/>
)
}
+6 -4
View File
@@ -63,8 +63,9 @@ const resources = {
ad: 'Реклама',
episode: 'Серия',
noChannels: 'Пока нет доступных каналов. Загляните позже.',
playbackError: 'Не удалось воспроизвести поток',
unsupported: 'Браузер не поддерживает воспроизведение HLS',
offline: 'Канал сейчас не в эфире',
offlineHint: 'Нет расписания или контента. Загляните позже.',
retry: 'Повторить',
},
settings: {
title: 'Настройки аккаунта',
@@ -230,8 +231,9 @@ const resources = {
ad: 'Ad',
episode: 'Episode',
noChannels: 'No channels available yet. Check back later.',
playbackError: 'Failed to play the stream',
unsupported: 'This browser cannot play HLS',
offline: 'This channel is off the air',
offlineHint: 'No schedule or content yet. Check back later.',
retry: 'Retry',
},
settings: {
title: 'Account settings',