Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6d93119
feat(schedule): sourced reference programmes for VOV Traffic HN, HCM …
hoangsvit Oct 14, 2026
0fb286a
feat(player): load reference VOV programmes in existing schedule side…
hoangsvit Oct 14, 2026
8e66fd8
feat(schedule): pass Vietnamese official guide metadata through persi…
hoangsvit Oct 14, 2026
9de1a0d
feat(schedule): pass Vietnamese official guide metadata through persi…
hoangsvit Oct 14, 2026
c7af71d
feat(queue): sourced VOV program guide, correct ICT clock and no fake…
hoangsvit Oct 14, 2026
b3672fc
feat(station): show Vietnamese station schedule on detail page even b…
hoangsvit Oct 14, 2026
25f2b68
feat(i18n): sourced Vietnam broadcast guide strings in vi-VN
hoangsvit Oct 14, 2026
8cccbfb
feat(i18n): sourced Vietnam broadcast guide strings in en-US
hoangsvit Oct 14, 2026
61d7598
feat(i18n): sourced Vietnam broadcast guide strings in fr-FR
hoangsvit Oct 14, 2026
41e63e0
feat(i18n): sourced Vietnam broadcast guide strings in ja-JP
hoangsvit Oct 14, 2026
9c75892
feat(i18n): sourced Vietnam broadcast guide strings in zh-CN
hoangsvit Oct 14, 2026
ef1d47c
feat(i18n): sourced Vietnam broadcast guide strings in zh-TW
hoangsvit Oct 14, 2026
9633ab3
feat(i18n): sourced Vietnam broadcast guide strings in ru-RU
hoangsvit Oct 14, 2026
5f65254
test(schedule): validate official source links, no overlapping progra…
hoangsvit Oct 14, 2026
61828b5
docs(schedule): document official VOV sources, reference limitations …
hoangsvit Oct 14, 2026
39cc0a5
feat(schedule): link official VOH AM/FM daily guides without inventin…
hoangsvit Oct 14, 2026
1b507b3
test(schedule): include four official VOH guide links with no fixed p…
hoangsvit Oct 14, 2026
4010fb2
docs(schedule): add official VOH station guide sources
hoangsvit Oct 14, 2026
3741c1a
fix(schedule): support dots in existing VOH FM frequency station IDs
hoangsvit Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions components/player/Player.vue
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@
<p dir="auto" class="truncate text-sm font-semibold">{{ song.nextMedia.title }}</p>
<p dir="auto" class="mt-1 truncate text-xs text-[var(--radio-text-muted)]">{{ song.nextMedia.artists }}</p>
</div>
<PlayerQueue class="min-h-0 flex-1" :playlist="playlist || []" :program="program || []" :stations="stations || []" :active-station-id="activeStationId" :active-station-url="stationUrl" :country-name="countryName || $t(country)" :schedule-loading="queueLoading" :thumb="thumb" :station-name="streamName" @select-station="selectStation" />
<PlayerQueue class="min-h-0 flex-1" :playlist="playlist || []" :program="program || []" :stations="stations || []" :active-station-id="activeStationId" :active-station-url="stationUrl" :country-name="countryName || $t(country)" :schedule-loading="queueLoading" :schedule-source-url="scheduleSourceUrl" :schedule-is-reference="scheduleIsReference" :thumb="thumb" :station-name="streamName" @select-station="selectStation" />
</aside>
</Teleport>

