Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 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" :schedule-source-url="scheduleSourceUrl" :schedule-is-reference="scheduleIsReference" :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" :schedule-source-url="scheduleSourceUrl" :schedule-is-reference="scheduleIsReference" :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
99 changes: 94 additions & 5 deletions components/player/Queue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
</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') }}
Expand Down Expand Up @@ -69,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 @@ -142,6 +150,7 @@
<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="
isCurrentProgram(item, index)
Expand Down Expand Up @@ -250,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 @@ -282,6 +292,7 @@ const props = withDefaults(
scheduleLoading?: boolean;
scheduleSourceUrl?: string;
scheduleIsReference?: boolean;
visible?: boolean;
}>(),
{
playlist: () => [],
Expand All @@ -293,6 +304,7 @@ const props = withDefaults(
scheduleLoading: false,
scheduleSourceUrl: "",
scheduleIsReference: false,
visible: true,
},
);

Expand Down Expand Up @@ -326,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 @@ -385,10 +476,8 @@ const formatProgramTime = (timestamp: number): string => props.scheduleIsReferen
: 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 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
1 change: 1 addition & 0 deletions lang/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lang/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lang/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"description": "説明",
"category": "カテゴリー",
"schedule": "スケジュール",
"schedule_jump_to_now": "現在の時間帯へ",
"schedule_reference_note": "放送局の参考番組表です。日によって変更される場合があります。最新情報は公式番組表をご確認ください。",
"schedule_official_note": "放送局の公式番組表。",
"schedule_official_link": "公式番組表を見る",
Expand Down
1 change: 1 addition & 0 deletions lang/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"description": "Описание",
"category": "Категория",
"schedule": "Программа передач",
"schedule_jump_to_now": "К текущему времени",
"schedule_reference_note": "Ориентировочные часы программ по данным вещателя. Расписание может меняться; актуальные данные — на официальном сайте.",
"schedule_official_note": "Официальный источник программы передач.",
"schedule_official_link": "Открыть официальное расписание",
Expand Down
1 change: 1 addition & 0 deletions lang/vi-VN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lang/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"description": "描述",
"category": "分类",
"schedule": "节目表",
"schedule_jump_to_now": "跳转到当前时段",
"schedule_reference_note": "以下为广播台提供的参考时段,节目可能因日期而变化。请查看官方节目表获取最新信息。",
"schedule_official_note": "广播台官方节目表来源。",
"schedule_official_link": "查看官方节目表",
Expand Down
1 change: 1 addition & 0 deletions lang/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"description": "描述",
"category": "類別",
"schedule": "節目表",
"schedule_jump_to_now": "跳至目前時段",
"schedule_reference_note": "以下為電台提供的參考時段,節目可能依日期調整。請查看官方節目表取得最新資訊。",
"schedule_official_note": "電台官方節目表來源。",
"schedule_official_link": "查看官方節目表",
Expand Down
62 changes: 62 additions & 0 deletions tests/browser/schedule-autoscroll.spec.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
34 changes: 34 additions & 0 deletions tests/station-schedule.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
isZingScheduleUrl,
normalizeScheduleUrl,
schedulePosition,
} from "../utils/station-schedule.mjs";

test("normalizes only real HTTP schedule URLs", () => {
Expand Down Expand Up @@ -33,3 +34,36 @@ test("detects Zing schedule by provider endpoint instead of country", () => {
false,
);
});

test('reference schedule focuses current programme at exact interval boundaries', () => {
const schedule = [
{ startTime: 100, endTime: 200, title: 'Morning' },
{ startTime: 220, endTime: 400, title: 'Noon' },
{ startTime: 500, endTime: 700, title: 'Afternoon' },
];
assert.deepEqual(schedulePosition(schedule, 100, true), { currentIndex: 0, scrollIndex: 0 });
assert.deepEqual(schedulePosition(schedule, 199, true), { currentIndex: 0, scrollIndex: 0 });
assert.deepEqual(schedulePosition(schedule, 220, true), { currentIndex: 1, scrollIndex: 1 });
assert.deepEqual(schedulePosition(schedule, 399, true), { currentIndex: 1, scrollIndex: 1 });
assert.deepEqual(schedulePosition(schedule, 500, true), { currentIndex: 2, scrollIndex: 2 });
assert.deepEqual(schedulePosition(schedule, 700, true), { currentIndex: -1, scrollIndex: 2 });
});

test('schedule gaps scroll to nearest upcoming programme without falsely marking it live', () => {
const schedule = [
{ startTime: 100, endTime: 200 },
{ startTime: 300, endTime: 400 },
{ startTime: 600, endTime: 900 },
];
assert.deepEqual(schedulePosition(schedule, 0, true), { currentIndex: -1, scrollIndex: 0 });
assert.deepEqual(schedulePosition(schedule, 250, true), { currentIndex: -1, scrollIndex: 1 });
assert.deepEqual(schedulePosition(schedule, 500, true), { currentIndex: -1, scrollIndex: 2 });
assert.deepEqual(schedulePosition(schedule, 999, true), { currentIndex: -1, scrollIndex: 2 });
assert.deepEqual(schedulePosition([], 350, true), { currentIndex: -1, scrollIndex: -1 });
assert.deepEqual(schedulePosition(null, 350, true), { currentIndex: -1, scrollIndex: -1 });
});

test('legacy remote schedule keeps the provider first programme convention', () => {
const schedule = [{ title: 'Provider now' }, { title: 'Later' }];
assert.deepEqual(schedulePosition(schedule, 1234, false), { currentIndex: 0, scrollIndex: 0 });
});
26 changes: 26 additions & 0 deletions utils/station-schedule.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
Loading