mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(player): keyboard-reachable, bounded labels for timeline segments (#1854)
Seek-bar segments (catch-up programmes, file chapters) now reach keyboard, touch and screen-reader users through the shared timelineSegments path: - aria-valuetext reads the translated "<title> · <time>" inside a titled segment, the plain time otherwise. - ControlsTimelineLabel anchors the label on keyboard focus and drag previews as well as pointer hover. - Two-part label (ellipsized title, whole time), clamped by its measured width and re-placed on resize to stay 8px inside the player. - Opaque --pc-timeline-track with white separators clamped to the track, so boundaries hold 3:1 over any frame. - Translated LIVE badge; LIVE and --:-- are named role="img" elements in both docks. - epglong Xtream mock scenario and an Electron E2E in both themes at 1280/800px and in de/ru. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1058f8d985
commit
015ea203b7
49 files changed
+1675
-278
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: player
|
||||
---
|
||||
|
||||
Programme names on the seek bar now reach keyboard, touch and screen-reader
|
||||
users: the bar announces the programme with the time, and its label appears
|
||||
while you seek with the arrow keys or drag on a touch screen. Long names are
|
||||
shortened instead of spilling past the player, and boundaries between
|
||||
programmes are easier to see. LIVE is translated.
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
/**
|
||||
* Catch-up programme names on the shared controls' seek bar reach keyboard
|
||||
* and screen-reader users, and the label stays inside the player. The
|
||||
* `epglong` mock user is the EPG fixture with a 120-character title on the
|
||||
* past programme, whose timeshift URL plays a local clip.
|
||||
*/
|
||||
|
||||
const CHANNEL = 'Timezone News';
|
||||
const CREDENTIALS = { username: 'epglong', password: 'epglong' };
|
||||
/** `--pc-glass-bg-dense`: the label is theme-independent. */
|
||||
const LABEL_GLASS = 'rgba(12, 16, 23, 0.86)';
|
||||
|
||||
interface Messages {
|
||||
WORKSPACE: { SHELL: { RAIL_LIVE: string } };
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: {
|
||||
LIVE_STREAM: string;
|
||||
LIVE_BADGE: string;
|
||||
TIMELINE_SEGMENT_POSITION: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function messages(locale: string): Messages {
|
||||
return JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, `../../web/src/assets/i18n/${locale}.json`),
|
||||
'utf8'
|
||||
)
|
||||
) as Messages;
|
||||
}
|
||||
|
||||
function valueText(template: string, title: string, time: string): string {
|
||||
return template.replace('{{title}}', title).replace('{{time}}', time);
|
||||
}
|
||||
|
||||
/** Imports the source and opens the guide of the catch-up channel. */
|
||||
async function openChannel(
|
||||
page: Page,
|
||||
request: Parameters<typeof fetchXtreamEpgFixture>[0],
|
||||
locale: string | null
|
||||
) {
|
||||
const fixture = await fetchXtreamEpgFixture(request, CREDENTIALS);
|
||||
const longTitle = fixture.fullEpg
|
||||
.map((programme) => programme.title)
|
||||
.find((title) => title.length >= 120);
|
||||
expect(longTitle).toHaveLength(120);
|
||||
|
||||
// Keep the live stub request pending: everything played here is local.
|
||||
await page.route('https://test-streams.mux.dev/**', () => undefined);
|
||||
await addXtreamPortal(page, { name: 'Long catch-up titles', ...CREDENTIALS });
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
if (locale) {
|
||||
await openSettings(page);
|
||||
await page.getByTestId('select-language').click();
|
||||
await page.locator(`mat-option[data-test-id="${locale}"]`).click();
|
||||
await saveSettings(page);
|
||||
}
|
||||
await openWorkspaceSection(
|
||||
page,
|
||||
messages(locale ?? 'en').WORKSPACE.SHELL.RAIL_LIVE
|
||||
);
|
||||
await clickCategoryByNameExact(page, fixture.categoryName);
|
||||
const row = channelItemByTitle(page, CHANNEL).first();
|
||||
await expect(row).toBeVisible({ timeout: 20000 });
|
||||
await row.click();
|
||||
const player = page.locator('app-web-player-view');
|
||||
await expect(player).toBeVisible({ timeout: 20000 });
|
||||
return { player, row, longTitle: longTitle ?? '' };
|
||||
}
|
||||
|
||||
/** Plays the long-titled programme's archive from the guide. */
|
||||
async function playCatchup(page: Page, player: Locator, longTitle: string) {
|
||||
const block = page
|
||||
.locator('app-epg-timeline .epg-timeline__block')
|
||||
.filter({ hasText: longTitle.slice(0, 40) })
|
||||
.first();
|
||||
await expect(block).toBeVisible({ timeout: 20000 });
|
||||
await block.click();
|
||||
await expect(
|
||||
player.locator('.player-controls__timeline-segment--titled')
|
||||
).toHaveCount(1, { timeout: 30000 });
|
||||
const controls = player.locator('app-player-controls');
|
||||
const slider = controls.locator('.player-controls__slider--timeline');
|
||||
await expect(slider).toBeEnabled({ timeout: 30000 });
|
||||
// Paused, so the position the assertions read stands still.
|
||||
const video = player.locator('video').first();
|
||||
if (!(await video.evaluate((el: HTMLVideoElement) => el.paused))) {
|
||||
await controls.locator('[data-test-id="player-controls-play"]').click();
|
||||
}
|
||||
await expect
|
||||
.poll(() => video.evaluate((el: HTMLVideoElement) => el.paused))
|
||||
.toBe(true);
|
||||
// Park the pointer off the bar: a hovering pointer outranks the keyboard.
|
||||
await page.mouse.move(1, 1);
|
||||
return { controls, slider };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaches the seek bar from the keyboard: it is the next Tab stop after the
|
||||
* stream-info button in the top corner.
|
||||
*/
|
||||
async function tabToSeekBar(controls: Locator, slider: Locator) {
|
||||
await controls
|
||||
.locator('[data-test-id="player-controls-stream-info-button"]')
|
||||
.focus();
|
||||
await controls.page().keyboard.press('Tab');
|
||||
await expect(slider).toBeFocused();
|
||||
expect(
|
||||
await slider.evaluate((element) => element.matches(':focus-visible'))
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
/** The label's box against the player's (the controls host fills it). */
|
||||
function labelPlacement(controls: Locator) {
|
||||
return controls.evaluate((host) => {
|
||||
const part = (name: string) =>
|
||||
host.querySelector<HTMLElement>(
|
||||
`[data-test-id="player-controls-timeline-label${name}"]`
|
||||
);
|
||||
const label = part('');
|
||||
const title = part('-title');
|
||||
const time = part('-time');
|
||||
if (!label || !time) {
|
||||
return null;
|
||||
}
|
||||
const player = host.getBoundingClientRect();
|
||||
const box = label.getBoundingClientRect();
|
||||
const timeBox = time.getBoundingClientRect();
|
||||
return {
|
||||
fromLeft: Math.round(box.left - player.left),
|
||||
fromRight: Math.round(player.right - box.right),
|
||||
insideVertically:
|
||||
box.top >= player.top && box.bottom <= player.bottom,
|
||||
width: Math.round(box.width),
|
||||
playerWidth: Math.round(player.width),
|
||||
title: title?.textContent ?? null,
|
||||
titleEllipsized: title
|
||||
? title.scrollWidth > title.clientWidth &&
|
||||
getComputedStyle(title).textOverflow === 'ellipsis'
|
||||
: false,
|
||||
time: time.textContent,
|
||||
timeWhole:
|
||||
time.scrollWidth <= time.clientWidth &&
|
||||
timeBox.left >= box.left &&
|
||||
timeBox.right <= box.right,
|
||||
background: getComputedStyle(label).backgroundColor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function hideCategories(page: Page, player: Locator) {
|
||||
await page.locator('[data-test-id="context-hide-categories"]').click();
|
||||
await page.mouse.move(1, 1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
player.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.right <= window.innerWidth && box.width > 300;
|
||||
})
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectLabelInsidePlayer(
|
||||
controls: Locator,
|
||||
expected: { title: string; time: string }
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const placement = await labelPlacement(controls);
|
||||
return (
|
||||
placement !== null &&
|
||||
placement.time === expected.time &&
|
||||
placement.fromLeft >= 0 &&
|
||||
placement.fromRight >= 0
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
const placement = await labelPlacement(controls);
|
||||
expect(placement).not.toBeNull();
|
||||
expect(placement?.title).toBe(expected.title);
|
||||
expect(placement?.insideVertically).toBe(true);
|
||||
// A 120-character title never fits whole: it ellipsizes, the time not.
|
||||
expect(placement?.titleEllipsized).toBe(true);
|
||||
expect(placement?.timeWhole).toBe(true);
|
||||
expect(placement?.width).toBeLessThan(placement?.playerWidth ?? 0);
|
||||
return placement;
|
||||
}
|
||||
|
||||
test('@epg @xtream @electron keyboard reaches the catch-up programme name and its label stays inside the player', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(240000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
const template = messages('en').EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION;
|
||||
|
||||
try {
|
||||
const { player, longTitle } = await openChannel(page, request, null);
|
||||
const { controls, slider } = await playCatchup(page, player, longTitle);
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
if (width < 900) {
|
||||
// Below the desktop window's minimum width the guide pushes
|
||||
// the player off-screen; hiding the categories is how such a
|
||||
// narrow layout gets a (narrow) player.
|
||||
await hideCategories(page, player);
|
||||
}
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
await tabToSeekBar(controls, slider);
|
||||
|
||||
// Keyboard focus alone shows the label at the position.
|
||||
await expect(
|
||||
controls.locator(
|
||||
'[data-test-id="player-controls-timeline-label"]'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// The left end: the label is pinned inside the player.
|
||||
await page.keyboard.press('Home');
|
||||
await expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
valueText(template, longTitle, '0:00')
|
||||
);
|
||||
const start = await expectLabelInsidePlayer(controls, {
|
||||
title: longTitle,
|
||||
time: '0:00',
|
||||
});
|
||||
expect(start?.background).toBe(LABEL_GLASS);
|
||||
|
||||
// An arrow moves into the next second of the programme.
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
valueText(template, longTitle, '0:01')
|
||||
);
|
||||
await expectLabelInsidePlayer(controls, {
|
||||
title: longTitle,
|
||||
time: '0:01',
|
||||
});
|
||||
|
||||
// The right end.
|
||||
await page.keyboard.press('End');
|
||||
await expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
valueText(template, longTitle, '0:06')
|
||||
);
|
||||
await expectLabelInsidePlayer(controls, {
|
||||
title: longTitle,
|
||||
time: '0:06',
|
||||
});
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${theme}-${width}.png`),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['de', 'ru'] as const) {
|
||||
test(`@epg @xtream @electron names the catch-up programme and LIVE in the ${locale} locale`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(240000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
const player$ = messages(locale).EMBEDDED_MPV.PLAYER;
|
||||
|
||||
try {
|
||||
const { player, longTitle } = await openChannel(
|
||||
page,
|
||||
request,
|
||||
locale
|
||||
);
|
||||
// Live playback: the translated badge, announced by its name.
|
||||
await expect(
|
||||
player.getByRole('img', {
|
||||
name: player$.LIVE_STREAM,
|
||||
exact: true,
|
||||
})
|
||||
).toHaveText(player$.LIVE_BADGE, { timeout: 20000 });
|
||||
|
||||
const { controls, slider } = await playCatchup(
|
||||
page,
|
||||
player,
|
||||
longTitle
|
||||
);
|
||||
await page.setViewportSize({ width: 800, height: 800 });
|
||||
await hideCategories(page, player);
|
||||
await tabToSeekBar(controls, slider);
|
||||
await page.keyboard.press('Home');
|
||||
await expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
valueText(player$.TIMELINE_SEGMENT_POSITION, longTitle, '0:00')
|
||||
);
|
||||
await expectLabelInsidePlayer(controls, {
|
||||
title: longTitle,
|
||||
time: '0:00',
|
||||
});
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${locale}-800.png`),
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -65,8 +65,10 @@ test('@epg @xtream @electron draws the catch-up programme on the seek bar', asyn
|
||||
const bar = player.locator('.player-controls__timeline-bar');
|
||||
await bar.hover();
|
||||
await expect(
|
||||
player.locator('[data-test-id="player-controls-timeline-label"]')
|
||||
).toContainText(`${PAST_PROGRAM} ·`);
|
||||
player.locator(
|
||||
'[data-test-id="player-controls-timeline-label-title"]'
|
||||
)
|
||||
).toHaveText(PAST_PROGRAM);
|
||||
|
||||
await row.click();
|
||||
await expect(titledSegments).toHaveCount(0, { timeout: 20000 });
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "رجوع 10 ثوانٍ",
|
||||
"FORWARD_10_SECONDS": "تقديم 10 ثوانٍ",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"LIVE_BADGE": "مباشر",
|
||||
"PLAYBACK_POSITION": "موضع التشغيل",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "المدة غير معروفة",
|
||||
"MUTE": "كتم الصوت",
|
||||
"UNMUTE": "إلغاء كتم الصوت",
|
||||
"MUTED": "الصوت مكتوم",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "رجع 10 ثواني",
|
||||
"FORWARD_10_SECONDS": "قدّم 10 ثواني",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"LIVE_BADGE": "مباشر",
|
||||
"PLAYBACK_POSITION": "موضع التشغيل",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "المدة ما معروفاش",
|
||||
"MUTE": "كتم الصوت",
|
||||
"UNMUTE": "رجّع الصوت",
|
||||
"MUTED": "الصوت مكتوم",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Назад на 10 секунд",
|
||||
"FORWARD_10_SECONDS": "Наперад на 10 секунд",
|
||||
"LIVE_STREAM": "Прамая трансляцыя",
|
||||
"LIVE_BADGE": "ЭФІР",
|
||||
"PLAYBACK_POSITION": "Пазіцыя прайгравання",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Працягласць невядомая",
|
||||
"MUTE": "Адключыць гук",
|
||||
"UNMUTE": "Уключыць гук",
|
||||
"MUTED": "Гук адключаны",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "10 Sekunden zurück",
|
||||
"FORWARD_10_SECONDS": "10 Sekunden vor",
|
||||
"LIVE_STREAM": "Live-Stream",
|
||||
"LIVE_BADGE": "LIVE",
|
||||
"PLAYBACK_POSITION": "Wiedergabeposition",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Dauer unbekannt",
|
||||
"MUTE": "Stummschalten",
|
||||
"UNMUTE": "Stummschaltung aufheben",
|
||||
"MUTED": "Stummgeschaltet",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Πίσω 10 δευτερόλεπτα",
|
||||
"FORWARD_10_SECONDS": "Μπροστά 10 δευτερόλεπτα",
|
||||
"LIVE_STREAM": "Ζωντανή ροή",
|
||||
"LIVE_BADGE": "ΖΩΝΤΑΝΑ",
|
||||
"PLAYBACK_POSITION": "Θέση αναπαραγωγής",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Άγνωστη διάρκεια",
|
||||
"MUTE": "Σίγαση",
|
||||
"UNMUTE": "Κατάργηση σίγασης",
|
||||
"MUTED": "Σε σίγαση",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Back 10 seconds",
|
||||
"FORWARD_10_SECONDS": "Forward 10 seconds",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"LIVE_BADGE": "LIVE",
|
||||
"PLAYBACK_POSITION": "Playback position",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Duration unknown",
|
||||
"MUTE": "Mute",
|
||||
"UNMUTE": "Unmute",
|
||||
"MUTED": "Muted",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Retroceder 10 segundos",
|
||||
"FORWARD_10_SECONDS": "Avanzar 10 segundos",
|
||||
"LIVE_STREAM": "Transmisión en vivo",
|
||||
"LIVE_BADGE": "EN VIVO",
|
||||
"PLAYBACK_POSITION": "Posición de reproducción",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Duración desconocida",
|
||||
"MUTE": "Silenciar",
|
||||
"UNMUTE": "Activar sonido",
|
||||
"MUTED": "Silenciado",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Reculer de 10 secondes",
|
||||
"FORWARD_10_SECONDS": "Avancer de 10 secondes",
|
||||
"LIVE_STREAM": "Diffusion en direct",
|
||||
"LIVE_BADGE": "DIRECT",
|
||||
"PLAYBACK_POSITION": "Position de lecture",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Durée inconnue",
|
||||
"MUTE": "Muet",
|
||||
"UNMUTE": "Activer le son",
|
||||
"MUTED": "Son coupé",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Ugrás 10 másodperccel vissza",
|
||||
"FORWARD_10_SECONDS": "Ugrás 10 másodperccel előre",
|
||||
"LIVE_STREAM": "Élő adás",
|
||||
"LIVE_BADGE": "ÉLŐ",
|
||||
"PLAYBACK_POSITION": "Lejátszási pozíció",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Ismeretlen időtartam",
|
||||
"MUTE": "Némítás",
|
||||
"UNMUTE": "Némítás feloldása",
|
||||
"MUTED": "Némítva",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Indietro di 10 secondi",
|
||||
"FORWARD_10_SECONDS": "Avanti di 10 secondi",
|
||||
"LIVE_STREAM": "Stream in diretta",
|
||||
"LIVE_BADGE": "DIRETTA",
|
||||
"PLAYBACK_POSITION": "Posizione di riproduzione",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Durata sconosciuta",
|
||||
"MUTE": "Disattiva audio",
|
||||
"UNMUTE": "Attiva audio",
|
||||
"MUTED": "Audio disattivato",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "10秒戻る",
|
||||
"FORWARD_10_SECONDS": "10秒進む",
|
||||
"LIVE_STREAM": "ライブストリーム",
|
||||
"LIVE_BADGE": "ライブ",
|
||||
"PLAYBACK_POSITION": "再生位置",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "再生時間不明",
|
||||
"MUTE": "ミュート",
|
||||
"UNMUTE": "ミュート解除",
|
||||
"MUTED": "ミュート中",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "10초 뒤로",
|
||||
"FORWARD_10_SECONDS": "10초 앞으로",
|
||||
"LIVE_STREAM": "라이브 스트림",
|
||||
"LIVE_BADGE": "라이브",
|
||||
"PLAYBACK_POSITION": "재생 위치",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "재생 시간 알 수 없음",
|
||||
"MUTE": "음소거",
|
||||
"UNMUTE": "음소거 해제",
|
||||
"MUTED": "음소거됨",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "10 seconden terug",
|
||||
"FORWARD_10_SECONDS": "10 seconden vooruit",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"LIVE_BADGE": "LIVE",
|
||||
"PLAYBACK_POSITION": "Afspeelpositie",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Duur onbekend",
|
||||
"MUTE": "Dempen",
|
||||
"UNMUTE": "Dempen opheffen",
|
||||
"MUTED": "Gedempt",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Cofnij o 10 sekund",
|
||||
"FORWARD_10_SECONDS": "Do przodu o 10 sekund",
|
||||
"LIVE_STREAM": "Transmisja na żywo",
|
||||
"LIVE_BADGE": "NA ŻYWO",
|
||||
"PLAYBACK_POSITION": "Pozycja odtwarzania",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Nieznany czas trwania",
|
||||
"MUTE": "Wycisz",
|
||||
"UNMUTE": "Wyłącz wyciszenie",
|
||||
"MUTED": "Wyciszono",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Voltar 10 segundos",
|
||||
"FORWARD_10_SECONDS": "Avançar 10 segundos",
|
||||
"LIVE_STREAM": "Transmissão ao vivo",
|
||||
"LIVE_BADGE": "AO VIVO",
|
||||
"PLAYBACK_POSITION": "Posição de reprodução",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Duração desconhecida",
|
||||
"MUTE": "Silenciar",
|
||||
"UNMUTE": "Ativar som",
|
||||
"MUTED": "Silenciado",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "Назад на 10 секунд",
|
||||
"FORWARD_10_SECONDS": "Вперёд на 10 секунд",
|
||||
"LIVE_STREAM": "Прямой эфир",
|
||||
"LIVE_BADGE": "ЭФИР",
|
||||
"PLAYBACK_POSITION": "Позиция воспроизведения",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Длительность неизвестна",
|
||||
"MUTE": "Отключить звук",
|
||||
"UNMUTE": "Включить звук",
|
||||
"MUTED": "Звук отключён",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "10 saniye geri",
|
||||
"FORWARD_10_SECONDS": "10 saniye ileri",
|
||||
"LIVE_STREAM": "Canlı yayın",
|
||||
"LIVE_BADGE": "CANLI",
|
||||
"PLAYBACK_POSITION": "Oynatma konumu",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "Süre bilinmiyor",
|
||||
"MUTE": "Sesi kapat",
|
||||
"UNMUTE": "Sesi aç",
|
||||
"MUTED": "Ses kapalı",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "快退 10 秒",
|
||||
"FORWARD_10_SECONDS": "快进 10 秒",
|
||||
"LIVE_STREAM": "直播",
|
||||
"LIVE_BADGE": "直播",
|
||||
"PLAYBACK_POSITION": "播放位置",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "时长未知",
|
||||
"MUTE": "静音",
|
||||
"UNMUTE": "取消静音",
|
||||
"MUTED": "已静音",
|
||||
|
||||
@@ -643,7 +643,10 @@
|
||||
"BACK_10_SECONDS": "倒轉 10 秒",
|
||||
"FORWARD_10_SECONDS": "快轉 10 秒",
|
||||
"LIVE_STREAM": "現場直播",
|
||||
"LIVE_BADGE": "直播",
|
||||
"PLAYBACK_POSITION": "播放位置",
|
||||
"TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"DURATION_UNKNOWN": "時長未知",
|
||||
"MUTE": "靜音",
|
||||
"UNMUTE": "取消靜音",
|
||||
"MUTED": "已靜音",
|
||||
|
||||
@@ -115,6 +115,7 @@ media. Barriers and delays are coordination tools, not timing inputs.
|
||||
| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active |
|
||||
| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active |
|
||||
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
|
||||
| `epglong` | `epglong` | EPG fixture, long title | 2 | 1 | 1 | 3 | active |
|
||||
| `tzoffset` | `tzoffset` | EPG fixture, `UTC+3` clock | 2 | 1 | 1 | 3 | active |
|
||||
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
|
||||
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
buildPerformanceSeriesInfo,
|
||||
buildPerformanceVodDetails,
|
||||
} from './generators/performance.generator.js';
|
||||
import { getScenario, ScenarioConfig } from './scenarios.js';
|
||||
import {
|
||||
getScenario,
|
||||
LONG_PROGRAMME_TITLE,
|
||||
ScenarioConfig,
|
||||
} from './scenarios.js';
|
||||
|
||||
export interface PortalData {
|
||||
scenario: ScenarioConfig;
|
||||
@@ -100,7 +104,11 @@ function generatePortalData(username: string, password: string): PortalData {
|
||||
const vodStreams = generateVodStreams(vodCategories, itemsPerCategory);
|
||||
const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory);
|
||||
if (scenario.epgFixture === 'timezone-focus') {
|
||||
const timezoneFixture = buildTimezoneFixture();
|
||||
const timezoneFixture = buildTimezoneFixture(
|
||||
scenario.longProgrammeTitle
|
||||
? LONG_PROGRAMME_TITLE
|
||||
: 'Earlier Bulletin'
|
||||
);
|
||||
liveCategories = timezoneFixture.liveCategories;
|
||||
liveStreams = timezoneFixture.liveStreams;
|
||||
timezoneFixture.epgListingsByStreamId.forEach((listings, streamId) => {
|
||||
@@ -249,7 +257,7 @@ function detailCacheKey(
|
||||
return `${username}:${password}:${itemId}`;
|
||||
}
|
||||
|
||||
function buildTimezoneFixture(): Pick<
|
||||
function buildTimezoneFixture(pastTitle: string): Pick<
|
||||
PortalData,
|
||||
'liveCategories' | 'liveStreams' | 'epgListingsByStreamId'
|
||||
> {
|
||||
@@ -304,7 +312,7 @@ function buildTimezoneFixture(): Pick<
|
||||
];
|
||||
|
||||
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
|
||||
epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000));
|
||||
epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000, pastTitle));
|
||||
epgListingsByStreamId.set(10_001, []);
|
||||
epgListingsByStreamId.set(10_002, []);
|
||||
|
||||
@@ -315,7 +323,10 @@ function buildTimezoneFixture(): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] {
|
||||
function buildTimezoneNewsEpg(
|
||||
streamId: number,
|
||||
pastTitle: string
|
||||
): RawEpgListing[] {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const roundedNow = now - (now % (15 * 60));
|
||||
const nextUtcMidnight = getFutureUtcMidnight(now);
|
||||
@@ -325,7 +336,7 @@ function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] {
|
||||
const listings = [
|
||||
{
|
||||
id: `${streamId}-past`,
|
||||
title: 'Earlier Bulletin',
|
||||
title: pastTitle,
|
||||
description:
|
||||
'Past schedule item used to anchor current-program detection.',
|
||||
startTimestamp: roundedNow - 75 * 60,
|
||||
|
||||
@@ -13,6 +13,12 @@ export interface ScenarioConfig {
|
||||
expiryDate: string;
|
||||
/** Optional deterministic EPG fixture profile for scenario-specific tests. */
|
||||
epgFixture?: 'timezone-focus';
|
||||
/**
|
||||
* With `epgFixture`, the past programme carries
|
||||
* {@link LONG_PROGRAMME_TITLE} instead of its short title, for the
|
||||
* catch-up seek bar's label bounds.
|
||||
*/
|
||||
longProgrammeTitle?: true;
|
||||
/** Large, deliberately reordered categories for sidebar scroll coverage. */
|
||||
categoryFixture?: 'scroll';
|
||||
/**
|
||||
@@ -50,6 +56,10 @@ export interface ScenarioConfig {
|
||||
silentActions?: readonly string[];
|
||||
}
|
||||
|
||||
/** A 120-character programme title for label overflow tests. */
|
||||
export const LONG_PROGRAMME_TITLE =
|
||||
'Extended Late Night Roundtable With Viewer Questions, Studio Guests and a Closing Summary of Every Story Covered Tonight';
|
||||
|
||||
/**
|
||||
* Predefined scenarios keyed by "username:password".
|
||||
* Auth is username+password for Xtream (not MAC address like Stalker).
|
||||
@@ -175,6 +185,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
expiryDate: '2099-12-31',
|
||||
epgFixture: 'timezone-focus',
|
||||
},
|
||||
'epglong:epglong': {
|
||||
name: 'epg-fixture-long-title',
|
||||
description:
|
||||
'The EPG fixture with a 120-character title on the past programme, whose catch-up plays the local clip',
|
||||
seed: 6006,
|
||||
categoryCount: { live: 2, vod: 1, series: 1 },
|
||||
itemsPerCategory: 3,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 3,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
epgFixture: 'timezone-focus',
|
||||
longProgrammeTitle: true,
|
||||
},
|
||||
'tzoffset:tzoffset': {
|
||||
name: 'epg-fixture-offset-clock',
|
||||
description:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { connect } from 'node:net';
|
||||
import express from 'express';
|
||||
import { resetAll } from './data-store.js';
|
||||
import { LONG_PROGRAMME_TITLE } from './scenarios.js';
|
||||
import {
|
||||
createXtreamMockApp,
|
||||
parseXtreamMockServerEnvironment,
|
||||
@@ -76,6 +77,37 @@ describe('Xtream mock server factory', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('serves the long-title EPG scenario beside the unchanged epg one', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
);
|
||||
const titles = async (user: string) => {
|
||||
const response = await fetch(
|
||||
`${running.origin}/player_api.php?username=${user}&password=${user}&action=get_simple_data_table&stream_id=10000`
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
epg_listings: { title: string }[];
|
||||
};
|
||||
return body.epg_listings.map((listing) =>
|
||||
Buffer.from(listing.title, 'base64').toString('utf8')
|
||||
);
|
||||
};
|
||||
try {
|
||||
expect(LONG_PROGRAMME_TITLE).toHaveLength(120);
|
||||
expect(await titles('epglong')).toEqual([
|
||||
LONG_PROGRAMME_TITLE,
|
||||
'Global Headlines',
|
||||
'Market Wrap',
|
||||
'Overnight Update',
|
||||
'Late Edition',
|
||||
'After Midnight',
|
||||
]);
|
||||
expect((await titles('epg'))[0]).toBe('Earlier Bulletin');
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps performance controls absent unless explicitly enabled', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
|
||||
@@ -51,6 +51,8 @@ https://example.channels/path-to-file/3.m3u8
|
||||
https://example.channels/path-to-file/4.m3u8
|
||||
`;
|
||||
const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
|
||||
/** EPG-fixture users whose timeshift URLs play the local MPEG-TS clip. */
|
||||
const TIMESHIFT_CLIP_USERS = new Set(['epg', 'epglong']);
|
||||
// Loopback by default: the fixtures serve fabricated but unauthenticated
|
||||
// content, so they should not be reachable from other hosts unless a dev
|
||||
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them).
|
||||
@@ -323,7 +325,7 @@ function installStreamRoutes(
|
||||
app.all(
|
||||
'/timeshift/:username/:password/:duration/:start/:streamId.ts',
|
||||
(request, response) => {
|
||||
if (request.params['username'] === 'epg') {
|
||||
if (TIMESHIFT_CLIP_USERS.has(request.params['username'])) {
|
||||
response
|
||||
.type('video/mp2t')
|
||||
.send(
|
||||
|
||||
@@ -288,9 +288,11 @@ It owns only transient presentation behavior:
|
||||
- `ControlsShortcuts` — document keyboard routing;
|
||||
- `ControlsSurface` — pointer/click/double-click surface interactions;
|
||||
- `ControlsTimeline` — scrub state and timeline projections;
|
||||
- `ControlsTimelineHover` — the time under the pointer over the timeline;
|
||||
- `ControlsTimelineLabel` — where the timeline label points: the time under
|
||||
a hovering pointer, else the slider value while the keyboard or a drag
|
||||
moves it, plus the pure `clampTimelineLabelLeft` placement;
|
||||
- `app-player-timeline` — presentation of the timeline row (current time,
|
||||
segment track, knob, hover label, remaining time / LIVE, recording
|
||||
segment track, knob, label, remaining time / LIVE, recording
|
||||
status); scrub `input`/`change` events go back to the controls component,
|
||||
which owns reveal and seeking;
|
||||
- `ControlsLayout` — the compact/wide dock mode from the host's width;
|
||||
@@ -314,7 +316,10 @@ primary action and progress, cyan `#5cd6ff` for "something is on", violet
|
||||
`#6b7384` text ramp, and two reds: `--pc-live` `#d32f2f` fills the LIVE
|
||||
badge (white label 5.0:1), and `--pc-danger` `#ff5252` colours the active
|
||||
record glyph and the recording status (6.2:1 on the glass over a black
|
||||
frame). They are literal on purpose: the overlay is
|
||||
frame). The seek bar's unplayed track is the opaque `--pc-timeline-track`
|
||||
`#313437` and its segment separators are `--pc-timeline-separator` white,
|
||||
so their contrast does not depend on the frame (see Timeline segments).
|
||||
They are literal on purpose: the overlay is
|
||||
theme-independent (see the UI guidelines' player theme boundary), and the
|
||||
app's `--app-selection-color` is a different blue that would fight the video.
|
||||
Watch progress reads `--pc-progress`, the accent blue declared by the
|
||||
@@ -327,13 +332,20 @@ one colour in every player surface (UI guidelines, "Watch progress colour").
|
||||
a white knob ringed in translucent blue) · remaining time as `−7:03`
|
||||
(`formatRemainingTime`; the LIVE badge replaces it on live streams and
|
||||
`--:--` stands in while no duration is known) · the recording status.
|
||||
The badge text is the translated `LIVE_BADGE`, and both it and the
|
||||
`--:--` placeholder (`UNKNOWN_TIME_TEXT`) are `role="img"` elements named
|
||||
`LIVE_STREAM` / `DURATION_UNKNOWN`: a name on a role-less span is never
|
||||
announced, and the dashes are not words.
|
||||
The `<input type="range">` stays as the interaction and accessibility
|
||||
layer, invisible and full-size over the drawn track: dragging, arrow
|
||||
keys, `aria-valuetext` and the focus ring (drawn on the track through
|
||||
`:has(:focus-visible)`) all belong to it, so scrubbing semantics are
|
||||
unchanged. Hovering the bar with a mouse shows a white marker and a
|
||||
`1:40` label above the pointer (`ControlsTimelineHover`); touch never
|
||||
hovers and a non-seekable timeline never labels.
|
||||
`1:40` label above the pointer. Keyboard focus on the slider
|
||||
(`:focus-visible`, or any seek key) and a drag preview — the only way
|
||||
touch, which never hovers, gets it — show the same label at the slider
|
||||
value instead, without the marker (`ControlsTimelineLabel`; a hovering
|
||||
pointer wins). A non-seekable timeline never labels.
|
||||
- **Control row**: `minmax(0,1fr) auto minmax(0,1fr)`. Left: the volume
|
||||
button, with the slider **inline** (72px) in the wide mode and behind
|
||||
the hover/tap popover in the compact mode — inline, the button is a
|
||||
@@ -359,13 +371,12 @@ one colour in every player surface (UI guidelines, "Watch progress colour").
|
||||
|
||||
### Timeline segments
|
||||
|
||||
The track is drawn as a row of segments, one flex item per segment with
|
||||
`flex-grow` equal to its share of the duration and its own accent fill, so
|
||||
The track is drawn as a row of segments, each with its own accent fill, so
|
||||
a film's chapters or a catch-up recording's programmes read directly off
|
||||
the bar. Each segment is placed absolutely at its time position (`left` =
|
||||
start percent, `width` = share minus the 3px gap every segment but the
|
||||
start percent, `width` = share minus the 2px gap every segment but the
|
||||
last keeps), so a drawn boundary sits exactly where the linear seek input
|
||||
and the hover label change segment; a segment shorter than the gap
|
||||
and the label change segment; a segment shorter than the gap
|
||||
collapses instead of pushing its neighbours. The optional `timelineSegments` input
|
||||
(`PlayerTimelineSegment { startSeconds, endSeconds, title }`) supplies
|
||||
them; `normalizeTimelineSegments` (`controls-timeline-segments.ts`) clamps
|
||||
@@ -374,9 +385,36 @@ the previous end and fills every gap with an untitled segment so the row
|
||||
always covers `[0, duration]`. Without segments — live playback, VOD and
|
||||
series — the row is one untitled segment, which is the plain bar.
|
||||
`ControlsTimeline` owns the normalized list and the per-segment fill for the
|
||||
current scrub or playback value; the hover label becomes `Chapter 2 · 12:40`
|
||||
current scrub or playback value; the label becomes `Chapter 2 · 12:40`
|
||||
over a titled segment. mpv's chapter list is not a producer yet.
|
||||
|
||||
Boundaries hold 3:1 over any frame. Segments only round the bar's two
|
||||
ends, and every gap (2px, `TIMELINE_SEGMENT_GAP_PX`) is filled by a
|
||||
separator (`separatorLeft`, never left of the track's start, where a
|
||||
boundary in the bar's first pixels would put it) in
|
||||
`--pc-timeline-separator` white: 12.5:1 on
|
||||
the opaque `--pc-timeline-track`, 3.2:1 on the `--pc-progress` fill, which
|
||||
keeps 4.0:1 on the track. The former translucent track with see-through
|
||||
gaps measured 1.3–1.6:1 at its boundaries, and its fill fell to 1.1:1 over
|
||||
a bright frame (`player-controls.palette.spec.ts` pins the ratios).
|
||||
|
||||
The names reach every user, whichever source drew the segments. The
|
||||
slider's `aria-valuetext` is the translated `TIMELINE_SEGMENT_POSITION`
|
||||
(`{{title}} · {{time}}`) whenever the scrub or playback value — the
|
||||
keyboard's target included — lies in a titled segment
|
||||
(`ControlsTimeline.segmentTitle`), and the plain time otherwise. The visible
|
||||
label (`aria-hidden`; it mirrors the valuetext) is two parts: the title
|
||||
ellipsizes at `min(320px, 60cqw)` and the time never truncates. The
|
||||
stylesheet centres it on its anchor; once laid out,
|
||||
`PlayerTimelineComponent` moves it by its measured width
|
||||
(`clampTimelineLabelLeft`, an `afterRenderEffect`) so it stays 8px inside
|
||||
the player (`.player-controls-host`) — past the bar's ends when needed, as
|
||||
the bar is narrower than the player. A `ResizeObserver` on the bar and the
|
||||
player re-places it when only the size changes (a paused player whose
|
||||
slider the keyboard holds, in a narrowed window). `xtream-catchup-timeline-label.e2e.ts`
|
||||
tabs to the bar with a 120-character programme title at 1280 and 800px, in
|
||||
both themes and in de and ru.
|
||||
|
||||
**Catch-up producer.** Archive playback of a live channel passes the EPG
|
||||
programmes overlapping its archive window.
|
||||
`buildCatchupTimelineSegments(programmes, activeProgramme, windowEnd?)`
|
||||
@@ -1661,7 +1699,7 @@ libs/ui/playback/src/lib/player-controls/
|
||||
├── controls-feedback.ts
|
||||
├── controls-format.utils.ts
|
||||
├── controls-layout.ts
|
||||
├── controls-timeline-hover.ts
|
||||
├── controls-timeline-label.ts
|
||||
├── controls-timeline-segments.ts
|
||||
├── controls-settings.ts
|
||||
├── controls-settings-groups.ts
|
||||
|
||||
@@ -401,6 +401,7 @@ sometimes only respond to that misspelled action.
|
||||
| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active |
|
||||
| `minimal:minimal` | 3003 | 2 each | 5 | active |
|
||||
| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active |
|
||||
| `epglong:epglong` | 6006 | live:2, vod:1, series:1 | 3 | active |
|
||||
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
|
||||
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
|
||||
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
|
||||
@@ -421,6 +422,12 @@ That deliberate mismatch lets Electron tests verify the renderer uses timestamp
|
||||
fields for sorting, current-program selection, progress bars, and local clock
|
||||
labels instead of trusting provider-local strings.
|
||||
|
||||
Timeshift URLs of `epg` and `epglong` play the local MPEG-TS clip
|
||||
(`src/fixtures/live.mpegts`). `epglong:epglong` is the same fixture with
|
||||
`LONG_PROGRAMME_TITLE`, a 120-character title, on the past programme, for
|
||||
the catch-up seek bar's label bounds; `epg:epg` keeps its short titles,
|
||||
which other tests assert verbatim.
|
||||
|
||||
### `marketing:marketing` fixture details
|
||||
|
||||
This scenario is reserved for release screenshots and marketing materials:
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
} @else if (isLivePlayback()) {
|
||||
<span
|
||||
class="embedded-mpv-player__live-badge"
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.LIVE_STREAM'
|
||||
| translate
|
||||
@@ -273,10 +274,17 @@
|
||||
class="embedded-mpv-player__live-dot"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
LIVE
|
||||
{{ 'EMBEDDED_MPV.PLAYER.LIVE_BADGE' | translate }}
|
||||
</span>
|
||||
} @else {
|
||||
<span>--:--</span>
|
||||
<span
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.DURATION_UNKNOWN'
|
||||
| translate
|
||||
"
|
||||
>--:--</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (recordingStatusText(); as recordingStatus) {
|
||||
|
||||
@@ -192,11 +192,12 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
|
||||
By.css('[data-test-id="embedded-mpv-next-episode"]')
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('.embedded-mpv-player__live-badge')
|
||||
)
|
||||
).not.toBeNull();
|
||||
const liveBadge = fixture.debugElement.query(
|
||||
By.css('.embedded-mpv-player__live-badge')
|
||||
);
|
||||
expect(liveBadge).not.toBeNull();
|
||||
// Named on an element with a role, so the name is announced.
|
||||
expect(liveBadge.attributes['role']).toBe('img');
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css(
|
||||
@@ -366,6 +367,13 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
|
||||
)
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('--:--');
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css(
|
||||
'.embedded-mpv-player__time [role="img"][aria-label="EMBEDDED_MPV.PLAYER.DURATION_UNKNOWN"]'
|
||||
)
|
||||
)?.nativeElement.textContent
|
||||
).toBe('--:--');
|
||||
});
|
||||
|
||||
it('offers Retry when a live stream ends without a reconnect in progress', () => {
|
||||
|
||||
@@ -15,6 +15,14 @@ export function formatTime(value: number | null | undefined): string {
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end-time slot while no duration is known: {@link formatTime}'s shape
|
||||
* with the digits blanked, no more a language string than `1:40` is. The
|
||||
* docks pair it with the translated `DURATION_UNKNOWN` name for assistive
|
||||
* technology, which would otherwise read out the dashes.
|
||||
*/
|
||||
export const UNKNOWN_TIME_TEXT = '--:--';
|
||||
|
||||
/**
|
||||
* Time left until the end, as `−7:03`. The dock shows it in place of the
|
||||
* total duration: it is the number a viewer actually wants at a glance.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { signal } from '@angular/core';
|
||||
import {
|
||||
ControlsTimelineHover,
|
||||
projectPointerToSeconds,
|
||||
} from './controls-timeline-hover';
|
||||
|
||||
describe('projectPointerToSeconds', () => {
|
||||
const rect = { left: 100, width: 200 };
|
||||
|
||||
it('maps the pointer across the bar to the duration', () => {
|
||||
expect(projectPointerToSeconds(100, rect, 600)).toBe(0);
|
||||
expect(projectPointerToSeconds(200, rect, 600)).toBe(300);
|
||||
expect(projectPointerToSeconds(300, rect, 600)).toBe(600);
|
||||
});
|
||||
|
||||
it('clamps positions outside the bar', () => {
|
||||
expect(projectPointerToSeconds(20, rect, 600)).toBe(0);
|
||||
expect(projectPointerToSeconds(999, rect, 600)).toBe(600);
|
||||
});
|
||||
|
||||
it('returns null without a usable bar or duration', () => {
|
||||
expect(projectPointerToSeconds(150, { left: 0, width: 0 }, 600)).toBe(
|
||||
null
|
||||
);
|
||||
expect(projectPointerToSeconds(150, rect, 0)).toBeNull();
|
||||
expect(projectPointerToSeconds(Number.NaN, rect, 600)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ControlsTimelineHover', () => {
|
||||
function createHover(duration = 600, interactive = true) {
|
||||
const durationSignal = signal(duration);
|
||||
const interactiveSignal = signal(interactive);
|
||||
const hover = new ControlsTimelineHover({
|
||||
duration: durationSignal,
|
||||
interactive: interactiveSignal,
|
||||
});
|
||||
const bar = document.createElement('div');
|
||||
bar.getBoundingClientRect = () => ({ left: 0, width: 400 }) as DOMRect;
|
||||
return { hover, bar, durationSignal, interactiveSignal };
|
||||
}
|
||||
|
||||
function pointer(clientX: number, pointerType = 'mouse'): PointerEvent {
|
||||
return { clientX, pointerType } as PointerEvent;
|
||||
}
|
||||
|
||||
it('reports the hovered time, percent and label', () => {
|
||||
const { hover, bar } = createHover();
|
||||
|
||||
hover.move(pointer(100), bar);
|
||||
|
||||
expect(hover.seconds()).toBe(150);
|
||||
expect(hover.percent()).toBe(25);
|
||||
expect(hover.label()).toBe('2:30');
|
||||
});
|
||||
|
||||
it('clears on leave', () => {
|
||||
const { hover, bar } = createHover();
|
||||
hover.move(pointer(100), bar);
|
||||
|
||||
hover.clear();
|
||||
|
||||
expect(hover.seconds()).toBeNull();
|
||||
expect(hover.percent()).toBeNull();
|
||||
expect(hover.label()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores touch pointers and non-interactive timelines', () => {
|
||||
const { hover, bar, interactiveSignal } = createHover();
|
||||
|
||||
hover.move(pointer(100, 'touch'), bar);
|
||||
expect(hover.seconds()).toBeNull();
|
||||
|
||||
interactiveSignal.set(false);
|
||||
hover.move(pointer(100), bar);
|
||||
expect(hover.seconds()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the bar from the event target when none is passed', () => {
|
||||
const { hover, bar } = createHover();
|
||||
|
||||
hover.move({ ...pointer(200), currentTarget: bar } as PointerEvent);
|
||||
expect(hover.seconds()).toBe(300);
|
||||
|
||||
hover.move({ ...pointer(200), currentTarget: null } as PointerEvent);
|
||||
expect(hover.seconds()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a stale hover once the duration disappears', () => {
|
||||
const { hover, bar, durationSignal } = createHover();
|
||||
hover.move(pointer(100), bar);
|
||||
|
||||
durationSignal.set(0);
|
||||
|
||||
expect(hover.percent()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Signal, computed, signal } from '@angular/core';
|
||||
import { formatTime } from './controls-format.utils';
|
||||
import {
|
||||
findTimelineSegment,
|
||||
type TimelineSegmentView,
|
||||
} from './controls-timeline-segments';
|
||||
|
||||
/**
|
||||
* Projects a pointer's horizontal position over the timeline bar onto a
|
||||
* playback time (seconds), clamped to the bar. Null when the bar has no
|
||||
* width or the duration is unknown.
|
||||
*/
|
||||
export function projectPointerToSeconds(
|
||||
clientX: number,
|
||||
rect: Pick<DOMRect, 'left' | 'width'>,
|
||||
durationSeconds: number
|
||||
): number | null {
|
||||
if (
|
||||
!Number.isFinite(clientX) ||
|
||||
!Number.isFinite(rect.width) ||
|
||||
rect.width <= 0 ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const fraction = Math.min(
|
||||
1,
|
||||
Math.max(0, (clientX - rect.left) / rect.width)
|
||||
);
|
||||
return fraction * durationSeconds;
|
||||
}
|
||||
|
||||
export interface ControlsTimelineHoverDeps {
|
||||
duration: Signal<number>;
|
||||
/** Whether the timeline accepts pointer interaction at all. */
|
||||
interactive: Signal<boolean>;
|
||||
/** Drawn segments, for the title in the label; none means time only. */
|
||||
segments?: Signal<readonly TimelineSegmentView[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover state of the timeline: the time under the pointer, its position as
|
||||
* a percentage of the bar (for the marker and label), and the label text.
|
||||
* Touch never hovers, so a coarse pointer leaves this empty.
|
||||
*/
|
||||
export class ControlsTimelineHover {
|
||||
readonly seconds = signal<number | null>(null);
|
||||
|
||||
constructor(private readonly deps: ControlsTimelineHoverDeps) {}
|
||||
|
||||
readonly percent = computed(() => {
|
||||
const seconds = this.seconds();
|
||||
const duration = this.deps.duration();
|
||||
if (seconds === null || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(100, Math.max(0, (seconds / duration) * 100));
|
||||
});
|
||||
|
||||
/** The titled segment under the pointer, if any. */
|
||||
readonly segmentTitle = computed(() => {
|
||||
const seconds = this.seconds();
|
||||
const segments = this.deps.segments?.();
|
||||
if (seconds === null || !segments) {
|
||||
return null;
|
||||
}
|
||||
return findTimelineSegment(segments, seconds)?.title ?? null;
|
||||
});
|
||||
|
||||
/** `Chapter 2 · 12:40` over a titled segment, else the time alone. */
|
||||
readonly label = computed(() => {
|
||||
const seconds = this.seconds();
|
||||
if (seconds === null) {
|
||||
return null;
|
||||
}
|
||||
const time = formatTime(seconds);
|
||||
const title = this.segmentTitle();
|
||||
return title ? `${title} \u00b7 ${time}` : time;
|
||||
});
|
||||
|
||||
/** Bound to `pointermove` on the bar element itself (`currentTarget`). */
|
||||
move(event: PointerEvent, bar?: HTMLElement): void {
|
||||
const target = bar ?? event.currentTarget;
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
!this.deps.interactive() ||
|
||||
event.pointerType === 'touch'
|
||||
) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
this.seconds.set(
|
||||
projectPointerToSeconds(
|
||||
event.clientX,
|
||||
target.getBoundingClientRect(),
|
||||
this.deps.duration()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.seconds() !== null) {
|
||||
this.seconds.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { buildCatchupTimelineSegments } from './catchup-timeline-segments';
|
||||
import {
|
||||
ControlsTimelineLabel,
|
||||
TIMELINE_LABEL_EDGE_PX,
|
||||
clampTimelineLabelLeft,
|
||||
projectPointerToSeconds,
|
||||
} from './controls-timeline-label';
|
||||
import { normalizeTimelineSegments } from './controls-timeline-segments';
|
||||
import type { PlayerTimelineSegment } from './player-controls.model';
|
||||
|
||||
describe('projectPointerToSeconds', () => {
|
||||
const rect = { left: 100, width: 200 };
|
||||
|
||||
it('maps the pointer across the bar to the duration', () => {
|
||||
expect(projectPointerToSeconds(100, rect, 600)).toBe(0);
|
||||
expect(projectPointerToSeconds(200, rect, 600)).toBe(300);
|
||||
expect(projectPointerToSeconds(300, rect, 600)).toBe(600);
|
||||
});
|
||||
|
||||
it('clamps positions outside the bar', () => {
|
||||
expect(projectPointerToSeconds(20, rect, 600)).toBe(0);
|
||||
expect(projectPointerToSeconds(999, rect, 600)).toBe(600);
|
||||
});
|
||||
|
||||
it('returns null without a usable bar or duration', () => {
|
||||
expect(projectPointerToSeconds(150, { left: 0, width: 0 }, 600)).toBe(
|
||||
null
|
||||
);
|
||||
expect(projectPointerToSeconds(150, rect, 0)).toBeNull();
|
||||
expect(projectPointerToSeconds(Number.NaN, rect, 600)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampTimelineLabelLeft', () => {
|
||||
// A 1280px player whose bar runs from x=90 to x=1190: the current and
|
||||
// remaining times flank it.
|
||||
const player = { left: 0, right: 1280 };
|
||||
const bar = { left: 90, width: 1100 };
|
||||
const edge = TIMELINE_LABEL_EDGE_PX;
|
||||
|
||||
it('centres the label on its anchor when it fits', () => {
|
||||
// Anchor at 550px into the bar; a 200px label starts 100px before.
|
||||
expect(clampTimelineLabelLeft(50, 200, bar, player)).toBe(450);
|
||||
});
|
||||
|
||||
it('lets the label pass the bar ends but not the player edges', () => {
|
||||
// At the bar's start the centred label would begin at -100px (bar
|
||||
// coordinates), i.e. 10px left of the player: pinned to its edge.
|
||||
expect(clampTimelineLabelLeft(0, 200, bar, player)).toBe(
|
||||
-bar.left + edge
|
||||
);
|
||||
// A narrower label centred there still fits past the bar's start.
|
||||
expect(clampTimelineLabelLeft(0, 60, bar, player)).toBe(-30);
|
||||
// At the bar's end the right edge is pinned to the player's.
|
||||
const left = clampTimelineLabelLeft(100, 300, bar, player);
|
||||
expect(bar.left + left + 300).toBe(player.right - edge);
|
||||
});
|
||||
|
||||
it('keeps a measured 120-character title label inside narrow players', () => {
|
||||
// The title caps at min(320px, 60cqw); with the time and padding an
|
||||
// 800px player's label measures about 390px.
|
||||
const narrow = { left: 0, right: 800 };
|
||||
const narrowBar = { left: 70, width: 660 };
|
||||
for (const percent of [0, 3, 50, 97, 100]) {
|
||||
const left = clampTimelineLabelLeft(percent, 390, narrowBar, narrow);
|
||||
const start = narrowBar.left + left;
|
||||
expect(start).toBeGreaterThanOrEqual(narrow.left + edge);
|
||||
expect(start + 390).toBeLessThanOrEqual(narrow.right - edge);
|
||||
}
|
||||
});
|
||||
|
||||
it('starts a label wider than the player at its left edge', () => {
|
||||
const tiny = { left: 0, right: 200 };
|
||||
expect(
|
||||
clampTimelineLabelLeft(50, 400, { left: 40, width: 120 }, tiny)
|
||||
).toBe(-40 + edge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ControlsTimelineLabel', () => {
|
||||
function createLabel(options: {
|
||||
duration?: number;
|
||||
interactive?: boolean;
|
||||
segments?: readonly PlayerTimelineSegment[] | null;
|
||||
} = {}) {
|
||||
const duration = signal(options.duration ?? 600);
|
||||
const interactive = signal(options.interactive ?? true);
|
||||
const value = signal(0);
|
||||
const scrubbing = signal(false);
|
||||
const label = new ControlsTimelineLabel({
|
||||
duration,
|
||||
interactive,
|
||||
segments: signal(
|
||||
normalizeTimelineSegments(options.segments, duration())
|
||||
),
|
||||
value,
|
||||
scrubbing,
|
||||
});
|
||||
const bar = document.createElement('div');
|
||||
bar.getBoundingClientRect = () => ({ left: 0, width: 400 }) as DOMRect;
|
||||
return { label, bar, duration, interactive, value, scrubbing };
|
||||
}
|
||||
|
||||
function pointer(clientX: number, pointerType = 'mouse'): PointerEvent {
|
||||
return { clientX, pointerType } as PointerEvent;
|
||||
}
|
||||
|
||||
/** A focus event whose target answers `:focus-visible` as given. */
|
||||
function focusEvent(visible: boolean): FocusEvent {
|
||||
const slider = document.createElement('input');
|
||||
Object.defineProperty(slider, 'matches', {
|
||||
value: (selector: string) =>
|
||||
selector === ':focus-visible' ? visible : false,
|
||||
});
|
||||
return { target: slider } as unknown as FocusEvent;
|
||||
}
|
||||
|
||||
function key(name: string): KeyboardEvent {
|
||||
return { key: name } as KeyboardEvent;
|
||||
}
|
||||
|
||||
it('reports the hovered time, percent, marker and text', () => {
|
||||
const { label, bar } = createLabel();
|
||||
|
||||
label.move(pointer(100), bar);
|
||||
|
||||
expect(label.seconds()).toBe(150);
|
||||
expect(label.percent()).toBe(25);
|
||||
expect(label.markerPercent()).toBe(25);
|
||||
expect(label.text()).toEqual({ title: null, time: '2:30' });
|
||||
});
|
||||
|
||||
it('clears on leave', () => {
|
||||
const { label, bar } = createLabel();
|
||||
label.move(pointer(100), bar);
|
||||
|
||||
label.clear();
|
||||
|
||||
expect(label.seconds()).toBeNull();
|
||||
expect(label.percent()).toBeNull();
|
||||
expect(label.text()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores touch hovers and non-interactive timelines', () => {
|
||||
const { label, bar, interactive } = createLabel();
|
||||
|
||||
label.move(pointer(100, 'touch'), bar);
|
||||
expect(label.seconds()).toBeNull();
|
||||
|
||||
interactive.set(false);
|
||||
label.move(pointer(100), bar);
|
||||
expect(label.seconds()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the bar from the event target when none is passed', () => {
|
||||
const { label, bar } = createLabel();
|
||||
|
||||
label.move({ ...pointer(200), currentTarget: bar } as PointerEvent);
|
||||
expect(label.seconds()).toBe(300);
|
||||
|
||||
label.move({ ...pointer(200), currentTarget: null } as PointerEvent);
|
||||
expect(label.seconds()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a stale label once the duration disappears', () => {
|
||||
const { label, bar, duration } = createLabel();
|
||||
label.move(pointer(100), bar);
|
||||
|
||||
duration.set(0);
|
||||
|
||||
expect(label.percent()).toBeNull();
|
||||
expect(label.text()).toBeNull();
|
||||
});
|
||||
|
||||
describe('keyboard', () => {
|
||||
it('labels the slider value on keyboard focus, without a marker', () => {
|
||||
const { label, value } = createLabel();
|
||||
value.set(90);
|
||||
|
||||
label.focus(focusEvent(true));
|
||||
|
||||
expect(label.percent()).toBe(15);
|
||||
expect(label.text()).toEqual({ title: null, time: '1:30' });
|
||||
expect(label.markerPercent()).toBeNull();
|
||||
|
||||
value.set(95);
|
||||
expect(label.text()?.time).toBe('1:35');
|
||||
|
||||
label.blur();
|
||||
expect(label.text()).toBeNull();
|
||||
});
|
||||
|
||||
it('stays hidden on pointer focus until a seek key arrives', () => {
|
||||
const { label, value } = createLabel();
|
||||
value.set(60);
|
||||
|
||||
label.focus(focusEvent(false));
|
||||
expect(label.text()).toBeNull();
|
||||
|
||||
label.keydown(key('Tab'));
|
||||
expect(label.text()).toBeNull();
|
||||
|
||||
label.keydown(key('ArrowRight'));
|
||||
expect(label.text()?.time).toBe('1:00');
|
||||
});
|
||||
|
||||
it('treats focus as visible where :focus-visible is unsupported', () => {
|
||||
const { label } = createLabel();
|
||||
const slider = document.createElement('input');
|
||||
Object.defineProperty(slider, 'matches', {
|
||||
value: () => {
|
||||
throw new SyntaxError('unknown pseudo-class');
|
||||
},
|
||||
});
|
||||
|
||||
label.focus({ target: slider } as unknown as FocusEvent);
|
||||
|
||||
expect(label.keyboard()).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a hovering pointer take over, then returns to the value', () => {
|
||||
const { label, bar, value } = createLabel();
|
||||
value.set(60);
|
||||
label.keydown(key('End'));
|
||||
|
||||
label.move(pointer(200), bar);
|
||||
expect(label.text()?.time).toBe('5:00');
|
||||
|
||||
label.clear();
|
||||
expect(label.text()?.time).toBe('1:00');
|
||||
});
|
||||
|
||||
it('never labels a non-interactive timeline', () => {
|
||||
const { label, interactive } = createLabel();
|
||||
label.keydown(key('ArrowLeft'));
|
||||
|
||||
interactive.set(false);
|
||||
|
||||
expect(label.text()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('follows a drag preview, which is how touch users see it', () => {
|
||||
const { label, bar, value, scrubbing } = createLabel();
|
||||
label.move(pointer(100, 'touch'), bar);
|
||||
value.set(300);
|
||||
|
||||
scrubbing.set(true);
|
||||
expect(label.text()?.time).toBe('5:00');
|
||||
expect(label.markerPercent()).toBeNull();
|
||||
|
||||
scrubbing.set(false);
|
||||
expect(label.text()).toBeNull();
|
||||
});
|
||||
|
||||
describe('segment titles', () => {
|
||||
it('names catch-up programmes', () => {
|
||||
// A 60-minute archive window holding two programmes.
|
||||
const programme = (title: string, start: number, stop: number) => ({
|
||||
title,
|
||||
start: '',
|
||||
stop: '',
|
||||
startTimestamp: start,
|
||||
stopTimestamp: stop,
|
||||
});
|
||||
const active = programme('Morning Report', 10_000, 11_800);
|
||||
const segments = buildCatchupTimelineSegments(
|
||||
[active, programme('Weather', 11_800, 13_600)],
|
||||
active,
|
||||
13_600
|
||||
);
|
||||
const { label, value } = createLabel({
|
||||
duration: 3600,
|
||||
segments,
|
||||
});
|
||||
label.keydown(key('ArrowRight'));
|
||||
|
||||
value.set(600);
|
||||
expect(label.text()).toEqual({
|
||||
title: 'Morning Report',
|
||||
time: '10:00',
|
||||
});
|
||||
value.set(1800);
|
||||
expect(label.text()?.title).toBe('Weather');
|
||||
});
|
||||
|
||||
it('names file chapters and leaves untitled ones to the time', () => {
|
||||
// Chapters as Embedded MPV reports them: each runs to the next
|
||||
// one's start, the last to the end; an untitled one has no name.
|
||||
const { label, value } = createLabel({
|
||||
duration: 600,
|
||||
segments: [
|
||||
{ startSeconds: 0, endSeconds: 90, title: 'Opening' },
|
||||
{ startSeconds: 90, endSeconds: 480, title: null },
|
||||
{ startSeconds: 480, endSeconds: 600, title: 'Credits' },
|
||||
],
|
||||
});
|
||||
label.keydown(key('Home'));
|
||||
|
||||
value.set(30);
|
||||
expect(label.text()).toEqual({ title: 'Opening', time: '0:30' });
|
||||
value.set(200);
|
||||
expect(label.text()).toEqual({ title: null, time: '3:20' });
|
||||
value.set(600);
|
||||
expect(label.text()?.title).toBe('Credits');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Signal, computed, signal } from '@angular/core';
|
||||
import { formatTime } from './controls-format.utils';
|
||||
import {
|
||||
findTimelineSegment,
|
||||
type TimelineSegmentView,
|
||||
} from './controls-timeline-segments';
|
||||
|
||||
/**
|
||||
* Projects a pointer's horizontal position over the timeline bar onto a
|
||||
* playback time (seconds), clamped to the bar. Null when the bar has no
|
||||
* width or the duration is unknown.
|
||||
*/
|
||||
export function projectPointerToSeconds(
|
||||
clientX: number,
|
||||
rect: Pick<DOMRect, 'left' | 'width'>,
|
||||
durationSeconds: number
|
||||
): number | null {
|
||||
if (
|
||||
!Number.isFinite(clientX) ||
|
||||
!Number.isFinite(rect.width) ||
|
||||
rect.width <= 0 ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const fraction = Math.min(
|
||||
1,
|
||||
Math.max(0, (clientX - rect.left) / rect.width)
|
||||
);
|
||||
return fraction * durationSeconds;
|
||||
}
|
||||
|
||||
/** Room (px) the label keeps from the player's left and right edges. */
|
||||
export const TIMELINE_LABEL_EDGE_PX = 8;
|
||||
|
||||
/**
|
||||
* Left edge (px, from the bar's left) of a label `labelWidth` wide: centred
|
||||
* on the anchor at `percent` of the bar, but kept inside the player with
|
||||
* {@link TIMELINE_LABEL_EDGE_PX} to spare on each side. The bar is narrower
|
||||
* than the player (the times flank it), so the label may extend past the
|
||||
* bar's ends. A label wider than the room starts at the player's left edge.
|
||||
*/
|
||||
export function clampTimelineLabelLeft(
|
||||
percent: number,
|
||||
labelWidth: number,
|
||||
bar: Pick<DOMRect, 'left' | 'width'>,
|
||||
player: Pick<DOMRect, 'left' | 'right'>
|
||||
): number {
|
||||
const centred = (percent / 100) * bar.width - labelWidth / 2;
|
||||
const min = player.left - bar.left + TIMELINE_LABEL_EDGE_PX;
|
||||
const max = player.right - bar.left - TIMELINE_LABEL_EDGE_PX - labelWidth;
|
||||
return Math.max(min, Math.min(centred, max));
|
||||
}
|
||||
|
||||
/** Keys a focused range input moves its value with. */
|
||||
const SEEK_KEYS = new Set([
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Home',
|
||||
'End',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
]);
|
||||
|
||||
/** `:focus-visible`; focus counts as visible where the selector is unknown. */
|
||||
function hasVisibleFocus(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return target.matches(':focus-visible');
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ControlsTimelineLabelDeps {
|
||||
duration: Signal<number>;
|
||||
/** Whether the timeline accepts interaction at all. */
|
||||
interactive: Signal<boolean>;
|
||||
/** Drawn segments, for the title in the label; none means time only. */
|
||||
segments?: Signal<readonly TimelineSegmentView[]>;
|
||||
/** The slider value: the scrub preview, else the playback position. */
|
||||
value?: Signal<number>;
|
||||
/** A drag is previewing a position (see `ControlsTimeline`). */
|
||||
scrubbing?: Signal<boolean>;
|
||||
}
|
||||
|
||||
/** The label's parts: the segment title, when there is one, and the time. */
|
||||
export interface TimelineLabelText {
|
||||
title: string | null;
|
||||
time: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline label and where it points: the time under a hovering mouse
|
||||
* or pen; otherwise the slider value while the keyboard drives the slider
|
||||
* (keyboard focus or a seek key) or a drag previews a position — touch never
|
||||
* hovers, so a touch drag is how touch users see it. A non-interactive
|
||||
* timeline never labels.
|
||||
*/
|
||||
export class ControlsTimelineLabel {
|
||||
/** Time under a hovering mouse or pen. */
|
||||
readonly pointerSeconds = signal<number | null>(null);
|
||||
/** The keyboard holds the slider. */
|
||||
readonly keyboard = signal(false);
|
||||
|
||||
constructor(private readonly deps: ControlsTimelineLabelDeps) {}
|
||||
|
||||
readonly seconds = computed(() => {
|
||||
if (!this.deps.interactive()) {
|
||||
return null;
|
||||
}
|
||||
const pointer = this.pointerSeconds();
|
||||
if (pointer !== null) {
|
||||
return pointer;
|
||||
}
|
||||
const followsValue = this.keyboard() || !!this.deps.scrubbing?.();
|
||||
return followsValue ? (this.deps.value?.() ?? null) : null;
|
||||
});
|
||||
|
||||
readonly percent = computed(() => {
|
||||
const seconds = this.seconds();
|
||||
const duration = this.deps.duration();
|
||||
if (seconds === null || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(100, Math.max(0, (seconds / duration) * 100));
|
||||
});
|
||||
|
||||
/** The pointer's marker; for the keyboard and drags the knob is there. */
|
||||
readonly markerPercent = computed(() =>
|
||||
this.pointerSeconds() === null ? null : this.percent()
|
||||
);
|
||||
|
||||
/** The titled segment the label points into, if any. */
|
||||
readonly segmentTitle = computed(() => {
|
||||
const seconds = this.seconds();
|
||||
const segments = this.deps.segments?.();
|
||||
if (seconds === null || !segments) {
|
||||
return null;
|
||||
}
|
||||
return findTimelineSegment(segments, seconds)?.title ?? null;
|
||||
});
|
||||
|
||||
/** `{ title: 'Chapter 2', time: '12:40' }`, or the time alone. */
|
||||
readonly text = computed<TimelineLabelText | null>(() => {
|
||||
const seconds = this.seconds();
|
||||
if (seconds === null || this.percent() === null) {
|
||||
return null;
|
||||
}
|
||||
return { title: this.segmentTitle(), time: formatTime(seconds) };
|
||||
});
|
||||
|
||||
/** Bound to `pointermove` on the bar element itself (`currentTarget`). */
|
||||
move(event: PointerEvent, bar?: HTMLElement): void {
|
||||
const target = bar ?? event.currentTarget;
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
!this.deps.interactive() ||
|
||||
event.pointerType === 'touch'
|
||||
) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
this.pointerSeconds.set(
|
||||
projectPointerToSeconds(
|
||||
event.clientX,
|
||||
target.getBoundingClientRect(),
|
||||
this.deps.duration()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.pointerSeconds() !== null) {
|
||||
this.pointerSeconds.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound to the slider's `focus`. Only keyboard focus shows the label: a
|
||||
* pointer that focuses the slider labels through hover or the drag.
|
||||
*/
|
||||
focus(event: FocusEvent): void {
|
||||
this.keyboard.set(hasVisibleFocus(event.target));
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this.keyboard.set(false);
|
||||
}
|
||||
|
||||
/** Bound to the slider's `keydown`: a seek key hands it to the keyboard. */
|
||||
keydown(event: KeyboardEvent): void {
|
||||
if (SEEK_KEYS.has(event.key)) {
|
||||
this.keyboard.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,20 +68,42 @@ describe('normalizeTimelineSegments', () => {
|
||||
).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('places segments at their time positions with a gap after all but the last', () => {
|
||||
it('places segments at their time positions with a separated gap after all but the last', () => {
|
||||
const segments = normalizeTimelineSegments(
|
||||
[
|
||||
{ startSeconds: 0, endSeconds: 150, title: 'A' },
|
||||
{ startSeconds: 150, endSeconds: 600, title: 'B' },
|
||||
{ startSeconds: 150, endSeconds: 450, title: 'B' },
|
||||
],
|
||||
600
|
||||
);
|
||||
|
||||
expect(segments.map((s) => s.startPercent)).toEqual([0, 25]);
|
||||
expect(segments.map((s) => s.startPercent)).toEqual([0, 25, 75]);
|
||||
expect(segments.map((s) => s.width)).toEqual([
|
||||
'max(0px, calc(25% - 3px))',
|
||||
'75%',
|
||||
'max(0px, calc(25% - 2px))',
|
||||
'max(0px, calc(50% - 2px))',
|
||||
'25%',
|
||||
]);
|
||||
// The separator fills the gap, ending where the next segment starts.
|
||||
expect(segments.map((s) => s.separatorLeft)).toEqual([
|
||||
'max(0px, calc(25% - 2px))',
|
||||
'max(0px, calc(75% - 2px))',
|
||||
null,
|
||||
]);
|
||||
expect(normalizeTimelineSegments(null, 600)[0].separatorLeft).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the separator of a boundary in the first pixels on the track', () => {
|
||||
// A 1-second lead-in of an hour: on a 400px bar its boundary sits
|
||||
// at 0.11px, so `calc(… - 2px)` alone would start before the bar.
|
||||
const [leadIn] = normalizeTimelineSegments(
|
||||
[{ startSeconds: 1, endSeconds: 3600, title: 'Programme' }],
|
||||
3600
|
||||
);
|
||||
|
||||
expect(leadIn.title).toBeNull();
|
||||
expect(leadIn.separatorLeft).toMatch(/^max\(0px, calc\(/);
|
||||
});
|
||||
|
||||
it('clamps to the duration, cuts overlaps and drops empty segments', () => {
|
||||
|
||||
@@ -11,10 +11,17 @@ export interface TimelineSegmentView {
|
||||
startPercent: number;
|
||||
/** CSS width: the share, minus the gap every segment but the last keeps. */
|
||||
width: string;
|
||||
/**
|
||||
* CSS `left` of the separator drawn in the gap after this segment, so a
|
||||
* boundary keeps its contrast whatever the frame behind it; null for
|
||||
* the last segment, which has no gap. Never left of the track: a
|
||||
* boundary in its first pixels would otherwise paint before the bar.
|
||||
*/
|
||||
separatorLeft: string | null;
|
||||
}
|
||||
|
||||
/** Visual gap (px) after every segment except the last. */
|
||||
export const TIMELINE_SEGMENT_GAP_PX = 3;
|
||||
/** Gap (px) after every segment except the last; its separator fills it. */
|
||||
export const TIMELINE_SEGMENT_GAP_PX = 2;
|
||||
|
||||
const WHOLE_TIMELINE: TimelineSegmentView = {
|
||||
startSeconds: 0,
|
||||
@@ -23,6 +30,7 @@ const WHOLE_TIMELINE: TimelineSegmentView = {
|
||||
share: 1,
|
||||
startPercent: 0,
|
||||
width: '100%',
|
||||
separatorLeft: null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,13 +94,17 @@ export function normalizeTimelineSegments(
|
||||
const share =
|
||||
(segment.endSeconds - segment.startSeconds) / durationSeconds;
|
||||
const last = index === cover.length - 1;
|
||||
const gap = `${TIMELINE_SEGMENT_GAP_PX}px`;
|
||||
return {
|
||||
...segment,
|
||||
share,
|
||||
startPercent: (segment.startSeconds / durationSeconds) * 100,
|
||||
width: last
|
||||
? `${share * 100}%`
|
||||
: `max(0px, calc(${share * 100}% - ${TIMELINE_SEGMENT_GAP_PX}px))`,
|
||||
: `max(0px, calc(${share * 100}% - ${gap}))`,
|
||||
separatorLeft: last
|
||||
? null
|
||||
: `max(0px, calc(${(segment.endSeconds / durationSeconds) * 100}% - ${gap}))`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Signal, computed, signal } from '@angular/core';
|
||||
import {
|
||||
findTimelineSegment,
|
||||
normalizeTimelineSegments,
|
||||
segmentFillPercent,
|
||||
type TimelineSegmentView,
|
||||
@@ -50,6 +51,14 @@ export class ControlsTimeline {
|
||||
normalizeTimelineSegments(this.hostSegments(), this.duration())
|
||||
);
|
||||
|
||||
/**
|
||||
* Title of the segment holding the current (scrub or playback) value —
|
||||
* the programme or chapter the slider names in its `aria-valuetext`.
|
||||
*/
|
||||
readonly segmentTitle = computed(
|
||||
() => findTimelineSegment(this.segments(), this.value())?.title ?? null
|
||||
);
|
||||
|
||||
/** Played share of one segment for the current (scrub or playback) value. */
|
||||
fillPercent(segment: TimelineSegmentView): number {
|
||||
if (!this.state().canSeek) {
|
||||
|
||||
@@ -57,6 +57,10 @@ describe('PlayerControlsComponent dock', () => {
|
||||
const query = (selector: string) =>
|
||||
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
|
||||
|
||||
const labelPart = (part: 'title' | 'time') =>
|
||||
query(`[data-test-id="player-controls-timeline-label-${part}"]`)
|
||||
?.textContent ?? null;
|
||||
|
||||
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
|
||||
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
|
||||
|
||||
@@ -110,6 +114,9 @@ describe('PlayerControlsComponent dock', () => {
|
||||
FORWARD_10_SECONDS: 'Forward 10 seconds',
|
||||
MUTE: 'Mute',
|
||||
UNMUTE: 'Unmute',
|
||||
LIVE_STREAM: 'Live stream',
|
||||
LIVE_BADGE: 'LIVE',
|
||||
DURATION_UNKNOWN: 'Duration unknown',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -221,14 +228,22 @@ describe('PlayerControlsComponent dock', () => {
|
||||
it('shows a placeholder for unknown durations and LIVE for live streams', () => {
|
||||
setState({ canSeek: false, isLive: false });
|
||||
fixture.detectChanges();
|
||||
expect(query('.player-controls__time--end')?.textContent).toBe(
|
||||
'--:--'
|
||||
const placeholder = query('.player-controls__time--end');
|
||||
expect(placeholder?.textContent).toBe('--:--');
|
||||
// Assistive technology hears the name, not the dashes.
|
||||
expect(placeholder?.getAttribute('role')).toBe('img');
|
||||
expect(placeholder?.getAttribute('aria-label')).toBe(
|
||||
'Duration unknown'
|
||||
);
|
||||
|
||||
setState({ canSeek: false, isLive: true });
|
||||
fixture.detectChanges();
|
||||
expect(query('.player-controls__time--end')).toBeNull();
|
||||
expect(query('.player-controls__live-badge')).not.toBeNull();
|
||||
const badge = query('.player-controls__live-badge');
|
||||
expect(badge?.textContent?.trim()).toBe('LIVE');
|
||||
// A name on a role-less span is never announced.
|
||||
expect(badge?.getAttribute('role')).toBe('img');
|
||||
expect(badge?.getAttribute('aria-label')).toBe('Live stream');
|
||||
});
|
||||
|
||||
it('draws the progress fill and knob from the timeline progress', () => {
|
||||
@@ -281,7 +296,7 @@ describe('PlayerControlsComponent dock', () => {
|
||||
'[data-test-id="player-controls-timeline-label"]'
|
||||
);
|
||||
expect(label?.textContent?.trim()).toBe('1:40');
|
||||
expect(label?.style.getPropertyValue('--hover-x')).toBe('25%');
|
||||
expect(label?.style.getPropertyValue('--anchor-x')).toBe('25%');
|
||||
expect(query('.player-controls__timeline-marker')?.style.left).toBe(
|
||||
'25%'
|
||||
);
|
||||
@@ -324,6 +339,15 @@ describe('PlayerControlsComponent dock', () => {
|
||||
'75%',
|
||||
]);
|
||||
expect(segments.at(-1)?.style.width).toBe('25%');
|
||||
// A separator fills the gap before every following segment.
|
||||
const separators = Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.player-controls__timeline-separator'
|
||||
) as NodeListOf<HTMLElement>
|
||||
).map((separator) => separator.style.left);
|
||||
expect(separators).toHaveLength(2);
|
||||
expect(separators[0]).toContain('calc(25% - 2px)');
|
||||
expect(separators[1]).toContain('calc(75% - 2px)');
|
||||
expect(
|
||||
segments.map(
|
||||
(s) =>
|
||||
@@ -359,21 +383,136 @@ describe('PlayerControlsComponent dock', () => {
|
||||
new MouseEvent('pointermove', { clientX: 100, bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
query(
|
||||
'[data-test-id="player-controls-timeline-label"]'
|
||||
)?.textContent?.trim()
|
||||
).toBe('Intro \u00b7 1:40');
|
||||
// Two parts, so a long title can ellipsize without the time.
|
||||
expect(labelPart('title')).toBe('Intro');
|
||||
expect(labelPart('time')).toBe('1:40');
|
||||
|
||||
bar.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 300, bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(labelPart('title')).toBeNull();
|
||||
expect(labelPart('time')).toBe('5:00');
|
||||
});
|
||||
|
||||
it('labels the keyboard position without a pointer marker', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({
|
||||
canSeek: true,
|
||||
durationSeconds: 400,
|
||||
positionSeconds: 100,
|
||||
});
|
||||
fixture.componentRef.setInput('timelineSegments', [
|
||||
{ startSeconds: 0, endSeconds: 200, title: 'Intro' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
const slider = query(
|
||||
'.player-controls__slider--timeline'
|
||||
) as HTMLInputElement;
|
||||
Object.defineProperty(slider, 'matches', {
|
||||
value: (selector: string) => selector === ':focus-visible',
|
||||
});
|
||||
|
||||
slider.dispatchEvent(new FocusEvent('focus'));
|
||||
fixture.detectChanges();
|
||||
expect(labelPart('title')).toBe('Intro');
|
||||
expect(labelPart('time')).toBe('1:40');
|
||||
expect(query('.player-controls__timeline-marker')).toBeNull();
|
||||
|
||||
slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
|
||||
slider.value = '400';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
expect(labelPart('title')).toBeNull();
|
||||
expect(labelPart('time')).toBe('6:40');
|
||||
// The step commits at once, as a key press does.
|
||||
slider.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
expect(fake.commands.seekTo).toHaveBeenCalledWith(400);
|
||||
|
||||
slider.dispatchEvent(new FocusEvent('blur'));
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
query(
|
||||
query('[data-test-id="player-controls-timeline-label"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('moves the label by its measured width to stay inside the player', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({
|
||||
canSeek: true,
|
||||
durationSeconds: 400,
|
||||
positionSeconds: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
// The player spans 0–800px; the bar 70–730px.
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
host.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 800, width: 800 }) as DOMRect;
|
||||
const bar = query('.player-controls__timeline-bar') as HTMLElement;
|
||||
bar.getBoundingClientRect = () =>
|
||||
({ left: 70, width: 660 }) as DOMRect;
|
||||
const offsetWidth = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'offsetWidth'
|
||||
);
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.matches('.player-controls__timeline-label')
|
||||
? 390
|
||||
: 0;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
bar.dispatchEvent(
|
||||
new MouseEvent('pointermove', {
|
||||
clientX: 70,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const label = query(
|
||||
'[data-test-id="player-controls-timeline-label"]'
|
||||
)?.textContent?.trim()
|
||||
).toBe('5:00');
|
||||
) as HTMLElement;
|
||||
// 8px in from the player's left edge, in bar coordinates.
|
||||
expect(label.style.left).toBe('-62px');
|
||||
expect(label.style.transform).toBe('none');
|
||||
|
||||
bar.dispatchEvent(
|
||||
new MouseEvent('pointermove', {
|
||||
clientX: 730,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
// Its right edge 8px in from the player's right edge.
|
||||
expect(label.style.left).toBe(`${800 - 8 - 390 - 70}px`);
|
||||
|
||||
// The player narrows with the anchor and text unchanged (a
|
||||
// paused player, the slider held): the label is re-placed.
|
||||
host.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 600, width: 600 }) as DOMRect;
|
||||
bar.getBoundingClientRect = () =>
|
||||
({ left: 70, width: 460 }) as DOMRect;
|
||||
resizeTo(600);
|
||||
expect(
|
||||
(
|
||||
query(
|
||||
'[data-test-id="player-controls-timeline-label"]'
|
||||
) as HTMLElement
|
||||
).style.left
|
||||
).toBe(`${600 - 8 - 390 - 70}px`);
|
||||
} finally {
|
||||
if (offsetWidth) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'offsetWidth',
|
||||
offsetWidth
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not hover-label a non-seekable timeline', () => {
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
>
|
||||
<app-player-timeline
|
||||
[timeline]="timeline"
|
||||
[hover]="timelineHover"
|
||||
[label]="timelineLabel"
|
||||
[seekable]="capabilities().seek"
|
||||
[canSeek]="state().canSeek"
|
||||
[isLive]="state().isLive"
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
--pc-text-dim: rgba(255, 255, 255, 0.82);
|
||||
--pc-scrim-ink: 4, 7, 11;
|
||||
--pc-track: rgba(255, 255, 255, 0.18);
|
||||
// The seek bar's unplayed track: `--pc-track` over the scrim ink, made
|
||||
// opaque so its contrast no longer depends on the frame. The white
|
||||
// separators between programmes or chapters read 12.5:1 on it and 3.2:1
|
||||
// on the played fill, which reads 4.0:1 on it. Translucent, over a
|
||||
// bright frame, a separator fell to 2.5:1 and the fill to 1.1:1.
|
||||
--pc-timeline-track: #313437;
|
||||
--pc-timeline-separator: #ffffff;
|
||||
--pc-hover: rgba(255, 255, 255, 0.1);
|
||||
--pc-glass-bg: rgba(12, 16, 23, 0.72);
|
||||
// The settings panel carries small secondary text over whatever frame is
|
||||
|
||||
+118
-1
@@ -1,6 +1,7 @@
|
||||
import { WritableSignal, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { buildCatchupTimelineSegments } from './catchup-timeline-segments';
|
||||
import {
|
||||
DEFAULT_PLAYER_CAPABILITIES,
|
||||
createEmptyControlsState,
|
||||
@@ -211,3 +212,119 @@ describe('PlayerControlsComponent timeline scrubbing', () => {
|
||||
expect(currentTimeText()).toBe('0:30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayerControlsComponent timeline value text', () => {
|
||||
let fixture: ComponentFixture<PlayerControlsComponent>;
|
||||
let fake: ReturnType<typeof createFakeController>;
|
||||
let translate: TranslateService;
|
||||
|
||||
const slider = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'.player-controls__slider'
|
||||
) as HTMLInputElement;
|
||||
|
||||
const valueText = () => slider().getAttribute('aria-valuetext');
|
||||
|
||||
/** A keyboard step: the range input moves, then commits. */
|
||||
const step = (value: number) => {
|
||||
const element = slider();
|
||||
element.value = String(value);
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem('volume');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: { TIMELINE_SEGMENT_POSITION: '{{title}} · {{time}}' },
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
fake = createFakeController();
|
||||
fixture = TestBed.createComponent(PlayerControlsComponent);
|
||||
fixture.componentRef.setInput('controller', fake.controller);
|
||||
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, seek: true });
|
||||
fake.state.set({
|
||||
...createEmptyControlsState(),
|
||||
canSeek: true,
|
||||
durationSeconds: 3600,
|
||||
positionSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('reads the plain time without segments', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(valueText()).toBe('5:00');
|
||||
});
|
||||
|
||||
it('names the catch-up programme at the position and at the keyboard target', () => {
|
||||
const programme = (title: string, start: number, stop: number) => ({
|
||||
title,
|
||||
start: '',
|
||||
stop: '',
|
||||
startTimestamp: start,
|
||||
stopTimestamp: stop,
|
||||
});
|
||||
const active = programme('Morning Report', 50_000, 51_800);
|
||||
fixture.componentRef.setInput(
|
||||
'timelineSegments',
|
||||
buildCatchupTimelineSegments(
|
||||
[active, programme('Weather', 51_800, 53_600)],
|
||||
active,
|
||||
53_600
|
||||
)
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(valueText()).toBe('Morning Report · 5:00');
|
||||
|
||||
step(1801);
|
||||
expect(valueText()).toBe('Weather · 30:01');
|
||||
});
|
||||
|
||||
it('names file chapters and reads plain time in an untitled one', () => {
|
||||
// Chapters as Embedded MPV reports them: each runs to the next.
|
||||
fixture.componentRef.setInput('timelineSegments', [
|
||||
{ startSeconds: 0, endSeconds: 240, title: 'Opening' },
|
||||
{ startSeconds: 240, endSeconds: 3300, title: null },
|
||||
{ startSeconds: 3300, endSeconds: 3600, title: 'Credits' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(valueText()).toBe('5:00');
|
||||
|
||||
step(120);
|
||||
expect(valueText()).toBe('Opening · 2:00');
|
||||
|
||||
step(3600);
|
||||
expect(valueText()).toBe('Credits · 1:00:00');
|
||||
});
|
||||
|
||||
it('follows the locale template', () => {
|
||||
translate.setTranslation('de', {
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: { TIMELINE_SEGMENT_POSITION: '{{time}} – {{title}}' },
|
||||
},
|
||||
});
|
||||
fixture.componentRef.setInput('timelineSegments', [
|
||||
{ startSeconds: 0, endSeconds: 600, title: 'Kapitel 1' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
translate.use('de');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(valueText()).toBe('5:00 – Kapitel 1');
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { ControlsShortcuts } from './controls-shortcuts';
|
||||
import { ControlsStreamStats } from './controls-stream-stats';
|
||||
import { ControlsSurface } from './controls-surface';
|
||||
import { ControlsTimeline } from './controls-timeline';
|
||||
import { ControlsTimelineHover } from './controls-timeline-hover';
|
||||
import { ControlsTimelineLabel } from './controls-timeline-label';
|
||||
import { ControlsUpNext } from './controls-up-next';
|
||||
import { ControlsVisibility } from './controls-visibility';
|
||||
import { createControlsViewModel } from './controls-view-model';
|
||||
@@ -165,12 +165,14 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
private readonly controllerVolume = computed(() => this.state().volume);
|
||||
readonly timeline = new ControlsTimeline(this.state, this.timelineSegments);
|
||||
readonly scrubPosition = this.timeline.scrubPosition;
|
||||
readonly timelineHover = new ControlsTimelineHover({
|
||||
readonly timelineLabel = new ControlsTimelineLabel({
|
||||
duration: this.timeline.duration,
|
||||
interactive: computed(
|
||||
() => this.capabilities().seek && this.state().canSeek
|
||||
),
|
||||
segments: this.timeline.segments,
|
||||
value: this.timeline.value,
|
||||
scrubbing: computed(() => this.scrubPosition() !== null),
|
||||
});
|
||||
/** `−7:03` while a finite duration is known; the dock prefers it to the total. */
|
||||
readonly remainingTimeText = computed(() =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { TIMELINE_SEGMENT_GAP_PX } from './controls-timeline-segments';
|
||||
|
||||
// The overlay sits on video in both app themes, so its colours come from the
|
||||
// fixed `--pc-*` palette. A theme token read here stays invisible only while
|
||||
@@ -32,6 +33,7 @@ const TIMELINE_STYLES =
|
||||
STYLE_SOURCES.get('player-timeline.component.scss') ?? '';
|
||||
const SETTINGS_STYLES =
|
||||
STYLE_SOURCES.get('player-settings-panel.component.scss') ?? '';
|
||||
const PALETTE_STYLES = STYLE_SOURCES.get('_player-palette.scss') ?? '';
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
@@ -211,6 +213,59 @@ describe('player controls overlay palette', () => {
|
||||
expect(focused).toMatch(/outline:\s*2px solid var\(--pc-text[,)]/);
|
||||
});
|
||||
|
||||
it('separates timeline segments at 3:1 over any frame', () => {
|
||||
const track = paletteColor('--pc-timeline-track');
|
||||
const separator = paletteColor('--pc-timeline-separator');
|
||||
const accent = PALETTE_STYLES.match(
|
||||
/\$accent-blue:\s*#([0-9a-f]{6});/i
|
||||
)?.[1];
|
||||
const fill = [0, 2, 4].map((offset) =>
|
||||
Number.parseInt(accent?.slice(offset, offset + 2) ?? '', 16)
|
||||
) as Rgb;
|
||||
|
||||
// Opaque, so no frame behind the bar can change these ratios.
|
||||
expect(track.alpha).toBe(1);
|
||||
expect(separator.alpha).toBe(1);
|
||||
expect(
|
||||
ruleBody(TIMELINE_STYLES, '.player-controls__timeline-segment')
|
||||
).toMatch(/background:\s*var\(--pc-timeline-track\);/);
|
||||
expect(
|
||||
ruleBody(TIMELINE_STYLES, '.player-controls__timeline-fill')
|
||||
).toMatch(/background:\s*var\(--pc-progress\);/);
|
||||
const separatorRule = ruleBody(
|
||||
TIMELINE_STYLES,
|
||||
'.player-controls__timeline-separator'
|
||||
);
|
||||
expect(separatorRule).toMatch(
|
||||
/background:\s*var\(--pc-timeline-separator\);/
|
||||
);
|
||||
// The separator fills exactly the gap the segment widths leave.
|
||||
expect(separatorRule).toMatch(
|
||||
new RegExp(`width:\\s*${TIMELINE_SEGMENT_GAP_PX}px;`)
|
||||
);
|
||||
|
||||
// A boundary in the unplayed and in the played stretch.
|
||||
expect(contrastRatio(separator.rgb, track.rgb)).toBeGreaterThanOrEqual(
|
||||
3
|
||||
);
|
||||
expect(contrastRatio(separator.rgb, fill)).toBeGreaterThanOrEqual(3);
|
||||
// And the played fill still reads against the track.
|
||||
expect(contrastRatio(fill, track.rgb)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('sets the timeline label on the dense glass', () => {
|
||||
const white: Rgb = [255, 255, 255];
|
||||
expect(
|
||||
ruleBody(TIMELINE_STYLES, '.player-controls__timeline-label')
|
||||
).toMatch(/background:\s*var\(--pc-glass-bg-dense\);/);
|
||||
expect(
|
||||
contrastRatio(
|
||||
paletteColor('--pc-text').rgb,
|
||||
over(paletteColor('--pc-glass-bg-dense'), white)
|
||||
)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it('keeps the palette reds readable on video', () => {
|
||||
const white: Rgb = [255, 255, 255];
|
||||
const glass = paletteColor('--pc-glass-bg');
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
@let position = formatTime(timeline().value());
|
||||
@let positionTitle = timeline().segmentTitle();
|
||||
<span class="player-controls__time player-controls__time--current">{{
|
||||
formatTime(timeline().value())
|
||||
position
|
||||
}}</span>
|
||||
@if (seekable()) {
|
||||
<div
|
||||
#bar
|
||||
class="player-controls__timeline-bar"
|
||||
[class.player-controls__timeline-bar--disabled]="!canSeek()"
|
||||
(pointermove)="hover().move($event)"
|
||||
(pointerleave)="hover().clear()"
|
||||
(pointermove)="label().move($event)"
|
||||
(pointerleave)="label().clear()"
|
||||
>
|
||||
<div class="player-controls__timeline-track" aria-hidden="true">
|
||||
@for (segment of timeline().segments(); track $index) {
|
||||
@@ -24,6 +27,12 @@
|
||||
[style.width.%]="timeline().fillPercent(segment)"
|
||||
></div>
|
||||
</div>
|
||||
@if (segment.separatorLeft; as separatorLeft) {
|
||||
<div
|
||||
class="player-controls__timeline-separator"
|
||||
[style.left]="separatorLeft"
|
||||
></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (canSeek()) {
|
||||
@@ -33,19 +42,33 @@
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
@if (hover().percent() !== null) {
|
||||
@if (label().markerPercent() !== null) {
|
||||
<div
|
||||
class="player-controls__timeline-marker"
|
||||
[style.left.%]="hover().percent()"
|
||||
[style.left.%]="label().markerPercent()"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
@if (label().text(); as text) {
|
||||
<div
|
||||
#labelElement
|
||||
class="player-controls__timeline-label"
|
||||
[style.--hover-x.%]="hover().percent()"
|
||||
[style.--anchor-x.%]="label().percent()"
|
||||
data-test-id="player-controls-timeline-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ hover().label() }}
|
||||
@if (text.title) {
|
||||
<span
|
||||
class="player-controls__timeline-label-title"
|
||||
data-test-id="player-controls-timeline-label-title"
|
||||
>{{ text.title }}</span
|
||||
>
|
||||
}
|
||||
<span
|
||||
class="player-controls__timeline-label-time"
|
||||
data-test-id="player-controls-timeline-label-time"
|
||||
>{{ text.time }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<input
|
||||
@@ -59,7 +82,15 @@
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.PLAYBACK_POSITION' | translate
|
||||
"
|
||||
[attr.aria-valuetext]="formatTime(timeline().value())"
|
||||
[attr.aria-valuetext]="
|
||||
positionTitle
|
||||
? ('EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION'
|
||||
| translate: { title: positionTitle, time: position })
|
||||
: position
|
||||
"
|
||||
(focus)="label().focus($event)"
|
||||
(blur)="label().blur()"
|
||||
(keydown)="label().keydown($event)"
|
||||
(input)="scrubInput.emit($event)"
|
||||
(change)="scrubCommit.emit($event)"
|
||||
/>
|
||||
@@ -76,13 +107,19 @@
|
||||
} @else if (isLive()) {
|
||||
<span
|
||||
class="player-controls__live-badge"
|
||||
role="img"
|
||||
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate"
|
||||
>
|
||||
<span class="player-controls__live-dot" aria-hidden="true"></span>
|
||||
LIVE
|
||||
{{ 'EMBEDDED_MPV.PLAYER.LIVE_BADGE' | translate }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="player-controls__time player-controls__time--end">--:--</span>
|
||||
<span
|
||||
class="player-controls__time player-controls__time--end"
|
||||
role="img"
|
||||
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.DURATION_UNKNOWN' | translate"
|
||||
>{{ unknownTime }}</span
|
||||
>
|
||||
}
|
||||
@if (recordingStatus(); as status) {
|
||||
<div
|
||||
|
||||
@@ -49,16 +49,36 @@
|
||||
}
|
||||
|
||||
// Segments sit at their exact time positions (`left` = start, `width` =
|
||||
// share, minus the 3px gap at each segment's end), so a drawn boundary is
|
||||
// where the linear seek input and the hover label change segment. A segment
|
||||
// shorter than the gap simply collapses rather than shifting its neighbours.
|
||||
// share, minus the 2px gap after every segment but the last), so a drawn
|
||||
// boundary is where the linear seek input and the label change segment. A
|
||||
// segment shorter than the gap simply collapses rather than shifting its
|
||||
// neighbours. Only the bar's two ends are rounded; inside it, a white
|
||||
// separator fills each gap, so boundaries hold 3:1 over any frame.
|
||||
.player-controls__timeline-segment {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
background: var(--pc-track);
|
||||
background: var(--pc-timeline-track);
|
||||
}
|
||||
|
||||
.player-controls__timeline-segment:first-child {
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.player-controls__timeline-segment:last-child {
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
// Width = `TIMELINE_SEGMENT_GAP_PX`.
|
||||
.player-controls__timeline-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--pc-timeline-separator);
|
||||
}
|
||||
|
||||
.player-controls__timeline-fill {
|
||||
@@ -98,16 +118,26 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Above the bar at its anchor: the pointer, or the slider value while the
|
||||
// keyboard or a drag moves it. Centred on the anchor here; once laid out,
|
||||
// the component moves it by its measured width to stay inside the player
|
||||
// (`clampTimelineLabelLeft`), so its width must not depend on `left`. A
|
||||
// long title ellipsizes; the time never truncates.
|
||||
.player-controls__timeline-label {
|
||||
--hover-x: 0%;
|
||||
--anchor-x: 0%;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 2px);
|
||||
// Centered on the pointer, but never past the bar's ends.
|
||||
left: clamp(28px, var(--hover-x), calc(100% - 28px));
|
||||
left: clamp(28px, var(--anchor-x), calc(100% - 28px));
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
// The player's width less `TIMELINE_LABEL_EDGE_PX` on each side.
|
||||
max-width: calc(100cqw - 16px);
|
||||
padding: 5px 8px;
|
||||
color: var(--pc-text);
|
||||
background: rgba(12, 16, 23, 0.85);
|
||||
background: var(--pc-glass-bg-dense);
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
@@ -116,6 +146,23 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.player-controls__timeline-label-title {
|
||||
min-width: 0;
|
||||
max-width: min(320px, 60cqw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-controls__timeline-label-time {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.player-controls__timeline-label-title
|
||||
+ .player-controls__timeline-label-time::before {
|
||||
content: '\00b7';
|
||||
margin: 0 0.4em;
|
||||
}
|
||||
|
||||
.player-controls__recording-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
afterRenderEffect,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { formatTime } from './controls-format.utils';
|
||||
import { UNKNOWN_TIME_TEXT, formatTime } from './controls-format.utils';
|
||||
import type { ControlsTimeline } from './controls-timeline';
|
||||
import type { ControlsTimelineHover } from './controls-timeline-hover';
|
||||
import {
|
||||
type ControlsTimelineLabel,
|
||||
clampTimelineLabelLeft,
|
||||
} from './controls-timeline-label';
|
||||
|
||||
/**
|
||||
* The dock's timeline row: current time, the drawn segment track with its
|
||||
* knob and hover label, the remaining time (or LIVE / `--:--`), and the
|
||||
* knob and label, the remaining time (or LIVE / `--:--`), and the
|
||||
* recording status. Presentation only — scrub state lives in
|
||||
* {@link ControlsTimeline}, and the range input's `input`/`change` events
|
||||
* go back to the controls component, which owns reveal and seeking.
|
||||
@@ -26,8 +35,9 @@ import type { ControlsTimelineHover } from './controls-timeline-hover';
|
||||
host: { class: 'player-controls__timeline' },
|
||||
})
|
||||
export class PlayerTimelineComponent {
|
||||
private readonly host = inject(ElementRef<HTMLElement>).nativeElement;
|
||||
readonly timeline = input.required<ControlsTimeline>();
|
||||
readonly hover = input.required<ControlsTimelineHover>();
|
||||
readonly label = input.required<ControlsTimelineLabel>();
|
||||
/** The engine can seek at all; without it the track is not drawn. */
|
||||
readonly seekable = input(false);
|
||||
/** Seeking is possible right now. */
|
||||
@@ -41,4 +51,73 @@ export class PlayerTimelineComponent {
|
||||
readonly scrubCommit = output<Event>();
|
||||
|
||||
readonly formatTime = formatTime;
|
||||
readonly unknownTime = UNKNOWN_TIME_TEXT;
|
||||
|
||||
private readonly bar = viewChild<ElementRef<HTMLElement>>('bar');
|
||||
private readonly labelElement =
|
||||
viewChild<ElementRef<HTMLElement>>('labelElement');
|
||||
/**
|
||||
* Bumped when the bar or the player resizes: the label's pixel `left`
|
||||
* is stale then, even with its anchor and text unchanged (a paused
|
||||
* player, the keyboard holding the slider, a narrowed window).
|
||||
*/
|
||||
private readonly resized = signal(0);
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const bar = this.bar()?.nativeElement;
|
||||
if (!bar || typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() =>
|
||||
this.resized.update((count) => count + 1)
|
||||
);
|
||||
observer.observe(bar);
|
||||
const player = this.host.closest('.player-controls-host');
|
||||
if (player) {
|
||||
observer.observe(player);
|
||||
}
|
||||
onCleanup(() => observer.disconnect());
|
||||
});
|
||||
// The stylesheet centres the label on its anchor; once laid out, it
|
||||
// is moved by its measured width to stay inside the player, which a
|
||||
// long programme or chapter title would otherwise overflow.
|
||||
afterRenderEffect({
|
||||
earlyRead: () => {
|
||||
const percent = this.label().percent();
|
||||
// A new title or time changes the width to clamp, and a
|
||||
// resize the room it is clamped into.
|
||||
this.label().text();
|
||||
this.resized();
|
||||
const label = this.labelElement()?.nativeElement;
|
||||
const bar = this.bar()?.nativeElement;
|
||||
if (percent === null || !label || !bar) {
|
||||
return null;
|
||||
}
|
||||
const barRect = bar.getBoundingClientRect();
|
||||
if (!(barRect.width > 0)) {
|
||||
return null;
|
||||
}
|
||||
const player = (
|
||||
this.host.closest('.player-controls-host') ?? bar
|
||||
).getBoundingClientRect();
|
||||
return {
|
||||
label,
|
||||
left: clampTimelineLabelLeft(
|
||||
percent,
|
||||
label.offsetWidth,
|
||||
barRect,
|
||||
player
|
||||
),
|
||||
};
|
||||
},
|
||||
write: (placement) => {
|
||||
const value = placement();
|
||||
if (value) {
|
||||
value.label.style.left = `${value.left}px`;
|
||||
value.label.style.transform = 'none';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer",
|
||||
"CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT": "User-Agent",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
"HOME.PLAYLIST_TYPES.STALKER": "Stalker",
|
||||
@@ -111,6 +112,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_STALKER": "Stalker Portal",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
@@ -219,6 +221,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
@@ -326,11 +329,13 @@
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"DOWNLOADS.TITLE": "Downloads",
|
||||
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
|
||||
"EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE",
|
||||
"EMBEDDED_MPV.PLAYER.PAUSE": "Pause",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_CONTAINER": "Container",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
|
||||
"EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN": "Cyan",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EPG.GUIDE.ZOOM": "Zoom",
|
||||
"EPG.LIVE_STREAM": "Live stream",
|
||||
"EPG.PROGRAM_DIALOG.EPISODE": "Episode",
|
||||
@@ -453,6 +458,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
|
||||
"HOME.FILE_UPLOAD.OR": "or",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
@@ -569,6 +575,7 @@
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EPG.GUIDE.ZOOM": "Zoom",
|
||||
"EPG.TIMELINE.MINUTES_SHORT": "min",
|
||||
"EPG.TIMELINE.ZOOM": "Zoom",
|
||||
@@ -701,6 +708,7 @@
|
||||
"EMBEDDED_MPV.PLAYER.QUALITY_AUTO": "Auto",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
|
||||
"EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN": "Cyan",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
|
||||
"EPG.GUIDE.KEYS.PROGRAMME": "programme",
|
||||
"EPG.GUIDE.ZOOM": "Zoom",
|
||||
@@ -837,6 +845,7 @@
|
||||
"DOWNLOADS.RESUME": "Resume",
|
||||
"DOWNLOADS.STATUS.PAUSED": "Paused",
|
||||
"EMBEDDED_MPV.PLAYER.BACK": "Back",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EPG_MAPPING_DIALOG.CLOSE": "Close",
|
||||
"EPG_MAPPING_DIALOG.CURRENT_MAPPING": "Current mapping",
|
||||
"EPG_MAPPING_DIALOG.NO_MAPPING": "No EPG mapping set",
|
||||
@@ -970,6 +979,7 @@
|
||||
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
|
||||
"EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%",
|
||||
"EPG.GUIDE.ZOOM": "Zoom",
|
||||
@@ -1100,6 +1110,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
@@ -1203,6 +1214,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EPG.DOWNLOAD_SUCCESS": "EPG was successfully downloaded.",
|
||||
"EPG.ERROR": "Ooops, EPG could not be loaded.",
|
||||
"EPG.LIVE_STREAM": "Live stream",
|
||||
@@ -1324,11 +1336,13 @@
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"DOWNLOADS.TITLE": "Downloads",
|
||||
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
|
||||
"EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE",
|
||||
"EMBEDDED_MPV.PLAYER.LIVE_STREAM": "Live stream",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_CONTAINER": "Container",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
|
||||
"EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%",
|
||||
"EPG.GUIDE.KEYS.DETAILS": "details",
|
||||
@@ -1488,6 +1502,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EPG.TIMELINE.MINUTES_SHORT": "min",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
@@ -1602,6 +1617,7 @@
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"DOWNLOADS.TITLE": "Downloads",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
|
||||
"EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%",
|
||||
"EPG.GUIDE.ZOOM": "Zoom",
|
||||
@@ -1726,6 +1742,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
@@ -1778,6 +1795,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLIST_TYPES.STALKER": "Stalker",
|
||||
@@ -1886,6 +1904,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
@@ -1988,6 +2007,7 @@
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
|
||||
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
|
||||
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
|
||||
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
|
||||
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
|
||||
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
|
||||
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
|
||||
|
||||
Reference in new issue
Block a user