Skip to content
Merged
24 changes: 18 additions & 6 deletions components/player/Queue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,18 @@
</div>
</div>

<div v-else-if="activeTab === 'schedule' && hasSchedule" class="space-y-1">
<template v-if="program.length">
<div v-else-if="activeTab === 'schedule' && hasSchedule"
:class="scheduleIsReference && program.length ? '' : 'space-y-1'">
<!-- Broadcast timeline is a time-scaled canvas. Keep legacy remote
provider EPG as a compact list because it lacks reliable intervals. -->
<PlayerScheduleTimeline
v-if="scheduleIsReference && program.length"
:program="program"
:timestamp="scheduleNow"
:thumb="thumb"
:station-name="stationName"
/>
<template v-else-if="program.length">
<template v-for="(item, index) in program" :key="programKey(item, index)">
<PlayerScheduleGap
v-if="scheduleGap?.insertIndex === index"
Expand Down Expand Up @@ -396,11 +406,13 @@ const jumpToSchedulePosition = async () => {
if (generation !== scrollGeneration || activeTab.value !== 'schedule' || !props.visible) return;
const container = scheduleScrollContainer.value;
const index = schedulePositionNow.value.scrollIndex;
const row = scheduleGap.value
const row = props.scheduleIsReference
? container?.querySelector<HTMLElement>('[data-schedule-now]')
: index < 0 ? null : container?.querySelector<HTMLElement>(
`[data-program-index="${index}"]`,
);
: scheduleGap.value
? container?.querySelector<HTMLElement>('[data-schedule-now]')
: 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();
Expand Down
109 changes: 109 additions & 0 deletions components/player/ScheduleTimeline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<template>
<!-- One continuous day ruler; durations use real elapsed clock time. -->
<div v-if="layout" data-schedule-timeline
class="radio-schedule-timeline relative isolate ml-0.5 mr-1"
:style="{ height: layout.height + 'px' }"
role="group" :aria-label="$t('panel.schedule')">
<div v-for="hour in layout.hours" :key="hour.time"
class="pointer-events-none absolute inset-x-0 z-0 flex items-start gap-2"
:style="{ top: hour.top + 'px' }">
<div class="w-12 shrink-0 -translate-y-2 text-[11px] font-semibold tabular-nums text-[var(--radio-text-muted)]">
<span v-if="hour.nextDay" class="text-rose-700 dark:text-rose-300">{{ formatDate(hour.time) }}</span>
<span v-else>{{ formatHour(hour.time) }}</span>
</div>
<span class="h-px flex-1 bg-[var(--radio-border)]"></span>
</div>

<div class="absolute inset-y-0 left-[52px] right-0 border-l border-[var(--radio-border)]"
aria-hidden="true"></div>

<div v-for="block in layout.programmes" :key="block.item.startTime + '-' + block.index"
:data-program-index="block.index"
class="absolute left-[54px] right-1 z-10 overflow-hidden rounded-md border-l-[3px] transition-colors"
:class="block.active
? 'border-rose-500 bg-rose-950/[0.88] text-white shadow-sm shadow-rose-950/30'
: 'border-rose-500/40 bg-rose-500/[0.09] text-[var(--radio-text)] dark:bg-rose-950/40'"
:style="{ top: block.top + 'px', height: block.height + 'px' }"
:aria-label="formatHour(block.item.startTime) + '–' + formatHour(block.item.endTime) + ': ' + (block.item.title || $t('panel.updating'))">
<div class="flex h-full min-w-0 items-start gap-2 overflow-hidden p-2"
:class="block.height < 40 ? 'py-0.5' : ''">
<img v-if="block.active && thumb && block.height >= 52"
:src="siteAssetUrl(thumb)" :alt="stationName"
loading="lazy"
class="h-10 w-10 shrink-0 rounded-md bg-white/90 p-1 object-contain" />
<div class="min-w-0">
<span v-if="block.height >= 56" class="block text-[10px] font-semibold opacity-75">
{{ formatHour(block.item.startTime) }}–{{ formatHour(block.item.endTime) }}
</span>
<p dir="auto" class="truncate text-xs font-semibold sm:text-sm">
{{ block.item.title || $t('panel.updating') }}
</p>
<p v-if="block.item.description && block.height >= 90"
class="mt-1 line-clamp-2 text-xs opacity-80">{{ block.item.description }}</p>
</div>
</div>
</div>

<template v-if="layout.nowTop !== null">
<!-- Follows the ICT wall clock each second. Does not depend on audio
playback and must not claim that an unspecified show is live. -->
<div data-schedule-now
class="pointer-events-none absolute inset-x-0 z-30 h-0 border-t-2 border-emerald-400 motion-safe:transition-[top] motion-safe:duration-1000 motion-safe:ease-linear"
:style="{ top: layout.nowTop + 'px' }">
<span data-schedule-clock role="timer"
class="absolute left-0 top-0 -translate-y-1/2 rounded bg-emerald-400 px-1.5 py-0.5 text-[11px] font-bold tabular-nums text-emerald-950 shadow-sm">
{{ formattedNow }}
</span>
<span class="absolute -top-1 left-[50px] h-2 w-2 rounded-full border border-emerald-200 bg-emerald-500"></span>
</div>

<div v-if="layout.gap" data-schedule-gap
class="pointer-events-none absolute left-[59px] right-3 z-20 rounded-lg border border-[var(--radio-border)] bg-[var(--radio-surface)]/95 px-2.5 py-1.5 shadow-md"
:style="{ top: Math.min(layout.height - 72, layout.nowTop + 10) + 'px' }">
<p class="text-[11px] font-semibold leading-4 text-[var(--radio-text)]">
{{ $t('panel.schedule_gap_unknown') }}
</p>
<p class="mt-0.5 truncate text-[10px] leading-4 text-[var(--radio-text-muted)]">
{{ layout.gap.nextStartTime && layout.gap.nextTitle
? $t('panel.schedule_gap_next', {
title: layout.gap.nextTitle, time: formatHour(layout.gap.nextStartTime),
})
: $t('panel.schedule_gap_no_next') }}
</p>
</div>
</template>
</div>
</template>

<script setup lang="ts">
import { referenceTimelineLayout } from '~/utils/station-schedule.mjs';

interface TimelineProgramme {
startTime?: number;
endTime?: number;
title?: string;
description?: string;
}
const props = defineProps<{
program: TimelineProgramme[];
timestamp: number;
thumb?: string;
stationName: string;
}>();

const layout = computed(() => referenceTimelineLayout(props.program, props.timestamp));
const hourFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
const secondsFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit',
second: '2-digit', hourCycle: 'h23',
});
const dateFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', day: '2-digit', month: '2-digit',
});
const formatHour = (time: number) => hourFormatter.format(new Date(time * 1000));
const formatDate = (time: number) => dateFormatter.format(new Date(time * 1000));
const formattedNow = computed(() => secondsFormatter.format(new Date(props.timestamp * 1000)));
const siteAssetUrl = useSiteAssetUrl();
</script>
31 changes: 31 additions & 0 deletions docs/VIETNAM_SCHEDULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,34 @@ boundary, the synthetic gap marker disappears and the real schedule row takes
over. See `referenceScheduleGap` in `utils/station-schedule.mjs` and the
regressions in `tests/station-schedule.test.mjs` and
`tests/browser/schedule-autoscroll.spec.mjs`.

