Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions components/player/Queue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,14 @@

<div v-else-if="activeTab === 'schedule' && hasSchedule" class="space-y-1">
<template v-if="program.length">
<template v-for="(item, index) in program" :key="programKey(item, index)">
<PlayerScheduleGap
v-if="scheduleGap?.insertIndex === index"
:timestamp="scheduleNow"
:next-title="scheduleGap.nextTitle"
:next-start-time="scheduleGap.nextStartTime"
/>
<div
v-for="(item, index) in program"
:key="programKey(item, index)"
:data-program-index="index"
class="relative grid grid-cols-[72px_minmax(0,1fr)] gap-3 overflow-hidden rounded-xl px-2.5 py-2.5 transition"
:class="
Expand Down Expand Up @@ -195,6 +200,13 @@
</p>
</div>
</div>
</template>
<PlayerScheduleGap
v-if="scheduleGap?.insertIndex === program.length"
:timestamp="scheduleNow"
:next-title="scheduleGap.nextTitle"
:next-start-time="scheduleGap.nextStartTime"
/>
</template>

<template v-else-if="playlist.length">
Expand Down Expand Up @@ -259,7 +271,7 @@
import type { RadioStationOption } from "~/types/radio";
import { normalizeStationQuery } from "~/utils/station-search.mjs";
import { stationCountryKey, queueCountryKey } from "~/utils/queue-station-country.mjs";
import { schedulePosition } from "~/utils/station-schedule.mjs";
import { schedulePosition, referenceScheduleGap } from "~/utils/station-schedule.mjs";

const dayjs = useDayjs();
const { t } = useI18n();
Expand Down Expand Up @@ -344,6 +356,9 @@ const manuallyBrowsingSchedule = ref(false);
const schedulePositionNow = computed(() =>
schedulePosition(props.program, scheduleNow.value, props.scheduleIsReference),
);
const scheduleGap = computed(() => props.scheduleIsReference
? referenceScheduleGap(props.program, scheduleNow.value)
: null);

