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:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-01 06:37:02 +02:00
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`),
});
});
}
+8 -97
View File
@@ -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 };
}
+2
View File
@@ -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": "افتراضي",
+2
View File
@@ -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": "افتراضي",
+2
View File
@@ -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": "Па змаўчанні",
+2
View File
@@ -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",
+2
View File
@@ -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": "Προεπιλογή",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "デフォルト",
+2
View File
@@ -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": "기본",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "По умолчанию",
+2
View File
@@ -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",
+2
View File
@@ -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": "默认",
+2
View File
@@ -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": "預設",