Expand Down Expand Up @@ -167,7 +167,7 @@
<p class="text-[10px] font-semibold uppercase tracking-wider text-rose-500">{{ $t('panel.next') }}</p>
<p dir="auto" class="mt-2 truncate text-sm">{{ song.nextMedia.title }}</p>
</div>
<PlayerQueue class="min-h-0 flex-1" :playlist="playlist || []" :program="program || []" :stations="stations || []" :active-station-id="activeStationId" :active-station-url="stationUrl" :country-name="countryName || $t(country)" :schedule-loading="queueLoading" :thumb="thumb" :station-name="streamName" @select-station="selectStation" />
<PlayerQueue class="min-h-0 flex-1" :playlist="playlist || []" :program="program || []" :stations="stations || []" :active-station-id="activeStationId" :active-station-url="stationUrl" :country-name="countryName || $t(country)" :schedule-loading="queueLoading" :schedule-source-url="scheduleSourceUrl" :schedule-is-reference="scheduleIsReference" :thumb="thumb" :station-name="streamName" @select-station="selectStation" />
</template>
</USlideover>
<UModal v-model:open="nowPlayingOpen" fullscreen :title="$t('redesign.now_playing')" :ui="{ content: 'bg-[var(--radio-page-bg)]' }" @after:leave="finishPlayerAction">
Expand Down Expand Up @@ -237,6 +237,8 @@ const props = defineProps<{
activeStationId?: string;
countryName?: string;
queueLoading?: boolean;
scheduleSourceUrl?: string;
scheduleIsReference?: boolean;
stationUrl?: string;
lyricsAvailable?: boolean;
playRequest?: number;
Expand Down
43 changes: 37 additions & 6 deletions components/player/Queue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@
</div>

<p v-if="scheduleLoading" class="mt-2 text-[11px] text-[var(--radio-text-muted)]" aria-live="polite">{{ $t('player.schedule_loading') }}</p>
<div v-if="activeTab === 'schedule' && scheduleSourceUrl" class="mt-3 space-y-1 text-xs leading-5">
<p class="text-[var(--radio-text-muted)]">
{{ $t(scheduleIsReference ? 'panel.schedule_reference_note' : 'panel.schedule_official_note') }}
</p>
<a :href="scheduleSourceUrl" target="_blank" rel="noopener noreferrer"
class="inline-flex min-h-9 items-center gap-1.5 font-semibold text-rose-700 underline-offset-4 hover:underline dark:text-rose-300">
{{ $t('panel.schedule_official_link') }}
<UIcon name="i-lucide-external-link" class="h-3.5 w-3.5" aria-hidden="true" />
</a>
<p v-if="scheduleIsReference" class="text-[11px] text-[var(--radio-text-muted)]">{{ $t('panel.schedule_vietnam_timezone') }}</p>
</div>

<div
v-if="activeTab === 'stations' && hasStations"
Expand Down Expand Up @@ -133,13 +144,13 @@
:key="programKey(item, index)"
class="relative grid grid-cols-[72px_minmax(0,1fr)] gap-3 overflow-hidden rounded-xl px-2.5 py-2.5 transition"
:class="
index === 0
isCurrentProgram(item, index)
? 'bg-[var(--radio-surface-soft)] ring-1 ring-inset ring-[var(--radio-border)]'
: 'hover:bg-[var(--radio-hover)]'
"
>
<span
v-if="index === 0"
v-if="isCurrentProgram(item, index)"
class="absolute inset-y-2 left-0 w-0.5 rounded-full bg-rose-500"
aria-hidden="true"
></span>
Expand All @@ -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)]"
>
<template v-if="item.startTime">
<div>{{ dayjs.unix(item.startTime).format("HH:mm") }}</div>
<div>{{ formatProgramTime(item.startTime) }}</div>
<div
v-if="item.endTime"
class="mt-0.5 text-[10px] font-normal text-[var(--radio-text-muted)]"
>
{{ dayjs.unix(item.endTime).format("HH:mm") }}
{{ formatProgramTime(item.endTime) }}
</div>
</template>
<template v-else>—</template>
Expand All @@ -177,7 +188,7 @@
</div>
</template>

<template v-else>
<template v-else-if="playlist.length">
<div
v-for="(item, index) in playlist"
:key="playlistKey(item, index)"
Expand Down Expand Up @@ -227,6 +238,9 @@
</span>
</div>
</template>
<p v-else-if="scheduleSourceUrl" class="p-3 text-sm leading-6 text-[var(--radio-text-muted)]">
{{ $t('panel.schedule_provider_only') }}
</p>
</div>
</div>
</section>
Expand Down Expand Up @@ -266,6 +280,8 @@ const props = withDefaults(
stationName: string;
countryName?: string;
scheduleLoading?: boolean;
scheduleSourceUrl?: string;
scheduleIsReference?: boolean;
}>(),
{
playlist: () => [],
Expand All @@ -275,6 +291,8 @@ const props = withDefaults(
activeStationUrl: "",
countryName: "",
scheduleLoading: false,
scheduleSourceUrl: "",
scheduleIsReference: false,
},
);