// Move just the INNER queue scroller: scrollIntoView() would move the entire
// mobile slideover/page and could hide the fixed player/header.
Expand All @@ -355,9 +370,11 @@ const jumpToSchedulePosition = async () => {
if (generation !== scrollGeneration || activeTab.value !== 'schedule' || !props.visible) return;
const container = scheduleScrollContainer.value;
const index = schedulePositionNow.value.scrollIndex;
const row = index < 0 ? null : container?.querySelector<HTMLElement>(
`[data-program-index="${index}"]`,
);
const row = scheduleGap.value
? container?.querySelector<HTMLElement>('[data-schedule-now]')
: index < 0 ? null : container?.querySelector<HTMLElement>(
`[data-program-index="${index}"]`,
);
if (!container || !row || container.clientHeight === 0) return;
const containerRect = container.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
Expand Down Expand Up @@ -399,11 +416,20 @@ watch(

// Update the highlight at a programme boundary. Follow it only if the visitor
// has not manually scrolled away. No polling/scrolling every animation frame.
watch(() => schedulePositionNow.value.scrollIndex, (next, previous) => {
if (next !== previous && activeTab.value === 'schedule' && props.visible && !manuallyBrowsingSchedule.value) {
void jumpToSchedulePosition();
}
}, { flush: 'post' });
watch(
[
() => schedulePositionNow.value.scrollIndex,
() => schedulePositionNow.value.currentIndex,
() => scheduleGap.value?.insertIndex,
],
(next, previous) => {
if (next.some((value, index) => value !== previous?.[index])
&& activeTab.value === 'schedule' && props.visible && !manuallyBrowsingSchedule.value) {
void jumpToSchedulePosition();
}
},
{ flush: 'post' },
);

let scheduleClockTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
Expand Down
43 changes: 43 additions & 0 deletions components/player/ScheduleGap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<template>
<div
data-schedule-now
role="note"
class="relative my-2 rounded-xl border border-rose-500/25 bg-rose-500/[0.06] px-3 py-3"
>
<div class="mb-2 flex items-center gap-2">
<span class="shrink-0 rounded-md bg-rose-500 px-2 py-1 text-xs font-bold tabular-nums text-white">
{{ formattedNow }}
</span>
<span class="h-px min-w-0 flex-1 bg-rose-500/60" aria-hidden="true"></span>
<span class="shrink-0 text-[10px] font-semibold text-rose-700 dark:text-rose-300">
{{ $t('panel.schedule_now_marker') }}
</span>
</div>
<p class="text-xs font-semibold leading-5 text-[var(--radio-text)]">
{{ $t('panel.schedule_gap_unknown') }}
</p>
<p v-if="nextStartTime && nextTitle" class="mt-1 text-xs leading-5 text-[var(--radio-text-muted)]">
{{ $t('panel.schedule_gap_next', { title: nextTitle, time: formattedNext }) }}
</p>
<p v-else class="mt-1 text-xs leading-5 text-[var(--radio-text-muted)]">
{{ $t('panel.schedule_gap_no_next') }}
</p>
</div>
</template>

<script setup lang="ts">
const props = withDefaults(defineProps<{
timestamp: number;
nextTitle?: string;
nextStartTime?: number | null;
}>(), { nextTitle: '', nextStartTime: null });

// Only used for broadcaster reference guides, all in Vietnam's UTC+7 zone.
const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
const formattedNow = computed(() => formatter.format(new Date(props.timestamp * 1000)));
const formattedNext = computed(() => props.nextStartTime
? formatter.format(new Date(props.nextStartTime * 1000))
: '');
</script>
15 changes: 15 additions & 0 deletions docs/VIETNAM_SCHEDULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,18 @@ not make requests while SSR prerenders thousands of pages.
caching, and a fallback when the broadcaster fails.
- Run `npm test`; the dedicated checks are in
`tests/vietnam-schedules.test.mjs`.

## Gaps between listed programmes

Reference schedules can omit a period (for example VOV Giao thông TP.HCM
**16:15–16:30**). The player now renders a neutral **current-time marker**
inside the gap, with Vietnam's clock and the next listed programme/time.
The absence of EPG metadata must **not** be represented as the radio going
off-air, a confirmed broadcast title, or a technical stream failure.

The queue auto-centers the marker on opening Schedule, while preserving
manual scrolling until the user asks to jump back. At the next valid programme
boundary, the synthetic gap marker disappears and the real schedule row takes
over. See `referenceScheduleGap` in `utils/station-schedule.mjs` and the
regressions in `tests/station-schedule.test.mjs` and
`tests/browser/schedule-autoscroll.spec.mjs`.
4 changes: 4 additions & 0 deletions lang/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"category": "Category",
"schedule": "Schedule",
"schedule_jump_to_now": "Jump to current time",
"schedule_now_marker": "Now",
"schedule_gap_unknown": "No programme details are listed for this time.",
"schedule_gap_next": "Up next (scheduled): {title} · {time}",
"schedule_gap_no_next": "No further programme is listed in today's reference guide.",
"schedule_reference_note": "Reference programme times from the broadcaster. Daily programming may change; check the official guide for the latest schedule.",
"schedule_official_note": "Official broadcast schedule source.",
"schedule_official_link": "View official schedule",
Expand Down
4 changes: 4 additions & 0 deletions lang/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"category": "Catégorie",
"schedule": "Programme",
"schedule_jump_to_now": "Aller à l'heure actuelle",
"schedule_now_marker": "Maintenant",
"schedule_gap_unknown": "Aucune émission n'est indiquée pour ce créneau.",
"schedule_gap_next": "À suivre (selon le programme) : {title} · {time}",
"schedule_gap_no_next": "Aucune autre émission n'est indiquée dans le programme du jour.",
"schedule_reference_note": "Horaires indicatifs de la station, susceptibles de changer selon le jour. Consultez le programme officiel.",
"schedule_official_note": "Source officielle des programmes.",
"schedule_official_link": "Voir le programme officiel",
Expand Down
4 changes: 4 additions & 0 deletions lang/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"category": "カテゴリー",
"schedule": "スケジュール",
"schedule_jump_to_now": "現在の時間帯へ",
"schedule_now_marker": "現在",
"schedule_gap_unknown": "この時間帯の番組情報は掲載されていません。",
"schedule_gap_next": "次の予定番組:{title} · {time}",
"schedule_gap_no_next": "今日の参考番組表に次の番組は掲載されていません。",
"schedule_reference_note": "放送局の参考番組表です。日によって変更される場合があります。最新情報は公式番組表をご確認ください。",
"schedule_official_note": "放送局の公式番組表。",
"schedule_official_link": "公式番組表を見る",
Expand Down
4 changes: 4 additions & 0 deletions lang/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"category": "Категория",
"schedule": "Программа передач",
"schedule_jump_to_now": "К текущему времени",
"schedule_now_marker": "Сейчас",
"schedule_gap_unknown": "Для этого времени программа передач не указана.",
"schedule_gap_next": "Далее по расписанию: {title} · {time}",
"schedule_gap_no_next": "В сегодняшней справочной программе больше нет передач.",
"schedule_reference_note": "Ориентировочные часы программ по данным вещателя. Расписание может меняться; актуальные данные — на официальном сайте.",
"schedule_official_note": "Официальный источник программы передач.",
"schedule_official_link": "Открыть официальное расписание",
Expand Down
4 changes: 4 additions & 0 deletions lang/vi-VN.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"category": "Thể Loại",
"schedule": "Lịch phát sóng",
"schedule_jump_to_now": "Đến khung giờ hiện tại",
"schedule_now_marker": "Bây giờ",
"schedule_gap_unknown": "Chưa có thông tin chương trình cho thời điểm này.",
"schedule_gap_next": "Tiếp theo (theo lịch): {title} · {time}",
"schedule_gap_no_next": "Không có chương trình tiếp theo trong lịch tham khảo hôm nay.",
"schedule_reference_note": "Khung giờ tham khảo từ đài phát thanh; chương trình có thể thay đổi theo ngày. Xem lịch chính thức để kiểm tra mới nhất.",
"schedule_official_note": "Nguồn lịch chính thức của đài phát thanh.",
"schedule_official_link": "Xem lịch chính thức",
Expand Down
4 changes: 4 additions & 0 deletions lang/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"category": "分类",
"schedule": "节目表",
"schedule_jump_to_now": "跳转到当前时段",
"schedule_now_marker": "现在",
"schedule_gap_unknown": "该时段暂无节目表信息。",
"schedule_gap_next": "接下来(节目表):{title} · {time}",
"schedule_gap_no_next": "今日参考节目表中没有后续节目。",
"schedule_reference_note": "以下为广播台提供的参考时段,节目可能因日期而变化。请查看官方节目表获取最新信息。",
"schedule_official_note": "广播台官方节目表来源。",
"schedule_official_link": "查看官方节目表",
Expand Down
4 changes: 4 additions & 0 deletions lang/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"category": "類別",
"schedule": "節目表",
"schedule_jump_to_now": "跳至目前時段",
"schedule_now_marker": "現在",
"schedule_gap_unknown": "此時段尚無節目表資訊。",
"schedule_gap_next": "接下來(節目表):{title} · {time}",
"schedule_gap_no_next": "今日參考節目表中沒有後續節目。",
"schedule_reference_note": "以下為電台提供的參考時段,節目可能依日期調整。請查看官方節目表取得最新資訊。",
"schedule_official_note": "電台官方節目表來源。",
"schedule_official_link": "查看官方節目表",
Expand Down
45 changes: 39 additions & 6 deletions tests/browser/schedule-autoscroll.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,54 @@ test('VOV Giao thông guide opens at the current Vietnam programme, not the firs
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(40);
});

test('guide in a gap focuses the next slot without labeling it as live', async ({ page }) => {
test('16:29 ICT shows a Now marker in an unlisted interval, then switches to 16:30 programme', async ({ page }) => {
await page.setViewportSize({ width: 1536, height: 900 });
// 08:20 UTC = 15:20; instead test 11:30 ICT gap between 11:00 and 12:30.
await page.clock.install({ time: new Date('2026-10-14T04:30:00Z') });
// 09:29 UTC = 16:29 ICT, exactly between FM91 (ends 16:15)
// and Giờ cao điểm chiều (starts 16:30).
await page.clock.install({ time: new Date('2026-10-14T09:29:00Z') });
await page.goto('/asia/viet-nam/vov-gt-hcm');
await page.waitForFunction(() => Boolean(document.querySelector('#__nuxt')?.__vue_app__));
await page.locator('.radio-player-dock').getByRole('button', { name: 'Channels & schedule' }).click();
await queue(page).getByRole('tab', { name: /Schedule/ }).click();

const nextSlot = queue(page).locator('[data-program-index="5"]'); // 12:30
await expect(nextSlot).toBeVisible();
await expect.poll(() => nextSlot.evaluate(row => {
const marker = queue(page).locator('[data-schedule-now]');
await expect(marker).toBeVisible();
await expect(marker).toContainText('16:29');
await expect(marker).toContainText('No programme details are listed for this time');
await expect(marker).toContainText('Giờ cao điểm chiều');
await expect(marker).toContainText('16:30');
await expect.poll(() => marker.evaluate(row => {
const viewport = row.closest('.radio-scrollbar').getBoundingClientRect();
const box = row.getBoundingClientRect();
return box.top >= viewport.top && box.bottom <= viewport.bottom;
})).toBe(true);

const nextSlot = queue(page).locator('[data-program-index="7"]');
await expect(nextSlot.locator('.absolute.inset-y-2')).toHaveCount(0);

// No invented live programme during the gap; exactly at 16:30 the
// synthetic marker vanishes and the next actual reference slot is active.
await page.clock.runFor(60_000);
await expect(marker).toHaveCount(0);
await expect(nextSlot.locator('.absolute.inset-y-2')).toHaveCount(1);
});

test('manual browsing in a missing-guide window does not get auto-recentered', async ({ page }) => {
await page.setViewportSize({ width: 1536, height: 900 });
await page.clock.install({ time: new Date('2026-10-14T04:30:00Z') }); // 11:30 ICT
await page.goto('/asia/viet-nam/vov-gt-hcm');
await page.waitForFunction(() => Boolean(document.querySelector('#__nuxt')?.__vue_app__));
await page.locator('.radio-player-dock').getByRole('button', { name: 'Channels & schedule' }).click();
await queue(page).getByRole('tab', { name: /Schedule/ }).click();

const scroll = scrollArea(page);
const marker = queue(page).locator('[data-schedule-now]');
await expect(marker).toContainText('11:30');
await expect(marker).toContainText('12:30');
await scroll.evaluate(node => { node.scrollTop = 0; });
await scroll.dispatchEvent('wheel', { deltaY: -200, bubbles: true });
await page.clock.runFor(16_000);
expect(await scroll.evaluate(node => node.scrollTop)).toBe(0);
await queue(page).getByRole('button', { name: 'Jump to current time' }).click();
await expect.poll(() => scroll.evaluate(node => node.scrollTop)).toBeGreaterThan(0);
});
48 changes: 48 additions & 0 deletions tests/station-schedule.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isZingScheduleUrl,
normalizeScheduleUrl,
schedulePosition,
referenceScheduleGap,
} from "../utils/station-schedule.mjs";

test("normalizes only real HTTP schedule URLs", () => {
Expand Down Expand Up @@ -67,3 +68,50 @@ test('legacy remote schedule keeps the provider first programme convention', ()
const schedule = [{ title: 'Provider now' }, { title: 'Later' }];
assert.deepEqual(schedulePosition(schedule, 1234, false), { currentIndex: 0, scrollIndex: 0 });
});

test('16:29 ICT is an unlisted gap before VOV Giao thông TP.HCM 16:30', async () => {
const { vietnamReferencePrograms } = await import('../utils/vietnam-schedules.mjs');
const programs = vietnamReferencePrograms('/asia/viet-nam/vov-gt-hcm', new Date('2026-10-14T09:29:00Z'));
const nowSeconds = Date.parse('2026-10-14T09:29:00Z') / 1000;
const gap = referenceScheduleGap(programs, nowSeconds);
assert.deepEqual(gap, {
insertIndex: 7,
nextTitle: 'Giờ cao điểm chiều',
nextStartTime: Date.parse('2026-10-14T09:30:00Z') / 1000,
});
assert.deepEqual(schedulePosition(programs, nowSeconds, true), { currentIndex: -1, scrollIndex: 7 });
assert.equal(referenceScheduleGap(programs, nowSeconds + 60), null,
'16:30 switches to the actual on-air slot, removing the gap marker');
assert.deepEqual(schedulePosition(programs, nowSeconds + 60, true), { currentIndex: 7, scrollIndex: 7 });
});

test('unlisted reference gaps include before first, between slots and after last', () => {
const schedule = [
{ startTime: 100, endTime: 200, title: 'First' },
{ startTime: 300, endTime: 400, title: 'Next' },
];
assert.deepEqual(referenceScheduleGap(schedule, 50),
{ insertIndex: 0, nextTitle: 'First', nextStartTime: 100 });
assert.equal(referenceScheduleGap(schedule, 150), null);
assert.deepEqual(referenceScheduleGap(schedule, 250),
{ insertIndex: 1, nextTitle: 'Next', nextStartTime: 300 });
assert.equal(referenceScheduleGap(schedule, 300), null);
assert.deepEqual(referenceScheduleGap(schedule, 400),
{ insertIndex: 2, nextTitle: '', nextStartTime: null });
assert.equal(referenceScheduleGap([], 250), null);
assert.equal(referenceScheduleGap(null, 250), null);
assert.equal(referenceScheduleGap(schedule, NaN), null);
});

test('Queue UI scrolls to missing-programme marker, never displays the next show as live', async () => {
const { readFile } = await import('node:fs/promises');
const queue = await readFile(new URL('../components/player/Queue.vue', import.meta.url), 'utf8');
const marker = await readFile(new URL('../components/player/ScheduleGap.vue', import.meta.url), 'utf8');
assert.match(queue, /scheduleGap\.value\s*\?\s*container\?\.querySelector<HTMLElement>\('\[data-schedule-now\]'\)/);
assert.match(queue, /scheduleGap\?\.insertIndex === index/);
assert.match(queue, /scheduleGap\?\.insertIndex === program\.length/);
assert.match(marker, /data-schedule-now/);
assert.match(marker, /panel\.schedule_gap_unknown/);
assert.match(marker, /panel\.schedule_gap_next/);
assert.doesNotMatch(marker, /player\.live|schedule_loading/);
});
30 changes: 30 additions & 0 deletions utils/station-schedule.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,33 @@ export const schedulePosition = (programs, nowSeconds, isReference = false) => {
scrollIndex: upcomingIndex !== -1 ? upcomingIndex : programs.length - 1,
};
};

// A missing block of *reference metadata* is not evidence that the radio has
// stopped broadcasting. Show a neutral "programme not listed" time marker,
// rather than attributing the next programme to the current moment.
// Index is where the marker belongs among chronologically ordered programmes.
export const referenceScheduleGap = (programs, nowSeconds) => {
if (!Array.isArray(programs) || !programs.length || !Number.isFinite(nowSeconds)) return null;
for (let index = 0; index < programs.length; index++) {
const next = programs[index];
if (!Number.isFinite(next?.startTime) || !Number.isFinite(next?.endTime)) return null;
if (next.startTime <= nowSeconds && nowSeconds < next.endTime) return null;
if (nowSeconds < next.startTime) {
const previous = programs[index - 1];
// Explicit inter-programme gaps and time before the first listed slot.
if (!previous || (Number.isFinite(previous.endTime) && nowSeconds >= previous.endTime)) {
return {
insertIndex: index,
nextTitle: next.title || '',
nextStartTime: next.startTime,
};
}
return null;
}
}
// Today's published guide has finished. Don't label this as off-air.
const last = programs[programs.length - 1];
return Number.isFinite(last?.endTime) && nowSeconds >= last.endTime
? { insertIndex: programs.length, nextTitle: '', nextStartTime: null }
: null;
};
Loading