From 6d93119c10d31a3894dd00a437af9bb231bcb984 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 14 Oct 2026 14:41:08 +0700 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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 @@
-