From 6d93119c10d31a3894dd00a437af9bb231bcb984 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 14:41:08 +0700 Subject: [PATCH 01/34] feat(schedule): sourced reference programmes for VOV Traffic HN, HCM and Mekong; official guides for VOV1/VOV3 --- utils/vietnam-schedules.mjs | 96 +++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 utils/vietnam-schedules.mjs diff --git a/utils/vietnam-schedules.mjs b/utils/vietnam-schedules.mjs new file mode 100644 index 00000000..b81e59bc --- /dev/null +++ b/utils/vietnam-schedules.mjs @@ -0,0 +1,96 @@ +// Public programme guides checked against the broadcasters' own pages. +// These are REFERENCE time slots, not real-time provider EPG responses. +// Do not present them as guaranteed broadcasts for every calendar day. +export const VIETNAM_SCHEDULE_TIMEZONE = 'Asia/Ho_Chi_Minh'; +export const VIETNAM_SCHEDULES = Object.freeze({ + 'vov-giao-thong': { + sourceUrl: 'https://vovgiaothong.vn/lich-phat-song-hn/', + slots: [ + ['04:00','05:30','Nhạc Việt Nam'], + ['05:30','06:30','Nhạc quốc tế'], + ['06:30','08:15','Giờ cao điểm sáng'], + ['08:15','09:15','Giờ cao điểm sáng'], + ['09:15','10:30','Đời sống thị thành'], + ['10:30','12:00','Giờ cao điểm trưa'], + ['12:30','14:30','Đường tình ca – Trên mọi nẻo đường'], + ['14:30','16:30','FM91 – Buổi chiều'], + ['16:30','19:40','Giờ cao điểm chiều'], + ['19:40','20:00','FM Bất động sản'], + ['20:00','21:30','Kết nối yêu thương'], + ['21:30','23:00','Đường tin 91 – Đường khuya'], + ], + }, + 'vov-gt-hcm': { + sourceUrl: 'https://vovgiaothong.vn/lich-phat-song-hcm/', + slots: [ + ['04:00','05:00','Nhạc quốc tế'], + ['05:00','06:30','Nhạc Việt Nam'], + ['06:30','08:00','Giờ cao điểm sáng'], + ['09:00','10:30','Đời sống thị thành'], + ['10:30','11:00','Giờ cao điểm trưa'], + ['12:30','14:30','Đường tình ca – Trên mọi nẻo đường'], + ['14:30','16:15','FM91 – Buổi chiều'], + ['16:30','19:00','Giờ cao điểm chiều'], + ], + }, + 'vov-mekong': { + sourceUrl: 'https://vovgiaothong.vn/lich-phat-song-mk/', + slots: [ + ['06:00','07:00','Bình minh châu thổ – Chào ngày mới'], + ['07:00','08:00','Bình minh châu thổ – Chuyện đồng bằng'], + ['08:00','09:00','Bình minh châu thổ – Tin phương Nam'], + ['10:30','12:00','Đứng bóng mặt trời'], + ['14:00','16:30','Quà tặng âm nhạc'], + ['16:30','18:00','Đường về hoàng hôn'], + ['18:00','19:00','Đường về hoàng hôn – Bản tin miền Tây'], + ], + }, + // Daily programmes on these channels vary. Link directly to the official + // live guide instead of inventing a repeating timetable. + vov1: { sourceUrl: 'https://vov1.vov.vn/', slots: [] }, + vov3: { sourceUrl: 'https://vov3.vov.vn/lich-phat-song', slots: [] }, +}); + +const clockMinutes = clock => { + if (!/^(?:[01]\d|2[0-3]):[0-5]\d$|^24:00$/.test(clock)) return -1; + const [hours, minutes] = clock.split(':').map(Number); + return hours * 60 + minutes; +}; + +export const getVietnamSchedule = stationPath => { + const match = /^\/asia\/viet-nam\/([a-z0-9-]+)$/.exec(stationPath || ''); + return match ? VIETNAM_SCHEDULES[match[1]] || null : null; +}; + +export const vietnamDateKey = (instant = new Date()) => { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: VIETNAM_SCHEDULE_TIMEZONE, + year: 'numeric', month: '2-digit', day: '2-digit', + }); + const pieces = Object.fromEntries(formatter.formatToParts(instant) + .filter(part => part.type !== 'literal').map(part => [part.type, part.value])); + return [pieces.year, pieces.month, pieces.day].join('-'); +}; + +// Vietnam does not observe DST; convert its local midnight to a UTC instant. +export const vietnamMidnightUtc = (instant = new Date()) => { + const [year, month, day] = vietnamDateKey(instant).split('-').map(Number); + return Date.UTC(year, month - 1, day) - 7 * 3_600_000; +}; + +export const vietnamReferencePrograms = (stationPath, instant = new Date()) => { + const schedule = getVietnamSchedule(stationPath); + if (!schedule?.slots?.length) return []; + const midnight = vietnamMidnightUtc(instant); + return schedule.slots.flatMap(([start, end, title]) => { + const from = clockMinutes(start); + const to = clockMinutes(end); + if (from < 0 || to < 0 || to <= from || !title) return []; + return [{ + startTime: Math.floor((midnight + from * 60_000) / 1000), + endTime: Math.floor((midnight + to * 60_000) / 1000), + title, + description: '', + }]; + }); +}; From 0fb286a9357ba23c3185e15d398f225daa3062d2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 14:41:39 +0700 Subject: [PATCH 02/34] feat(player): load reference VOV programmes in existing schedule sidebar without running provider polling --- composables/useStationRealtime.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/composables/useStationRealtime.ts b/composables/useStationRealtime.ts index 8fb08215..7c9e1d5b 100644 --- a/composables/useStationRealtime.ts +++ b/composables/useStationRealtime.ts @@ -1,6 +1,7 @@ import { usePlaybackStore } from '~/stores/playback'; import type { RadioProgram, RadioSong, RadioTrack } from '~/types/radio'; import { isZingScheduleUrl } from '~/utils/station-schedule.mjs'; +import { getVietnamSchedule, vietnamReferencePrograms } from '~/utils/vietnam-schedules.mjs'; import { createStationPolling } from '~/utils/station-polling.mjs'; import { resolveLiveTrack } from '~/utils/live-track.mjs'; @@ -17,6 +18,9 @@ export function useStationRealtime() { let clientReady = false; const isZingStation = computed(() => isZingScheduleUrl(playback.station?.schedule)); + const referenceSchedule = computed(() => getVietnamSchedule(playback.station?.url)); + const scheduleSourceUrl = computed(() => referenceSchedule.value?.sourceUrl || ''); + const scheduleIsReference = computed(() => Boolean(referenceSchedule.value)); const timestampUrl = (url: string) => /[?&](?:time|timestamp)=$/i.test(url) ? `${url}${Math.floor(Date.now() / 1000)}` : url; const fetchLyric = async (id: string, signal: AbortSignal): Promise => { @@ -71,7 +75,9 @@ export function useStationRealtime() { }); const activate = () => { - program.value = []; + // When the broadcaster provides a daily reference guide but no accessible + // realtime EPG API, show its sourced time slots without fake network calls. + program.value = vietnamReferencePrograms(playback.station?.url); playlist.value = []; song.value = undefined; playback.setLiveTrack(playback.station?.url || '', null); @@ -81,13 +87,23 @@ export function useStationRealtime() { polling.start(playback.station?.schedule || ''); }; watch(() => playback.station?.url, activate); + let referenceTimer: ReturnType | null = null; onMounted(() => { clientReady = true; activate(); + // Update the local Vietnam date at midnight; leave provider EPG polling + // untouched and never reinitialize audio merely because the date changed. + referenceTimer = setInterval(() => { + const path = playback.station?.url; + if (!referenceSchedule.value?.slots.length) return; + const next = vietnamReferencePrograms(path); + if (next[0]?.startTime !== program.value[0]?.startTime) program.value = next; + }, 60_000); }); onBeforeUnmount(() => { clientReady = false; polling.stop(); + if (referenceTimer) clearInterval(referenceTimer); }); - return { program, playlist, song, lyrics, lyricsOpen, loading, isZingStation }; + return { program, playlist, song, lyrics, lyricsOpen, loading, isZingStation, scheduleSourceUrl, scheduleIsReference }; } From 8e66fd8889f69413c4feb679b2535931b57b8374 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 14:41:59 +0700 Subject: [PATCH 03/34] feat(schedule): pass Vietnamese official guide metadata through persistent player --- components/player/Session.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/player/Session.vue b/components/player/Session.vue index 571490c9..b348f93c 100644 --- a/components/player/Session.vue +++ b/components/player/Session.vue @@ -15,6 +15,8 @@ :playlist="playlist" :song="song" :queue-loading="loading" + :schedule-source-url="scheduleSourceUrl" + :schedule-is-reference="scheduleIsReference" :lyrics-available="isZingStation" @select-station="selectStation" @open-lyrics="lyricsOpen = true" @@ -34,7 +36,7 @@ import type { RadioSource, RadioStationOption } from '~/types/radio'; const playback = usePlaybackStore(); useRestoreLastStation(); const { station } = storeToRefs(playback); -const { program, playlist, song, lyrics, lyricsOpen, loading, isZingStation } = useStationRealtime(); +const { program, playlist, song, lyrics, lyricsOpen, loading, isZingStation, scheduleSourceUrl, scheduleIsReference } = useStationRealtime(); const emptySource: RadioSource = { src: '', type: 'audio/mpeg' }; const emptySources: RadioSource[] = []; From 9de1a0de6d858388ff119be17bc084dfa0b9c881 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 14:42:02 +0700 Subject: [PATCH 04/34] feat(schedule): pass Vietnamese official guide metadata through persistent player --- components/player/Player.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/player/Player.vue b/components/player/Player.vue index 04aed2fa..5b17a16b 100644 --- a/components/player/Player.vue +++ b/components/player/Player.vue @@ -134,7 +134,7 @@