Expand All @@ -295,7 +313,7 @@ const isActiveStation = (station: RadioStationOption): boolean =>
? station.url === props.activeStationUrl
: station.id === props.activeStationId;
const hasSchedule = computed(
() => props.playlist.length > 0 || props.program.length > 0,
() => props.playlist.length > 0 || props.program.length > 0 || Boolean(props.scheduleSourceUrl),
);
const scheduleCount = computed(
() => props.program.length || props.playlist.length,
Expand Down Expand Up @@ -359,6 +377,19 @@ watch([hasStations, hasSchedule], ([stationsReady, scheduleReady]) => {
}
});

const vietnamTimeFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
const formatProgramTime = (timestamp: number): string => props.scheduleIsReference
? vietnamTimeFormatter.format(new Date(timestamp * 1000))
: dayjs.unix(timestamp).format('HH:mm');
// Provider EPG usually puts the current programme first. The reference
// guide must never falsely highlight its first (morning) slot as "on air".
const isCurrentProgram = (item: ProgramItem, index: number): boolean =>
props.scheduleIsReference
? Boolean(item.startTime && item.endTime && item.startTime <= Date.now() / 1000 && item.endTime > Date.now() / 1000)
: index === 0;

const playlistKey = (item: PlaylistItem, index: number): string =>
[item.track_title, item.track_artist, item.track_played, index].join("-");

Expand Down
4 changes: 3 additions & 1 deletion components/player/Session.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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[] = [];

Expand Down
20 changes: 18 additions & 2 deletions composables/useStationRealtime.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string[]> => {
Expand Down Expand Up @@ -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);
Expand All @@ -81,13 +87,23 @@ export function useStationRealtime() {
polling.start(playback.station?.schedule || '');
};
watch(() => playback.station?.url, activate);
let referenceTimer: ReturnType<typeof setInterval> | 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 };
}
48 changes: 48 additions & 0 deletions docs/VIETNAM_SCHEDULES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Vietnamese radio programme guides

## Scope

The station pages and the player **Schedule** panel offer a broadcaster-sourced
reference guide for three VOV Traffic services:

| Catalog station | Official broadcaster programme guide | Included |
| --- | --- | --- |
| `vov-giao-thong` (Hà Nội) | https://vovgiaothong.vn/lich-phat-song-hn/ | Reference programme blocks |
| `vov-gt-hcm` (TP.HCM) | https://vovgiaothong.vn/lich-phat-song-hcm/ | Reference programme blocks |
| `vov-mekong` (Mê Kông) | https://vovgiaothong.vn/lich-phat-song-mk/ | Reference programme blocks |
| `vov1` | https://vov1.vov.vn/ | Official link only (daily schedules vary) |
| `vov3` | https://vov3.vov.vn/lich-phat-song | Official link only (daily schedules vary) |
| `voh-fm-99.9-mhz` | https://voh.com.vn/radios/schedule | Official link only |
| `voh-fm-95.6-mhz` | https://voh.com.vn/radios/schedule | Official link only |
| `voh-fm-87.7-mhz` | https://voh.com.vn/radios/schedule | Official link only |
| `voh-am-610khz` | https://voh.com.vn/radios/schedule | Official link only |

The programmes are reference time blocks extracted from the broadcaster's
public guides in October 2026, not a live EPG and not a promise that each slot
will air every day. Provider schedules may contain omissions, overlapping
segments and occasional variations. **Do not extrapolate a missing interval**
or silently label the first schedule row as "Now playing." The public UI always
discloses the reference status and opens the official site in a safe new tab.

All listed times are **Asia/Ho_Chi_Minh (UTC+7)**, not the browser's local
timezone. The programme model computes Unix timestamps at Vietnam local midnight
so a listener elsewhere sees the correct broadcast day and clock labels.
The station page displays the reference guide even before playing; selecting
the station also exposes it in the existing Queue → Schedule tab.

The station registry is in `utils/vietnam-schedules.mjs`, independent of
the existing remote Zing and provincial schedule providers. Its lookups are
based on the canonical `/asia/viet-nam/<station>` route. The registry does
not make requests while SSR prerenders thousands of pages.

## Updating