## Vertical broadcast timeline

The reference-guide Queue now uses a real **hourly vertical EPG**, instead
of equally tall programme cards. Every hour on the left has an aligned grid
line, and each station programme occupies a duration-proportional column
block (76 CSS pixels per hour). The visible ruler continues past **midnight**
with the next Vietnam calendar date and early next-day hours.

A **green now line** crosses the grid at the correct clock position. Its
HH:mm:ss badge and position advance once per second using the
`Asia/Ho_Chi_Minh` clock, regardless of the user's local timezone. This is
a **schedule clock**, *not audio playback progress*; radio can be paused or
offline while the reference clock still indicates wall time.

At an unpublished interval such as the 16:15–16:30 VOV Giao thông HCM gap,
the grid remains visually empty of invented programme blocks and shows the
neutral message plus the next listed programme beside the current-time line.
No false "currently broadcasting" claim is made. At the 16:30 boundary, the
new real programme becomes highlighted automatically.

When the Queue/Schedule opens, the scrollbar centers the **now line** inside
the list. The listener can scroll to other periods without being pulled back;
a dedicated **Jump to current time** button restores the view. The existing
compact list remains in place for non-reference provider EPGs that lack
reliable programme start/end timestamps.

`referenceTimelineLayout` and `scheduleTimelineProgress` in
`utils/station-schedule.mjs` contain the timezone-aware geometry and
boundary maths. Regression coverage includes program length, next-day
ticks, a 16:29 gap and the 16:30 transition.
46 changes: 26 additions & 20 deletions tests/browser/schedule-autoscroll.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ test('VOV Giao thông guide opens at the current Vietnam programme, not the firs
const current = visibleCurrent(page);
await expect(current).toBeVisible();
await expect(current).toContainText('FM91');
await expect(queue(page).locator('[data-schedule-timeline]')).toBeVisible();
await expect(current).toHaveAttribute('style', /height: 133px/);
await expect(queue(page).locator('[data-schedule-now]')).toContainText('15:00:00');
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(40);
await expect.poll(() => current.evaluate(row => {
const viewport = row.closest('.radio-scrollbar').getBoundingClientRect();
Expand All @@ -42,36 +45,39 @@ test('VOV Giao thông guide opens at the current Vietnam programme, not the firs
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(40);
});

test('16:29 ICT shows a Now marker in an unlisted interval, then switches to 16:30 programme', async ({ page }) => {
test('16:29 ICT draws a moving green NOW line across an empty time gap', async ({ page }) => {
await page.setViewportSize({ width: 1536, height: 900 });
// 09:29 UTC = 16:29 ICT, exactly between FM91 (ends 16:15)
// and Giờ cao điểm chiều (starts 16:30).
await page.clock.install({ time: new Date('2026-10-14T09:29: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 marker = queue(page).locator('[data-schedule-now]');
await expect(marker).toBeVisible();
await expect(marker).toContainText('16:29');
await expect(marker).toContainText('No programme details are listed for this time');
await expect(marker).toContainText('Giờ cao điểm chiều');
await expect(marker).toContainText('16:30');
const timeline = queue(page).locator('[data-schedule-timeline]');
const marker = timeline.locator('[data-schedule-now]');
const unknown = timeline.locator('[data-schedule-gap]');
await expect(timeline).toBeVisible();
await expect(marker).toContainText('16:29:00');
await expect(unknown).toContainText('No programme details are listed for this time');
await expect(unknown).toContainText('Giờ cao điểm chiều');
await expect(unknown).toContainText('16:30');
await expect.poll(() => marker.evaluate(row => {
const viewport = row.closest('.radio-scrollbar').getBoundingClientRect();
const box = row.getBoundingClientRect();
return box.top >= viewport.top && box.bottom <= viewport.bottom;
return box.top >= viewport.top && box.top <= viewport.bottom;
})).toBe(true);

const nextSlot = queue(page).locator('[data-program-index="7"]');
await expect(nextSlot.locator('.absolute.inset-y-2')).toHaveCount(0);

// No invented live programme during the gap; exactly at 16:30 the
// synthetic marker vanishes and the next actual reference slot is active.
await page.clock.runFor(60_000);
await expect(marker).toHaveCount(0);
await expect(nextSlot.locator('.absolute.inset-y-2')).toHaveCount(1);
const nextSlot = timeline.locator('[data-program-index="7"]');
await expect(nextSlot).not.toHaveClass(/bg-rose-950\/\[0\.88\]/);
const firstTop = await marker.evaluate(element => parseFloat(element.style.top));
await page.clock.runFor(30_000);
await expect(marker).toContainText('16:29:30');
expect(await marker.evaluate(element => parseFloat(element.style.top))).toBeGreaterThan(firstTop);

await page.clock.runFor(30_000);
await expect(unknown).toHaveCount(0);
await expect(marker).toContainText('16:30:00');
await expect(nextSlot).toHaveClass(/bg-rose-950/);
});

test('manual browsing in a missing-guide window does not get auto-recentered', async ({ page }) => {
Expand All @@ -84,8 +90,8 @@ test('manual browsing in a missing-guide window does not get auto-recentered', a

const scroll = scrollArea(page);
const marker = queue(page).locator('[data-schedule-now]');
await expect(marker).toContainText('11:30');
await expect(marker).toContainText('12:30');
await expect(marker).toContainText('11:30:00');
await expect(queue(page).locator('[data-schedule-gap]')).toContainText('12:30');
await scroll.evaluate(node => { node.scrollTop = 0; });
await scroll.dispatchEvent('wheel', { deltaY: -200, bubbles: true });
await page.clock.runFor(16_000);
Expand Down
59 changes: 59 additions & 0 deletions tests/station-schedule.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
normalizeScheduleUrl,
schedulePosition,
referenceScheduleGap,
referenceTimelineLayout,
SCHEDULE_TIMELINE_HOUR_PX,
scheduleTimelineProgress,
} from "../utils/station-schedule.mjs";

Expand Down Expand Up @@ -165,3 +167,60 @@ test('live time bar is wired to a one-second clock only for visible reference gu
assert.match(gap, /data-schedule-time-progress/);
assert.match(gap, /timelineProgress \+ '%'/);
});

test('timeline uses proportional clock time rather than equal-height station cards', async () => {
const { vietnamReferencePrograms } = await import('../utils/vietnam-schedules.mjs');
const now = Date.parse('2026-10-14T08:00:00Z') / 1000; // 15:00 ICT
const programmes = vietnamReferencePrograms('/asia/viet-nam/vov-gt-hcm', new Date(now * 1000));
const layout = referenceTimelineLayout(programmes, now);
assert.ok(layout);
assert.equal(SCHEDULE_TIMELINE_HOUR_PX, 76);
assert.equal(new Date(layout.dayStart * 1000).toISOString(), '2026-10-13T17:00:00.000Z');
assert.equal(layout.height, 27 * 76);
assert.equal(layout.hours.length, 28);
assert.equal(layout.hours[24].nextDay, true);
assert.equal(layout.hours[24].time, layout.dayStart + 86400);
assert.equal(layout.programmes[0].top, 4 * 76);
assert.equal(layout.programmes[0].height, 76); // 04:00–05:00
assert.equal(layout.programmes[6].height, 1.75 * 76); // FM91 14:30–16:15
assert.equal(layout.programmes[6].active, true);
assert.equal(layout.nowTop, 15 * 76);
assert.equal(layout.gap, null);
assert.ok(Math.abs(scheduleTimelineProgress(programmes[6].startTime, programmes[6].endTime, now)
- (30 / 105) * 100) < 1e-9);
});

test('timeline puts the green NOW line at 16:29 in the unpublished 16:15–16:30 window', async () => {
const { vietnamReferencePrograms } = await import('../utils/vietnam-schedules.mjs');
const before = Date.parse('2026-10-14T09:29:00Z') / 1000;
const programmes = vietnamReferencePrograms('/asia/viet-nam/vov-gt-hcm', new Date(before * 1000));
const gapLayout = referenceTimelineLayout(programmes, before);
assert.ok(Math.abs(gapLayout.nowTop - (16 + 29 / 60) * 76) < 1e-7);
assert.equal(gapLayout.programmes.some(block => block.active), false);
assert.equal(gapLayout.gap.startTime, Date.parse('2026-10-14T09:15:00Z') / 1000);
assert.equal(gapLayout.gap.endTime, Date.parse('2026-10-14T09:30:00Z') / 1000);
assert.equal(gapLayout.gap.nextTitle, 'Giờ cao điểm chiều');
const liveLayout = referenceTimelineLayout(programmes, before + 60);
assert.equal(liveLayout.gap, null);
assert.equal(liveLayout.programmes[7].active, true);
assert.ok(Math.abs((liveLayout.nowTop - gapLayout.nowTop) - 76 / 60) < 1e-9);
});

test('timeline continues past midnight with next-date axis and bounded progress', () => {
const programmes = [{
startTime: Date.parse('2026-10-14T12:00:00Z') / 1000,
endTime: Date.parse('2026-10-15T01:30:00Z') / 1000,
title: 'Night programme',
}];
const now = Date.parse('2026-10-14T20:50:00Z') / 1000; // 03:50 ICT next day
const layout = referenceTimelineLayout(programmes, now);
assert.ok(layout.hours.some(hour => hour.nextDay));
assert.equal(layout.programmes[0].height, 13.5 * 76);
assert.equal(layout.nowTop, (27 + 50 / 60) * 76);
assert.ok(layout.height > layout.nowTop);
assert.equal(scheduleTimelineProgress(100, 200, 50), 0);
assert.equal(scheduleTimelineProgress(100, 200, 250), 100);
assert.equal(scheduleTimelineProgress(100, 100, 100), null);
assert.equal(scheduleTimelineProgress(undefined, 200, 150), null);
assert.equal(referenceTimelineLayout([], now), null);
});
48 changes: 48 additions & 0 deletions utils/station-schedule.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,51 @@ export const scheduleTimelineProgress = (startSeconds, endSeconds, nowSeconds) =
|| !Number.isFinite(nowSeconds) || endSeconds <= startSeconds) return null;
return Math.min(100, Math.max(0, (nowSeconds - startSeconds) * 100 / (endSeconds - startSeconds)));
};

// Fixed-scale broadcast timeline. Unix timestamps stay in Vietnam's ICT zone:
// no browser-local timezone or daylight-saving conversion can move a programme.
export const SCHEDULE_TIMELINE_HOUR_PX = 76;
export const referenceTimelineLayout = (programs, nowSeconds, hourPixels = SCHEDULE_TIMELINE_HOUR_PX) => {
if (!Array.isArray(programs) || !programs.length || !Number.isFinite(nowSeconds)
|| !Number.isFinite(hourPixels) || hourPixels <= 0) return null;
const sorted = programs.filter(item =>
Number.isFinite(item?.startTime) && Number.isFinite(item?.endTime)
&& item.endTime > item.startTime
).slice().sort((a, b) => a.startTime - b.startTime);
if (!sorted.length) return null;
const vietnamOffset = 7 * 3600;
const dayStart = Math.floor((sorted[0].startTime + vietnamOffset) / 86400) * 86400 - vietnamOffset;
// Display a continuous hourly ruler through 03:00 of the following day.
// Keep longer source events visible without clipping them at midnight.
const lastEnd = Math.max(...sorted.map(item => item.endTime));
const hourCount = Math.max(27, Math.ceil((lastEnd - dayStart) / 3600) + 2);
const timeTop = seconds => (seconds - dayStart) / 3600 * hourPixels;
const programsLayout = sorted.map((item, index) => ({
index,
item,
top: timeTop(item.startTime),
height: Math.max(1, timeTop(item.endTime) - timeTop(item.startTime)),
active: item.startTime <= nowSeconds && nowSeconds < item.endTime,
}));
const gap = referenceScheduleGap(sorted, nowSeconds);
const previous = gap?.insertIndex > 0 ? sorted[gap.insertIndex - 1] : null;
const next = gap && gap.insertIndex < sorted.length ? sorted[gap.insertIndex] : null;
return {
dayStart,
height: hourCount * hourPixels,
hours: Array.from({ length: hourCount + 1 }, (_, index) => ({
time: dayStart + index * 3600,
top: index * hourPixels,
nextDay: index > 0 && index % 24 === 0,
})),
programmes: programsLayout,
nowTop: nowSeconds >= dayStart && nowSeconds <= dayStart + hourCount * 3600
? timeTop(nowSeconds) : null,
// An unknown interval is neutral metadata, never a live station status.
gap: gap ? {
...gap,
startTime: previous?.endTime ?? dayStart,
endTime: next?.startTime ?? dayStart + hourCount * 3600,
} : null,
};
};
Loading