From 7f3b3c9420e1b4016499104b2ced2c6714b9d7e2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:28:07 +0700 Subject: [PATCH 01/15] feat(schedule): distinguish current broadcast from nearest slot when scrolling programme guides --- utils/station-schedule.mjs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/utils/station-schedule.mjs b/utils/station-schedule.mjs index 488360a0..c6283410 100644 --- a/utils/station-schedule.mjs +++ b/utils/station-schedule.mjs @@ -21,3 +21,29 @@ export const isZingScheduleUrl = (value) => { return false; } }; + + +// A reference guide contains actual time windows, while legacy provider EPG +// reports the currently playing item first. When there is a gap, scroll to +// the next listed programme (or the last if today's listings have ended). +// Never *highlight* a nearest entry as live unless its interval contains now. +export const schedulePosition = (programs, nowSeconds, isReference = false) => { + if (!Array.isArray(programs) || programs.length === 0) { + return { currentIndex: -1, scrollIndex: -1 }; + } + if (!isReference) return { currentIndex: 0, scrollIndex: 0 }; + + const currentIndex = programs.findIndex(item => + Number.isFinite(item?.startTime) && Number.isFinite(item?.endTime) + && item.startTime <= nowSeconds && nowSeconds < item.endTime + ); + if (currentIndex !== -1) return { currentIndex, scrollIndex: currentIndex }; + + const upcomingIndex = programs.findIndex(item => + Number.isFinite(item?.startTime) && item.startTime > nowSeconds + ); + return { + currentIndex: -1, + scrollIndex: upcomingIndex !== -1 ? upcomingIndex : programs.length - 1, + }; +}; From ecdd5429496a007ce4e44a6eeec72ec476479972 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:29:21 +0700 Subject: [PATCH 02/15] fix(queue): auto-scroll schedule to current broadcast without hijacking manual scrolling --- components/player/Queue.vue | 92 +++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/components/player/Queue.vue b/components/player/Queue.vue index a51ada2b..3d118e0c 100644 --- a/components/player/Queue.vue +++ b/components/player/Queue.vue @@ -24,6 +24,11 @@

{{ $t('player.schedule_loading') }}

+
+ {{ $t('panel.schedule_jump_to_now') }} +

{{ $t(scheduleIsReference ? 'panel.schedule_reference_note' : 'panel.schedule_official_note') }} @@ -69,7 +74,10 @@

-
+
- + @@ -167,7 +167,7 @@

{{ $t('panel.next') }}

{{ song.nextMedia.title }}