- Verify exact programme names and time windows on each **official source**.
- Update the relevant station entry only; never copy a Hà Nội schedule to HCM
or Mê Kông. Keep source links, timezone and reference disclaimer.
- Do not schedule recurring updates from unofficial scrapers. If a stable,
permitted real-time EPG is available in the future, it may supersede these
reference entries through the existing polling API, with source validation,
caching, and a fallback when the broadcaster fails.
- Run `npm test`; the dedicated checks are in
`tests/vietnam-schedules.test.mjs`.
5 changes: 5 additions & 0 deletions lang/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@
"description": "Description",
"category": "Category",
"schedule": "Schedule",
"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",
"schedule_vietnam_timezone": "Vietnam time (UTC+7)",
"schedule_provider_only": "The daily schedule varies. See the broadcaster's official guide for updated programmes.",
"playlist": "Playlist",
"next": "Next",
"lyric": "Lyrics",
Expand Down
5 changes: 5 additions & 0 deletions lang/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@
"description": "Description",
"category": "Catégorie",
"schedule": "Programme",
"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",
"schedule_vietnam_timezone": "Heure du Vietnam (UTC+7)",
"schedule_provider_only": "La programmation varie selon le jour. Consultez le site officiel de la station.",
"playlist": "Liste de lecture",
"next": "Suivant",
"lyric": "Paroles",
Expand Down
5 changes: 5 additions & 0 deletions lang/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@
"description": "説明",
"category": "カテゴリー",
"schedule": "スケジュール",
"schedule_reference_note": "放送局の参考番組表です。日によって変更される場合があります。最新情報は公式番組表をご確認ください。",
"schedule_official_note": "放送局の公式番組表。",
"schedule_official_link": "公式番組表を見る",
"schedule_vietnam_timezone": "ベトナム時間 (UTC+7)",
"schedule_provider_only": "日によって番組が異なります。最新の番組表は公式サイトでご覧ください。",
"playlist": "プレイリスト",
"next": "次",
"lyric": "歌詞",
Expand Down
5 changes: 5 additions & 0 deletions lang/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@
"description": "Описание",
"category": "Категория",
"schedule": "Программа передач",
"schedule_reference_note": "Ориентировочные часы программ по данным вещателя. Расписание может меняться; актуальные данные — на официальном сайте.",
"schedule_official_note": "Официальный источник программы передач.",
"schedule_official_link": "Открыть официальное расписание",
"schedule_vietnam_timezone": "Время Вьетнама (UTC+7)",
"schedule_provider_only": "Программы могут меняться каждый день. Проверьте официальный сайт радиостанции.",
"playlist": "Плейлист",
"next": "Следующий",
"lyric": "Текст песни",
Expand Down
5 changes: 5 additions & 0 deletions lang/vi-VN.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@
"description": "Mô tả",
"category": "Thể Loại",
"schedule": "Lịch phát sóng",
"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",
"schedule_vietnam_timezone": "Giờ Việt Nam (UTC+7)",
"schedule_provider_only": "Lịch cụ thể thay đổi theo ngày; vui lòng xem tại website chính thức của đài.",
"playlist": "Danh sách phát",
"next": "Kế tiếp",
"lyric": "Lời bài hát",
Expand Down
5 changes: 5 additions & 0 deletions lang/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@
"description": "描述",
"category": "分类",
"schedule": "节目表",
"schedule_reference_note": "以下为广播台提供的参考时段,节目可能因日期而变化。请查看官方节目表获取最新信息。",
"schedule_official_note": "广播台官方节目表来源。",
"schedule_official_link": "查看官方节目表",
"schedule_vietnam_timezone": "越南时间 (UTC+7)",
"schedule_provider_only": "每日节目可能不同,请查看广播台官方网站。",
"playlist": "播放列表",
"next": "下一个",
"lyric": "歌词",
Expand Down
5 changes: 5 additions & 0 deletions lang/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@
"description": "描述",
"category": "類別",
"schedule": "節目表",
"schedule_reference_note": "以下為電台提供的參考時段,節目可能依日期調整。請查看官方節目表取得最新資訊。",
"schedule_official_note": "電台官方節目表來源。",
"schedule_official_link": "查看官方節目表",
"schedule_vietnam_timezone": "越南時間 (UTC+7)",
"schedule_provider_only": "每日節目可能不同,請查看電台官方網站。",
"playlist": "播放清單",
"next": "下一個",
"lyric": "歌詞",
Expand Down
Loading
Loading