From aa1b22d3c08466acf6efeff55065fd6044d7200b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 17:06:54 +0700 Subject: [PATCH 1/5] feat(schedule): calculate clock-based live timeline progress for listed shows and missing gaps --- utils/station-schedule.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/utils/station-schedule.mjs b/utils/station-schedule.mjs index 805e6a0a..99c5ef54 100644 --- a/utils/station-schedule.mjs +++ b/utils/station-schedule.mjs @@ -77,3 +77,12 @@ export const referenceScheduleGap = (programs, nowSeconds) => { ? { insertIndex: programs.length, nextTitle: '', nextStartTime: null } : null; }; + +// Percentage of actual elapsed wall-clock time in a scheduled interval. +// Used only as a reference-guide timeline, not an audio playback progress bar. +// Invalid or unknown bounds intentionally have no calculable position. +export const scheduleTimelineProgress = (startSeconds, endSeconds, nowSeconds) => { + if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) + || !Number.isFinite(nowSeconds) || endSeconds <= startSeconds) return null; + return Math.min(100, Math.max(0, (nowSeconds - startSeconds) * 100 / (endSeconds - startSeconds))); +}; From 610c7046a298a8fac5dd6646eb692646095d6a0c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 17:07:21 +0700 Subject: [PATCH 2/5] feat(schedule): animate real-time clock and moving bar across unlisted guide gap --- components/player/ScheduleGap.vue | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/components/player/ScheduleGap.vue b/components/player/ScheduleGap.vue index 0a787606..d96ca6d2 100644 --- a/components/player/ScheduleGap.vue +++ b/components/player/ScheduleGap.vue @@ -5,10 +5,16 @@ class="relative my-2 rounded-xl border border-rose-500/25 bg-rose-500/[0.06] px-3 py-3" >
- + {{ formattedNow }} - + {{ $t('panel.schedule_now_marker') }} @@ -26,18 +32,25 @@ From 5639cb9185c6032d65bab9cf70f75ed04a0e66be Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 17:08:05 +0700 Subject: [PATCH 3/5] feat(schedule): 1-second live progress bar and Vietnamese clock in active programme and guide gaps --- components/player/Queue.vue | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/components/player/Queue.vue b/components/player/Queue.vue index 8c5e2567..8911a0ef 100644 --- a/components/player/Queue.vue +++ b/components/player/Queue.vue @@ -151,6 +151,7 @@ @@ -168,6 +169,14 @@ class="absolute inset-y-2 left-0 w-0.5 rounded-full bg-rose-500" aria-hidden="true" > +
{{ item.description }}

+

+ {{ $t('panel.schedule_now_marker') }} · {{ formattedScheduleNow }} +

@@ -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 4/5] 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 5/5] 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); +});