From 9b3bcbfa457ba80d18c9d74b22b892c8738bbcf0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 16:32:46 +0700 Subject: [PATCH 01/18] feat(schedule): model uncovered guide times without inventing an on-air programme --- utils/station-schedule.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/utils/station-schedule.mjs b/utils/station-schedule.mjs index c6283410..805e6a0a 100644 --- a/utils/station-schedule.mjs +++ b/utils/station-schedule.mjs @@ -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; +}; From d31e73d3979a8f566f2bcabb186b0431ad8c48b4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 16:33:28 +0700 Subject: [PATCH 02/18] feat(ui): show precise current-time marker and next listed show when broadcaster guide has a gap --- components/player/ScheduleGap.vue | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 components/player/ScheduleGap.vue diff --git a/components/player/ScheduleGap.vue b/components/player/ScheduleGap.vue new file mode 100644 index 00000000..0a787606 --- /dev/null +++ b/components/player/ScheduleGap.vue @@ -0,0 +1,43 @@ + + + From 3862971d7ea14383165e56d2e43fe15c821dc5aa Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 16:34:00 +0700 Subject: [PATCH 03/18] fix(schedule): insert a live time marker in unlisted intervals and auto-center it --- components/player/Queue.vue | 48 ++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/components/player/Queue.vue b/components/player/Queue.vue index 8f891e85..8c5e2567 100644 --- a/components/player/Queue.vue +++ b/components/player/Queue.vue @@ -147,9 +147,14 @@
@@ -271,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, referenceScheduleGap } from "~/utils/station-schedule.mjs"; +import { schedulePosition, referenceScheduleGap, scheduleTimelineProgress } from "~/utils/station-schedule.mjs"; const dayjs = useDayjs(); const { t } = useI18n(); @@ -359,6 +374,17 @@ const schedulePositionNow = computed(() => 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. @@ -435,8 +461,12 @@ let scheduleClockTimer: ReturnType | 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(() => { From 2e08cf06d025e09a4aa9dc249ed57b71131eaa0b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 17:09:02 +0700 Subject: [PATCH 17/18] test(schedule): continuous second ticks, 16:29 gap progress and transition into 16:30 show --- tests/station-schedule.test.mjs | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/station-schedule.test.mjs b/tests/station-schedule.test.mjs index a5727060..85fca521 100644 --- a/tests/station-schedule.test.mjs +++ b/tests/station-schedule.test.mjs @@ -6,6 +6,7 @@ import { normalizeScheduleUrl, schedulePosition, referenceScheduleGap, + scheduleTimelineProgress, } from "../utils/station-schedule.mjs"; test("normalizes only real HTTP schedule URLs", () => { @@ -115,3 +116,52 @@ test('Queue UI scrolls to missing-programme marker, never displays the next show assert.match(marker, /panel\.schedule_gap_next/); assert.doesNotMatch(marker, /player\.live|schedule_loading/); }); + +test('live timeline percentage advances smoothly with wall-clock seconds', () => { + assert.equal(scheduleTimelineProgress(100, 200, 100), 0); + assert.equal(scheduleTimelineProgress(100, 200, 125), 25); + assert.equal(scheduleTimelineProgress(100, 200, 150), 50); + assert.equal(scheduleTimelineProgress(100, 200, 199), 99); + assert.equal(scheduleTimelineProgress(100, 200, 200), 100); + assert.equal(scheduleTimelineProgress(100, 200, 90), 0); + assert.equal(scheduleTimelineProgress(100, 200, 250), 100); + assert.equal(scheduleTimelineProgress(null, 200, 150), null); + assert.equal(scheduleTimelineProgress(100, null, 150), null); + assert.equal(scheduleTimelineProgress(200, 200, 150), null); + assert.equal(scheduleTimelineProgress(300, 200, 150), null); + assert.equal(scheduleTimelineProgress(100, 200, Number.NaN), null); +}); + +test('VOV HCM at 16:29 shows 93.33% of the 16:15–16:30 gap, ticking every second', async () => { + const { vietnamReferencePrograms } = await import('../utils/vietnam-schedules.mjs'); + const now = Date.parse('2026-10-14T09:29:00Z') / 1000; // ICT +07:00. + const programs = vietnamReferencePrograms('/asia/viet-nam/vov-gt-hcm', new Date(now * 1000)); + const gap = referenceScheduleGap(programs, now); + assert.equal(gap.insertIndex, 7); + const start = programs[gap.insertIndex - 1].endTime; + const end = gap.nextStartTime; + assert.equal(new Date(start * 1000).toISOString(), '2026-10-14T09:15:00.000Z'); + assert.equal(scheduleTimelineProgress(start, end, now), 100 * 14 / 15); + assert.ok(scheduleTimelineProgress(start, end, now + 1) > scheduleTimelineProgress(start, end, now)); + assert.equal(scheduleTimelineProgress(start, end, end), 100); + assert.equal(referenceScheduleGap(programs, end), null); + const show = programs[gap.insertIndex]; + assert.equal(scheduleTimelineProgress(show.startTime, show.endTime, end), 0); + assert.ok(scheduleTimelineProgress(show.startTime, show.endTime, end + 1) > 0); +}); + +test('live time bar is wired to a one-second clock only for visible reference guides', async () => { + const { readFile } = await import('node:fs/promises'); + const q = await readFile(new URL('../components/player/Queue.vue', import.meta.url), 'utf8'); + const gap = await readFile(new URL('../components/player/ScheduleGap.vue', import.meta.url), 'utf8'); + assert.match(q, /scheduleTimelineProgress\(item\.startTime, item\.endTime, scheduleNow\.value\)/); + assert.match(q, /scheduleClockTimer = setInterval\(/); + assert.match(q, /\}, 1_000\)/); + assert.match(q, /props\.visible && props\.scheduleIsReference/); + assert.match(q, /data-schedule-clock role="timer"/); + assert.match(q, /data-schedule-time-progress/); + assert.match(q, /:start-time="gapStartTime\(scheduleGap\.insertIndex\)"/); + assert.match(gap, /second: '2-digit'/); + assert.match(gap, /data-schedule-time-progress/); + assert.match(gap, /timelineProgress \+ '%'/); +}); From 3e9071341e10926b24025ee92e4e5a7a6c6f1400 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 17:09:32 +0700 Subject: [PATCH 18/18] test(browser): verify live seconds and moving line in gap and next broadcast --- tests/browser/schedule-autoscroll.spec.mjs | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/browser/schedule-autoscroll.spec.mjs b/tests/browser/schedule-autoscroll.spec.mjs index 70e0a7f5..91f19d42 100644 --- a/tests/browser/schedule-autoscroll.spec.mjs +++ b/tests/browser/schedule-autoscroll.spec.mjs @@ -93,3 +93,34 @@ test('manual browsing in a missing-guide window does not get auto-recentered', a await queue(page).getByRole('button', { name: 'Jump to current time' }).click(); await expect.poll(() => scroll.evaluate(node => node.scrollTop)).toBeGreaterThan(0); }); + +test('1-second time bar moves during an unlisted gap and transfers into scheduled show', async ({ page }) => { + await page.setViewportSize({ width: 1536, height: 900 }); + 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 marker = queue(page).locator('[data-schedule-now]'); + const time = marker.locator('[data-schedule-clock]'); + const line = marker.locator('[data-schedule-time-progress]'); + await expect(time).toHaveText('16:29:00'); + const initialProgress = await line.evaluate(element => parseFloat(element.style.width)); + expect(initialProgress).toBeCloseTo(93.333, 1); + + await page.clock.runFor(2_000); + await expect(time).toHaveText('16:29:02'); + const advancedProgress = await line.evaluate(element => parseFloat(element.style.width)); + expect(advancedProgress).toBeGreaterThan(initialProgress); + + await page.clock.runFor(58_000); // Exactly 16:30 ICT. + await expect(marker).toHaveCount(0); + const programme = queue(page).locator('[data-program-index="7"]'); + await expect(programme.locator('[data-schedule-clock]')).toContainText('16:30:00'); + const showProgress = programme.locator('[data-schedule-time-progress]'); + await expect(showProgress).toHaveCount(1); + expect(await showProgress.evaluate(element => parseFloat(element.style.width))).toBe(0); + await page.clock.runFor(2_000); + expect(await showProgress.evaluate(element => parseFloat(element.style.width))).toBeGreaterThan(0); +});