{{ song.nextMedia.title }}

{{ song.nextMedia.artists }}

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

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

{{ song.nextMedia.title }}

- + @@ -237,6 +237,8 @@ const props = defineProps<{ activeStationId?: string; countryName?: string; queueLoading?: boolean; + scheduleSourceUrl?: string; + scheduleIsReference?: boolean; stationUrl?: string; lyricsAvailable?: boolean; playRequest?: number; From c7af71d3e2ef7a3b0669955882b90bf94a4c9380 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 14:42:38 +0700 Subject: [PATCH 05/34] feat(queue): sourced VOV program guide, correct ICT clock and no fake on-air slot --- components/player/Queue.vue | 43 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/components/player/Queue.vue b/components/player/Queue.vue index 4f82e905..a51ada2b 100644 --- a/components/player/Queue.vue +++ b/components/player/Queue.vue @@ -24,6 +24,17 @@

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

+
+

+ {{ $t(scheduleIsReference ? 'panel.schedule_reference_note' : 'panel.schedule_official_note') }} +

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

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

+
@@ -148,12 +159,12 @@ 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)]" > @@ -177,7 +188,7 @@
- From 20ad6943fe4d2c34895e21aeff1ea27003258741 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 15:31:43 +0700 Subject: [PATCH 25/34] 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 26/34] 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 27/34] 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 28/34] 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 29/34] 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 30/34] 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 31/34] 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 32/34] 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 33/34] 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 34/34] 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;