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:
@@ -1,10 +1,11 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { Radio } from 'lucide-react'
|
import { Radio, RotateCw } from 'lucide-react'
|
||||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
import { ChannelPlayer } from './ChannelPlayer'
|
import { ChannelPlayer } from './ChannelPlayer'
|
||||||
import { getEpg, listChannels, watchChannel } from './api'
|
import { getEpg, listChannels, watchChannel } from './api'
|
||||||
|
|
||||||
@@ -16,6 +17,14 @@ export function AirPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [watchReady, setWatchReady] = useState(false)
|
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({
|
const { data: channels, isLoading } = useQuery({
|
||||||
queryKey: ['air', 'channels'],
|
queryKey: ['air', 'channels'],
|
||||||
@@ -29,6 +38,7 @@ export function AirPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
setWatchReady(false)
|
setWatchReady(false)
|
||||||
|
setPlayerError(false)
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void watchChannel(selected)
|
void watchChannel(selected)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -87,7 +97,25 @@ export function AirPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{selected && watchReady ? (
|
{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" />
|
<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 Hls from 'hls.js'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому
|
* HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому
|
||||||
* запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS —
|
* запросы плейлиста/сегментов авторизуются автоматически. 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 videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const { t } = useTranslation()
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
setError(null)
|
|
||||||
const src = `/api/channels/${slug}/live.m3u8`
|
const src = `/api/channels/${slug}/live.m3u8`
|
||||||
let hls: Hls | null = null
|
let hls: Hls | null = null
|
||||||
|
|
||||||
@@ -25,31 +28,32 @@ export function ChannelPlayer({ slug }: { slug: string }) {
|
|||||||
hls.attachMedia(video)
|
hls.attachMedia(video)
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
|
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
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')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
video.src = src
|
video.src = src
|
||||||
video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined))
|
video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined))
|
||||||
video.addEventListener('error', () => setError(t('air.playbackError')))
|
video.addEventListener('error', () => onUnavailable?.())
|
||||||
} else {
|
} else {
|
||||||
setError(t('air.unsupported'))
|
onUnavailable?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
hls?.destroy()
|
hls?.destroy()
|
||||||
}
|
}
|
||||||
}, [slug, t])
|
}, [slug, onUnavailable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<video
|
||||||
<video
|
ref={videoRef}
|
||||||
ref={videoRef}
|
controls
|
||||||
controls
|
playsInline
|
||||||
playsInline
|
muted
|
||||||
muted
|
className="aspect-video w-full rounded-md border border-border bg-black"
|
||||||
className="aspect-video w-full rounded-md border border-border bg-black"
|
/>
|
||||||
/>
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ const resources = {
|
|||||||
ad: 'Реклама',
|
ad: 'Реклама',
|
||||||
episode: 'Серия',
|
episode: 'Серия',
|
||||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||||
playbackError: 'Не удалось воспроизвести поток',
|
offline: 'Канал сейчас не в эфире',
|
||||||
unsupported: 'Браузер не поддерживает воспроизведение HLS',
|
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
||||||
|
retry: 'Повторить',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Настройки аккаунта',
|
title: 'Настройки аккаунта',
|
||||||
@@ -230,8 +231,9 @@ const resources = {
|
|||||||
ad: 'Ad',
|
ad: 'Ad',
|
||||||
episode: 'Episode',
|
episode: 'Episode',
|
||||||
noChannels: 'No channels available yet. Check back later.',
|
noChannels: 'No channels available yet. Check back later.',
|
||||||
playbackError: 'Failed to play the stream',
|
offline: 'This channel is off the air',
|
||||||
unsupported: 'This browser cannot play HLS',
|
offlineHint: 'No schedule or content yet. Check back later.',
|
||||||
|
retry: 'Retry',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Account settings',
|
title: 'Account settings',
|
||||||
|
|||||||
Reference in New Issue
Block a user