mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): fullscreen episode panel for series playback (#1620)
Series playing in fullscreen get the same slide-in side panel the live channel list has, with season tabs and the episode list: rest the mouse on the left edge, click it, or press C; pick an episode and it plays inline without leaving fullscreen. - Panel contract: `FullscreenChannelPanelHost` gains optional `panelSearchEnabled` and `panelKind`; the template context gains `open`. Pointer/keyboard rules and the four live providers are unchanged. - Series host: `PortalInlinePlayerComponent` provides the token through `createEpisodePanelHost()` and stamps `app-fullscreen-episode-panel` (SeasonTabsComponent over rows with TMDB still or numeral tile, label, runtime, clamped overview, progress, watched check, now-playing marker; playing row centred on open). Episode clicks reuse the Up Next rail's inline path; season tab clicks reach the hosts' `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD load with a Retry row after a failed request). - Gates: `Settings.fullscreenChannelPanel` (label now covers both lists in all locales), episode content only, native-view Embedded MPV withheld by the view, external players excluded. - Inline-series e2e moved to `xtream-series-playback.e2e.ts` with shared Xtream helpers in a fixture; adds a fullscreen episode switch through the panel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
c110519412
commit
fa8ce26991
55 files changed
+2961
-570
No files matched your search
@@ -0,0 +1,242 @@
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
playFirstSeriesEpisode,
|
||||
rewriteSeriesEpisodesToMp4,
|
||||
routeEpisodeClip,
|
||||
selectWebPlayer,
|
||||
} from './xtream-series-playback.fixture';
|
||||
|
||||
/**
|
||||
* Xtream inline series playback in fullscreen: episode switches, the
|
||||
* fullscreen episode panel and the vendor-chrome shortcuts. Split out of
|
||||
* `xtream.e2e.ts` for size; same mock server and default scenario
|
||||
* (3 seasons × 8 episodes per series).
|
||||
*
|
||||
* Tag: @xtream
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
await page.goto('/');
|
||||
await interceptXtreamRequests(page);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen survives an episode switch
|
||||
//
|
||||
// app-web-player-view remounts the engine component for every playback
|
||||
// application (next episode, channel, or alternative source). DOM fullscreen
|
||||
// used to be owned by that engine's shell, so the Fullscreen API exited the
|
||||
// moment the old shell left the document — every "next episode" click and
|
||||
// every autoplay hand-off dropped the viewer back to the page. The owner is
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('stays in fullscreen across manual and automatic episode switches', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await waitForMetadata();
|
||||
|
||||
// The bar auto-hides during playback; a hover reveals it. The
|
||||
// overlay title renders only while the controls consider themselves
|
||||
// fullscreen, so it doubles as the controls-state assertion.
|
||||
// Asserted as "some element is fullscreen" rather than by owner tag,
|
||||
// so the test guards the behavior, not the wiring.
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// Chromium leaves the clicked button focused; the shared controls
|
||||
// release that focus so Space reaches the playback shortcut instead
|
||||
// of activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Manual switch from the shared controls' own next-episode button.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.locator('[data-test-id="player-controls-next-episode"]')
|
||||
.click();
|
||||
await expect(overlayTitle).toContainText('S01E02', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Automatic switch: the clip reaching its end autoplays the next
|
||||
// episode with no user activation anywhere near it.
|
||||
await waitForMetadata();
|
||||
await video.evaluate(async (el) => {
|
||||
const media = el as HTMLVideoElement;
|
||||
media.currentTime = Math.max(0, media.duration - 0.2);
|
||||
// Headless autoplay is not guaranteed for a remounted element;
|
||||
// the document already carries user activation from the clicks.
|
||||
await media.play();
|
||||
});
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView } = await playFirstSeriesEpisode(page, request);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// C slides in the episode panel: season tabs instead of a search
|
||||
// field, the season's eight rows, the playing one marked.
|
||||
await page.keyboard.press('c');
|
||||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel).toHaveAttribute('data-panel-kind', 'episodes');
|
||||
await expect(
|
||||
panel.locator('[data-test-id="fullscreen-channel-panel-search"]')
|
||||
).toHaveCount(0);
|
||||
await expect(panel.locator('.season-tabs__pill')).toHaveCount(3);
|
||||
const rows = panel.locator(
|
||||
'[data-test-id="fullscreen-episode-panel-episode"]'
|
||||
);
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(rows.first()).toHaveAttribute('aria-current', 'true');
|
||||
|
||||
// Picking an episode plays it inline through the same path as the Up
|
||||
// Next rail: fullscreen survives the engine remount, the panel closes.
|
||||
await rows.nth(2).click();
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||||
await playerView.hover();
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Reopened, the panel marks the new episode; another season's tab
|
||||
// lists that season, offers the way back, and plays from there too.
|
||||
await page.keyboard.press('c');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(rows.nth(2)).toHaveAttribute('aria-current', 'true');
|
||||
await panel.locator('.season-tabs__pill').nth(1).click();
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(panel.locator('[aria-current="true"]')).toHaveCount(0);
|
||||
await expect(
|
||||
panel.locator('[data-testid="back-to-playing"]')
|
||||
).toBeVisible();
|
||||
await rows.first().click();
|
||||
await playerView.hover();
|
||||
await expect(overlayTitle).toContainText('S02E01', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
} from './provider-target-route';
|
||||
|
||||
/**
|
||||
* Shared Xtream helpers for `xtream.e2e.ts` and `xtream-series-playback.e2e.ts`:
|
||||
* the mock-server address and credentials, the PWA proxy intercept, the
|
||||
* portal import, and the inline series playback setup.
|
||||
*/
|
||||
|
||||
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
||||
export const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
|
||||
/** Default scenario credentials */
|
||||
export const DEFAULT_USERNAME = 'user1';
|
||||
export const DEFAULT_PASSWORD = 'pass1';
|
||||
|
||||
/**
|
||||
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
|
||||
* to the mock server. This avoids any real backend requirement.
|
||||
*/
|
||||
export async function interceptXtreamRequests(page: Page): Promise<void> {
|
||||
const providerTargets = await interceptProviderTargetRegistration(page);
|
||||
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
const providerUrl = getRegisteredProviderUrl(
|
||||
originalUrl,
|
||||
providerTargets
|
||||
);
|
||||
|
||||
if (providerUrl) {
|
||||
mockUrl.searchParams.set('url', providerUrl);
|
||||
}
|
||||
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
if (key === 'targetId') {
|
||||
return;
|
||||
}
|
||||
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Xtream portal via the UI.
|
||||
*/
|
||||
export async function addXtreamPortal(
|
||||
page: Page,
|
||||
options: { name?: string; username?: string; password?: string } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name = 'Mock Xtream Portal',
|
||||
username = DEFAULT_USERNAME,
|
||||
password = DEFAULT_PASSWORD,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('#title'), name);
|
||||
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
|
||||
await setInputValue(dialog.locator('#username'), username);
|
||||
await setInputValue(dialog.locator('#password'), password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
export async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.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: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
export async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
export async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
export async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
rasterizedBorderContrast,
|
||||
@@ -9,9 +7,12 @@ import {
|
||||
waitForScrollIdle,
|
||||
} from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
} from './provider-target-route';
|
||||
addXtreamPortal,
|
||||
DEFAULT_PASSWORD,
|
||||
DEFAULT_USERNAME,
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
} from './xtream-series-playback.fixture';
|
||||
|
||||
/**
|
||||
* Xtream Codes E2E Tests
|
||||
@@ -28,12 +29,6 @@ import {
|
||||
* Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream"
|
||||
*/
|
||||
|
||||
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
||||
const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
|
||||
/** Default scenario credentials */
|
||||
const DEFAULT_USERNAME = 'user1';
|
||||
const DEFAULT_PASSWORD = 'pass1';
|
||||
const EPG_USERNAME = 'epg';
|
||||
const EPG_PASSWORD = 'epg';
|
||||
|
||||
@@ -41,65 +36,6 @@ const EPG_PASSWORD = 'epg';
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
|
||||
* to the mock server. This avoids any real backend requirement.
|
||||
*/
|
||||
async function interceptXtreamRequests(page: Page): Promise<void> {
|
||||
const providerTargets = await interceptProviderTargetRegistration(page);
|
||||
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
const providerUrl = getRegisteredProviderUrl(
|
||||
originalUrl,
|
||||
providerTargets
|
||||
);
|
||||
|
||||
if (providerUrl) {
|
||||
mockUrl.searchParams.set('url', providerUrl);
|
||||
}
|
||||
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
if (key === 'targetId') {
|
||||
return;
|
||||
}
|
||||
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Xtream portal via the UI.
|
||||
*/
|
||||
async function addXtreamPortal(
|
||||
page: Page,
|
||||
options: { name?: string; username?: string; password?: string } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name = 'Mock Xtream Portal',
|
||||
username = DEFAULT_USERNAME,
|
||||
password = DEFAULT_PASSWORD,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('#title'), name);
|
||||
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
|
||||
await setInputValue(dialog.locator('#username'), username);
|
||||
await setInputValue(dialog.locator('#password'), password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
async function openPlaylistDetailsDialog(page: Page, title: string) {
|
||||
const playlistSettingsButton = page.getByRole('button', {
|
||||
name: 'Playlist Settings',
|
||||
@@ -965,308 +901,6 @@ function formatXtreamDateTime(timestampSeconds: number): string {
|
||||
.replace('.000Z', '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen survives an episode switch
|
||||
//
|
||||
// app-web-player-view remounts the engine component for every playback
|
||||
// application (next episode, channel, or alternative source). DOM fullscreen
|
||||
// used to be owned by that engine's shell, so the Fullscreen API exited the
|
||||
// moment the old shell left the document — every "next episode" click and
|
||||
// every autoplay hand-off dropped the viewer back to the page. The owner is
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.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: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('stays in fullscreen across manual and automatic episode switches', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await waitForMetadata();
|
||||
|
||||
// The bar auto-hides during playback; a hover reveals it. The
|
||||
// overlay title renders only while the controls consider themselves
|
||||
// fullscreen, so it doubles as the controls-state assertion.
|
||||
// Asserted as "some element is fullscreen" rather than by owner tag,
|
||||
// so the test guards the behavior, not the wiring.
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// Chromium leaves the clicked button focused; the shared controls
|
||||
// release that focus so Space reaches the playback shortcut instead
|
||||
// of activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Manual switch from the shared controls' own next-episode button.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.locator('[data-test-id="player-controls-next-episode"]')
|
||||
.click();
|
||||
await expect(overlayTitle).toContainText('S01E02', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Automatic switch: the clip reaching its end autoplays the next
|
||||
// episode with no user activation anywhere near it.
|
||||
await waitForMetadata();
|
||||
await video.evaluate(async (el) => {
|
||||
const media = el as HTMLVideoElement;
|
||||
media.currentTime = Math.max(0, media.duration - 0.2);
|
||||
// Headless autoplay is not guaranteed for a remounted element;
|
||||
// the document already carries user activation from the clicks.
|
||||
await media.play();
|
||||
});
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`@xtream detail surfaces stay visible (${theme})`, async ({
|
||||
page,
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات التالية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "في النوافذ العريضة، تثبيت مشغل المسلسل على اليسار وعرض الحلقات القادمة بجانبه.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "قائمة القنوات في وضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "في وضع ملء الشاشة، حرّك الماوس إلى الحافة اليسرى أو اضغط C لإظهار قائمة القنوات وتبديل القناة دون الخروج من ملء الشاشة.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "قائمة القنوات والحلقات في وضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "ضع الماوس على الحافة اليسرى أو انقر عليها أو اضغط C في وضع ملء الشاشة لإظهار قائمة قنوات البث المباشر أو قائمة حلقات المسلسل والتبديل دون مغادرة وضع ملء الشاشة.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "اختر لغة التطبيق",
|
||||
"THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة",
|
||||
"CHANNEL_LIST": "قائمة القنوات",
|
||||
"HIDE_CHANNEL_LIST": "إخفاء قائمة القنوات",
|
||||
"EPISODE_LIST": "قائمة الحلقات",
|
||||
"HIDE_EPISODE_LIST": "إخفاء قائمة الحلقات",
|
||||
"SEARCH_CHANNELS": "البحث عن القنوات",
|
||||
"SEARCH_IN": "البحث في {{title}}",
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
|
||||
"PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل",
|
||||
"LOADING_SEASONS": "جارٍ تحميل المواسم...",
|
||||
"LOADING_EPISODES": "جارٍ تحميل الحلقات...",
|
||||
"EPISODES_LOAD_FAILED": "تعذر تحميل الحلقات.",
|
||||
"LOADING_ALL_CHANNELS": "جارٍ تحميل قائمة القنوات الكاملة…",
|
||||
"REFRESH_CHANNEL_LIST": "تحديث قائمة القنوات",
|
||||
"NO_CHANNELS_IN_CATEGORY": "لا توجد قنوات في هذه الفئة",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات الجاية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "فالنوافذ العريضة، حط مشغل السلسلة على اليسار وبيّن الحلقات الجاية بحداه.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "لائحة القنوات فوضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "فوضع ملء الشاشة، حرّك الماوس للحافة اليسرى ولا ورّك على C باش تبان لائحة القنوات وتبدّل القناة بلا ما تخرج من ملء الشاشة.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "لائحة القنوات والحلقات فوضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "حط الفارة فالحافة اليسرى ولا كليكي عليها ولا ضغط C فوضع ملء الشاشة باش تبان لائحة القنوات ديال البث المباشر ولا لائحة الحلقات ديال المسلسل وتبدل بلا ما تخرج من ملء الشاشة.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "اختار لغة التطبيق",
|
||||
"THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "خرج من ملء الشاشة",
|
||||
"CHANNEL_LIST": "لائحة القنوات",
|
||||
"HIDE_CHANNEL_LIST": "خبّي لائحة القنوات",
|
||||
"EPISODE_LIST": "لائحة الحلقات",
|
||||
"HIDE_EPISODE_LIST": "خبّي لائحة الحلقات",
|
||||
"SEARCH_CHANNELS": "قلّب على القنوات",
|
||||
"SEARCH_IN": "قلّب ف {{title}}",
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
|
||||
"PLAYBACK_ERROR": "فشل جلب رابط التشغيل",
|
||||
"LOADING_SEASONS": "جاري تشريج المواسم...",
|
||||
"LOADING_EPISODES": "جاري تشريج الحلقات...",
|
||||
"EPISODES_LOAD_FAILED": "ما قدرناش نشرجيو الحلقات.",
|
||||
"LOADING_ALL_CHANNELS": "كيتم تحميل لائحة القنوات كاملة…",
|
||||
"REFRESH_CHANNEL_LIST": "تحديث لائحة القنوات",
|
||||
"NO_CHANNELS_IN_CATEGORY": "ماكاينش قنوات فهاد الصنف",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панэль наступных эпізодаў",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "На шырокіх вокнах замацоўваць прайгравальнік серыяла злева і паказваць наступныя эпізоды побач з ім.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Спіс каналаў у поўнаэкранным рэжыме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Падвядзіце мыш да левага краю або націсніце C у поўнаэкранным рэжыме, каб высунуць спіс каналаў і пераключыць канал, не выходзячы з поўнаэкраннага рэжыму.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Спіс каналаў і серый у поўнаэкранным рэжыме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Навядзіце мыш на левы край, націсніце на яго або націсніце C у поўнаэкранным рэжыме, каб паказаць спіс каналаў прамога эфіру або спіс серый серыяла і пераключыцца, не выходзячы з поўнаэкраннага рэжыму.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Выбраць мову праграмы",
|
||||
"THEME_DESCRIPTION": "Выбраць візуальную тэму афармлення",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму",
|
||||
"CHANNEL_LIST": "Спіс каналаў",
|
||||
"HIDE_CHANNEL_LIST": "Схаваць спіс каналаў",
|
||||
"EPISODE_LIST": "Спіс серый",
|
||||
"HIDE_EPISODE_LIST": "Схаваць спіс серый",
|
||||
"SEARCH_CHANNELS": "Пошук каналаў",
|
||||
"SEARCH_IN": "Пошук у {{title}}",
|
||||
"CLEAR_SEARCH": "Ачысціць пошук",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы",
|
||||
"PLAYBACK_ERROR": "Не ўдалося атрымаць URL для прайгравання",
|
||||
"LOADING_SEASONS": "Загрузка сезонаў...",
|
||||
"LOADING_EPISODES": "Загрузка серый...",
|
||||
"EPISODES_LOAD_FAILED": "Не ўдалося загрузіць серыі.",
|
||||
"LOADING_ALL_CHANNELS": "Загрузка поўнага спісу каналаў…",
|
||||
"REFRESH_CHANNEL_LIST": "Абнавіць спіс каналаў",
|
||||
"NO_CHANNELS_IN_CATEGORY": "У гэтай катэгорыі няма каналаў",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Füllt den Bereich um den Player mit einem unscharfen, gedimmten Poster auf, anstelle von einfachen schwarzen Balken.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Folgenleiste „Als Nächstes“",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Senderliste im Vollbild",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand oder drücke C, um die Senderliste einzublenden und den Sender zu wechseln, ohne das Vollbild zu verlassen.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Sender- und Episodenliste im Vollbild",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand, klicke darauf oder drücke C, um die Senderliste eines Live-Streams oder die Episodenliste einer Serie einzublenden und zu wechseln, ohne das Vollbild zu verlassen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto bevorzugt HLS. Schlägt Live-TV in MPV, VLC oder Embedded MPV fehl, den Player stoppen, ts wählen, speichern und den Sender erneut öffnen. Webplayer können bei einem anfänglichen HLS-HTTP-Fehler einmal angebotenes TS versuchen.",
|
||||
"LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus",
|
||||
"THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Vollbild beenden",
|
||||
"CHANNEL_LIST": "Senderliste",
|
||||
"HIDE_CHANNEL_LIST": "Senderliste ausblenden",
|
||||
"EPISODE_LIST": "Episodenliste",
|
||||
"HIDE_EPISODE_LIST": "Episodenliste ausblenden",
|
||||
"SEARCH_CHANNELS": "Sender suchen",
|
||||
"SEARCH_IN": "In {{title}} suchen",
|
||||
"CLEAR_SEARCH": "Suche löschen",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar",
|
||||
"PLAYBACK_ERROR": "Wiedergabe-URL konnte nicht abgerufen werden",
|
||||
"LOADING_SEASONS": "Staffeln werden geladen …",
|
||||
"LOADING_EPISODES": "Episoden werden geladen …",
|
||||
"EPISODES_LOAD_FAILED": "Die Episoden konnten nicht geladen werden.",
|
||||
"LOADING_ALL_CHANNELS": "Vollständige Senderliste wird geladen…",
|
||||
"REFRESH_CHANNEL_LIST": "Senderliste aktualisieren",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Keine Sender in dieser Kategorie",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Λίστα επόμενων επεισοδίων",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Σε πλατιά παράθυρα, ο αναπαραγωγός σειρών αγκυρώνεται στα αριστερά και τα επόμενα επεισόδια εμφανίζονται δίπλα του.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Λίστα καναλιών σε πλήρη οθόνη",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Σε πλήρη οθόνη, μετακινήστε το ποντίκι στην αριστερή άκρη ή πατήστε C για να εμφανιστεί η λίστα καναλιών και να αλλάξετε κανάλι χωρίς να βγείτε από την πλήρη οθόνη.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Λίστα καναλιών και επεισοδίων σε πλήρη οθόνη",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Αφήστε το ποντίκι στην αριστερή άκρη, κάντε κλικ σε αυτήν ή πατήστε C σε πλήρη οθόνη για να εμφανίσετε τη λίστα καναλιών μιας ζωντανής ροής ή τη λίστα επεισοδίων μιας σειράς και να αλλάξετε χωρίς να βγείτε από την πλήρη οθόνη.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής",
|
||||
"THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη",
|
||||
"CHANNEL_LIST": "Λίστα καναλιών",
|
||||
"HIDE_CHANNEL_LIST": "Απόκρυψη λίστας καναλιών",
|
||||
"EPISODE_LIST": "Λίστα επεισοδίων",
|
||||
"HIDE_EPISODE_LIST": "Απόκρυψη λίστας επεισοδίων",
|
||||
"SEARCH_CHANNELS": "Αναζήτηση καναλιών",
|
||||
"SEARCH_IN": "Αναζήτηση σε {{title}}",
|
||||
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
|
||||
"PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής",
|
||||
"LOADING_SEASONS": "Φόρτωση σεζόν...",
|
||||
"LOADING_EPISODES": "Φόρτωση επεισοδίων...",
|
||||
"EPISODES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των επεισοδίων.",
|
||||
"LOADING_ALL_CHANNELS": "Φόρτωση πλήρους λίστας καναλιών…",
|
||||
"REFRESH_CHANNEL_LIST": "Ανανέωση λίστας καναλιών",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Δεν υπάρχουν κανάλια σε αυτήν την κατηγορία",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Up Next episode rail",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Channel list in fullscreen",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge or press C while in fullscreen to slide in the channel list and switch channels without leaving fullscreen.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Channel and episode list in fullscreen",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge, click it, or press C while in fullscreen to slide in the channel list of a live stream or the episode list of a series and switch without leaving fullscreen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto prefers HLS. If live playback fails in MPV, VLC or Embedded MPV, stop the player, select ts, save and reopen the channel. Web players can try advertised TS once after an initial HLS HTTP failure.",
|
||||
"LANGUAGE_DESCRIPTION": "Select language of the application",
|
||||
"THEME_DESCRIPTION": "Select visual theme of the application",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Exit fullscreen",
|
||||
"CHANNEL_LIST": "Channel list",
|
||||
"HIDE_CHANNEL_LIST": "Hide channel list",
|
||||
"EPISODE_LIST": "Episode list",
|
||||
"HIDE_EPISODE_LIST": "Hide episode list",
|
||||
"SEARCH_CHANNELS": "Search channels",
|
||||
"SEARCH_IN": "Search in {{title}}",
|
||||
"CLEAR_SEARCH": "Clear search",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
|
||||
"PLAYBACK_ERROR": "Failed to get playback URL",
|
||||
"LOADING_SEASONS": "Loading seasons...",
|
||||
"LOADING_EPISODES": "Loading episodes...",
|
||||
"EPISODES_LOAD_FAILED": "The episodes could not be loaded.",
|
||||
"LOADING_ALL_CHANNELS": "Loading full channel list…",
|
||||
"REFRESH_CHANNEL_LIST": "Refresh channel list",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No channels in this category",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Rellena el espacio alrededor del reproductor con una imagen de póster desenfocada y oscurecida en lugar de barras negras simples.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Panel de próximos episodios",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "En ventanas anchas, ancla el reproductor de series a la izquierda y muestra los próximos episodios a su lado.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canales en pantalla completa",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "En pantalla completa, acerca el ratón al borde izquierdo o pulsa C para desplegar la lista de canales y cambiar de canal sin salir de la pantalla completa.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canales y episodios en pantalla completa",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Deja el ratón en el borde izquierdo, haz clic en él o pulsa C en pantalla completa para mostrar la lista de canales de una emisión en directo o la lista de episodios de una serie y cambiar sin salir de la pantalla completa.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación",
|
||||
"THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Salir de pantalla completa",
|
||||
"CHANNEL_LIST": "Lista de canales",
|
||||
"HIDE_CHANNEL_LIST": "Ocultar la lista de canales",
|
||||
"EPISODE_LIST": "Lista de episodios",
|
||||
"HIDE_EPISODE_LIST": "Ocultar la lista de episodios",
|
||||
"SEARCH_CHANNELS": "Buscar canales",
|
||||
"SEARCH_IN": "Buscar en {{title}}",
|
||||
"CLEAR_SEARCH": "Borrar búsqueda",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor",
|
||||
"PLAYBACK_ERROR": "No se pudo obtener la URL de reproducción",
|
||||
"LOADING_SEASONS": "Cargando temporadas…",
|
||||
"LOADING_EPISODES": "Cargando episodios…",
|
||||
"EPISODES_LOAD_FAILED": "No se pudieron cargar los episodios.",
|
||||
"LOADING_ALL_CHANNELS": "Cargando la lista completa de canales…",
|
||||
"REFRESH_CHANNEL_LIST": "Actualizar lista de canales",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No hay canales en esta categoría",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Remplit l'espace autour du lecteur avec une affiche floue et assombrie au lieu de barres noires simples.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Rail d'épisodes « À suivre »",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Sur les fenêtres larges, ancre le lecteur de séries à gauche et affiche les épisodes à venir à côté.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Liste des chaînes en plein écran",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "En plein écran, placez la souris sur le bord gauche ou appuyez sur C pour faire glisser la liste des chaînes et changer de chaîne sans quitter le plein écran.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Liste des chaînes et des épisodes en plein écran",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Laissez la souris sur le bord gauche, cliquez dessus ou appuyez sur C en plein écran pour afficher la liste des chaînes d'un direct ou la liste des épisodes d'une série et changer sans quitter le plein écran.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Sélectionnez le format de flux par défaut (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Sélectionnez la langue de l'application",
|
||||
"THEME_DESCRIPTION": "Sélectionnez le thème visuel de l'application",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Quitter le plein écran",
|
||||
"CHANNEL_LIST": "Liste des chaînes",
|
||||
"HIDE_CHANNEL_LIST": "Masquer la liste des chaînes",
|
||||
"EPISODE_LIST": "Liste des épisodes",
|
||||
"HIDE_EPISODE_LIST": "Masquer la liste des épisodes",
|
||||
"SEARCH_CHANNELS": "Rechercher des chaînes",
|
||||
"SEARCH_IN": "Rechercher dans {{title}}",
|
||||
"CLEAR_SEARCH": "Effacer la recherche",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur",
|
||||
"PLAYBACK_ERROR": "Échec de l'obtention de l'URL de lecture",
|
||||
"LOADING_SEASONS": "Chargement des saisons…",
|
||||
"LOADING_EPISODES": "Chargement des épisodes…",
|
||||
"EPISODES_LOAD_FAILED": "Les épisodes n'ont pas pu être chargés.",
|
||||
"LOADING_ALL_CHANNELS": "Chargement de la liste complète des chaînes…",
|
||||
"REFRESH_CHANNEL_LIST": "Actualiser la liste des chaînes",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Aucune chaîne dans cette catégorie",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
|
||||
"PLAYER_UP_NEXT_RAIL": "„Következik” epizódsáv",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Széles ablakban a sorozatlejátszó balra kerül, mellette pedig megjelennek a következő epizódok.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Csatornalista teljes képernyőn",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Teljes képernyőn vidd az egeret a bal szélre, vagy nyomd meg a C billentyűt: becsúszik a csatornalista, és a teljes képernyő elhagyása nélkül válthatsz csatornát.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Csatorna- és epizódlista teljes képernyőn",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Teljes képernyőn vidd az egeret a bal szélre, kattints rá, vagy nyomd meg a C billentyűt az élő adás csatornalistájának vagy a sorozat epizódlistájának megjelenítéséhez, és válts a teljes képernyő elhagyása nélkül.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Az alapértelmezett adatfolyam-formátum kiválasztása (Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Az alkalmazás nyelvének kiválasztása",
|
||||
"THEME_DESCRIPTION": "Az alkalmazás megjelenési témájának kiválasztása",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Kilépés a teljes képernyős módból",
|
||||
"CHANNEL_LIST": "Csatornalista",
|
||||
"HIDE_CHANNEL_LIST": "Csatornalista elrejtése",
|
||||
"EPISODE_LIST": "Epizódlista",
|
||||
"HIDE_EPISODE_LIST": "Epizódlista elrejtése",
|
||||
"SEARCH_CHANNELS": "Csatornák keresése",
|
||||
"SEARCH_IN": "Keresés itt: {{title}}",
|
||||
"CLEAR_SEARCH": "Keresés törlése",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ez a tartalom nem érhető el ezen a kiszolgálón",
|
||||
"PLAYBACK_ERROR": "Nem sikerült lekérni a lejátszáshoz szükséges URL-címet",
|
||||
"LOADING_SEASONS": "Évadok betöltése…",
|
||||
"LOADING_EPISODES": "Epizódok betöltése…",
|
||||
"EPISODES_LOAD_FAILED": "Az epizódokat nem sikerült betölteni.",
|
||||
"LOADING_ALL_CHANNELS": "Loading full channel list…",
|
||||
"REFRESH_CHANNEL_LIST": "Refresh channel list",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No channels in this category",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Riempie lo spazio intorno al lettore con un poster sfocato e scurito invece delle semplici barre nere.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Pannello \"A seguire\" con i prossimi episodi",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Nelle finestre ampie, ancora il lettore delle serie a sinistra e mostra i prossimi episodi accanto.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Elenco canali a schermo intero",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "A schermo intero, porta il mouse sul bordo sinistro o premi C per far scorrere l'elenco dei canali e cambiare canale senza uscire dallo schermo intero.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Elenco canali ed episodi a schermo intero",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "A schermo intero, lascia il mouse sul bordo sinistro, fai clic su di esso o premi C per mostrare l'elenco dei canali di una diretta o l'elenco degli episodi di una serie e cambiare senza uscire dallo schermo intero.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Seleziona il formato di streaming predefinito (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Seleziona la lingua dell'applicazione",
|
||||
"THEME_DESCRIPTION": "Seleziona il tema visivo dell'applicazione",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Esci dallo schermo intero",
|
||||
"CHANNEL_LIST": "Elenco canali",
|
||||
"HIDE_CHANNEL_LIST": "Nascondi l'elenco dei canali",
|
||||
"EPISODE_LIST": "Elenco episodi",
|
||||
"HIDE_EPISODE_LIST": "Nascondi l'elenco degli episodi",
|
||||
"SEARCH_CHANNELS": "Cerca canali",
|
||||
"SEARCH_IN": "Cerca in {{title}}",
|
||||
"CLEAR_SEARCH": "Cancella ricerca",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server",
|
||||
"PLAYBACK_ERROR": "Impossibile ottenere l'URL di riproduzione",
|
||||
"LOADING_SEASONS": "Caricamento stagioni...",
|
||||
"LOADING_EPISODES": "Caricamento episodi...",
|
||||
"EPISODES_LOAD_FAILED": "Impossibile caricare gli episodi.",
|
||||
"LOADING_ALL_CHANNELS": "Caricamento dell'elenco completo dei canali…",
|
||||
"REFRESH_CHANNEL_LIST": "Aggiorna elenco canali",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Nessun canale in questa categoria",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「次に再生」エピソードレール",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "ウィンドウ幅が広い場合、シリーズのプレーヤーを左側に配置し、その横に次のエピソードの一覧を表示します。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全画面表示でのチャンネルリスト",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全画面表示中にマウスを左端に置くか C キーを押すと、チャンネルリストがスライド表示され、全画面を解除せずにチャンネルを切り替えられます。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全画面表示でのチャンネル・エピソードリスト",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全画面表示中に左端にマウスを置く、クリックする、または C キーを押すと、ライブ配信のチャンネルリストやシリーズのエピソードリストが表示され、全画面表示のまま切り替えられます。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択",
|
||||
"THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "全画面表示を終了",
|
||||
"CHANNEL_LIST": "チャンネルリスト",
|
||||
"HIDE_CHANNEL_LIST": "チャンネルリストを隠す",
|
||||
"EPISODE_LIST": "エピソードリスト",
|
||||
"HIDE_EPISODE_LIST": "エピソードリストを隠す",
|
||||
"SEARCH_CHANNELS": "チャンネルを検索",
|
||||
"SEARCH_IN": "{{title}} 内を検索",
|
||||
"CLEAR_SEARCH": "検索をクリア",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
|
||||
"PLAYBACK_ERROR": "再生URLの取得に失敗しました",
|
||||
"LOADING_SEASONS": "シーズンを読み込み中...",
|
||||
"LOADING_EPISODES": "エピソードを読み込み中...",
|
||||
"EPISODES_LOAD_FAILED": "エピソードを読み込めませんでした。",
|
||||
"LOADING_ALL_CHANNELS": "全チャンネルリストを読み込み中…",
|
||||
"REFRESH_CHANNEL_LIST": "チャンネルリストを更新",
|
||||
"NO_CHANNELS_IN_CATEGORY": "このカテゴリーにチャンネルがありません",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.",
|
||||
"PLAYER_UP_NEXT_RAIL": "다음 에피소드 레일",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "넓은 창에서 시리즈 플레이어를 왼쪽에 고정하고 그 옆에 다음 에피소드를 표시합니다.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "전체 화면에서 채널 목록",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "전체 화면에서 마우스를 왼쪽 가장자리에 두거나 C 키를 누르면 채널 목록이 나타나 전체 화면을 벗어나지 않고 채널을 바꿀 수 있습니다.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "전체 화면에서 채널 및 에피소드 목록",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "전체 화면에서 마우스를 왼쪽 가장자리에 두거나 클릭하거나 C 키를 누르면 라이브 방송의 채널 목록이나 시리즈의 에피소드 목록이 나타나 전체 화면을 벗어나지 않고 전환할 수 있습니다.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택",
|
||||
"THEME_DESCRIPTION": "애플리케이션의 시각적 테마 선택",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "전체 화면 종료",
|
||||
"CHANNEL_LIST": "채널 목록",
|
||||
"HIDE_CHANNEL_LIST": "채널 목록 숨기기",
|
||||
"EPISODE_LIST": "에피소드 목록",
|
||||
"HIDE_EPISODE_LIST": "에피소드 목록 숨기기",
|
||||
"SEARCH_CHANNELS": "채널 검색",
|
||||
"SEARCH_IN": "{{title}}에서 검색",
|
||||
"CLEAR_SEARCH": "검색 지우기",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다",
|
||||
"PLAYBACK_ERROR": "재생 URL을 가져오지 못했습니다",
|
||||
"LOADING_SEASONS": "시즌을 불러오는 중...",
|
||||
"LOADING_EPISODES": "에피소드를 불러오는 중...",
|
||||
"EPISODES_LOAD_FAILED": "에피소드를 불러올 수 없습니다.",
|
||||
"LOADING_ALL_CHANNELS": "전체 채널 목록 불러오는 중…",
|
||||
"REFRESH_CHANNEL_LIST": "채널 목록 새로고침",
|
||||
"NO_CHANNELS_IN_CATEGORY": "이 카테고리에 채널이 없습니다",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Vult de ruimte rond de speler met een vervaagde, gedimde posterafbeelding in plaats van zwarte balken.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Hierna-paneel met afleveringen",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Zet de seriespeler in brede vensters links vast en toont de komende afleveringen ernaast.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Zenderlijst in volledig scherm",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Beweeg in volledig scherm de muis naar de linkerrand of druk op C om de zenderlijst in te schuiven en van zender te wisselen zonder het volledige scherm te verlaten.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Zender- en afleveringenlijst in volledig scherm",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Laat de muis op de linkerrand rusten, klik erop of druk op C in volledig scherm om de zenderlijst van een live-uitzending of de afleveringenlijst van een serie te tonen en te wisselen zonder het volledige scherm te verlaten.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Selecteer het standaard streamformaat (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Selecteer de taal van de applicatie",
|
||||
"THEME_DESCRIPTION": "Kies het visuele thema van de applicatie",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Volledig scherm sluiten",
|
||||
"CHANNEL_LIST": "Zenderlijst",
|
||||
"HIDE_CHANNEL_LIST": "Zenderlijst verbergen",
|
||||
"EPISODE_LIST": "Afleveringenlijst",
|
||||
"HIDE_EPISODE_LIST": "Afleveringenlijst verbergen",
|
||||
"SEARCH_CHANNELS": "Zenders zoeken",
|
||||
"SEARCH_IN": "Zoeken in {{title}}",
|
||||
"CLEAR_SEARCH": "Zoekopdracht wissen",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server",
|
||||
"PLAYBACK_ERROR": "Ophalen van afspeel-URL mislukt",
|
||||
"LOADING_SEASONS": "Seizoenen laden...",
|
||||
"LOADING_EPISODES": "Afleveringen laden...",
|
||||
"EPISODES_LOAD_FAILED": "De afleveringen konden niet worden geladen.",
|
||||
"LOADING_ALL_CHANNELS": "Volledige zenderlijst wordt geladen…",
|
||||
"REFRESH_CHANNEL_LIST": "Zenderlijst vernieuwen",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Geen zenders in deze categorie",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Wypełnij przestrzeń wokół odtwarzacza rozmytym, przyciemnianym plakatem zamiast zwykłych czarnych pasków.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Panel następnych odcinków",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "W szerokich oknach zadokuj odtwarzacz serialu po lewej stronie i pokaż obok kolejne odcinki.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista kanałów na pełnym ekranie",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Na pełnym ekranie przesuń mysz do lewej krawędzi lub naciśnij C, aby wysunąć listę kanałów i zmienić kanał bez wychodzenia z pełnego ekranu.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista kanałów i odcinków w trybie pełnoekranowym",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "W trybie pełnoekranowym przytrzymaj mysz przy lewej krawędzi, kliknij ją lub naciśnij C, aby wysunąć listę kanałów transmisji na żywo lub listę odcinków serialu i przełączać bez wychodzenia z pełnego ekranu.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Wybierz język aplikacji",
|
||||
"THEME_DESCRIPTION": "Wybierz motyw wizualny aplikacji",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Wyłącz pełny ekran",
|
||||
"CHANNEL_LIST": "Lista kanałów",
|
||||
"HIDE_CHANNEL_LIST": "Ukryj listę kanałów",
|
||||
"EPISODE_LIST": "Lista odcinków",
|
||||
"HIDE_EPISODE_LIST": "Ukryj listę odcinków",
|
||||
"SEARCH_CHANNELS": "Szukaj kanałów",
|
||||
"SEARCH_IN": "Szukaj w {{title}}",
|
||||
"CLEAR_SEARCH": "Wyczyść wyszukiwanie",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze",
|
||||
"PLAYBACK_ERROR": "Nie udało się uzyskać URL odtwarzania",
|
||||
"LOADING_SEASONS": "Ładowanie sezonów...",
|
||||
"LOADING_EPISODES": "Ładowanie odcinków...",
|
||||
"EPISODES_LOAD_FAILED": "Nie udało się załadować odcinków.",
|
||||
"LOADING_ALL_CHANNELS": "Ładowanie pełnej listy kanałów…",
|
||||
"REFRESH_CHANNEL_LIST": "Odśwież listę kanałów",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Brak kanałów w tej kategorii",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Preenche o espaço ao redor do reprodutor com um pôster desfocado e escurecido em vez de barras pretas simples.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Painel de próximos episódios",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Em janelas largas, ancora o reprodutor de séries à esquerda e mostra os próximos episódios ao lado.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canais em tela cheia",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Em tela cheia, leve o mouse até a borda esquerda ou pressione C para deslizar a lista de canais e trocar de canal sem sair da tela cheia.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canais e episódios em tela cheia",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Deixe o mouse na borda esquerda, clique nela ou pressione C em tela cheia para mostrar a lista de canais de uma transmissão ao vivo ou a lista de episódios de uma série e trocar sem sair da tela cheia.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Selecionar idioma do aplicativo",
|
||||
"THEME_DESCRIPTION": "Selecionar tema visual do aplicativo",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Sair da tela cheia",
|
||||
"CHANNEL_LIST": "Lista de canais",
|
||||
"HIDE_CHANNEL_LIST": "Ocultar lista de canais",
|
||||
"EPISODE_LIST": "Lista de episódios",
|
||||
"HIDE_EPISODE_LIST": "Ocultar lista de episódios",
|
||||
"SEARCH_CHANNELS": "Pesquisar canais",
|
||||
"SEARCH_IN": "Pesquisar em {{title}}",
|
||||
"CLEAR_SEARCH": "Limpar pesquisa",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor",
|
||||
"PLAYBACK_ERROR": "Falha ao obter URL de reprodução",
|
||||
"LOADING_SEASONS": "Carregando temporadas...",
|
||||
"LOADING_EPISODES": "Carregando episódios...",
|
||||
"EPISODES_LOAD_FAILED": "Não foi possível carregar os episódios.",
|
||||
"LOADING_ALL_CHANNELS": "A carregar a lista completa de canais…",
|
||||
"REFRESH_CHANNEL_LIST": "Atualizar lista de canais",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Nenhum canal nesta categoria",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панель следующих серий",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Список каналов в полноэкранном режиме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Подведите мышь к левому краю или нажмите C в полноэкранном режиме, чтобы выдвинуть список каналов и переключить канал, не выходя из полноэкранного режима.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Список каналов и серий в полноэкранном режиме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "В полноэкранном режиме наведите мышь на левый край, нажмите на него или нажмите C, чтобы показать список каналов прямого эфира или список серий сериала и переключиться, не выходя из полноэкранного режима.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto предпочитает HLS. При сбое в MPV, VLC или Embedded MPV остановите плеер, выберите ts, сохраните настройки и снова откройте канал. Веб-плееры могут один раз попробовать заявленный TS после первоначальной HTTP-ошибки HLS.",
|
||||
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
|
||||
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима",
|
||||
"CHANNEL_LIST": "Список каналов",
|
||||
"HIDE_CHANNEL_LIST": "Скрыть список каналов",
|
||||
"EPISODE_LIST": "Список серий",
|
||||
"HIDE_EPISODE_LIST": "Скрыть список серий",
|
||||
"SEARCH_CHANNELS": "Поиск каналов",
|
||||
"SEARCH_IN": "Поиск в {{title}}",
|
||||
"CLEAR_SEARCH": "Очистить поиск",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере",
|
||||
"PLAYBACK_ERROR": "Не удалось получить URL для воспроизведения",
|
||||
"LOADING_SEASONS": "Загрузка сезонов…",
|
||||
"LOADING_EPISODES": "Загрузка серий…",
|
||||
"EPISODES_LOAD_FAILED": "Не удалось загрузить серии.",
|
||||
"LOADING_ALL_CHANNELS": "Загрузка полного списка каналов…",
|
||||
"REFRESH_CHANNEL_LIST": "Обновить список каналов",
|
||||
"NO_CHANNELS_IN_CATEGORY": "В этой категории нет каналов",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Oynatıcının çevresindeki boşluğu siyah çubuklar yerine bulanık ve karartılmış bir poster ile doldurur.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Sıradaki bölümler paneli",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Geniş pencerelerde dizi oynatıcısını sola yaslar ve sıradaki bölümleri yanında gösterir.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Tam ekranda kanal listesi",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Tam ekrandayken fareyi sol kenara getirin veya C tuşuna basın: kanal listesi kayarak açılır ve tam ekrandan çıkmadan kanal değiştirebilirsiniz.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Tam ekranda kanal ve bölüm listesi",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Tam ekrandayken fareyi sol kenara getirin, tıklayın veya C tuşuna basın; canlı yayının kanal listesi ya da dizinin bölüm listesi açılır ve tam ekrandan çıkmadan geçiş yapabilirsiniz.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Varsayılan yayın formatını seçin (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Uygulamanın dilini seçin",
|
||||
"THEME_DESCRIPTION": "Uygulamanın görünüm temasını seçin",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Tam ekrandan çık",
|
||||
"CHANNEL_LIST": "Kanal listesi",
|
||||
"HIDE_CHANNEL_LIST": "Kanal listesini gizle",
|
||||
"EPISODE_LIST": "Bölüm listesi",
|
||||
"HIDE_EPISODE_LIST": "Bölüm listesini gizle",
|
||||
"SEARCH_CHANNELS": "Kanal ara",
|
||||
"SEARCH_IN": "{{title}} içinde ara",
|
||||
"CLEAR_SEARCH": "Aramayı temizle",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil",
|
||||
"PLAYBACK_ERROR": "Oynatma URL'si alınamadı",
|
||||
"LOADING_SEASONS": "Sezonlar yükleniyor...",
|
||||
"LOADING_EPISODES": "Bölümler yükleniyor...",
|
||||
"EPISODES_LOAD_FAILED": "Bölümler yüklenemedi.",
|
||||
"LOADING_ALL_CHANNELS": "Tüm kanal listesi yükleniyor…",
|
||||
"REFRESH_CHANNEL_LIST": "Kanal listesini yenile",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Bu kategoride kanal yok",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「接下来播放」剧集栏",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在宽窗口下,将剧集播放器停靠在左侧,并在旁边显示后续剧集。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全屏时显示频道列表",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全屏播放时,将鼠标移到左边缘或按 C 键即可滑出频道列表,无需退出全屏即可切换频道。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全屏时的频道和剧集列表",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全屏时将鼠标停在左边缘、点击它或按 C 键,即可滑出直播的频道列表或剧集的分集列表,无需退出全屏即可切换。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "选择应用程序的语言",
|
||||
"THEME_DESCRIPTION": "选择应用程序的视觉主题",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "退出全屏",
|
||||
"CHANNEL_LIST": "频道列表",
|
||||
"HIDE_CHANNEL_LIST": "隐藏频道列表",
|
||||
"EPISODE_LIST": "剧集列表",
|
||||
"HIDE_EPISODE_LIST": "隐藏剧集列表",
|
||||
"SEARCH_CHANNELS": "搜索频道",
|
||||
"SEARCH_IN": "在 {{title}} 中搜索",
|
||||
"CLEAR_SEARCH": "清除搜索",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "此服务器上没有此内容",
|
||||
"PLAYBACK_ERROR": "获取播放 URL 失败",
|
||||
"LOADING_SEASONS": "正在加载季…",
|
||||
"LOADING_EPISODES": "正在加载剧集…",
|
||||
"EPISODES_LOAD_FAILED": "无法加载剧集。",
|
||||
"LOADING_ALL_CHANNELS": "正在加载完整频道列表…",
|
||||
"REFRESH_CHANNEL_LIST": "刷新频道列表",
|
||||
"NO_CHANNELS_IN_CATEGORY": "此分类中没有频道",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。",
|
||||
"PLAYER_UP_NEXT_RAIL": "即將播放集數側欄",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在較寬的視窗中,將影集播放器停靠在左側,並在旁邊顯示即將播放的集數。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全螢幕時顯示頻道清單",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全螢幕播放時,將滑鼠移到左邊緣或按 C 鍵即可滑出頻道清單,不必離開全螢幕就能切換頻道。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全螢幕時的頻道與集數清單",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全螢幕時將滑鼠停在左側邊緣、點擊它或按 C 鍵,即可滑出直播的頻道清單或影集的集數清單,不需離開全螢幕即可切換。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "選擇應用程式語言",
|
||||
"THEME_DESCRIPTION": "選擇應用程式視覺主題",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "離開全螢幕",
|
||||
"CHANNEL_LIST": "頻道清單",
|
||||
"HIDE_CHANNEL_LIST": "隱藏頻道清單",
|
||||
"EPISODE_LIST": "集數清單",
|
||||
"HIDE_EPISODE_LIST": "隱藏集數清單",
|
||||
"SEARCH_CHANNELS": "搜尋頻道",
|
||||
"SEARCH_IN": "在 {{title}} 中搜尋",
|
||||
"CLEAR_SEARCH": "清除搜尋",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容",
|
||||
"PLAYBACK_ERROR": "無法取得播放網址",
|
||||
"LOADING_SEASONS": "正在載入季數...",
|
||||
"LOADING_EPISODES": "正在載入集數...",
|
||||
"EPISODES_LOAD_FAILED": "無法載入集數。",
|
||||
"LOADING_ALL_CHANNELS": "正在載入完整頻道清單…",
|
||||
"REFRESH_CHANNEL_LIST": "重新整理頻道清單",
|
||||
"NO_CHANNELS_IN_CATEGORY": "此分類中沒有頻道",
|
||||
|
||||
Reference in new issue
Block a user