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
36 changes: 33 additions & 3 deletions components/player/Queue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
<PlayerScheduleGap
v-if="scheduleGap?.insertIndex === index"
:timestamp="scheduleNow"
:start-time="gapStartTime(scheduleGap.insertIndex)"
:next-title="scheduleGap.nextTitle"
:next-start-time="scheduleGap.nextStartTime"
/>
Expand All @@ -168,6 +169,14 @@
class="absolute inset-y-2 left-0 w-0.5 rounded-full bg-rose-500"
aria-hidden="true"
></span>
<div v-if="isCurrentProgram(item, index) && scheduleIsReference"
class="pointer-events-none absolute inset-x-2 bottom-0 h-1 rounded-full bg-rose-500/15"
aria-hidden="true">
<div data-schedule-time-progress class="relative h-full rounded-full bg-rose-500 transition-[width] duration-1000 ease-linear"
:style="{ width: (programmeProgress(item) ?? 0) + '%' }">
<span class="absolute right-0 top-1/2 h-2.5 w-2.5 translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--radio-surface)] bg-rose-500 shadow-sm"></span>
</div>
</div>

<div
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)]"
Expand Down Expand Up @@ -198,12 +207,18 @@
>
{{ item.description }}
</p>
<p v-if="isCurrentProgram(item, index) && scheduleIsReference"
data-schedule-clock role="timer"
class="mt-1 text-[11px] font-semibold tabular-nums text-rose-700 dark:text-rose-300">
{{ $t('panel.schedule_now_marker') }} · {{ formattedScheduleNow }}
</p>
</div>
</div>
</template>
<PlayerScheduleGap
v-if="scheduleGap?.insertIndex === program.length"
:timestamp="scheduleNow"
:start-time="gapStartTime(scheduleGap.insertIndex)"
:next-title="scheduleGap.nextTitle"
:next-start-time="scheduleGap.nextStartTime"
/>
Expand Down Expand Up @@ -271,7 +286,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, referenceScheduleGap } from "~/utils/station-schedule.mjs";
import { schedulePosition, referenceScheduleGap, scheduleTimelineProgress } from "~/utils/station-schedule.mjs";

const dayjs = useDayjs();
const { t } = useI18n();
Expand Down Expand Up @@ -359,6 +374,17 @@ const schedulePositionNow = computed(() =>
const scheduleGap = computed(() => props.scheduleIsReference
? referenceScheduleGap(props.program, scheduleNow.value)
: null);
// Only a known interval has a meaningful progress percentage.
const gapStartTime = (index: number): number | null =>
index > 0 ? props.program[index - 1]?.endTime ?? null : null;
const programmeProgress = (item: ProgramItem): number | null =>
scheduleTimelineProgress(item.startTime, item.endTime, scheduleNow.value);
const vietnamSecondsFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
});
const formattedScheduleNow = computed(() =>
vietnamSecondsFormatter.format(new Date(scheduleNow.value * 1000)));

