Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
ccfa38a
feat: Vietnamese radio schedules with official VOV and VOH guides
hoangsvit Oct 14, 2026
7f3b3c9
feat(schedule): distinguish current broadcast from nearest slot when …
hoangsvit Oct 14, 2026
ecdd542
fix(queue): auto-scroll schedule to current broadcast without hijacki…
hoangsvit Oct 14, 2026
cb15151
test(schedule): exact current slot, midnight gaps, and legacy provide…
hoangsvit Oct 14, 2026
ea06b10
fix(queue): recenter when desktop panel or mobile drawer becomes visible
hoangsvit Oct 14, 2026
151cb41
fix(player): tell each schedule queue when its desktop/mobile panel o…
hoangsvit Oct 14, 2026
20ad694
feat(i18n): current schedule jump action (en-US)
hoangsvit Oct 14, 2026
e91839c
feat(i18n): current schedule jump action (vi-VN)
hoangsvit Oct 14, 2026
1dd746f
feat(i18n): current schedule jump action (fr-FR)
hoangsvit Oct 14, 2026
4b2d05a
feat(i18n): current schedule jump action (ja-JP)
hoangsvit Oct 14, 2026
6c3373f
feat(i18n): current schedule jump action (zh-CN)
hoangsvit Oct 14, 2026
daaebd1
feat(i18n): current schedule jump action (zh-TW)
hoangsvit Oct 14, 2026
6243f90
feat(i18n): current schedule jump action (ru-RU)
hoangsvit Oct 14, 2026
210388e
test(browser): VOV schedule auto-scroll, manual browse, re-center, an…
hoangsvit Oct 14, 2026
b00a54b
fix(schedule): background EPG refresh must preserve manual scroll pos…
hoangsvit Oct 14, 2026
3f59f0b
fix(schedule): type-safe Vue watch previous values on initial render
hoangsvit Oct 14, 2026
9bc7961
Merge pull request #290 from ePlus-DEV/fix/schedule-scroll-current-20…
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)" :visible="playback.queueOpen" :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)" :visible="mobileQueueOpen" :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
134 changes: 127 additions & 7 deletions components/player/Queue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@
</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' && program.length" class="mt-2 flex justify-end">
<UButton color="neutral" variant="ghost" size="xs" icon="i-lucide-locate-fixed"
class="min-h-9" :aria-label="$t('panel.schedule_jump_to_now')"
@click="recenterSchedule">{{ $t('panel.schedule_jump_to_now') }}</UButton>
</div>
<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 @@ -58,7 +74,10 @@
</div>
</div>

<div class="radio-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain p-2.5">
<div ref="scheduleScrollContainer" class="radio-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain p-2.5"
tabindex="0" :aria-label="activeTab === 'schedule' ? $t('panel.schedule') : $t('panel.channels')"
@wheel.passive="markScheduleBrowsed" @touchstart.passive="markScheduleBrowsed"
@pointerdown="markScheduleBrowsed" @keydown="onScheduleScrollKeydown">
<div v-if="activeTab === 'stations' && hasStations" class="space-y-1">
<button
v-for="station in filteredStations"
Expand Down Expand Up @@ -131,15 +150,16 @@
<div
v-for="(item, index) in program"
:key="programKey(item, index)"
:data-program-index="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 +168,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 +197,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 +247,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 All @@ -236,6 +259,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 } from "~/utils/station-schedule.mjs";

const dayjs = useDayjs();
const { t } = useI18n();
Expand Down Expand Up @@ -266,6 +290,9 @@ const props = withDefaults(
stationName: string;
countryName?: string;
scheduleLoading?: boolean;
scheduleSourceUrl?: string;
scheduleIsReference?: boolean;
visible?: boolean;
}>(),
{
playlist: () => [],
Expand All @@ -275,6 +302,9 @@ const props = withDefaults(
activeStationUrl: "",
countryName: "",
scheduleLoading: false,
scheduleSourceUrl: "",
scheduleIsReference: false,
visible: true,
},
);

Expand All @@ -295,7 +325,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 All @@ -308,6 +338,85 @@ const activeTab = ref<"stations" | "schedule">(
hasStations.value ? "stations" : "schedule",
);
const preferScheduleAfterLoad = ref(false);
const scheduleScrollContainer = ref<HTMLElement | null>(null);
const scheduleNow = ref(Math.floor(Date.now() / 1000));
const manuallyBrowsingSchedule = ref(false);
const schedulePositionNow = computed(() =>
schedulePosition(props.program, scheduleNow.value, props.scheduleIsReference),
);

// Move just the INNER queue scroller: scrollIntoView() would move the entire
// mobile slideover/page and could hide the fixed player/header.
let scrollGeneration = 0;
const jumpToSchedulePosition = async () => {
const generation = ++scrollGeneration;
if (activeTab.value !== 'schedule' || !props.visible) return;
await nextTick();
if (generation !== scrollGeneration || activeTab.value !== 'schedule' || !props.visible) return;
const container = scheduleScrollContainer.value;
const index = schedulePositionNow.value.scrollIndex;
const row = index < 0 ? null : container?.querySelector<HTMLElement>(
`[data-program-index="${index}"]`,
);
if (!container || !row || container.clientHeight === 0) return;
const containerRect = container.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const desiredTop = container.scrollTop + rowRect.top - containerRect.top
- Math.max(0, (container.clientHeight - rowRect.height) / 3);
container.scrollTop = Math.max(0, desiredTop);
};

const recenterSchedule = () => {
manuallyBrowsingSchedule.value = false;
scheduleNow.value = Math.floor(Date.now() / 1000);
void jumpToSchedulePosition();
};
const markScheduleBrowsed = () => {
if (activeTab.value === 'schedule') manuallyBrowsingSchedule.value = true;
};
const onScheduleScrollKeydown = (event: KeyboardEvent) => {
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) {
markScheduleBrowsed();
}
};

// Recenter once on opening the tab, after a provider schedule arrives, or
// 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], previous) => {
if (tab !== 'schedule' || !visible) return;
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;
if (!manuallyBrowsingSchedule.value) void jumpToSchedulePosition();
},
{ flush: 'post', immediate: true },
);

// Update the highlight at a programme boundary. Follow it only if the visitor
// has not manually scrolled away. No polling/scrolling every animation frame.
watch(() => schedulePositionNow.value.scrollIndex, (next, previous) => {
if (next !== previous && activeTab.value === 'schedule' && props.visible && !manuallyBrowsingSchedule.value) {
void jumpToSchedulePosition();
}
}, { flush: 'post' });

let scheduleClockTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
scheduleNow.value = Math.floor(Date.now() / 1000);
scheduleClockTimer = setInterval(() => {
scheduleNow.value = Math.floor(Date.now() / 1000);
}, 15_000);
if (activeTab.value === 'schedule' && props.visible) void jumpToSchedulePosition();
});
onBeforeUnmount(() => {
if (scheduleClockTimer !== null) clearInterval(scheduleClockTimer);
scrollGeneration += 1;
});

const filteredStations = computed(() => {
const keyword = normalizeStationQuery(query.value);
Expand Down Expand Up @@ -359,6 +468,17 @@ 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 =>
schedulePositionNow.value.currentIndex === index;

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`.
6 changes: 6 additions & 0 deletions lang/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@
"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",
"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
Loading
Loading