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
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