// Move just the INNER queue scroller: scrollIntoView() would move the entire
// mobile slideover/page and could hide the fixed player/header.
Expand Down Expand Up @@ -435,8 +461,12 @@ 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);
// Clock and the timeline fill move once per second. Only update the
// visible reference guide; real-time provider EPG is unaffected.
if (activeTab.value === 'schedule' && props.visible && props.scheduleIsReference) {
scheduleNow.value = Math.floor(Date.now() / 1000);
}
}, 1_000);
if (activeTab.value === 'schedule' && props.visible) void jumpToSchedulePosition();
});
onBeforeUnmount(() => {
Expand Down
23 changes: 18 additions & 5 deletions components/player/ScheduleGap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@
class="relative my-2 rounded-xl border border-rose-500/25 bg-rose-500/[0.06] px-3 py-3"
>
<div class="mb-2 flex items-center gap-2">
<span class="shrink-0 rounded-md bg-rose-500 px-2 py-1 text-xs font-bold tabular-nums text-white">
<span data-schedule-clock role="timer" class="shrink-0 rounded-md bg-rose-500 px-2 py-1 text-xs font-bold tabular-nums text-white">
{{ formattedNow }}
</span>
<span class="h-px min-w-0 flex-1 bg-rose-500/60" aria-hidden="true"></span>
<div class="relative h-1 min-w-0 flex-1 rounded-full bg-rose-500/15" aria-hidden="true">
<div data-schedule-time-progress class="h-full rounded-full bg-rose-500 transition-[width] duration-1000 ease-linear"
:style="{ width: timelineProgress === null ? '0%' : timelineProgress + '%' }"></div>
<span v-if="timelineProgress !== null"
class="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--radio-surface)] bg-rose-500 shadow-sm transition-[left] duration-1000 ease-linear"
:style="{ left: timelineProgress + '%' }"></span>
</div>
<span class="shrink-0 text-[10px] font-semibold text-rose-700 dark:text-rose-300">
{{ $t('panel.schedule_now_marker') }}
</span>
Expand All @@ -26,18 +32,25 @@
</template>

<script setup lang="ts">
import { scheduleTimelineProgress } from '~/utils/station-schedule.mjs';
const props = withDefaults(defineProps<{
timestamp: number;
startTime?: number | null;
nextTitle?: string;
nextStartTime?: number | null;
}>(), { nextTitle: '', nextStartTime: null });
}>(), { startTime: null, nextTitle: '', nextStartTime: null });

// Only used for broadcaster reference guides, all in Vietnam's UTC+7 zone.
const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
});
const formattedNow = computed(() => formatter.format(new Date(props.timestamp * 1000)));
const formattedNext = computed(() => props.nextStartTime
? formatter.format(new Date(props.nextStartTime * 1000))
? new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).format(new Date(props.nextStartTime * 1000))
: '');
const timelineProgress = computed(() => scheduleTimelineProgress(
props.startTime, props.nextStartTime, props.timestamp,
));
</script>
31 changes: 31 additions & 0 deletions tests/browser/schedule-autoscroll.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,34 @@ test('manual browsing in a missing-guide window does not get auto-recentered', a
await queue(page).getByRole('button', { name: 'Jump to current time' }).click();
await expect.poll(() => scroll.evaluate(node => node.scrollTop)).toBeGreaterThan(0);
});

test('1-second time bar moves during an unlisted gap and transfers into scheduled show', async ({ page }) => {
await page.setViewportSize({ width: 1536, height: 900 });
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]');
const time = marker.locator('[data-schedule-clock]');
const line = marker.locator('[data-schedule-time-progress]');
await expect(time).toHaveText('16:29:00');
const initialProgress = await line.evaluate(element => parseFloat(element.style.width));
expect(initialProgress).toBeCloseTo(93.333, 1);

await page.clock.runFor(2_000);
await expect(time).toHaveText('16:29:02');
const advancedProgress = await line.evaluate(element => parseFloat(element.style.width));
expect(advancedProgress).toBeGreaterThan(initialProgress);

await page.clock.runFor(58_000); // Exactly 16:30 ICT.
await expect(marker).toHaveCount(0);
const programme = queue(page).locator('[data-program-index="7"]');
await expect(programme.locator('[data-schedule-clock]')).toContainText('16:30:00');
const showProgress = programme.locator('[data-schedule-time-progress]');
await expect(showProgress).toHaveCount(1);
expect(await showProgress.evaluate(element => parseFloat(element.style.width))).toBe(0);
await page.clock.runFor(2_000);
expect(await showProgress.evaluate(element => parseFloat(element.style.width))).toBeGreaterThan(0);
});
50 changes: 50 additions & 0 deletions tests/station-schedule.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
normalizeScheduleUrl,
schedulePosition,
referenceScheduleGap,
scheduleTimelineProgress,
} from "../utils/station-schedule.mjs";

