Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9b3bcbf
feat(schedule): model uncovered guide times without inventing an on-a…
hoangsvit Oct 14, 2026
d31e73d
feat(ui): show precise current-time marker and next listed show when …
hoangsvit Oct 14, 2026
3862971
fix(schedule): insert a live time marker in unlisted intervals and au…
hoangsvit Oct 14, 2026
3c64c1c
feat(i18n): schedule timeline gap marker and upcoming show (vi-VN)
hoangsvit Oct 14, 2026
86d83ec
feat(i18n): schedule timeline gap marker and upcoming show (en-US)
hoangsvit Oct 14, 2026
ccc3968
feat(i18n): schedule timeline gap marker and upcoming show (fr-FR)
hoangsvit Oct 14, 2026
9ab08c0
feat(i18n): schedule timeline gap marker and upcoming show (ja-JP)
hoangsvit Oct 14, 2026
51c1f81
feat(i18n): schedule timeline gap marker and upcoming show (zh-CN)
hoangsvit Oct 14, 2026
6aaf3d9
feat(i18n): schedule timeline gap marker and upcoming show (zh-TW)
hoangsvit Oct 14, 2026
f54d088
feat(i18n): schedule timeline gap marker and upcoming show (ru-RU)
hoangsvit Oct 14, 2026
ff35a6a
test(schedule): 16:29 ICT gap marker and 16:30 transition without fak…
hoangsvit Oct 14, 2026
88a980a
test(browser): render 16:29 guide gap marker, transition at 16:30 and…
hoangsvit Oct 14, 2026
c814a69
docs(schedule): explain missing-guide time markers and no false off-a…
hoangsvit Oct 14, 2026
d8a8b97
fix: show current-time timeline marker between unlisted programmes
hoangsvit Oct 14, 2026
aa1b22d
feat(schedule): calculate clock-based live timeline progress for list…
hoangsvit Oct 14, 2026
610c704
feat(schedule): animate real-time clock and moving bar across unliste…
hoangsvit Oct 14, 2026
5639cb9
feat(schedule): 1-second live progress bar and Vietnamese clock in ac…
hoangsvit Oct 14, 2026
2e08cf0
test(schedule): continuous second ticks, 16:29 gap progress and trans…
hoangsvit Oct 14, 2026
3e90713
test(browser): verify live seconds and moving line in gap and next br…
hoangsvit Oct 14, 2026
d791498
feat: smooth live timeline in reference radio schedules
hoangsvit Oct 14, 2026
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
82 changes: 69 additions & 13 deletions components/player/Queue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,15 @@

<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"
:start-time="gapStartTime(scheduleGap.insertIndex)"
: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 All @@ -163,6 +169,14 @@
class="absolute inset-y-2 left-0 w-0.5 rounded-full bg-rose-500"
aria-hidden="true"
></span>
<div v-if="isCurrentProgram(item, index) && scheduleIsReference"
class="pointer-events-none absolute inset-x-2 bottom-0 h-1 rounded-full bg-rose-500/15"
aria-hidden="true">
<div data-schedule-time-progress class="relative h-full rounded-full bg-rose-500 transition-[width] duration-1000 ease-linear"
:style="{ width: (programmeProgress(item) ?? 0) + '%' }">
<span class="absolute right-0 top-1/2 h-2.5 w-2.5 translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--radio-surface)] bg-rose-500 shadow-sm"></span>
</div>
</div>

<div
class="rounded-lg bg-[var(--radio-surface)] px-2 py-1.5 text-xs font-semibold tabular-nums text-[var(--radio-text)] ring-1 ring-inset ring-[var(--radio-border)]"
Expand Down Expand Up @@ -193,8 +207,21 @@
>
{{ item.description }}
</p>
<p v-if="isCurrentProgram(item, index) && scheduleIsReference"
data-schedule-clock role="timer"
class="mt-1 text-[11px] font-semibold tabular-nums text-rose-700 dark:text-rose-300">
{{ $t('panel.schedule_now_marker') }} · {{ formattedScheduleNow }}
</p>
</div>
</div>
</template>
<PlayerScheduleGap
v-if="scheduleGap?.insertIndex === program.length"
:timestamp="scheduleNow"
:start-time="gapStartTime(scheduleGap.insertIndex)"
:next-title="scheduleGap.nextTitle"
:next-start-time="scheduleGap.nextStartTime"
/>
</template>

<template v-else-if="playlist.length">
Expand Down Expand Up @@ -259,7 +286,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, scheduleTimelineProgress } from "~/utils/station-schedule.mjs";

const dayjs = useDayjs();
const { t } = useI18n();
Expand Down Expand Up @@ -344,6 +371,20 @@ 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);
// Only a known interval has a meaningful progress percentage.
const gapStartTime = (index: number): number | null =>
index > 0 ? props.program[index - 1]?.endTime ?? null : null;
const programmeProgress = (item: ProgramItem): number | null =>
scheduleTimelineProgress(item.startTime, item.endTime, scheduleNow.value);
const vietnamSecondsFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
});
const formattedScheduleNow = computed(() =>
vietnamSecondsFormatter.format(new Date(scheduleNow.value * 1000)));

// 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 +396,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,18 +442,31 @@ 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(() => {
scheduleNow.value = Math.floor(Date.now() / 1000);
scheduleClockTimer = setInterval(() => {
scheduleNow.value = Math.floor(Date.now() / 1000);
}, 15_000);
// Clock and the timeline fill move once per second. Only update the
// visible reference guide; real-time provider EPG is unaffected.
if (activeTab.value === 'schedule' && props.visible && props.scheduleIsReference) {
scheduleNow.value = Math.floor(Date.now() / 1000);
}
}, 1_000);
if (activeTab.value === 'schedule' && props.visible) void jumpToSchedulePosition();
});
onBeforeUnmount(() => {
Expand Down
56 changes: 56 additions & 0 deletions components/player/ScheduleGap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<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 data-schedule-clock role="timer" class="shrink-0 rounded-md bg-rose-500 px-2 py-1 text-xs font-bold tabular-nums text-white">
{{ formattedNow }}
</span>
<div class="relative h-1 min-w-0 flex-1 rounded-full bg-rose-500/15" aria-hidden="true">
<div data-schedule-time-progress class="h-full rounded-full bg-rose-500 transition-[width] duration-1000 ease-linear"
:style="{ width: timelineProgress === null ? '0%' : timelineProgress + '%' }"></div>
<span v-if="timelineProgress !== null"
class="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--radio-surface)] bg-rose-500 shadow-sm transition-[left] duration-1000 ease-linear"
:style="{ left: timelineProgress + '%' }"></span>
</div>
<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">
import { scheduleTimelineProgress } from '~/utils/station-schedule.mjs';
const props = withDefaults(defineProps<{
timestamp: number;
startTime?: number | null;
nextTitle?: string;
nextStartTime?: number | null;
}>(), { startTime: 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', second: '2-digit', hourCycle: 'h23',
});
const formattedNow = computed(() => formatter.format(new Date(props.timestamp * 1000)));
const formattedNext = computed(() => props.nextStartTime
? new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).format(new Date(props.nextStartTime * 1000))
: '');
const timelineProgress = computed(() => scheduleTimelineProgress(
props.startTime, props.nextStartTime, props.timestamp,
));
</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
Loading
Loading