Refactor GridScheduleGenerator and related components to improve cursor management and group handling
Updated the GridScheduleGenerator to reset cursors for slots with no aired positions, ensuring proper state management during rebuilds. Introduced a new method, ResetUnairedCursorsAsync, to handle cursor resets effectively. Enhanced the handling of interstitial groups in the scheduling process, preventing them from being included in slots or fallback groups. Updated related tests to verify the correct behavior of these changes, ensuring robust scheduling logic and accurate group categorization.
This commit is contained in:
@@ -10,6 +10,19 @@ function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
||||
|
||||
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
|
||||
|
||||
// Аварийный запас и конец вещания подписаны отдельно: у них есть и шоу, и номер серии, и без
|
||||
// метки полоса роликов из аварийной группы читается как обычная программа — то есть как будто
|
||||
// сетка так и задумана, хотя на деле это дыра.
|
||||
if (entry.kind === 'Fallback' || entry.kind === 'SignOff')
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t(entry.kind === 'Fallback' ? 'air.fallback' : 'air.signOff')}
|
||||
</Badge>
|
||||
<span className="min-w-0 truncate text-muted-foreground">{entry.showName ?? '—'}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
if (entry.kind === 'Bumper')
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -47,6 +47,20 @@ function startPlaybackWithAudio(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Насколько плеер вправе отстать от живого края, прежде чем его вернут туда прыжком. Порог заметно
|
||||
* больше окна плейлиста: обычное отставание — это секунды буфера, и дёргать зрителя из-за них
|
||||
* нельзя. Догонять ускорением тут нечего — отставание набирается минутами, а не секундами.
|
||||
*/
|
||||
const MaxDriftSeconds = 30
|
||||
|
||||
/** Живой край в шкале плеера: у hls.js он свой, у нативного HLS — конец seekable-диапазона. */
|
||||
function liveEdgeOf(video: HTMLVideoElement, hls: Hls | null): number | null {
|
||||
if (hls) return hls.liveSyncPosition ?? null
|
||||
const { seekable } = video
|
||||
return seekable.length > 0 ? seekable.end(seekable.length - 1) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||
@@ -120,8 +134,29 @@ export function ChannelPlayer({
|
||||
|
||||
const startPlayback = () => startPlaybackWithAudio(video, audioRef.current, setMuted)
|
||||
|
||||
/**
|
||||
* Возвращает плеер к живому краю, если он отстал. Отставание копится, пока вкладка в фоне:
|
||||
* браузер душит таймеры, hls.js не успевает тянуть сегменты, а видео доигрывает буфер. Вернувшись
|
||||
* на вкладку, зритель видит программу, которая в эфире давно кончилась, — для линейного канала
|
||||
* это поломка, а не задержка: догонять эфир нельзя, его можно только застать.
|
||||
*/
|
||||
const syncToLive = () => {
|
||||
const edge = liveEdgeOf(video, hls)
|
||||
if (edge === null || !Number.isFinite(edge)) return
|
||||
if (edge - video.currentTime <= MaxDriftSeconds) return
|
||||
|
||||
video.currentTime = edge
|
||||
if (video.paused) void video.play().catch(() => undefined)
|
||||
}
|
||||
|
||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||
const onNativeError = () => onUnavailable?.()
|
||||
// Возврат на вкладку — единственный момент, когда отставание можно поймать в нативной ветке:
|
||||
// своего события «плейлист обновился» она не даёт.
|
||||
const onVisible = () => {
|
||||
if (!document.hidden) syncToLive()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||
@@ -129,15 +164,25 @@ export function ChannelPlayer({
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
||||
// Проверяем дрейф на каждом обновлении медиаплейлиста, а не только по возврату на вкладку:
|
||||
// так же чинится залипание после сетевого сбоя, когда буфер доигрался, а окно ушло вперёд.
|
||||
hls.on(Hls.Events.LEVEL_UPDATED, syncToLive)
|
||||
|
||||
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
||||
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
||||
let recoverAttempts = 0
|
||||
// Счётчик про подряд идущие неудачи: за долгий просмотр три разных сбоя — это норма, и гасить
|
||||
// из-за них канал нельзя. Успешно доехавший сегмент означает, что сбой позади.
|
||||
hls.on(Hls.Events.FRAG_BUFFERED, () => {
|
||||
recoverAttempts = 0
|
||||
})
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.startLoad()
|
||||
// −1 — «продолжить с живого края»: окно за время сбоя ушло вперёд, и возвращаться
|
||||
// к позиции, на которой всё встало, значит доигрывать вчерашний эфир.
|
||||
hls?.startLoad(-1)
|
||||
return
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
||||
@@ -161,6 +206,7 @@ export function ChannelPlayer({
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
video.removeEventListener('loadedmetadata', startPlayback)
|
||||
video.removeEventListener('error', onNativeError)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export const en = {
|
||||
next: 'Up next',
|
||||
ad: 'Ad',
|
||||
bumper: 'Bumper',
|
||||
fallback: 'Emergency filler',
|
||||
signOff: 'Sign-off',
|
||||
episode: 'Episode',
|
||||
collectionPart: 'part {{part}} of {{total}}',
|
||||
volume: 'Volume',
|
||||
|
||||
@@ -58,6 +58,8 @@ export const ru = {
|
||||
next: 'Далее',
|
||||
ad: 'Реклама',
|
||||
bumper: 'Заставка',
|
||||
fallback: 'Аварийный запас',
|
||||
signOff: 'Конец вещания',
|
||||
episode: 'Серия',
|
||||
collectionPart: 'часть {{part}} из {{total}}',
|
||||
live: 'В эфире',
|
||||
|
||||
Reference in New Issue
Block a user