test("normalizes only real HTTP schedule URLs", () => {
Expand Down Expand Up @@ -115,3 +116,52 @@ test('Queue UI scrolls to missing-programme marker, never displays the next show
assert.match(marker, /panel\.schedule_gap_next/);
assert.doesNotMatch(marker, /player\.live|schedule_loading/);
});

test('live timeline percentage advances smoothly with wall-clock seconds', () => {
assert.equal(scheduleTimelineProgress(100, 200, 100), 0);
assert.equal(scheduleTimelineProgress(100, 200, 125), 25);
assert.equal(scheduleTimelineProgress(100, 200, 150), 50);
assert.equal(scheduleTimelineProgress(100, 200, 199), 99);
assert.equal(scheduleTimelineProgress(100, 200, 200), 100);
assert.equal(scheduleTimelineProgress(100, 200, 90), 0);
assert.equal(scheduleTimelineProgress(100, 200, 250), 100);
assert.equal(scheduleTimelineProgress(null, 200, 150), null);
assert.equal(scheduleTimelineProgress(100, null, 150), null);
assert.equal(scheduleTimelineProgress(200, 200, 150), null);
assert.equal(scheduleTimelineProgress(300, 200, 150), null);
assert.equal(scheduleTimelineProgress(100, 200, Number.NaN), null);
});

test('VOV HCM at 16:29 shows 93.33% of the 16:15–16:30 gap, ticking every second', async () => {
const { vietnamReferencePrograms } = await import('../utils/vietnam-schedules.mjs');
const now = Date.parse('2026-10-14T09:29:00Z') / 1000; // ICT +07:00.
const programs = vietnamReferencePrograms('/asia/viet-nam/vov-gt-hcm', new Date(now * 1000));
const gap = referenceScheduleGap(programs, now);
assert.equal(gap.insertIndex, 7);
const start = programs[gap.insertIndex - 1].endTime;
const end = gap.nextStartTime;
assert.equal(new Date(start * 1000).toISOString(), '2026-10-14T09:15:00.000Z');
assert.equal(scheduleTimelineProgress(start, end, now), 100 * 14 / 15);
assert.ok(scheduleTimelineProgress(start, end, now + 1) > scheduleTimelineProgress(start, end, now));
assert.equal(scheduleTimelineProgress(start, end, end), 100);
assert.equal(referenceScheduleGap(programs, end), null);
const show = programs[gap.insertIndex];
assert.equal(scheduleTimelineProgress(show.startTime, show.endTime, end), 0);
assert.ok(scheduleTimelineProgress(show.startTime, show.endTime, end + 1) > 0);
});

test('live time bar is wired to a one-second clock only for visible reference guides', async () => {
const { readFile } = await import('node:fs/promises');
const q = await readFile(new URL('../components/player/Queue.vue', import.meta.url), 'utf8');
const gap = await readFile(new URL('../components/player/ScheduleGap.vue', import.meta.url), 'utf8');
assert.match(q, /scheduleTimelineProgress\(item\.startTime, item\.endTime, scheduleNow\.value\)/);
assert.match(q, /scheduleClockTimer = setInterval\(/);
assert.match(q, /\}, 1_000\)/);
assert.match(q, /props\.visible && props\.scheduleIsReference/);
assert.match(q, /data-schedule-clock role="timer"/);
assert.match(q, /data-schedule-time-progress/);
assert.match(q, /:start-time="gapStartTime\(scheduleGap\.insertIndex\)"/);
assert.match(gap, /second: '2-digit'/);
assert.match(gap, /data-schedule-time-progress/);
assert.match(gap, /timelineProgress \+ '%'/);
});
9 changes: 9 additions & 0 deletions utils/station-schedule.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,12 @@ export const referenceScheduleGap = (programs, nowSeconds) => {
? { insertIndex: programs.length, nextTitle: '', nextStartTime: null }
: null;
};

// Percentage of actual elapsed wall-clock time in a scheduled interval.
// Used only as a reference-guide timeline, not an audio playback progress bar.
// Invalid or unknown bounds intentionally have no calculable position.
export const scheduleTimelineProgress = (startSeconds, endSeconds, nowSeconds) => {
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds)
|| !Number.isFinite(nowSeconds) || endSeconds <= startSeconds) return null;
return Math.min(100, Math.max(0, (nowSeconds - startSeconds) * 100 / (endSeconds - startSeconds)));
};
Loading