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:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-10 08:47:38 +02:00
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 });
+3
View File
@@ -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": "الصوت مكتوم",
+3
View File
@@ -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": "الصوت مكتوم",
+3
View File
@@ -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": "Гук адключаны",
+3
View File
@@ -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",
+3
View File
@@ -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": "Σε σίγαση",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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é",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "ミュート中",
+3
View File
@@ -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": "음소거됨",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "Звук отключён",
+3
View File
@@ -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ı",
+3
View File
@@ -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": "已静音",
+3
View File
@@ -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": "已靜音",
+1
View File
@@ -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 |
+17 -6
View File
@@ -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 })
+3 -1
View File
@@ -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(
+49 -11
View File
@@ -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
+7
View File
@@ -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
@@ -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';
}
},
});
}
}
+20
View File
@@ -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",