- + From 20ad6943fe4d2c34895e21aeff1ea27003258741 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:31:43 +0700 Subject: [PATCH 06/15] feat(i18n): current schedule jump action (en-US) --- lang/en-US.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/en-US.json b/lang/en-US.json index 56619a72..3819f47b 100644 --- a/lang/en-US.json +++ b/lang/en-US.json @@ -148,6 +148,7 @@ "description": "Description", "category": "Category", "schedule": "Schedule", + "schedule_jump_to_now": "Jump to current time", "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", From e91839c15000c6d40e6f65a2a8c8a7305f506010 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:31:47 +0700 Subject: [PATCH 07/15] feat(i18n): current schedule jump action (vi-VN) --- lang/vi-VN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/vi-VN.json b/lang/vi-VN.json index 878c8c4c..fd358c60 100644 --- a/lang/vi-VN.json +++ b/lang/vi-VN.json @@ -148,6 +148,7 @@ "description": "Mô tả", "category": "Thể Loại", "schedule": "Lịch phát sóng", + "schedule_jump_to_now": "Đến khung giờ hiện tại", "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", From 1dd746f096b76e99cb03b1c5adb7b6817296e69f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:31:50 +0700 Subject: [PATCH 08/15] feat(i18n): current schedule jump action (fr-FR) --- lang/fr-FR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/fr-FR.json b/lang/fr-FR.json index a29ecdc3..cd652200 100644 --- a/lang/fr-FR.json +++ b/lang/fr-FR.json @@ -148,6 +148,7 @@ "description": "Description", "category": "Catégorie", "schedule": "Programme", + "schedule_jump_to_now": "Aller à l'heure actuelle", "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", From 4b2d05ac1f3e1d806a1520dff70f9731ecb7af04 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:31:53 +0700 Subject: [PATCH 09/15] feat(i18n): current schedule jump action (ja-JP) --- lang/ja-JP.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/ja-JP.json b/lang/ja-JP.json index 1e759b2a..f3a42cac 100644 --- a/lang/ja-JP.json +++ b/lang/ja-JP.json @@ -148,6 +148,7 @@ "description": "説明", "category": "カテゴリー", "schedule": "スケジュール", + "schedule_jump_to_now": "現在の時間帯へ", "schedule_reference_note": "放送局の参考番組表です。日によって変更される場合があります。最新情報は公式番組表をご確認ください。", "schedule_official_note": "放送局の公式番組表。", "schedule_official_link": "公式番組表を見る", From 6c3373fbbdf7c40ec7a51dc6c5cd5795c0ab094c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:31:57 +0700 Subject: [PATCH 10/15] feat(i18n): current schedule jump action (zh-CN) --- lang/zh-CN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/zh-CN.json b/lang/zh-CN.json index 9216374b..82d3bdc9 100644 --- a/lang/zh-CN.json +++ b/lang/zh-CN.json @@ -148,6 +148,7 @@ "description": "描述", "category": "分类", "schedule": "节目表", + "schedule_jump_to_now": "跳转到当前时段", "schedule_reference_note": "以下为广播台提供的参考时段,节目可能因日期而变化。请查看官方节目表获取最新信息。", "schedule_official_note": "广播台官方节目表来源。", "schedule_official_link": "查看官方节目表", From daaebd122414456e82cb7740ef781bdedfd80af0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:32:00 +0700 Subject: [PATCH 11/15] feat(i18n): current schedule jump action (zh-TW) --- lang/zh-TW.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/zh-TW.json b/lang/zh-TW.json index 9d219ba0..55655eda 100644 --- a/lang/zh-TW.json +++ b/lang/zh-TW.json @@ -148,6 +148,7 @@ "description": "描述", "category": "類別", "schedule": "節目表", + "schedule_jump_to_now": "跳至目前時段", "schedule_reference_note": "以下為電台提供的參考時段,節目可能依日期調整。請查看官方節目表取得最新資訊。", "schedule_official_note": "電台官方節目表來源。", "schedule_official_link": "查看官方節目表", From 6243f9009eed764162977d4eb9b3daeb753a1796 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:32:03 +0700 Subject: [PATCH 12/15] feat(i18n): current schedule jump action (ru-RU) --- lang/ru-RU.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/ru-RU.json b/lang/ru-RU.json index 46790038..4f582b16 100644 --- a/lang/ru-RU.json +++ b/lang/ru-RU.json @@ -148,6 +148,7 @@ "description": "Описание", "category": "Категория", "schedule": "Программа передач", + "schedule_jump_to_now": "К текущему времени", "schedule_reference_note": "Ориентировочные часы программ по данным вещателя. Расписание может меняться; актуальные данные — на официальном сайте.", "schedule_official_note": "Официальный источник программы передач.", "schedule_official_link": "Открыть официальное расписание", From 210388e44bb93daac04a09164f37fb7d7cdbe9ff Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:33:09 +0700 Subject: [PATCH 13/15] test(browser): VOV schedule auto-scroll, manual browse, re-center, and gap behavior --- tests/browser/schedule-autoscroll.spec.mjs | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/browser/schedule-autoscroll.spec.mjs diff --git a/tests/browser/schedule-autoscroll.spec.mjs b/tests/browser/schedule-autoscroll.spec.mjs new file mode 100644 index 00000000..5060fcd0 --- /dev/null +++ b/tests/browser/schedule-autoscroll.spec.mjs @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; + +const queue = page => page.locator('.radio-right-panel'); +const scrollArea = page => queue(page).locator('.radio-scrollbar[tabindex="0"]'); +const visibleCurrent = page => queue(page).locator('[data-program-index="6"]'); // HCM FM91 at 14:30. + +test('VOV Giao thông guide opens at the current Vietnam programme, not the first row', async ({ page }) => { + await page.setViewportSize({ width: 1536, height: 900 }); + // 08:00 UTC = 15:00 Vietnam time, inside the 14:30–16:15 reference slot. + await page.clock.install({ time: new Date('2026-10-14T08:00: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 expect(queue(page)).toBeVisible(); + await queue(page).getByRole('tab', { name: /Schedule/ }).click(); + + const scroll = scrollArea(page); + const current = visibleCurrent(page); + await expect(current).toBeVisible(); + await expect(current).toContainText('FM91'); + await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(40); + await expect.poll(() => current.evaluate(row => { + const viewport = row.closest('.radio-scrollbar').getBoundingClientRect(); + const target = row.getBoundingClientRect(); + return target.top >= viewport.top && target.bottom <= viewport.bottom; + })).toBe(true); + + // A manual browse is never snapped back by a routine clock tick. + await scroll.evaluate(element => { element.scrollTop = 0; }); + await scroll.dispatchEvent('wheel', { deltaY: -200, bubbles: true }); + await page.clock.runFor(31_000); + expect(await scroll.evaluate(element => element.scrollTop)).toBe(0); + + // An explicit "jump to now" button restores the active row. + await queue(page).getByRole('button', { name: 'Jump to current time' }).click(); + await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(40); + + // Switching away and back to Schedule must center the active row again. + await queue(page).getByRole('tab', { name: /All channels/ }).click(); + await scroll.evaluate(element => { element.scrollTop = 0; }); + await queue(page).getByRole('tab', { name: /Schedule/ }).click(); + 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 }) => { + 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') }); + 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 viewport = row.closest('.radio-scrollbar').getBoundingClientRect(); + const box = row.getBoundingClientRect(); + return box.top >= viewport.top && box.bottom <= viewport.bottom; + })).toBe(true); + await expect(nextSlot.locator('.absolute.inset-y-2')).toHaveCount(0); +}); From b00a54b24bee4e0b97bb5af7e68203d2efba8a2c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:35:12 +0700 Subject: [PATCH 14/15] fix(schedule): background EPG refresh must preserve manual scroll position --- components/player/Queue.vue | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/components/player/Queue.vue b/components/player/Queue.vue index 1f2310b0..fa52c550 100644 --- a/components/player/Queue.vue +++ b/components/player/Queue.vue @@ -384,10 +384,15 @@ const onScheduleScrollKeydown = (event: KeyboardEvent) => { // after changing station. Ordinary list re-renders do not steal manual scroll. watch( [activeTab, () => props.program, () => props.activeStationUrl, () => props.scheduleIsReference, () => props.visible], - ([tab]) => { - if (tab !== 'schedule' || !props.visible) return; - manuallyBrowsingSchedule.value = false; - void jumpToSchedulePosition(); + ([tab, program, station, isReference, visible], [previousTab, previousProgram, previousStation, previousReference, previousVisible] = []) => { + if (tab !== 'schedule' || !visible) return; + const opened = previousTab !== 'schedule' || !previousVisible; + const changedStation = station !== previousStation || isReference !== previousReference; + const justLoaded = Boolean(program?.length && !previousProgram?.length); + // Only an explicit open/station switch resets manual browsing. A background + // provider refresh must never yank a user who is reading earlier programmes. + if (opened || changedStation || justLoaded) manuallyBrowsingSchedule.value = false; + if (!manuallyBrowsingSchedule.value) void jumpToSchedulePosition(); }, { flush: 'post', immediate: true }, ); From 3f59f0bfb3d69ed0b5625b16ebc058ff2ad8bfa4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:35:39 +0700 Subject: [PATCH 15/15] fix(schedule): type-safe Vue watch previous values on initial render --- components/player/Queue.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/player/Queue.vue b/components/player/Queue.vue index fa52c550..8f891e85 100644 --- a/components/player/Queue.vue +++ b/components/player/Queue.vue @@ -384,11 +384,11 @@ const onScheduleScrollKeydown = (event: KeyboardEvent) => { // after changing station. Ordinary list re-renders do not steal manual scroll. watch( [activeTab, () => props.program, () => props.activeStationUrl, () => props.scheduleIsReference, () => props.visible], - ([tab, program, station, isReference, visible], [previousTab, previousProgram, previousStation, previousReference, previousVisible] = []) => { + ([tab, program, station, isReference, visible], previous) => { if (tab !== 'schedule' || !visible) return; - const opened = previousTab !== 'schedule' || !previousVisible; - const changedStation = station !== previousStation || isReference !== previousReference; - const justLoaded = Boolean(program?.length && !previousProgram?.length); + const opened = previous?.[0] !== 'schedule' || !previous?.[4]; + const changedStation = station !== previous?.[2] || isReference !== previous?.[3]; + const justLoaded = Boolean(program?.length && !previous?.[1]?.length); // Only an explicit open/station switch resets manual browsing. A background // provider refresh must never yank a user who is reading earlier programmes. if (opened || changedStation || justLoaded) manuallyBrowsingSchedule.value = false;