mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(player): keyboard focus, contrast and ARIA for controls and settings (#1769)
* fix(player): keyboard focus, contrast and ARIA for controls and settings
Dock and settings-panel icon buttons draw a 2px --pc-text ring on
:focus-visible, and Material's theme-coloured focus layer is off, so
keyboard focus shows on video in the light theme too. A focused selected
subtitle swatch now differs from one that is only selected.
Settings headings read --pc-text-secondary on denser glass
(--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white
frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long
German and Russian headings stay inside the sheet's heading column.
The settings panel is now radio groups only (SettingsRadioGroupDirective
over a CDK FocusKeyManager): one Tab stop per group on the checked option,
arrows, Home and End move focus without applying, and Space/Enter checks.
The dialog and its groups are named by real h2/h3/h4 headings, the
load-file action sits outside the subtitle radio group, the subtitle and
speed chips carry their value in their name ("Subtitles: English"), and
tune has aria-haspopup="dialog".
Adds a web E2E for the keyboard path in both themes with an axe check on
the open panel, and de/ru sheet heading wrapping; axe-core is a new dev
dependency for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): arrows check settings radios; subtitle chip reads On
Review follow-up:
- Arrow keys, Home and End now check the settings radio they reach, as a
native radio group does (the directive clicks it, so the template's
handler applies the choice); an option the engine already reports as
checked is not applied again.
- With subtitles on but no track marked selected yet (the engine can
report the switch before the track list), the subtitle chip reads and
announces "On" (new SUBTITLES_ON key, 19 locales) instead of "Off".
- The swatch row has 4px padding on every side, so the outer focus ring
is not clipped at the scroll edge of the panel body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): re-apply a settings radio while a switch is pending
The arrow-key check skipped any option the engine still reported as
checked. Arrowing from audio track A to B and back to A before the engine
confirmed B therefore sent no command for A, and playback ended on B with
focus on A. An arrow move always lands on an option other than the last
one applied, so it now applies unconditionally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d50995c30
commit
adb4889b0f
37 files changed
+1450
-257
No files matched your search
@@ -0,0 +1,410 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pressTab } from './e2e-helpers';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
importPlaylist,
|
||||
selectPlayer,
|
||||
serveClip,
|
||||
startClip,
|
||||
} from './player-settings-panel.fixture';
|
||||
|
||||
/**
|
||||
* Keyboard and assistive-technology access to the shared controls in both
|
||||
* app themes: every dock control and every settings choice shows a focus
|
||||
* ring in the overlay's own colour, each settings radio group is one Tab
|
||||
* stop with arrow-key navigation, the open panel passes axe, its headings
|
||||
* stay readable over bright frames, and long German and Russian headings
|
||||
* wrap inside the compact sheet. ArtPlayer is used because it offers the
|
||||
* fullest panel (subtitle file, size and colour, speed).
|
||||
*/
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
/** `--pc-text`, the overlay's text colour, as computed styles report it. */
|
||||
const RING_COLOR = 'rgb(231, 236, 243)';
|
||||
const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
||||
|
||||
interface FocusStop {
|
||||
label: string;
|
||||
role: string | null;
|
||||
group: string | null;
|
||||
checked: string | null;
|
||||
iconButton: boolean;
|
||||
outline: string;
|
||||
outlineColor: string;
|
||||
}
|
||||
|
||||
/** Describes the focused element and the ring it draws. */
|
||||
function focusStop(page: Page): Promise<FocusStop | null> {
|
||||
return page.evaluate(() => {
|
||||
const element = document.activeElement;
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
const style = getComputedStyle(element);
|
||||
const group = element.closest('[role="radiogroup"]');
|
||||
return {
|
||||
label:
|
||||
element.getAttribute('aria-label') ??
|
||||
element.textContent?.trim() ??
|
||||
'',
|
||||
role: element.getAttribute('role'),
|
||||
group: group?.getAttribute('aria-labelledby') ?? null,
|
||||
checked: element.getAttribute('aria-checked'),
|
||||
iconButton: element.matches('button[mat-icon-button]'),
|
||||
outline: `${style.outlineStyle} ${style.outlineWidth}`,
|
||||
outlineColor: style.outlineColor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function focusIsInside(page: Page, selector: string): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
(within) => !!document.activeElement?.closest(within),
|
||||
selector
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Presses Tab (or Shift+Tab) until focus lies inside `target`, or leaves
|
||||
* `within` when that is given, and returns every stop on the way — the
|
||||
* last one included.
|
||||
*/
|
||||
async function tabUntil(
|
||||
page: Page,
|
||||
browserName: string,
|
||||
options: {
|
||||
target?: string;
|
||||
within?: string;
|
||||
direction?: 'forward' | 'backward';
|
||||
max?: number;
|
||||
}
|
||||
): Promise<FocusStop[]> {
|
||||
const stops: FocusStop[] = [];
|
||||
for (let step = 0; step < (options.max ?? 20); step++) {
|
||||
await pressTab(page, browserName, options.direction);
|
||||
if (options.within && !(await focusIsInside(page, options.within))) {
|
||||
return stops;
|
||||
}
|
||||
const stop = await focusStop(page);
|
||||
if (stop) stops.push(stop);
|
||||
if (options.target && (await focusIsInside(page, options.target))) {
|
||||
return stops;
|
||||
}
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
async function applyTheme(page: Page, theme: 'light' | 'dark') {
|
||||
await page.evaluate((dark) => {
|
||||
document.body.classList.toggle('dark-theme', dark);
|
||||
}, theme === 'dark');
|
||||
}
|
||||
|
||||
async function expectNoAxeViolations(panel: Locator) {
|
||||
await panel.page().evaluate(AXE_SOURCE);
|
||||
const violations = await panel.evaluate(async (element) => {
|
||||
const axe = (window as unknown as { axe: typeof import('axe-core') })
|
||||
.axe;
|
||||
const results = await axe.run(element, { resultTypes: ['violations'] });
|
||||
return results.violations.map((violation) => ({
|
||||
id: violation.id,
|
||||
nodes: violation.nodes.map((node) => node.target.join(' ')),
|
||||
}));
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading contrast on the panel glass composited over a mid-grey and a white
|
||||
* frame — the video behind the panel can be anything.
|
||||
*/
|
||||
function headingContrast(panel: Locator) {
|
||||
return panel.evaluate((host) => {
|
||||
type Color = [number, number, number, number];
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
const parse = (css: string): Color => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = css;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return [r, g, b, a / 255];
|
||||
};
|
||||
// `front` over an opaque `back`: the result is opaque too.
|
||||
const over = (front: Color, back: Color): Color => [
|
||||
front[0] * front[3] + back[0] * (1 - front[3]),
|
||||
front[1] * front[3] + back[1] * (1 - front[3]),
|
||||
front[2] * front[3] + back[2] * (1 - front[3]),
|
||||
1,
|
||||
];
|
||||
const luminance = (color: Color) => {
|
||||
const [r, g, b] = color.slice(0, 3).map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
};
|
||||
const ratio = (a: Color, b: Color) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const glass = parse(getComputedStyle(host).backgroundColor);
|
||||
return Array.from(host.querySelectorAll('h3, h4'), (heading) => {
|
||||
const text = parse(getComputedStyle(heading).color);
|
||||
const on = (frame: Color) => {
|
||||
const surface = over(glass, frame);
|
||||
return ratio(over(text, surface), surface);
|
||||
};
|
||||
return {
|
||||
heading: heading.textContent?.trim(),
|
||||
midGrey: on([128, 128, 128, 1]),
|
||||
white: on([255, 255, 255, 1]),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('@web @playback keyboard reaches the dock and the settings radios with a visible ring in both themes', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
// Chips and the side panel need a player of at least 960px.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await serveClip(page);
|
||||
await selectPlayer(page, 'ArtPlayer');
|
||||
await importPlaylist(page);
|
||||
const { view, video, controls } = await startClip(page);
|
||||
// Park the pointer on the video away from the dock and the panel: a
|
||||
// control that slides in under a resting pointer shows its hover
|
||||
// tooltip, and a visible tooltip spends the next Escape on itself.
|
||||
await view.hover({ position: { x: 24, y: 24 } });
|
||||
const tune = controls.locator(
|
||||
'[data-test-id="player-controls-settings-button"]'
|
||||
);
|
||||
const panel = controls.getByRole('dialog', { name: 'Settings' });
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
|
||||
// --- The dock, from the timeline to the last control. -------------
|
||||
await controls
|
||||
.getByRole('slider', { name: 'Playback position' })
|
||||
.focus();
|
||||
const dockButtons = (
|
||||
await tabUntil(page, browserName, { within: 'app-player-controls' })
|
||||
).filter((stop) => stop.iconButton);
|
||||
// Every icon button wears the ring, whatever the app theme.
|
||||
expect(dockButtons.map((stop) => stop.outline)).toEqual(
|
||||
dockButtons.map(() => 'solid 2px')
|
||||
);
|
||||
expect(dockButtons.map((stop) => stop.outlineColor)).toEqual(
|
||||
dockButtons.map(() => RING_COLOR)
|
||||
);
|
||||
expect(dockButtons.map((stop) => stop.label)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Mute',
|
||||
'Back 10 seconds',
|
||||
'Forward 10 seconds',
|
||||
'Settings',
|
||||
'Enter fullscreen',
|
||||
])
|
||||
);
|
||||
|
||||
// --- Open the panel from the keyboard: focus moves into it. -------
|
||||
await tabUntil(page, browserName, {
|
||||
target: '[data-test-id="player-controls-settings-button"]',
|
||||
direction: 'backward',
|
||||
max: 10,
|
||||
});
|
||||
await expect(tune).toBeFocused();
|
||||
await expect(tune).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${theme}-dock-focus.png`),
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toBeFocused();
|
||||
|
||||
// Tab enters every radio group once, on its checked option, and
|
||||
// every stop wears the ring.
|
||||
const panelStops = await tabUntil(page, browserName, {
|
||||
target: '[data-test-id="player-settings-speed"]',
|
||||
});
|
||||
expect(panelStops.map((stop) => stop.outline)).toEqual(
|
||||
panelStops.map(() => 'solid 2px')
|
||||
);
|
||||
expect(panelStops.map((stop) => stop.outlineColor)).toEqual(
|
||||
panelStops.map(() => RING_COLOR)
|
||||
);
|
||||
const radioStops = panelStops.filter((stop) => stop.role === 'radio');
|
||||
// Subtitle size, subtitle colour and speed: one stop each, checked.
|
||||
expect(radioStops.map((stop) => stop.checked)).toEqual([
|
||||
'true',
|
||||
'true',
|
||||
'true',
|
||||
]);
|
||||
expect(new Set(radioStops.map((stop) => stop.group)).size).toBe(3);
|
||||
|
||||
// --- Arrows move focus and apply the option they reach. ----------
|
||||
const speedGroup = panel.locator(
|
||||
'[data-test-id="player-settings-speed"] [role="radiogroup"]'
|
||||
);
|
||||
const rate = () =>
|
||||
video.evaluate((el: HTMLVideoElement) => el.playbackRate);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
const moved = await focusStop(page);
|
||||
expect(moved?.role).toBe('radio');
|
||||
await expect.poll(rate).toBe(Number.parseFloat(moved?.label ?? ''));
|
||||
await expect(
|
||||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||
).toHaveText(moved?.label ?? '');
|
||||
await page.keyboard.press('Home');
|
||||
expect((await focusStop(page))?.label).toBe('0.5×');
|
||||
await expect.poll(rate).toBe(0.5);
|
||||
await expect(
|
||||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||
).toHaveText('0.5×');
|
||||
// The ends wrap, as in a native radio group.
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect((await focusStop(page))?.label).toBe('2×');
|
||||
await expect.poll(rate).toBe(2);
|
||||
await expect(speedGroup.locator('[tabindex="0"]')).toHaveText('2×');
|
||||
// Leaving and re-entering lands on the checked option — the one the
|
||||
// engine reports, so wait for it to confirm the switch first.
|
||||
await expect(
|
||||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||
).toHaveText('2×');
|
||||
await pressTab(page, browserName, 'backward');
|
||||
await pressTab(page, browserName);
|
||||
expect((await focusStop(page))?.label).toBe('2×');
|
||||
|
||||
// --- A focused selected swatch differs from a selected one. -------
|
||||
await tabUntil(page, browserName, {
|
||||
target: '.player-settings__swatches',
|
||||
direction: 'backward',
|
||||
max: 5,
|
||||
});
|
||||
const focusedSwatch = panel.locator('.player-settings__swatch:focus');
|
||||
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||||
// The arrow checks the next swatch, which takes focus and the ring.
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||||
// Tab moves on to the speed group: the checked swatch keeps its
|
||||
// selected border but loses the ring.
|
||||
await pressTab(page, browserName);
|
||||
expect(
|
||||
await focusIsInside(page, '[data-test-id="player-settings-speed"]')
|
||||
).toBe(true);
|
||||
const selectedOnly = panel.locator(
|
||||
'.player-settings__swatch[aria-checked="true"]'
|
||||
);
|
||||
await expect(selectedOnly).toHaveCount(1);
|
||||
await expect(selectedOnly).not.toBeFocused();
|
||||
await expect(selectedOnly).toHaveCSS('outline-style', 'none');
|
||||
await expect(selectedOnly).toHaveCSS(
|
||||
'border-top-color',
|
||||
'rgb(255, 255, 255)'
|
||||
);
|
||||
|
||||
// --- Headings, axe and closing. ----------------------------------
|
||||
const contrast = await headingContrast(panel);
|
||||
expect(contrast.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
contrast.filter(
|
||||
(heading) => heading.midGrey < 4.5 || heading.white < 4.5
|
||||
)
|
||||
).toEqual([]);
|
||||
await expectNoAxeViolations(panel);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${theme}-panel-focus.png`),
|
||||
});
|
||||
|
||||
// Focus is on a speed radio, which has no tooltip. Material spends
|
||||
// an Escape on any tooltip still showing (the swatch's fades out),
|
||||
// so wait for it to go before closing.
|
||||
await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(tune).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
for (const [locale, speedHeading] of [
|
||||
['de', 'Wiedergabegeschwindigkeit'],
|
||||
['ru', 'Скорость воспроизведения'],
|
||||
] as const) {
|
||||
test(`@web @playback settings sheet headings wrap in the ${locale} locale`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
await serveClip(page);
|
||||
await selectPlayer(page, 'ArtPlayer');
|
||||
const playlistUrl = await importPlaylist(page);
|
||||
|
||||
await page.goto('/workspace/settings/general');
|
||||
await page.locator('[data-test-id="select-language"]').click();
|
||||
await page.locator(`mat-option[data-test-id="${locale}"]`).click();
|
||||
const save = page.locator('[data-test-id="save-settings"]');
|
||||
await save.click();
|
||||
await expect(save).toBeHidden();
|
||||
|
||||
await page.goto(playlistUrl);
|
||||
const { controls } = await startClip(page);
|
||||
await controls
|
||||
.locator('[data-test-id="player-controls-settings-button"]')
|
||||
.click();
|
||||
const sheet = controls.locator(
|
||||
'[data-test-id="player-controls-settings-panel"]'
|
||||
);
|
||||
await expect(sheet).toHaveClass(/player-controls__settings--sheet/);
|
||||
await expect(
|
||||
sheet.locator('[data-test-id="player-settings-speed"] h3')
|
||||
).toHaveText(speedHeading);
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
const headings = await sheet.evaluate((host) =>
|
||||
Array.from(host.querySelectorAll('h3, h4'), (heading) => {
|
||||
const box = heading.getBoundingClientRect();
|
||||
const control = heading.nextElementSibling;
|
||||
const style = getComputedStyle(heading);
|
||||
return {
|
||||
text: heading.textContent?.trim(),
|
||||
overflow: heading.scrollWidth - heading.clientWidth,
|
||||
// A group heading sits in the sheet's left column,
|
||||
// beside its control.
|
||||
intoControl:
|
||||
heading.tagName === 'H3' && control
|
||||
? box.right -
|
||||
control.getBoundingClientRect().left
|
||||
: 0,
|
||||
lines: Math.round(
|
||||
(box.height -
|
||||
Number.parseFloat(style.paddingTop) -
|
||||
Number.parseFloat(style.paddingBottom)) /
|
||||
Number.parseFloat(style.lineHeight)
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
for (const heading of headings) {
|
||||
expect(heading.overflow, heading.text).toBeLessThanOrEqual(0);
|
||||
expect(heading.intoControl, heading.text).toBeLessThanOrEqual(
|
||||
0
|
||||
);
|
||||
}
|
||||
expect(
|
||||
headings.find((heading) => heading.text === speedHeading)?.lines
|
||||
).toBeGreaterThan(1);
|
||||
await expectNoAxeViolations(sheet);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${locale}-sheet.png`),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
importPlaylist,
|
||||
selectPlayer,
|
||||
serveClip,
|
||||
startClip,
|
||||
} from './player-settings-panel.fixture';
|
||||
|
||||
/**
|
||||
* The shared controls' settings panel: on a wide player the speed chip opens
|
||||
@@ -11,100 +14,8 @@ import { expect, test } from './fixtures';
|
||||
* remote media is involved.
|
||||
*/
|
||||
|
||||
const FIXTURE_HOST = 'https://player-settings-fixture.local';
|
||||
const PLAYLIST = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:-1 group-title="Movies",Settings Clip',
|
||||
`${FIXTURE_HOST}/clip.mp4`,
|
||||
].join('\n');
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
async function serveClip(page: Page): Promise<void> {
|
||||
const clip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = clip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, clip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'content-type': 'video/webm',
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${clip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: clip.subarray(start, end + 1),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectHtml5Player(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
const select = page.locator('[data-test-id="select-video-player"]');
|
||||
await expect(select).toBeVisible();
|
||||
const previous = await select.innerText();
|
||||
await select.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'HTML5 video player', exact: true })
|
||||
.click();
|
||||
if (!previous.includes('HTML5 video player')) {
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
}
|
||||
|
||||
async function importPlaylist(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||||
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channels')).toBeVisible();
|
||||
}
|
||||
|
||||
async function startClip(page: Page) {
|
||||
await page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: 'Settings Clip' })
|
||||
.click();
|
||||
const view = page.locator('app-web-player-view');
|
||||
const video = view.locator('video');
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate(
|
||||
(el: HTMLVideoElement) =>
|
||||
Number.isFinite(el.duration) && el.duration > 5
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
// Reveal the dock and keep it revealed for the assertions below.
|
||||
await view.hover();
|
||||
const controls = view.locator('app-player-controls');
|
||||
await controls.getByRole('button', { name: 'Pause', exact: true }).click();
|
||||
return { view, video, controls };
|
||||
}
|
||||
|
||||
test('@web @playback settings panel opens from the speed chip and applies in place', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -112,7 +23,7 @@ test('@web @playback settings panel opens from the speed chip and applies in pla
|
||||
// Chips and the side panel need a player of at least 960px.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await serveClip(page);
|
||||
await selectHtml5Player(page);
|
||||
await selectPlayer(page);
|
||||
await importPlaylist(page);
|
||||
const { video, controls } = await startClip(page);
|
||||
|
||||
@@ -164,7 +75,7 @@ test('@web @playback compact player folds the chips into a tune button with a bo
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
await serveClip(page);
|
||||
await selectHtml5Player(page);
|
||||
await selectPlayer(page);
|
||||
await importPlaylist(page);
|
||||
const { view, controls } = await startClip(page);
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* A one-channel M3U playlist whose only stream is the local test clip, served
|
||||
* through route interception: the shared-controls specs play it in a web
|
||||
* engine without any remote media.
|
||||
*/
|
||||
const FIXTURE_HOST = 'https://player-settings-fixture.local';
|
||||
export const CLIP_TITLE = 'Settings Clip';
|
||||
const PLAYLIST = [
|
||||
'#EXTM3U',
|
||||
`#EXTINF:-1 group-title="Movies",${CLIP_TITLE}`,
|
||||
`${FIXTURE_HOST}/clip.mp4`,
|
||||
].join('\n');
|
||||
|
||||
export async function serveClip(page: Page): Promise<void> {
|
||||
const clip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = clip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, clip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'content-type': 'video/webm',
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${clip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: clip.subarray(start, end + 1),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectPlayer(
|
||||
page: Page,
|
||||
player = 'HTML5 video player'
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
const select = page.locator('[data-test-id="select-video-player"]');
|
||||
await expect(select).toBeVisible();
|
||||
const previous = await select.innerText();
|
||||
await select.click();
|
||||
await page.getByRole('option', { name: player, exact: true }).click();
|
||||
if (!previous.includes(player)) {
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
}
|
||||
|
||||
/** Imports the playlist and returns the URL of its channel list. */
|
||||
export async function importPlaylist(page: Page): Promise<string> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||||
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channels')).toBeVisible();
|
||||
return page.url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the clip and pauses it with the dock revealed. The play button is
|
||||
* found by test id, so this works in every app language.
|
||||
*/
|
||||
export async function startClip(page: Page) {
|
||||
await page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: CLIP_TITLE })
|
||||
.click();
|
||||
const view = page.locator('app-web-player-view');
|
||||
const video = view.locator('video');
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate(
|
||||
(el: HTMLVideoElement) =>
|
||||
Number.isFinite(el.duration) &&
|
||||
el.duration > 5 &&
|
||||
!el.paused
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
// Reveal the dock and keep it revealed for the assertions below.
|
||||
await view.hover();
|
||||
const controls = view.locator('app-player-controls');
|
||||
await controls.locator('[data-test-id="player-controls-play"]').click();
|
||||
await expect
|
||||
.poll(() => video.evaluate((el: HTMLVideoElement) => el.paused))
|
||||
.toBe(true);
|
||||
return { view, video, controls };
|
||||
}
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "مسارات الصوت",
|
||||
"SUBTITLES": "الترجمات",
|
||||
"SUBTITLES_OFF": "إيقاف",
|
||||
"SUBTITLES_ON": "تشغيل",
|
||||
"QUALITY": "الجودة",
|
||||
"QUALITY_AUTO": "تلقائي",
|
||||
"TRACK_DEFAULT": "افتراضي",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "الترجمات: {{subtitles}}",
|
||||
"ASPECT_RATIO": "نسبة العرض إلى الارتفاع",
|
||||
"ASPECT_TOOLTIP": "النسبة: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "افتراضي",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "مسارات الصوت",
|
||||
"SUBTITLES": "الترجمة",
|
||||
"SUBTITLES_OFF": "معطلة",
|
||||
"SUBTITLES_ON": "مفعلة",
|
||||
"QUALITY": "الجودة",
|
||||
"QUALITY_AUTO": "تلقائي",
|
||||
"TRACK_DEFAULT": "افتراضي",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "الترجمة: {{subtitles}}",
|
||||
"ASPECT_RATIO": "نسبة الأبعاد",
|
||||
"ASPECT_TOOLTIP": "الأبعاد: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "افتراضي",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Аўдыядарожкі",
|
||||
"SUBTITLES": "Субтытры",
|
||||
"SUBTITLES_OFF": "Выкл.",
|
||||
"SUBTITLES_ON": "Укл.",
|
||||
"QUALITY": "Якасць",
|
||||
"QUALITY_AUTO": "Аўта",
|
||||
"TRACK_DEFAULT": "Па змаўчанні",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
|
||||
"PLAYBACK_SPEED": "Хуткасць прайгравання",
|
||||
"SPEED_TOOLTIP": "Хуткасць: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Субтытры: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Суадносіны бакоў",
|
||||
"ASPECT_TOOLTIP": "Суадносіны: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Па змаўчанні",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Tonspuren",
|
||||
"SUBTITLES": "Untertitel",
|
||||
"SUBTITLES_OFF": "Aus",
|
||||
"SUBTITLES_ON": "An",
|
||||
"QUALITY": "Qualität",
|
||||
"QUALITY_AUTO": "Automatisch",
|
||||
"TRACK_DEFAULT": "Standard",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}",
|
||||
"PLAYBACK_SPEED": "Wiedergabegeschwindigkeit",
|
||||
"SPEED_TOOLTIP": "Geschwindigkeit: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Untertitel: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Seitenverhältnis",
|
||||
"ASPECT_TOOLTIP": "Seitenverhältnis: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Standard",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Κομμάτια ήχου",
|
||||
"SUBTITLES": "Υπότιτλοι",
|
||||
"SUBTITLES_OFF": "Ανενεργοί",
|
||||
"SUBTITLES_ON": "Ενεργοί",
|
||||
"QUALITY": "Ποιότητα",
|
||||
"QUALITY_AUTO": "Αυτόματη",
|
||||
"TRACK_DEFAULT": "Προεπιλογή",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
|
||||
"PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής",
|
||||
"SPEED_TOOLTIP": "Ταχύτητα: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Υπότιτλοι: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Αναλογία εικόνας",
|
||||
"ASPECT_TOOLTIP": "Αναλογία: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Προεπιλογή",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Audio tracks",
|
||||
"SUBTITLES": "Subtitles",
|
||||
"SUBTITLES_OFF": "Off",
|
||||
"SUBTITLES_ON": "On",
|
||||
"QUALITY": "Quality",
|
||||
"QUALITY_AUTO": "Auto",
|
||||
"TRACK_DEFAULT": "Default",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}",
|
||||
"PLAYBACK_SPEED": "Playback speed",
|
||||
"SPEED_TOOLTIP": "Speed: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Subtitles: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Aspect ratio",
|
||||
"ASPECT_TOOLTIP": "Aspect: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Default",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Pistas de audio",
|
||||
"SUBTITLES": "Subtítulos",
|
||||
"SUBTITLES_OFF": "Desactivados",
|
||||
"SUBTITLES_ON": "Activados",
|
||||
"QUALITY": "Calidad",
|
||||
"QUALITY_AUTO": "Automática",
|
||||
"TRACK_DEFAULT": "Predeterminada",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}",
|
||||
"PLAYBACK_SPEED": "Velocidad de reproducción",
|
||||
"SPEED_TOOLTIP": "Velocidad: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Subtítulos: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Relación de aspecto",
|
||||
"ASPECT_TOOLTIP": "Aspecto: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Predeterminada",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Pistes audio",
|
||||
"SUBTITLES": "Sous-titres",
|
||||
"SUBTITLES_OFF": "Désactivés",
|
||||
"SUBTITLES_ON": "Activés",
|
||||
"QUALITY": "Qualité",
|
||||
"QUALITY_AUTO": "Auto",
|
||||
"TRACK_DEFAULT": "Par défaut",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}",
|
||||
"PLAYBACK_SPEED": "Vitesse de lecture",
|
||||
"SPEED_TOOLTIP": "Vitesse : {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Sous-titres : {{subtitles}}",
|
||||
"ASPECT_RATIO": "Format d'image",
|
||||
"ASPECT_TOOLTIP": "Format : {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Par défaut",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Hangsávok",
|
||||
"SUBTITLES": "Feliratok",
|
||||
"SUBTITLES_OFF": "Kikapcsolva",
|
||||
"SUBTITLES_ON": "Bekapcsolva",
|
||||
"QUALITY": "Minőség",
|
||||
"QUALITY_AUTO": "Automatikus",
|
||||
"TRACK_DEFAULT": "Alapértelmezett",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv",
|
||||
"PLAYBACK_SPEED": "Lejátszási sebesség",
|
||||
"SPEED_TOOLTIP": "Sebesség: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Feliratok: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Képarány",
|
||||
"ASPECT_TOOLTIP": "Képarány: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Alapértelmezett",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Tracce audio",
|
||||
"SUBTITLES": "Sottotitoli",
|
||||
"SUBTITLES_OFF": "Disattivati",
|
||||
"SUBTITLES_ON": "Attivati",
|
||||
"QUALITY": "Qualità",
|
||||
"QUALITY_AUTO": "Automatica",
|
||||
"TRACK_DEFAULT": "Predefinita",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}",
|
||||
"PLAYBACK_SPEED": "Velocità di riproduzione",
|
||||
"SPEED_TOOLTIP": "Velocità: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Sottotitoli: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Proporzioni",
|
||||
"ASPECT_TOOLTIP": "Proporzioni: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Predefinite",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "音声トラック",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "オフ",
|
||||
"SUBTITLES_ON": "オン",
|
||||
"QUALITY": "画質",
|
||||
"QUALITY_AUTO": "自動",
|
||||
"TRACK_DEFAULT": "デフォルト",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "再生速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "アスペクト比",
|
||||
"ASPECT_TOOLTIP": "アスペクト比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "デフォルト",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "오디오 트랙",
|
||||
"SUBTITLES": "자막",
|
||||
"SUBTITLES_OFF": "끄기",
|
||||
"SUBTITLES_ON": "켜기",
|
||||
"QUALITY": "화질",
|
||||
"QUALITY_AUTO": "자동",
|
||||
"TRACK_DEFAULT": "기본",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
|
||||
"PLAYBACK_SPEED": "재생 속도",
|
||||
"SPEED_TOOLTIP": "속도: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "자막: {{subtitles}}",
|
||||
"ASPECT_RATIO": "화면 비율",
|
||||
"ASPECT_TOOLTIP": "비율: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "기본",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Audiosporen",
|
||||
"SUBTITLES": "Ondertiteling",
|
||||
"SUBTITLES_OFF": "Uit",
|
||||
"SUBTITLES_ON": "Aan",
|
||||
"QUALITY": "Kwaliteit",
|
||||
"QUALITY_AUTO": "Automatisch",
|
||||
"TRACK_DEFAULT": "Standaard",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}",
|
||||
"PLAYBACK_SPEED": "Afspeelsnelheid",
|
||||
"SPEED_TOOLTIP": "Snelheid: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Ondertiteling: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Beeldverhouding",
|
||||
"ASPECT_TOOLTIP": "Beeldverhouding: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Standaard",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Ścieżki audio",
|
||||
"SUBTITLES": "Napisy",
|
||||
"SUBTITLES_OFF": "Wyłączone",
|
||||
"SUBTITLES_ON": "Włączone",
|
||||
"QUALITY": "Jakość",
|
||||
"QUALITY_AUTO": "Automatycznie",
|
||||
"TRACK_DEFAULT": "Domyślna",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}",
|
||||
"PLAYBACK_SPEED": "Prędkość odtwarzania",
|
||||
"SPEED_TOOLTIP": "Prędkość: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Napisy: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Proporcje obrazu",
|
||||
"ASPECT_TOOLTIP": "Proporcje: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Domyślne",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Faixas de áudio",
|
||||
"SUBTITLES": "Legendas",
|
||||
"SUBTITLES_OFF": "Desativadas",
|
||||
"SUBTITLES_ON": "Ativadas",
|
||||
"QUALITY": "Qualidade",
|
||||
"QUALITY_AUTO": "Automática",
|
||||
"TRACK_DEFAULT": "Padrão",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}",
|
||||
"PLAYBACK_SPEED": "Velocidade de reprodução",
|
||||
"SPEED_TOOLTIP": "Velocidade: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Legendas: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Proporção da imagem",
|
||||
"ASPECT_TOOLTIP": "Proporção: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Padrão",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Аудиодорожки",
|
||||
"SUBTITLES": "Субтитры",
|
||||
"SUBTITLES_OFF": "Выкл.",
|
||||
"SUBTITLES_ON": "Вкл.",
|
||||
"QUALITY": "Качество",
|
||||
"QUALITY_AUTO": "Авто",
|
||||
"TRACK_DEFAULT": "По умолчанию",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
|
||||
"PLAYBACK_SPEED": "Скорость воспроизведения",
|
||||
"SPEED_TOOLTIP": "Скорость: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Субтитры: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Соотношение сторон",
|
||||
"ASPECT_TOOLTIP": "Соотношение: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "По умолчанию",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Ses parçaları",
|
||||
"SUBTITLES": "Altyazılar",
|
||||
"SUBTITLES_OFF": "Kapalı",
|
||||
"SUBTITLES_ON": "Açık",
|
||||
"QUALITY": "Kalite",
|
||||
"QUALITY_AUTO": "Otomatik",
|
||||
"TRACK_DEFAULT": "Varsayılan",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}",
|
||||
"PLAYBACK_SPEED": "Oynatma hızı",
|
||||
"SPEED_TOOLTIP": "Hız: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Altyazılar: {{subtitles}}",
|
||||
"ASPECT_RATIO": "En boy oranı",
|
||||
"ASPECT_TOOLTIP": "En boy oranı: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Varsayılan",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "音轨",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "关闭",
|
||||
"SUBTITLES_ON": "开启",
|
||||
"QUALITY": "画质",
|
||||
"QUALITY_AUTO": "自动",
|
||||
"TRACK_DEFAULT": "默认",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "播放速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "宽高比",
|
||||
"ASPECT_TOOLTIP": "宽高比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "默认",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "音軌",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "關閉",
|
||||
"SUBTITLES_ON": "開啟",
|
||||
"QUALITY": "畫質",
|
||||
"QUALITY_AUTO": "自動",
|
||||
"TRACK_DEFAULT": "預設",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "播放速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "長寬比",
|
||||
"ASPECT_TOOLTIP": "長寬比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "預設",
|
||||
|
||||
Reference in new issue
Block a user